diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce84dc..730834c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,25 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(yaml@2.9.0)) + prototypes/copy-for-latex: + dependencies: + roamjs-components: + specifier: 'catalog:' + version: 0.88.4(8b3dba144aa4c6a891ee791b6ddc21c2) + use-sync-external-store: + specifier: 'catalog:' + version: 1.6.0(react@17.0.2) + devDependencies: + '@discoursegraphs/extension-base': + specifier: workspace:* + version: link:../../packages/extension-base + jsdom: + specifier: 'catalog:' + version: 30.0.1 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(yaml@2.9.0)) + prototypes/loaded-dialog: dependencies: roamjs-components: diff --git a/prototypes/copy-for-latex/CHANGELOG.md b/prototypes/copy-for-latex/CHANGELOG.md new file mode 100644 index 0000000..0911a75 --- /dev/null +++ b/prototypes/copy-for-latex/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +## 0.0.4 - 2026-08-19 + +- Fixed the load failure. `import addStyle from "roamjs-components/dom/addStyle"` compiles to a + call on `{ default: fn }`: roamjs-components is CommonJS, and esbuild's ESM output uses + Node-interop mode, where a default import of a CommonJS module resolves to the whole module + object. The stylesheet is injected by six lines of local code instead. A source-level test now + rejects any default import from roamjs-components. +- The failure reporter no longer depends on the toast succeeding; the console gets the error + first and unconditionally. + +## 0.0.3 - 2026-08-19 + +- Load failures now report themselves. `runExtension` does not log the error in production, and + it reads `extensionAPI.settings` while reporting — which is undefined when the module is + imported from a `roam/js` block, so its reporter threw over the top of the real error. Errors + are caught before they reach it, and a missing `data.async.q` is named directly. + +## 0.0.2 - 2026-08-19 + +- The menu's stylesheet is carried in the bundle instead of shipped as a separate + `extension.css`. Roam injects that file only when it loads an extension from a URL, so under a + `roam/js` loader block the menu had no `position: fixed` and rendered as an invisible static + list at the end of the document. + +## 0.0.1 - 2026-08-19 + +- Ported from the standalone `roam/js` prototype into this repository. +- Graph reads moved to `roamAlphaAPI.data.async.*` with parameterized Datalog inputs. +- Right-click now works on a node's own page heading, not only on inline references. + +## 0.0.0 - 2026-08-19 + +- Created the Copy for LaTeX prototype scaffold. diff --git a/prototypes/copy-for-latex/README.md b/prototypes/copy-for-latex/README.md new file mode 100644 index 0000000..657eb7b --- /dev/null +++ b/prototypes/copy-for-latex/README.md @@ -0,0 +1,105 @@ +# Copy for LaTeX + +Copy a discourse node out of Roam as a LaTeX sentence carrying its own citation, or as a +hyperlink. Built for the case where a manuscript is outlined in Roam and written in Overleaf. + +## Status + +Internal prototype for evaluation by Discourse Graphs. + +## What it does + +Right-click a discourse node in either of two places: + +- a reference to it inside a block's text, anywhere in an outline +- the heading of its own page, once you have opened it + +A menu appears in place of Roam's: + +```text +Copy for LaTeX +Copy as hyperlink +───────────────── +Jump to page +Open in sidebar +``` + +**Copy for LaTeX** copies the node's content as a sentence with `\autocite{...}` built from its +Source: + +```latex +Uniform constriction forces on a simulated 3D membrane tube led to +asymmetric pinched tube deformation\autocite{vasan2020mechanical}. +``` + +The content is copied verbatim, never reworded. Roam markup is converted: `**bold**` becomes +`\textbf{}`, `__italic__` becomes `\textit{}`, `$$math$$` becomes inline math, page links are +unwrapped to their text, and tags are dropped. Everything else is escaped, so a stray `%` or `&` +cannot corrupt the document it is pasted into. + +**Copy as hyperlink** copies `[label](roam-url)` for pasting into Slack, Linear, or a doc. The +label drops the node-type marker and the brackets around the citekey. If the page's uid cannot be +found it falls back to the bare label with no link. + +**Jump to page** and **Open in sidebar** match Roam's own items of the same name. Jump to page is +left out when you right-click the heading of the page you are already on, since it would go +nowhere; from a sidebar heading it stays. + +## Requirements and limits + +- The pasted `\autocite{key}` only compiles if the key is already in the Overleaf project's + bibliography. Citekeys are expected to be Better BibTeX style, so an `@citekey` page title is + the LaTeX key with the `@` removed. Graphs that use another convention are out of scope. +- A node whose Source is not a usable citekey still copies, without the citation, and a toast + names the node so the gap does not pass unnoticed. Nothing this emits should ever fail to + compile. +- Only nodes it recognizes are intercepted. Right-clicking any other page reference, or the + heading of any other page, leaves Roam's own menu untouched. +- One node at a time. There is no multi-select. +- Node types are read once at load, from `discourse-graph/nodes/{Type}` pages. Add a node type + and you will need to reload for it to be recognized. + +## Install + +Load this developer-extension URL in Roam, under **Load Developer Extensions from URL**: + +```text +https://discoursegraphs.com/releases/prototypes/copy-for-latex/ +``` + +To try a pull-request preview without touching your settings, put a loader in a `roam/js` block +instead. The published bundle is an ES module, so it cannot be pasted into a block directly, but it +can be imported by one: + +```js +(async () => { + const url = + "https://discoursegraphs.com/releases/prototypes/copy-for-latex/extension.js"; + const globalKey = "__copyForLatexExtension"; + + const previous = window[globalKey]; + if (previous?.onunload) await previous.onunload(); + + const module = await import(`${url}?v=${Date.now()}`); + const extension = module.default; + if (!extension?.onload) throw new Error("The loaded module is not a Roam extension."); + + await extension.onload({ extensionAPI: undefined, extension: { version: "roam/js" } }); + window[globalKey] = extension; +})().catch((error) => console.error("Could not load Copy for LaTeX:", error)); +``` + +Give each extension its own `globalKey`. Two loaders sharing one key will unload each other. + +Note that this path does **not** get a published `extension.css` — Roam only injects that when it +loads an extension from a URL itself. This extension therefore carries its own styles in the +bundle, so both paths behave the same. Any prototype that ships CSS as a separate file will look +broken when loaded this way. + +Note also that **default imports from `roamjs-components` do not work** in this build. It is a +CommonJS package, and esbuild's ESM output resolves a default import of a CommonJS module to the +whole module object, so the value arrives as `{ default: fn }` and calling it throws. Use named +imports. Unit tests will not catch it, because vitest resolves CommonJS with ordinary interop. + +If nothing happens on right-click, open the console. The extension logs how many discourse node +types it loaded, and warns when it finds none. diff --git a/prototypes/copy-for-latex/SPEC.md b/prototypes/copy-for-latex/SPEC.md new file mode 100644 index 0000000..37aa39e --- /dev/null +++ b/prototypes/copy-for-latex/SPEC.md @@ -0,0 +1,160 @@ +# Copy for LaTeX — design + +> **Provenance.** Written while this prototype lived at `copy-for-latex/` inside the +> `dg-prototypes` repository. It has since been extracted to its own repository, so paths +> written below (`copy-for-latex/test/...`, `docs/superpowers/...`) describe the layout at +> the time. The code they refer to is now at the root of this repo, with tests under `test/`. +> The text is left as written: it is the record of what was decided, not current instructions. + +**Date:** 2026-08-04 +**Status:** approved, ready for implementation planning +**Roam source:** `[[ISS]] - send evd + citation to your authoring platform` (uid `5Ckq7OEJX`), drafted on the August 4th, 2026 daily note (uid `KPDRUJ1P8`) +**Related:** `[[Project/Legacy documents to and from discourse nodes]]`, `[[ISS]] - Knowledge package manager` + +## Problem + +A researcher outlines a manuscript in Roam out of discourse nodes, then writes the manuscript in Overleaf. Moving one node into the draft currently takes three manual steps: copy the node's content, copy its citekey, paste each into LaTeX. The `Story/PINN for accurate 3D segmentation of cellular membranes` outline is the worked example — a Results section built from a Question, an Evidence node citing prior work, and an internal analysis page. + +The target output for one Evidence node is a LaTeX sentence carrying its own citation: + +```latex +Uniform constriction forces on a simulated 3D membrane tube led to +asymmetric pinched tube deformation\autocite{vasan2020mechanical}. +``` + +## Decisions + +These were settled during design. Each records the alternative that was rejected, so the implementer does not relitigate them. + +| # | Decision | Rejected alternative | +|---|---|---| +| 1 | Copy the node's content **verbatim**, deterministically | LLM rewording into flowing prose. Deferred: the payload builder stays a pure function so a rewording layer can sit on top later. | +| 2 | Emit **clean text only** — no provenance comment, no wrapper macro | A `%` comment carrying node uid and Roam URL. Recorded as a deliberate tradeoff: once text lands in Overleaf the tie back to the graph is gone, and sync-back would need text matching. The payload builder must stay pluggable so this can be added without redesign. | +| 3 | Trigger is **right-click on a discourse-node page reference** in an outline | The block context menu, the multi-select context menu, and a page-level button. All are supported APIs and all were declined in favor of the gesture that matches where drafting actually happens. Dedicated node-specific affordances are named as future work. | +| 4 | A node with no usable citekey copies **without a citation**, with a toast naming the node | Per-node-type rules, and refusing to copy. The universal rule is simpler and nothing this tool emits should ever fail to compile. | + +## Verified API surface + +Established by reading the installed typings and the Discourse Graph plugin source, not by assumption. + +- `roamAlphaAPI.ui` exposes `commandPalette` (which accepts `default-hotkey`), `blockContextMenu`, `msContextMenu`, and `individualMultiselect.getSelectedUids()`. Declared in `roamjs-components/types/index.d.ts:153-181`. +- **There is no API for Roam's page-reference context menu.** The menu shown in the UX1 mockup, with "Jump to page" and "Delete reference", cannot be extended through a public interface. +- The two native items worth replicating do have APIs: `ui.mainWindow.openPage` and `ui.rightSidebar.addWindow`, declared in the same file at lines 345 and 131. +- The Discourse Graph Roam plugin already observes `span.rm-page-ref` elements and tests each one with `findDiscourseNode`, which is how the discourse context overlay attaches. See `apps/roam/src/utils/pageRefObserverHandlers.ts:14,100`. +- `extractContentFromTitle` already splits `{content}` out of a node's format string, and the same regex match yields `{Source}` by placeholder index. See `apps/roam/src/utils/extractContentFromTitle.ts`. +- Source pages carry `citekey` and `doi` in front matter but no BibTeX entry, so the bibliography entry cannot be copied alongside the citation without a Zotero round-trip. + +The last point sets a prerequisite: **the pasted `\autocite{key}` only compiles if the key is already in the Overleaf project's bibliography.** The prototype assumes Zotero is wired to Overleaf and the keys match. + +That assumption holds by design here. Citekeys in these graphs are deliberately written in Better BibTeX style for exactly this purpose, so the `@citekey` page title is the LaTeX key with the `@` removed and needs no translation. Graphs that do not follow that convention are out of scope; see future work. + +## Scope + +**In scope.** Right-click a discourse-node page reference in a Roam outline, get a menu, copy that node as a LaTeX sentence with its citation. + +**Out of scope, each a real follow-on:** the node-page button (UX2), the Overleaf integration (UX3), "Send to…", LLM rewording, provenance comments, BibTeX export, and multi-node selection. + +## Where it lives + +A standalone `roam/js` prototype at `copy-for-latex/extension.js`, following the existing `roam-inbox` and `roam-feedback` prototypes in this repository. It runs live in a graph with no build or deploy step, so it can be iterated in `sandbox-dg` and put in front of a pilot user the same day. + +The cost is that it cannot import the plugin's internals, so it re-implements two small pieces: + +1. A `MutationObserver` over `span.rm-page-ref`. +2. Format-to-regex parsing, reading node formats from the `discourse-graph/nodes/*` pages. + +Both are throwaway. If the gesture proves out, porting into the plugin is mechanical, because the equivalent utilities already exist there. + +## Component 1 — the payload builder + +One pure function. Everything else is plumbing. + +``` +nodeTitleToLatex(title: string, format: string) -> { latex: string, warning?: string } +``` + +It parses the title against the node's format, extracts `{content}` and `{Source}`, converts Roam markup to LaTeX, and assembles the sentence. + +### Assembly rule + +``` +{converted content}\autocite{key}. +``` + +No space before `\autocite`. The period sits outside the closing brace so biblatex can shift punctuation. Any period already ending the node content is stripped first, so `..` can never occur. + +When the source slot does not parse as a citekey, emit `{converted content}.` and return a warning naming the node. + +### Markup conversion + +This is the substance of the function and nearly all of the risk. Node content is Roam markup. Pasting it raw into LaTeX corrupts the document silently rather than failing loudly: a node reading "50% of cells showed..." becomes a LaTeX comment at the `%`, and the remainder of the sentence disappears from the compiled PDF with no error. + +| Roam input | LaTeX output | Note | +|---|---|---| +| `% & _ # $ { } ~ ^ \` in prose | escaped | Must not escape inside math. This is the highest-risk rule. | +| `$$x$$` | `$x$` | Roam uses `$$` for inline math; LaTeX `$$` is display math. Node titles do carry math in practice. | +| `**bold**` | `\textbf{bold}` | | +| `__italics__` | `\textit{italics}` | Roam italics is double underscore, not asterisks. | +| `^^highlight^^` | `highlight` | Markers dropped; no highlight package can be assumed. | +| `[[Page Name]]` | `Page Name` | Brackets unwrapped, inner text kept. | +| `#tag` | removed | Including `#.class` styling tags. | +| `((block-uid))` | the referenced block's text | Resolved, then converted by the same rules. | + +### Citekey extraction + +The `{Source}` capture is expected to look like `@vasan2020mechanical` or `[[@vasan2020mechanical]]`. Strip brackets and the leading `@` to get the key. If what remains contains characters illegal in a BibTeX key, or the slot is empty, treat it as having no citekey and take the warning path. An internal analysis page such as `@analysis/measure how radially isotropic membrane curvature is at membrane budding sites` must take the warning path, not be emitted as a citation. + +## Component 2 — the reference observer + +A `MutationObserver` over `span.rm-page-ref` elements. For each one, read the page title from `data-tag` or the parent's `data-link-title`, and test it against the graph's node formats. Mark matches with a data attribute so they can be styled and so the menu handler can identify them cheaply. + +Two conditions from the plugin's own observer are worth copying: skip a span whose parent is itself inside a `.rm-page-ref`, and skip spans inside `.rm-title-display`, so the menu does not attach to page titles. + +Results should be cached by title, since a long outline re-renders these spans frequently. + +## Component 3 — the menu + +On `contextmenu` over a marked reference: call `preventDefault`, then render a Blueprint menu at the cursor position. + +``` +Copy for LaTeX +Copy as hyperlink +───────────────── +Jump to page +Open in sidebar +``` + +References that are not discourse nodes keep Roam's native menu untouched, so nothing changes anywhere else in the graph. + +- **Copy for LaTeX** writes `nodeTitleToLatex` output to the clipboard, and raises a toast on the warning path. +- **Copy as hyperlink** writes `[node title](roam-url)` for pasting into Slack, Linear, or a document. +- **Jump to page** and **Open in sidebar** replicate the native items through `roamAlphaAPI.ui.mainWindow.openPage` and `roamAlphaAPI.ui.rightSidebar.addWindow`. + +Known loss: on a discourse-node reference the remaining native items, notably "Delete reference", are no longer reachable by right-click. They stay available from the bullet's own context menu. + +The menu must close on outside click and on Escape, and must position itself within the viewport when the reference sits near a window edge. + +## Testing + +**Unit tests** on `nodeTitleToLatex`, which is pure and needs no Roam runtime. Following the `roam-inbox/test` pattern. One case per markup-conversion rule, plus: + +- Each node type in use: Evidence, Result, Question, Claim. +- The no-citekey path, for an empty source slot and for an `@analysis/...` source. +- Content already ending in a period. +- The `50% of cells` case specifically, since it is the silent-corruption example. +- Content containing math alongside prose that needs escaping, confirming escaping stops at the math delimiters. + +**Manual verification** in `sandbox-dg` against real nodes, then one end-to-end check that a copied sentence compiles in the `Story/PINN` Overleaf project and renders the citation correctly. + +## Future work + +Recorded so the sequence is visible, not because any of it is planned now. + +1. Dedicated node-specific affordances on the reference, beyond a context menu. +2. Multi-node copy over a selection, using `msContextMenu` and `individualMultiselect.getSelectedUids()`. This is the natural fit for copying a whole Results section, and both APIs are already supported. +3. The node-page button and "Send to…", which connects to the existing Overleaf push bridge in `synched-manuscript-template/bridge/OVERLEAF-PUSH.md`. +4. LLM rewording, which merges several nodes into one sentence with a combined `\autocite{a,b}`. This is what the original mockup actually shows. +5. Provenance, revisiting decision 2. This is the citation-back half of the knowledge package manager and the answer to "what would it mean to make discourse graph data explicit in a LaTeX document". +6. Per-node-type citation rules, revisiting decision 4, so that a Result is understood to need no citation rather than being reported as a missing one. +7. Carrying the BibTeX entry along, which removes the wired-Zotero prerequisite. +8. A more accommodating citekey translator, for users whose citekeys are not written in Better BibTeX style. Nothing to build until such a user appears, since the convention is deliberate in the graphs this prototype targets. diff --git a/prototypes/copy-for-latex/package.json b/prototypes/copy-for-latex/package.json new file mode 100644 index 0000000..ffa13e7 --- /dev/null +++ b/prototypes/copy-for-latex/package.json @@ -0,0 +1,21 @@ +{ + "name": "copy-for-latex", + "version": "0.0.4", + "private": true, + "description": "Copy a discourse node as LaTeX prose with its citation, or as a hyperlink.", + "type": "module", + "scripts": { + "dev": "roam-prototype dev", + "build": "roam-prototype build", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "roamjs-components": "catalog:", + "use-sync-external-store": "catalog:" + }, + "devDependencies": { + "@discoursegraphs/extension-base": "workspace:*", + "jsdom": "catalog:", + "vitest": "catalog:" + } +} diff --git a/prototypes/copy-for-latex/src/contextMenu.ts b/prototypes/copy-for-latex/src/contextMenu.ts new file mode 100644 index 0000000..8e0d4aa --- /dev/null +++ b/prototypes/copy-for-latex/src/contextMenu.ts @@ -0,0 +1,41 @@ +/* The right-click decision: is this a discourse node, and if so which one. */ +import { findNodeTypeForTitle } from "~/graph"; +import { MARK_ATTR, TITLE_SELECTOR, titleForTarget } from "~/dom"; +import { closeMenu, openMenu, shouldCloseOnPointer } from "~/menu"; + +export const handleContextMenu = (e: MouseEvent): void => { + closeMenu(); + const target = e.target as Element | null; + const el = target?.closest?.(`[${MARK_ATTR}], ${TITLE_SELECTOR}`); + if (!el) return; + const isTitle = !!el.matches?.(TITLE_SELECTOR); + const title = titleForTarget(el); + /* Ask again rather than trusting the mark. A heading's parked answer can be + * a page behind, and Roam reuses the element across navigations, so whether + * this is a discourse node has to be settled for the title actually in + * hand. When it is not one, fall through to the native menu instead of + * swallowing the click. */ + if (!title || !findNodeTypeForTitle(title)) return; + e.preventDefault(); + e.stopPropagation(); + openMenu(title, e.clientX, e.clientY, { + /* On a page's own heading in the main window, "Jump to page" would + * navigate to where the user already is. In the right sidebar it still + * goes somewhere, so only the main window drops it. */ + omitJumpToPage: isTitle && !!el.closest?.(".roam-article"), + }); +}; + +export const handleKeydown = (e: KeyboardEvent): void => { + if (e.key === "Escape") closeMenu(); +}; + +/* Capture phase, and mousedown as well as click. + * + * Reported from real use: with the menu open, clicking into another block + * left it hanging over the outline. Capture fires before any handler further + * down can stop propagation, and mousedown fires before click, so this closes + * in a strict superset of the cases a bubble-phase click listener would. */ +export const handlePointer = (e: MouseEvent): void => { + if (shouldCloseOnPointer(e.target as Node | null)) closeMenu(); +}; diff --git a/prototypes/copy-for-latex/src/dom.ts b/prototypes/copy-for-latex/src/dom.ts new file mode 100644 index 0000000..051c106 --- /dev/null +++ b/prototypes/copy-for-latex/src/dom.ts @@ -0,0 +1,133 @@ +/* Finding discourse nodes on screen, and keeping that answer current. + * + * Two surfaces carry a node: a reference inside a block's text, and the + * heading of the node's own page. They need different handling. + * + * A reference is cheap. Roam puts the title on the element itself, so it can + * be read synchronously during the contextmenu event. + * + * A heading is not. Its title has to come from the page uid, and every read + * here is async, but preventDefault has to be called synchronously or the + * native menu wins. So headings are resolved ahead of time by the observer + * and the answer is parked on the element. The synchronous reconstruction + * below covers the gap before the observer catches up. + */ +import { cachedTypeForTitle, titleForUid } from "~/graph"; + +export const MARK_ATTR = "data-cfl-node"; +export const TITLE_ATTR = "data-cfl-title"; +/* Which page the parked answer was computed for. Roam reuses the heading + * element across navigations, so without this the menu would confidently + * answer for the page you were on before. */ +export const PAGE_UID_ATTR = "data-cfl-page-uid"; + +export const REF_SELECTOR = "span.rm-page-ref"; +export const TITLE_SELECTOR = "h1.rm-title-display"; +const TITLE_CONTAINER = ".rm-title-display-container"; + +export const titleForRef = (span: Element): string => + span.getAttribute("data-tag") || + span.parentElement?.getAttribute("data-link-title") || + ""; + +/* Rebuild a page title from its rendered heading. + * + * A discourse-node title contains page references ([[EVD]], [[@source]]) and + * Roam draws their brackets as separate .rm-page-ref__brackets elements, so + * textContent yields "EVD - content - @source", which no node format matches. + * + * roamjs-components ships elToTitle for this, but its only caller inspects + * the heading's FIRST child node, truncating the title to "[[EVD]]". This + * walks every child. */ +export const elToTitle = (n: Node | null | undefined): string => { + if (!n) return ""; + if (n.nodeType === 3) return n.nodeValue || ""; + if (n.nodeType !== 1) return ""; + const el = n as Element; + if (el.classList?.contains("rm-page-ref__brackets")) return ""; + const inner = Array.from(el.childNodes || []) + .map(elToTitle) + .join(""); + return el.classList?.contains("rm-page-ref") ? `[[${inner}]]` : inner; +}; + +export const titleFromHeadingDom = (h1: Element): string => + Array.from(h1.childNodes || []) + .map(elToTitle) + .join("") + .trim(); + +export const pageUidForHeading = (h1: Element): string => + h1.closest?.(TITLE_CONTAINER)?.getAttribute("data-page-uid") || ""; + +export const markRef = (span: Element): void => { + if (span.hasAttribute(MARK_ATTR)) return; + // A reference nested inside another one would otherwise answer for the + // inner page while the user was aiming at the outer. + if (span.parentElement?.closest(REF_SELECTOR)) return; + // The [[EVD]] inside a node's own heading is not the node. The heading is + // handled as a whole, by markHeading. + if (span.closest(`${TITLE_SELECTOR}, ${TITLE_CONTAINER}`)) return; + const title = titleForRef(span); + if (!title) return; + const type = cachedTypeForTitle(title); + if (type) span.setAttribute(MARK_ATTR, type); +}; + +/* Fire-and-forget from the observer, so a rejection here would surface only + * as an unhandled promise rejection with no attribution. Logged instead. */ +export const markHeadingSafely = (h1: Element): void => { + void markHeading(h1).catch((error) => { + console.error("copy-for-latex: could not resolve a page heading:", error); + }); +}; + +export const markHeading = async (h1: Element): Promise => { + const uid = pageUidForHeading(h1); + if (!uid) return; + // Claimed before the await so a burst of mutations for one heading does + // not fan out into a burst of identical queries. + if (h1.getAttribute(PAGE_UID_ATTR) === uid) return; + h1.setAttribute(PAGE_UID_ATTR, uid); + const title = await titleForUid(uid); + if (pageUidForHeading(h1) !== uid) return; // navigated away mid-flight + const type = title ? cachedTypeForTitle(title) : ""; + if (type) { + h1.setAttribute(TITLE_ATTR, title); + h1.setAttribute(MARK_ATTR, type); + } else { + h1.removeAttribute(TITLE_ATTR); + h1.removeAttribute(MARK_ATTR); + } +}; + +/* The title for whatever was right-clicked, synchronously. */ +export const titleForTarget = (el: Element): string => { + if (!el.matches?.(TITLE_SELECTOR)) return titleForRef(el); + return el.getAttribute(TITLE_ATTR) || titleFromHeadingDom(el); +}; + +export const scan = (root: ParentNode): void => { + root.querySelectorAll?.(REF_SELECTOR).forEach(markRef); + root.querySelectorAll?.(TITLE_SELECTOR).forEach(markHeadingSafely); +}; + +export const startObserver = (): MutationObserver => { + const observer = new MutationObserver((records) => { + for (const record of records) { + // Roam rewrites a heading's children in place when you navigate, which + // adds no new heading element for the addedNodes branch below to catch. + const heading = (record.target as Element)?.closest?.(TITLE_SELECTOR); + if (heading) markHeadingSafely(heading); + record.addedNodes.forEach((n) => { + if (n.nodeType !== 1) return; + const el = n as Element; + if (el.matches?.(REF_SELECTOR)) markRef(el); + else scan(el); + }); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + scan(document); + return observer; +}; diff --git a/prototypes/copy-for-latex/src/graph.ts b/prototypes/copy-for-latex/src/graph.ts new file mode 100644 index 0000000..3236b78 --- /dev/null +++ b/prototypes/copy-for-latex/src/graph.ts @@ -0,0 +1,149 @@ +/* Every read of the graph. + * + * All of it is async, because this repository forbids the legacy synchronous + * `roamAlphaAPI.q` alias. Datalog inputs are parameterized through `:in` + * rather than interpolated, which is also a repository rule and which removes + * a real hazard: node titles in these graphs contain LaTeX (`$$\frac{a}{b}$$`), + * and an unescaped backslash is an invalid Clojure string escape that throws. + */ +import { formatToRegex } from "~/nodeFormat"; + +export type DiscourseNodeType = { type: string; format: string; regex: RegExp }; + +const NODES_PAGE_PREFIX = "discourse-graph/nodes/"; + +/* Pull results come back with namespaced keys (":node/title") from some API + * surfaces and bare ones ("title") from others. Tolerate both rather than + * betting on one: guessing wrong yields zero node types, which makes the + * whole feature do nothing at all, silently and with no error to follow. */ +export const pick = (obj: unknown, attr: string): T | undefined => { + if (!obj || typeof obj !== "object") return undefined; + const rec = obj as Record; + const bare = attr.slice(attr.indexOf("/") + 1); + return rec[`:${attr}`] ?? rec[attr] ?? rec[bare]; +}; + +const q = (query: string, ...params: unknown[]): Promise => + window.roamAlphaAPI.data.async.q(query, ...params); + +let nodeTypes: DiscourseNodeType[] = []; + +export const getNodeTypes = (): DiscourseNodeType[] => nodeTypes; + +/* Exposed for tests, and for the reload path: the title cache below is only + * valid for one set of node types. */ +export const setNodeTypes = (types: { type: string; format: string }[]): void => { + titleCache.clear(); + nodeTypes = types.map((n) => ({ ...n, regex: formatToRegex(n.format).regex })); +}; + +export const loadNodeTypes = async (): Promise => { + titleCache.clear(); + const rows = await q( + `[:find (pull ?p [:node/title + {:block/children [:block/string + {:block/children [:block/string]}]}]) + :in $ ?prefix + :where [?p :node/title ?t] + [(clojure.string/starts-with? ?t ?prefix)]]`, + NODES_PAGE_PREFIX, + ); + nodeTypes = (rows || []) + .map(([page]) => { + const kids = pick(page, "block/children") || []; + const formatBlock = kids.find((c) => + /^format$/i.test((pick(c, "block/string") || "").trim()), + ); + const format = ( + pick( + (pick(formatBlock, "block/children") || [])[0], + "block/string", + ) || "" + ).trim(); + return { + type: (pick(page, "node/title") || "").slice(NODES_PAGE_PREFIX.length), + format, + }; + }) + .filter((n) => n.format) + .map((n) => { + /* Per node type, not around the whole batch. One malformed Format is a + * typo on one config page; letting it throw here would abort the map + * and leave the extension with zero node types and no menu anywhere. */ + try { + return { ...n, regex: formatToRegex(n.format).regex }; + } catch (e) { + console.warn( + `copy-for-latex: skipping node type "${n.type}" — its Format is not a valid pattern:`, + n.format, + ); + return null; + } + }) + .filter((n): n is DiscourseNodeType => n !== null); + console.log(`copy-for-latex: loaded ${nodeTypes.length} discourse node type(s).`); + if (nodeTypes.length === 0) { + console.warn( + `copy-for-latex: found zero discourse node types under "${NODES_PAGE_PREFIX}" — ` + + "is the Discourse Graph plugin configured, and are its config pages named with that prefix?", + ); + } + return nodeTypes; +}; + +export const findNodeTypeForTitle = (title: string): DiscourseNodeType | null => + nodeTypes.find((n) => n.regex.test(title || "")) || null; + +/* Keyed by title; only valid while nodeTypes is unchanged. */ +const titleCache = new Map(); + +export const cachedTypeForTitle = (title: string): string => { + if (!titleCache.has(title)) { + titleCache.set(title, findNodeTypeForTitle(title)?.type || ""); + } + return titleCache.get(title) as string; +}; + +export const uidForTitle = async (title: string): Promise => + pick( + ( + await q( + `[:find (pull ?p [:block/uid]) :in $ ?t :where [?p :node/title ?t]]`, + title, + ) + )?.[0]?.[0], + "block/uid", + ) || ""; + +export const titleForUid = async (uid: string): Promise => + pick( + ( + await q( + `[:find (pull ?p [:node/title]) :in $ ?u :where [?p :block/uid ?u]]`, + uid, + ) + )?.[0]?.[0], + "node/title", + ) || ""; + +const BLOCK_REF = /\(\(([\w\d_-]{9,})\)\)/g; + +/* One query for every reference in the string, not one per reference. */ +export const resolveBlockRefs = async (text: string): Promise => { + const uids = [...(text || "").matchAll(BLOCK_REF)].map((m) => m[1] as string); + if (!uids.length) return text || ""; + const rows = await q( + `[:find ?u ?s :in $ [?u ...] :where [?b :block/uid ?u] [?b :block/string ?s]]`, + uids, + ); + const byUid = new Map(rows.map(([u, s]) => [u as string, s as string])); + return (text || "").replace(BLOCK_REF, (whole, uid: string) => byUid.get(uid) ?? whole); +}; + +export const graphName = (): string => + (window.location.href.match(/\/app\/([^/#?]+)/) || [])[1] || ""; + +export const roamUrlForTitle = async (title: string): Promise => { + const uid = await uidForTitle(title); + return uid ? `https://roamresearch.com/#/app/${graphName()}/page/${uid}` : ""; +}; diff --git a/prototypes/copy-for-latex/src/index.ts b/prototypes/copy-for-latex/src/index.ts new file mode 100644 index 0000000..8aece3e --- /dev/null +++ b/prototypes/copy-for-latex/src/index.ts @@ -0,0 +1,108 @@ +/* copy-for-latex — copy a discourse node as a LaTeX sentence with its citation. + * + * Spec: SPEC.md + */ +import { render as renderToast } from "roamjs-components/components/Toast"; +import { runExtension } from "roamjs-components/util"; +import { loadNodeTypes } from "~/graph"; +import { startObserver } from "~/dom"; +import { handleContextMenu, handleKeydown, handlePointer } from "~/contextMenu"; +import { closeMenu } from "~/menu"; +import { MENU_CSS } from "~/styles"; + +/* Inject the menu's stylesheet. + * + * Deliberately not roamjs-components' addStyle, which is a default export. + * This repository builds with esbuild in ESM format, and its __toESM helper + * runs in Node-interop mode: a default import of a CommonJS module resolves + * to the whole module object, so `addStyle` arrives as `{ default: fn }` and + * calling it throws "is not a function". roamjs-components is CommonJS, so + * every default import from it has this shape. Named imports are unaffected, + * which is why the template's `{ render }` and `{ runExtension }` work. + * + * Six lines is cheaper than depending on that interop staying as it is. */ +const injectStyle = (css: string): HTMLStyleElement => { + const el = document.createElement("style"); + el.id = "copy-for-latex-style"; + el.textContent = css; + document.head.appendChild(el); + return el; +}; + +/* What this extension needs from Roam, checked before anything else so a + * missing capability reports itself by name instead of as a TypeError deep in + * a helper. */ +const missingCapability = (): string => { + const api = window.roamAlphaAPI as unknown as Record | undefined; + if (!api) return "window.roamAlphaAPI is not available"; + const data = api.data as { async?: { q?: unknown } } | undefined; + if (typeof data?.async?.q !== "function") + return "window.roamAlphaAPI.data.async.q is not available in this Roam build"; + return ""; +}; + +/* Report a load failure loudly, and never rethrow. + * + * runExtension's own failure path cannot be relied on. In production it does + * not log the error at all: it posts the message to SamePage and shows a + * generic "Failed to load" toast. Worse, it reads + * `args.extensionAPI.settings.getAll()` while doing so, and `extensionAPI` is + * undefined whenever the module is loaded by `import()` from a roam/js block + * rather than by Roam itself. The reporter then throws its own TypeError on + * top of ours, and the original error is lost entirely. That is exactly how + * this presented in real use: an unexplained "Cannot read properties of + * undefined (reading 'settings')" and no trace of the actual cause. + * + * So the error is caught here, where the message still exists. */ +const reportLoadFailure = (error: unknown): void => { + const message = error instanceof Error ? error.message : String(error); + // Console first, and unconditionally. The toast depends on Blueprint and a + // lazily-loaded Roam global, so it can throw on its own; if it did so from + // here the original error would be lost again, which is the exact failure + // this function exists to prevent. + console.error("copy-for-latex failed to load:", error); + try { + renderToast({ + id: "copy-for-latex-load-failure", + content: `Copy for LaTeX failed to load: ${message}`, + intent: "danger", + timeout: 0, + }); + } catch (toastError) { + console.error("copy-for-latex: the failure toast also failed:", toastError); + } +}; + +export default runExtension(async () => { + try { + const missing = missingCapability(); + if (missing) throw new Error(missing); + + /* Injected here rather than left to a published extension.css, which Roam + * only injects on the URL-loading path. See src/styles.ts. */ + const style = injectStyle(MENU_CSS); + + await loadNodeTypes(); + const observer = startObserver(); + + document.addEventListener("contextmenu", handleContextMenu, true); + document.addEventListener("mousedown", handlePointer, true); + document.addEventListener("click", handlePointer, true); + document.addEventListener("keydown", handleKeydown); + + return { + unload: () => { + document.removeEventListener("contextmenu", handleContextMenu, true); + document.removeEventListener("mousedown", handlePointer, true); + document.removeEventListener("click", handlePointer, true); + document.removeEventListener("keydown", handleKeydown); + observer.disconnect(); + closeMenu(); + style.remove(); + }, + }; + } catch (error) { + reportLoadFailure(error); + return {}; + } +}); diff --git a/prototypes/copy-for-latex/src/latex.ts b/prototypes/copy-for-latex/src/latex.ts new file mode 100644 index 0000000..cc9b150 --- /dev/null +++ b/prototypes/copy-for-latex/src/latex.ts @@ -0,0 +1,109 @@ +/* Roam markup to LaTeX. Pure: nothing here touches the graph or the DOM. + * + * The governing constraint for every function in this file is that nothing + * emitted may fail to compile. That is why escaping is exhaustive rather than + * minimal, and why an unusable citekey degrades to prose plus a warning + * instead of an empty or malformed \autocite. + */ + +const LATEX_ESCAPES: Record = { + "\\": "\\textbackslash{}", + "&": "\\&", + "%": "\\%", + $: "\\$", + "#": "\\#", + _: "\\_", + "{": "\\{", + "}": "\\}", + "~": "\\textasciitilde{}", + "^": "\\textasciicircum{}", +}; + +export const escapeLatex = (s: string): string => + s ? s.replace(/[\\&%$#_{}~^]/g, (c) => LATEX_ESCAPES[c] as string) : ""; + +/* One pass over every delimiter type: math, tags, links, spans. + * + * A single alternation rather than sequential passes, because slicing on + * `**` or `__` first would cut `^^…^^`, `[[…]]`, and `#[[…]]` pairs in half. + * Recursing on captured inner text is what makes nesting work. */ +export const roamToLatex = (input: string): string => { + if (!input) return ""; + const out: string[] = []; + const re = + /\$\$([\s\S]+?)\$\$|#\[\[([^\]]*)\]\]|#([\w.-]+)|\[\[([^\]]*)\]\]|\^\^([\s\S]*?)\^\^|\*\*([\s\S]+?)\*\*|__([\s\S]+?)__/g; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(input))) { + out.push(escapeLatex(input.slice(last, m.index))); + if (m[1] !== undefined) { + /* Per character, not /(^|[^\\])%/g: that pattern consumes the character + * it is guarding with, so the second % of a `%%` pair escapes nothing. */ + out.push( + `$${m[1].replace(/%/g, (c, i: number, str: string) => + str[i - 1] === "\\" ? c : "\\%", + )}$`, + ); + } else if (m[2] !== undefined) { + // bracketed tag — removed entirely + } else if (m[3] !== undefined) { + // bare tag — removed entirely + } else if (m[4] !== undefined) { + out.push(roamToLatex(m[4])); + } else if (m[5] !== undefined) { + out.push(roamToLatex(m[5])); + } else if (m[6] !== undefined) { + out.push(`\\textbf{${roamToLatex(m[6])}}`); + } else if (m[7] !== undefined) { + out.push(`\\textit{${roamToLatex(m[7])}}`); + } + last = re.lastIndex; + } + out.push(escapeLatex(input.slice(last))); + return out.join(""); +}; + +/* A usable citekey has no whitespace and no slash. That rejects the internal + * `@analysis/...` source pages, which is the point: they are real Roam pages + * but not bibliography entries. */ +const CITEKEY = /^[A-Za-z0-9][A-Za-z0-9_:.+-]*$/; + +export const toCitekey = (source: string): string => { + const bare = (source || "") + .trim() + .replace(/^\[\[/, "") + .replace(/\]\]$/, "") + .replace(/^@/, "") + .trim(); + return CITEKEY.test(bare) ? bare : ""; +}; + +export type LatexPayload = { latex: string; warning?: string }; + +export const assembleLatex = ( + content: string, + source: string, + title: string, +): LatexPayload => { + // /\.+\s*$/ not /\.\s*$/: content ending in an ellipsis would otherwise + // keep two of its three dots and read as a typo before the citation. + const body = roamToLatex(content).replace(/\.+\s*$/, ""); + // Content already ending in terminal punctuation gets no added period — + // "Why does it pinch?." reads as a mistake, not a sentence. + const period = /[?!]$/.test(body) ? "" : "."; + const key = toCitekey(source); + /* A space before \autocite. Parenthetical and inline biblatex styles want + * one ("...beyond Roam [12]."); footnote and superscript styles arguably do + * not, since the marker should hug the word. If the manuscript's style is + * footnote-based, this is the line to change. */ + if (key) return { latex: `${body} \\autocite{${key}}${period}` }; + return { + latex: `${body}${period}`, + warning: `No citekey on "${title}" — copied without a citation.`, + }; +}; + +/* A markdown link label cannot contain `[[…]]`: the brackets terminate the + * label early and the rest of the title leaks out beside a broken link. */ +export const unwrapLinks = (s: string): string => + (s || "").replace(/\[\[([^\]]*)\]\]/g, "$1"); diff --git a/prototypes/copy-for-latex/src/menu.ts b/prototypes/copy-for-latex/src/menu.ts new file mode 100644 index 0000000..8fed6c3 --- /dev/null +++ b/prototypes/copy-for-latex/src/menu.ts @@ -0,0 +1,160 @@ +/* The menu itself: clipboard writes and the popup. + * + * The popup is plain DOM with prototype-scoped classes rather than Roam's + * Blueprint menu classes. Borrowing bp3-* would track Roam's Blueprint + * version for styling this owns, and the repository asks prototypes not to + * depend on host implementation classes. Appearance is matched in styles.css. + */ +import { render as renderToast } from "roamjs-components/components/Toast"; +import { latexForTitle, labelForTitle } from "~/payload"; +import { roamUrlForTitle, uidForTitle } from "~/graph"; + +export const ROOT_CLASS = "roam-prototype-copy-for-latex"; +export const CLIPBOARD_FAIL_MESSAGE = + "Could not write to the clipboard — nothing was copied."; + +let toastSeq = 0; +export const toast = (message: string): void => { + renderToast({ + id: `copy-for-latex-${(toastSeq += 1)}`, + content: message, + timeout: 6000, + }); +}; + +/* execCommand is deprecated but still the only path that works when the + * document is not focused, which is exactly the case right after a + * contextmenu in some browsers. */ +const copyViaTextarea = (text: string): boolean => { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.cssText = "position:fixed;opacity:0"; + document.body.appendChild(ta); + try { + ta.select(); + return document.execCommand("copy") === true; + } catch (e) { + return false; + } finally { + ta.remove(); + } +}; + +export const copyText = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (e) { + return copyViaTextarea(text); + } +}; + +type MenuItem = { label: string; run: () => Promise } | { divider: true }; + +let openMenuEl: HTMLElement | null = null; + +export const closeMenu = (): void => { + openMenuEl?.remove(); + openMenuEl = null; +}; + +export const getOpenMenu = (): HTMLElement | null => openMenuEl; + +export const menuItemsFor = ( + title: string, + opts: { omitJumpToPage?: boolean } = {}, +): MenuItem[] => + ( + [ + { + label: "Copy for LaTeX", + run: async () => { + const { latex, warning } = await latexForTitle(title); + if (!latex) { + // Nothing usable to copy — leave the clipboard untouched rather + // than silently overwriting it with an empty string. + if (warning) toast(warning); + return; + } + const copied = await copyText(latex); + if (!copied) toast(CLIPBOARD_FAIL_MESSAGE); + else if (warning) toast(warning); + }, + }, + { + label: "Copy as hyperlink", + run: async () => { + const url = await roamUrlForTitle(title); + const label = labelForTitle(title); + const copied = await copyText(url ? `[${label}](${url})` : label); + if (!copied) toast(CLIPBOARD_FAIL_MESSAGE); + }, + }, + { divider: true as const }, + { + label: "Jump to page", + run: async () => { + const uid = await uidForTitle(title); + // The page can vanish between the right-click and the click. + if (uid) await window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + }, + }, + { + label: "Open in sidebar", + run: async () => { + const uid = await uidForTitle(title); + if (uid) + await window.roamAlphaAPI.ui.rightSidebar.addWindow({ + /* The typings declare "page-uid" for an outline window, but the + * runtime wants "block-uid" — verified live, and the Discourse + * Graph plugin suppresses the same error in seven places. Left + * as ts-expect-error rather than a cast so this starts failing + * the day roamjs-components corrects the type. */ + // @ts-expect-error stale roamjs-components typing + window: { type: "outline", "block-uid": uid }, + }); + }, + }, + ] as MenuItem[] + ).filter((item) => !(opts.omitJumpToPage && "label" in item && item.label === "Jump to page")); + +export const openMenu = ( + title: string, + x: number, + y: number, + opts: { omitJumpToPage?: boolean } = {}, +): HTMLElement => { + closeMenu(); + const menu = document.createElement("ul"); + menu.className = `${ROOT_CLASS} cfl-menu`; + menuItemsFor(title, opts).forEach((item) => { + const li = document.createElement("li"); + if ("divider" in item) { + li.className = "cfl-menu-divider"; + } else { + const a = document.createElement("a"); + a.className = "cfl-menu-item"; + a.textContent = item.label; + a.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + closeMenu(); + void item.run(); + }); + li.appendChild(a); + } + menu.appendChild(li); + }); + + document.body.appendChild(menu); + const rect = menu.getBoundingClientRect(); + menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`; + menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`; + openMenuEl = menu; + return menu; +}; + +/* A press on a menu item must not dismiss the menu, because that item's own + * handler still has to run. */ +export const shouldCloseOnPointer = (target: Node | null): boolean => + !(openMenuEl && target && openMenuEl.contains(target)); diff --git a/prototypes/copy-for-latex/src/nodeFormat.ts b/prototypes/copy-for-latex/src/nodeFormat.ts new file mode 100644 index 0000000..b2d797c --- /dev/null +++ b/prototypes/copy-for-latex/src/nodeFormat.ts @@ -0,0 +1,37 @@ +/* Discourse-node title grammar. Pure. + * + * A node type declares a format such as `[[EVD]] - {content} - {Source}` on + * its `discourse-graph/nodes/{Type}` page. These two functions turn that into + * a matcher and pull the pieces back out. + */ + +export type NodeFormat = { regex: RegExp; names: string[] }; + +/* Mirrors the plugin's getDiscourseNodeFormatExpression: escape only these + * five characters, lazy captures, anchored, dotall. Agreeing with the plugin + * about where content ends matters more than being cleverer than it. */ +export const formatToRegex = (format: string): NodeFormat => { + const names: string[] = []; + const placeholder = /\{([a-zA-Z]+)\}/g; + let m: RegExpExecArray | null; + while ((m = placeholder.exec(format))) names.push((m[1] as string).toLowerCase()); + const source = format + .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") + .replace(/\{[a-zA-Z]+\}/g, "(.*?)"); + return { regex: new RegExp(`^${source}$`, "s"), names }; +}; + +export type ParsedNode = { content: string; source: string }; + +export const parseNodeTitle = (title: string, format: string): ParsedNode => { + const empty: ParsedNode = { content: "", source: "" }; + if (!format) return empty; + const { regex, names } = formatToRegex(format); + const match = regex.exec(title || ""); + if (!match) return empty; + const at = (name: string) => { + const i = names.indexOf(name); + return i < 0 ? "" : (match[i + 1] || "").trim(); + }; + return { content: at("content"), source: at("source") }; +}; diff --git a/prototypes/copy-for-latex/src/payload.ts b/prototypes/copy-for-latex/src/payload.ts new file mode 100644 index 0000000..e081fb8 --- /dev/null +++ b/prototypes/copy-for-latex/src/payload.ts @@ -0,0 +1,23 @@ +/* What lands on the clipboard, for each of the two copy items. */ +import { assembleLatex, unwrapLinks, type LatexPayload } from "~/latex"; +import { parseNodeTitle } from "~/nodeFormat"; +import { findNodeTypeForTitle, resolveBlockRefs } from "~/graph"; + +export const latexForTitle = async (title: string): Promise => { + const nodeType = findNodeTypeForTitle(title); + if (!nodeType) return { latex: "", warning: `"${title}" is not a discourse node.` }; + const resolved = await resolveBlockRefs(title); + const { content, source } = parseNodeTitle(resolved, nodeType.format); + return assembleLatex(content, source, title); +}; + +/* Built from the node's parsed parts rather than its raw title, so the type + * marker is dropped and the citekey arrives unbracketed. */ +export const labelForTitle = (title: string): string => { + const nodeType = findNodeTypeForTitle(title); + if (!nodeType) return unwrapLinks(title); + const { content, source } = parseNodeTitle(title, nodeType.format); + const label = unwrapLinks(content); + const src = unwrapLinks(source); + return src ? `${label} - ${src}` : label; +}; diff --git a/prototypes/copy-for-latex/src/styles.ts b/prototypes/copy-for-latex/src/styles.ts new file mode 100644 index 0000000..7af7683 --- /dev/null +++ b/prototypes/copy-for-latex/src/styles.ts @@ -0,0 +1,67 @@ +/* The menu's stylesheet, carried in the bundle rather than shipped beside it. + * + * Roam injects a published `extension.css` when it loads an extension from a + * URL, but nothing injects it when the module is pulled in by `import()` from + * a `roam/js` block — which is how previews get tested. The menu is + * `position: fixed`, so without these rules it renders as a static, unstyled + * list at the end of : present, clickable, and invisible in practice. + * + * Keeping the rules here means the extension looks and behaves the same on + * both paths. `addStyle` returns the element so unload can remove it. + */ +export const MENU_CSS = ` +.roam-prototype-copy-for-latex.cfl-menu { + position: fixed; + z-index: 100; + min-width: 180px; + margin: 0; + padding: 5px; + list-style: none; + border-radius: 3px; + background: #fff; + color: #182026; + font-size: 14px; + line-height: 1.5; + box-shadow: + 0 0 0 1px rgba(16, 22, 26, 0.1), + 0 2px 4px rgba(16, 22, 26, 0.2), + 0 8px 24px rgba(16, 22, 26, 0.2); +} + +.roam-prototype-copy-for-latex .cfl-menu-item { + display: block; + padding: 5px 7px; + border-radius: 2px; + color: inherit; + text-decoration: none; + cursor: pointer; + white-space: nowrap; +} + +.roam-prototype-copy-for-latex .cfl-menu-item:hover { + background: rgba(167, 182, 194, 0.3); + text-decoration: none; +} + +.roam-prototype-copy-for-latex .cfl-menu-divider { + margin: 5px 0; + border-top: 1px solid rgba(16, 22, 26, 0.15); +} + +/* Roam marks dark mode on the body. There is no more semantic hook for it, + * so this one selector does track the host. */ +.bp3-dark .roam-prototype-copy-for-latex.cfl-menu, +body.bp3-dark .roam-prototype-copy-for-latex.cfl-menu { + background: #30404d; + color: #f5f8fa; + box-shadow: + 0 0 0 1px rgba(16, 22, 26, 0.2), + 0 2px 4px rgba(16, 22, 26, 0.4), + 0 8px 24px rgba(16, 22, 26, 0.4); +} + +.bp3-dark .roam-prototype-copy-for-latex .cfl-menu-divider, +body.bp3-dark .roam-prototype-copy-for-latex .cfl-menu-divider { + border-top-color: rgba(255, 255, 255, 0.15); +} +`; diff --git a/prototypes/copy-for-latex/tailwind.config.cjs b/prototypes/copy-for-latex/tailwind.config.cjs new file mode 100644 index 0000000..394b553 --- /dev/null +++ b/prototypes/copy-for-latex/tailwind.config.cjs @@ -0,0 +1,6 @@ +const base = require("../../packages/extension-base/tailwind.config.cjs"); + +module.exports = { + ...base, + content: ["./src/**/*.{js,jsx,ts,tsx}"], +}; diff --git a/prototypes/copy-for-latex/tests/dom.spec.ts b/prototypes/copy-for-latex/tests/dom.spec.ts new file mode 100644 index 0000000..488b5bb --- /dev/null +++ b/prototypes/copy-for-latex/tests/dom.spec.ts @@ -0,0 +1,193 @@ +/* Finding a discourse node on screen. Real jsdom, so closest/matches/classList + * behave the way they do in Roam. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + MARK_ATTR, + PAGE_UID_ATTR, + TITLE_ATTR, + elToTitle, + markHeading, + markRef, + titleForTarget, + titleFromHeadingDom, +} from "~/dom"; +import { setNodeTypes } from "~/graph"; + +const EVD = "[[EVD]] - {content} - {Source}"; +const NODE_TITLE = "[[EVD]] - The tube pinched - [[@vasan2020mechanical]]"; + +const stubTitles = (byUid: Record) => { + const q = vi.fn(async (_query: string, uid: unknown) => { + const title = byUid[uid as string]; + return title ? [[{ ":node/title": title }]] : []; + }); + (window as unknown as Record).roamAlphaAPI = { data: { async: { q } } }; + return q; +}; + +/* How Roam renders "[[EVD]]": the brackets are their own elements. */ +const heading = (inner: string, pageUid?: string) => { + document.body.innerHTML = `

${inner}

`; + return document.querySelector("h1.rm-title-display") as HTMLElement; +}; + +const ref = (label: string) => + `[[${label}]]`; + +const FULL_TITLE_DOM = `${ref("EVD")} - The tube pinched - ${ref("@vasan2020mechanical")}`; + +beforeEach(() => { + setNodeTypes([{ type: "Evidence", format: EVD }]); +}); +afterEach(() => { + setNodeTypes([]); + document.body.innerHTML = ""; +}); + +describe("elToTitle", () => { + /* Roam draws the brackets as separate elements. Read the text naively and + * "[[EVD]] - x - [[@y]]" comes back as "EVD - x - @y", which no node format + * matches, so the menu silently never appears. */ + it("restores brackets that are rendered as their own elements", () => { + const h1 = heading(ref("EVD")); + expect(elToTitle(h1)).toBe("[[EVD]]"); + }); + + /* roamjs-components ships elToTitle, but its only caller inspects the + * heading's FIRST child node, truncating this to "[[EVD]]". */ + it("walks every child, not just the first", () => { + expect(titleFromHeadingDom(heading(FULL_TITLE_DOM))).toBe(NODE_TITLE); + }); + + it("leaves a plain title alone", () => { + expect(titleFromHeadingDom(heading("Just a normal page"))).toBe("Just a normal page"); + }); +}); + +describe("markHeading", () => { + it("parks the title and type from the page uid", async () => { + stubTitles({ "uid-1": NODE_TITLE }); + const h1 = heading(FULL_TITLE_DOM, "uid-1"); + await markHeading(h1); + expect(h1.getAttribute(TITLE_ATTR)).toBe(NODE_TITLE); + expect(h1.getAttribute(MARK_ATTR)).toBe("Evidence"); + }); + + it("marks nothing on a page that is not a discourse node", async () => { + stubTitles({ "uid-1": "Meeting notes" }); + const h1 = heading("Meeting notes", "uid-1"); + await markHeading(h1); + expect(h1.getAttribute(MARK_ATTR)).toBeNull(); + }); + + it("does nothing without a page uid to resolve from", async () => { + const q = stubTitles({}); + await markHeading(heading(FULL_TITLE_DOM)); + expect(q).not.toHaveBeenCalled(); + }); + + /* A burst of mutations for one heading must not fan out into a burst of + * identical queries. */ + it("queries once for a heading, however many times it is asked", async () => { + const q = stubTitles({ "uid-1": NODE_TITLE }); + const h1 = heading(FULL_TITLE_DOM, "uid-1"); + await Promise.all([markHeading(h1), markHeading(h1), markHeading(h1)]); + expect(q).toHaveBeenCalledTimes(1); + }); + + /* Roam reuses the heading element across navigations. Without re-resolving, + * the menu would confidently answer for the page you were on before. */ + it("re-resolves when the same element is reused for another page", async () => { + stubTitles({ "uid-1": NODE_TITLE, "uid-2": "[[EVD]] - A second finding - @key2020" }); + const h1 = heading(FULL_TITLE_DOM, "uid-1"); + await markHeading(h1); + expect(h1.getAttribute(TITLE_ATTR)).toBe(NODE_TITLE); + + (h1.closest(".rm-title-display-container") as HTMLElement).setAttribute( + "data-page-uid", + "uid-2", + ); + await markHeading(h1); + expect(h1.getAttribute(TITLE_ATTR)).toBe("[[EVD]] - A second finding - @key2020"); + expect(h1.getAttribute(PAGE_UID_ATTR)).toBe("uid-2"); + }); + + it("clears a stale mark when the new page is not a node", async () => { + stubTitles({ "uid-1": NODE_TITLE, "uid-2": "Meeting notes" }); + const h1 = heading(FULL_TITLE_DOM, "uid-1"); + await markHeading(h1); + (h1.closest(".rm-title-display-container") as HTMLElement).setAttribute( + "data-page-uid", + "uid-2", + ); + await markHeading(h1); + expect(h1.getAttribute(MARK_ATTR)).toBeNull(); + expect(h1.getAttribute(TITLE_ATTR)).toBeNull(); + }); +}); + +describe("markRef", () => { + const refIn = (html: string) => { + document.body.innerHTML = html; + return document.querySelector("span.rm-page-ref") as HTMLElement; + }; + + it("marks a reference to a discourse node", () => { + const span = refIn(`x`); + markRef(span); + expect(span.getAttribute(MARK_ATTR)).toBe("Evidence"); + }); + + it("leaves an ordinary page reference alone", () => { + const span = refIn(`x`); + markRef(span); + expect(span.getAttribute(MARK_ATTR)).toBeNull(); + }); + + /* A reference nested inside another would answer for the inner page while + * the user was aiming at the outer. */ + it("skips a reference nested inside another reference", () => { + document.body.innerHTML = `x`; + const inner = document.getElementById("inner") as HTMLElement; + markRef(inner); + expect(inner.getAttribute(MARK_ATTR)).toBeNull(); + }); + + /* The [[EVD]] inside a node's own heading is not the node. */ + it("skips a reference inside a page heading, which markHeading owns", () => { + document.body.innerHTML = `

x

`; + const span = document.querySelector("span.rm-page-ref") as HTMLElement; + markRef(span); + expect(span.getAttribute(MARK_ATTR)).toBeNull(); + }); + + it("reads the title from the parent when the reference is bracketed", () => { + document.body.innerHTML = `x`; + const span = document.querySelector("span.rm-page-ref") as HTMLElement; + markRef(span); + expect(span.getAttribute(MARK_ATTR)).toBe("Evidence"); + }); +}); + +describe("titleForTarget", () => { + it("prefers the parked title on a heading", async () => { + stubTitles({ "uid-1": NODE_TITLE }); + const h1 = heading("stale rendered text", "uid-1"); + await markHeading(h1); + expect(titleForTarget(h1)).toBe(NODE_TITLE); + }); + + /* Covers the gap between the heading appearing and the observer resolving it. */ + it("falls back to the DOM before the observer has caught up", () => { + expect(titleForTarget(heading(FULL_TITLE_DOM, "uid-1"))).toBe(NODE_TITLE); + }); + + it("reads a reference from its own attribute", () => { + document.body.innerHTML = `x`; + const span = document.querySelector("span.rm-page-ref") as HTMLElement; + expect(titleForTarget(span)).toBe(NODE_TITLE); + }); +}); diff --git a/prototypes/copy-for-latex/tests/graph.spec.ts b/prototypes/copy-for-latex/tests/graph.spec.ts new file mode 100644 index 0000000..bd75dcb --- /dev/null +++ b/prototypes/copy-for-latex/tests/graph.spec.ts @@ -0,0 +1,217 @@ +/* Graph reads. Every query is stubbed; nothing here talks to Roam. + * + * graph.ts holds module-level node-type state, so each test sets it up + * explicitly rather than relying on order. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cachedTypeForTitle, + findNodeTypeForTitle, + graphName, + loadNodeTypes, + pick, + resolveBlockRefs, + roamUrlForTitle, + setNodeTypes, + titleForUid, + uidForTitle, +} from "~/graph"; + +const EVD = "[[EVD]] - {content} - {Source}"; +const RES = "[[RES]] - {content} - {Source}"; + +const stubQ = (impl: (query: string, ...params: unknown[]) => unknown[][]) => { + const q = vi.fn(async (query: string, ...params: unknown[]) => impl(query, ...params)); + (window as unknown as Record).roamAlphaAPI = { + data: { async: { q } }, + }; + return q; +}; + +/* Each datalog row is a one-element array holding the pulled page. */ +const rowsFor = (pages: unknown[]) => () => pages.map((p) => [p]); + +const nodePage = (type: string, format: string) => ({ + ":node/title": `discourse-graph/nodes/${type}`, + ":block/children": [ + { ":block/string": "Shortcut", ":block/children": [{ ":block/string": "R" }] }, + { ":block/string": "Format", ":block/children": [{ ":block/string": format }] }, + ], +}); + +beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); +afterEach(() => { + setNodeTypes([]); +}); + +describe("pick", () => { + /* Pull results come back namespaced from some API surfaces and bare from + * others. Betting on one silently yields zero node types. */ + it("reads a namespaced key", () => { + expect(pick({ ":node/title": "x" }, "node/title")).toBe("x"); + }); + it("reads the same key written plainly", () => { + expect(pick({ "node/title": "x" }, "node/title")).toBe("x"); + }); + it("reads the bare attribute name", () => { + expect(pick({ title: "x" }, "node/title")).toBe("x"); + }); + it("returns undefined rather than throwing on a missing entity", () => { + expect(pick(undefined, "node/title")).toBeUndefined(); + }); +}); + +describe("loadNodeTypes", () => { + it("reads the format from the Format block's child", async () => { + stubQ(rowsFor([nodePage("Result", RES)])); + await loadNodeTypes(); + expect(findNodeTypeForTitle("[[RES]] - A finding - @key2020")?.type).toBe("Result"); + }); + + it("skips a page with no Format block instead of failing", async () => { + stubQ( + rowsFor([ + { ":node/title": "discourse-graph/nodes/Bare", ":block/children": [] }, + nodePage("Result", RES), + ]), + ); + await loadNodeTypes(); + expect(findNodeTypeForTitle("[[RES]] - A finding - @key2020")?.type).toBe("Result"); + }); + + /* The per-type try/catch. Without it one typo on one config page aborts + * the whole batch and the extension has no node types at all. */ + it("survives a malformed Format on one page", async () => { + stubQ(rowsFor([nodePage("Weird", "[[WEI]] - {content} (unbalanced"), nodePage("Result", RES)])); + await loadNodeTypes(); + expect(findNodeTypeForTitle("[[RES]] - A finding - @key2020")?.type).toBe("Result"); + }); + + it("drops the malformed type itself", async () => { + stubQ(rowsFor([nodePage("Weird", "[[WEI]] - {content} (unbalanced"), nodePage("Result", RES)])); + await loadNodeTypes(); + expect(findNodeTypeForTitle("[[WEI]] - anything")).toBeNull(); + }); + + /* Zero types is otherwise invisible: nothing gets marked, right-click does + * nothing, and that looks identical to a graph with no discourse nodes. */ + it("warns when it finds none, naming the config prefix", async () => { + stubQ(() => []); + await loadNodeTypes(); + const warn = vi.mocked(console.warn); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain("discourse-graph/nodes/"); + }); + + it("stays silent on a normal, non-zero set", async () => { + stubQ(rowsFor([nodePage("Result", RES)])); + await loadNodeTypes(); + expect(vi.mocked(console.warn)).not.toHaveBeenCalled(); + }); + + it("logs the count either way", async () => { + stubQ(() => []); + await loadNodeTypes(); + expect(String(vi.mocked(console.log).mock.calls[0]?.[0])).toMatch(/\b0\b/); + }); + + it("passes the prefix as a query input rather than interpolating it", async () => { + const q = stubQ(() => []); + await loadNodeTypes(); + expect(q.mock.calls[0]?.[1]).toBe("discourse-graph/nodes/"); + }); +}); + +describe("findNodeTypeForTitle", () => { + beforeEach(() => setNodeTypes([{ type: "Evidence", format: EVD }, { type: "Result", format: RES }])); + + it("finds the Evidence type from a title", () => { + expect(findNodeTypeForTitle("[[EVD]] - A finding - @key2020")?.type).toBe("Evidence"); + }); + it("does not treat an ordinary page as a node", () => { + expect(findNodeTypeForTitle("Meeting notes")).toBeNull(); + }); + it("caches by title, and the cache clears when the types change", () => { + expect(cachedTypeForTitle("[[EVD]] - x - @k")).toBe("Evidence"); + setNodeTypes([]); + expect(cachedTypeForTitle("[[EVD]] - x - @k")).toBe(""); + }); +}); + +describe("resolveBlockRefs", () => { + it("replaces a reference with the referenced text", async () => { + stubQ(() => [["abcd12345", "the referenced text"]]); + expect(await resolveBlockRefs("as shown in ((abcd12345)) above")).toBe( + "as shown in the referenced text above", + ); + }); + + it("leaves an unresolvable reference alone rather than blanking it", async () => { + stubQ(() => []); + expect(await resolveBlockRefs("as shown in ((abcd12345)) above")).toBe( + "as shown in ((abcd12345)) above", + ); + }); + + it("does not query at all when there is nothing to resolve", async () => { + const q = stubQ(() => []); + expect(await resolveBlockRefs("plain content")).toBe("plain content"); + expect(q).not.toHaveBeenCalled(); + }); + + /* One query for every reference, not one per reference. */ + it("resolves several references in a single query", async () => { + const q = stubQ(() => [ + ["abcd12345", "first"], + ["efgh67890", "second"], + ]); + expect(await resolveBlockRefs("((abcd12345)) then ((efgh67890))")).toBe("first then second"); + expect(q).toHaveBeenCalledTimes(1); + }); +}); + +describe("uid and title lookups", () => { + it("finds a uid for a title, passing the title as an input", async () => { + const q = stubQ(() => [[{ ":block/uid": "uid-1" }]]); + expect(await uidForTitle("[[EVD]] - x - @k")).toBe("uid-1"); + expect(q.mock.calls[0]?.[1]).toBe("[[EVD]] - x - @k"); + }); + + /* Interpolation used to need backslash escaping, because titles in these + * graphs really do contain LaTeX and an unescaped backslash is an invalid + * Clojure string escape that throws. Parameterizing removes the hazard. */ + it("needs no escaping for a title containing LaTeX and quotes", async () => { + const q = stubQ(() => [[{ ":block/uid": "uid-2" }]]); + const title = '[[EVD]] - $$\\frac{a}{b}$$ and a "quote" - @k'; + expect(await uidForTitle(title)).toBe("uid-2"); + expect(q.mock.calls[0]?.[1]).toBe(title); + expect(String(q.mock.calls[0]?.[0])).not.toContain("frac"); + }); + + it("returns empty when the page is gone", async () => { + stubQ(() => []); + expect(await uidForTitle("nothing")).toBe(""); + }); + + it("finds a title for a uid", async () => { + stubQ(() => [[{ ":node/title": "[[EVD]] - x - @k" }]]); + expect(await titleForUid("uid-1")).toBe("[[EVD]] - x - @k"); + }); + + it("builds a Roam URL from the graph in the location", async () => { + stubQ(() => [[{ ":block/uid": "uid-1" }]]); + window.history.replaceState({}, "", "/#/app/sandbox-dg/page/abc"); + expect(graphName()).toBe("sandbox-dg"); + expect(await roamUrlForTitle("x")).toBe( + "https://roamresearch.com/#/app/sandbox-dg/page/uid-1", + ); + }); + + it("falls back to the bare label when there is no uid to link to", async () => { + stubQ(() => []); + expect(await roamUrlForTitle("x")).toBe(""); + }); +}); diff --git a/prototypes/copy-for-latex/tests/interop.spec.ts b/prototypes/copy-for-latex/tests/interop.spec.ts new file mode 100644 index 0000000..7ccadcc --- /dev/null +++ b/prototypes/copy-for-latex/tests/interop.spec.ts @@ -0,0 +1,47 @@ +/* A source-level guard for a bug this project shipped twice. + * + * roamjs-components is CommonJS. This repository builds with esbuild in ESM + * format, and its __toESM helper runs in Node-interop mode, where a default + * import of a CommonJS module resolves to the whole module object. So + * + * import addStyle from "roamjs-components/dom/addStyle"; + * addStyle(css); + * + * compiles to a call on `{ default: fn }` and throws "is not a function" at + * load. Named imports are unaffected, which is why the generated template's + * `{ render }` and `{ runExtension }` work and nothing warns you. + * + * Vitest cannot catch this: it resolves CommonJS with ordinary interop, so + * the same code passes every unit test and only fails in the built bundle. + * Hence a check on the source text rather than on behavior. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +// vitest runs with the prototype root as cwd. +const SRC = join(process.cwd(), "src"); + +const sourceFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.name.endsWith(".ts") ? [full] : []; + }); + +describe("module interop", () => { + it("never default-imports from roamjs-components", () => { + const offenders = sourceFiles(SRC).flatMap((file) => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => /^import\s+[A-Za-z_$][\w$]*\s*(,|from)/.test(line)) + .filter((line) => line.includes("roamjs-components")) + .map((line) => `${file.replace(SRC, "src")}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); + + it("finds the source files it is supposed to be checking", () => { + expect(sourceFiles(SRC).length).toBeGreaterThan(4); + }); +}); diff --git a/prototypes/copy-for-latex/tests/latex.spec.ts b/prototypes/copy-for-latex/tests/latex.spec.ts new file mode 100644 index 0000000..0513e6f --- /dev/null +++ b/prototypes/copy-for-latex/tests/latex.spec.ts @@ -0,0 +1,140 @@ +/* Roam markup to LaTeX. + * + * These are the rules that keep a pasted sentence from corrupting a document. + * The percent case is the one that motivated the table: it fails silently, + * swallowing the rest of the line into a LaTeX comment. + */ +import { describe, expect, it } from "vitest"; +import { assembleLatex, roamToLatex, toCitekey, unwrapLinks } from "~/latex"; + +describe("roamToLatex", () => { + const cases: [string, string, string][] = [ + [ + "escapes percent, so the rest of the sentence is not commented out", + "50% of cells showed budding", + "50\\% of cells showed budding", + ], + [ + "escapes the other specials", + "a & b _ c # d $ e { f } g", + "a \\& b \\_ c \\# d \\$ e \\{ f \\} g", + ], + [ + "escapes backslash, tilde and caret with text commands", + "a \\ b ~ c ^ d", + "a \\textbackslash{} b \\textasciitilde{} c \\textasciicircum{} d", + ], + [ + "converts Roam inline math to LaTeX inline math", + "curvature $$H = 1/R$$ at the neck", + "curvature $H = 1/R$ at the neck", + ], + ["does not escape inside math", "$$50\\% \\frac{a}{b}$$", "$50\\% \\frac{a}{b}$"], + [ + "a bare percent inside math is escaped, so it cannot comment out the line", + "yield $$50% \\alpha$$ measured", + "yield $50\\% \\alpha$ measured", + ], + [ + "every percent in a run inside math is escaped, not just the first", + "$$50%% off$$", + "$50\\%\\% off$", + ], + ["escaping resumes after math", "$$x^2$$ covers 50% of cases", "$x^2$ covers 50\\% of cases"], + ["bold becomes textbf", "a **bold** b", "a \\textbf{bold} b"], + ["double underscore is italics, not an escape", "a __italic__ b", "a \\textit{italic} b"], + ["escapes inside a formatting span", "**50% yield**", "\\textbf{50\\% yield}"], + ["highlight markers are dropped, text kept", "a ^^highlighted^^ b", "a highlighted b"], + [ + "page links are unwrapped to their text", + "observed in [[budding yeast]] cells", + "observed in budding yeast cells", + ], + ["bare tags are removed", "a finding #evd-candidate", "a finding "], + [ + "bracketed tags are removed whole, not unwrapped", + "a finding #[[needs review]] here", + "a finding here", + ], + [ + "highlight markers drop even with bold nested inside", + "^^highlighted **bold** text^^", + "highlighted \\textbf{bold} text", + ], + [ + "page links unwrap even with bold nested inside", + "observed in [[budding **yeast**]] cells", + "observed in budding \\textbf{yeast} cells", + ], + [ + "a bracketed tag is removed whole even with bold nested inside", + "a finding #[[needs **review**]] here", + "a finding here", + ], + [ + "a degenerate empty math span escapes rather than opening display math", + "$$$$", + "\\$\\$\\$\\$", + ], + ["empty input stays empty", "", ""], + ]; + it.each(cases)("%s", (_name, input, expected) => { + expect(roamToLatex(input)).toBe(expected); + }); +}); + +describe("toCitekey", () => { + it("passes a Better BibTeX key straight through", () => { + expect(toCitekey("@vasan2020mechanical")).toBe("vasan2020mechanical"); + }); + it("unwraps a bracketed source", () => { + expect(toCitekey("[[@vasan2020mechanical]]")).toBe("vasan2020mechanical"); + }); + it("rejects an internal analysis page, which is a page but not a bibliography entry", () => { + expect(toCitekey("@analysis/quantify issue claiming")).toBe(""); + }); + it("rejects anything with whitespace", () => { + expect(toCitekey("@two words")).toBe(""); + }); + it("rejects an empty source", () => { + expect(toCitekey("")).toBe(""); + }); +}); + +describe("assembleLatex", () => { + const title = "[[EVD]] - x - @k"; + it("puts the citation before the period, with a space", () => { + expect(assembleLatex("The tube pinched", "@vasan2020mechanical", title).latex).toBe( + "The tube pinched \\autocite{vasan2020mechanical}.", + ); + }); + it("strips a trailing period rather than doubling it", () => { + expect(assembleLatex("The tube pinched.", "@k", title).latex).toBe( + "The tube pinched \\autocite{k}.", + ); + }); + it("strips a whole ellipsis, not one dot of it", () => { + expect(assembleLatex("It went on...", "@k", title).latex).toBe( + "It went on \\autocite{k}.", + ); + }); + it("leaves a question mark alone instead of appending a period", () => { + expect(assembleLatex("Why does it pinch?", "@k", title).latex).toBe( + "Why does it pinch? \\autocite{k}", + ); + }); + it("copies without a citation, and warns, when the source is unusable", () => { + const out = assembleLatex("A finding", "@analysis/thing", title); + expect(out.latex).toBe("A finding."); + expect(out.warning).toContain("No citekey"); + }); + it("still escapes the body when there is no citekey", () => { + expect(assembleLatex("50% yield", "", title).latex).toBe("50\\% yield."); + }); +}); + +describe("unwrapLinks", () => { + it("removes the brackets that would terminate a markdown label early", () => { + expect(unwrapLinks("[[EVD]] - a finding")).toBe("EVD - a finding"); + }); +}); diff --git a/prototypes/copy-for-latex/tests/menu.spec.ts b/prototypes/copy-for-latex/tests/menu.spec.ts new file mode 100644 index 0000000..d2f5d19 --- /dev/null +++ b/prototypes/copy-for-latex/tests/menu.spec.ts @@ -0,0 +1,267 @@ +/* The menu, the clipboard, and the right-click decision. */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const toasts: string[] = []; +vi.mock("roamjs-components/components/Toast", () => ({ + render: ({ content }: { content: string }) => { + toasts.push(content); + return () => {}; + }, +})); + +import { CLIPBOARD_FAIL_MESSAGE, ROOT_CLASS, closeMenu, copyText, getOpenMenu, menuItemsFor, openMenu, shouldCloseOnPointer } from "~/menu"; +import { handleContextMenu, handleKeydown, handlePointer } from "~/contextMenu"; +import { latexForTitle, labelForTitle } from "~/payload"; +import { setNodeTypes } from "~/graph"; +import { MARK_ATTR } from "~/dom"; + +const EVD = "[[EVD]] - {content} - {Source}"; +const NODE_TITLE = "[[EVD]] - The tube pinched - @vasan2020mechanical"; + +const stubGraph = (rows: unknown[][] = []) => { + (window as unknown as Record).roamAlphaAPI = { + data: { async: { q: vi.fn(async () => rows) } }, + ui: { + mainWindow: { openPage: vi.fn(async () => {}) }, + rightSidebar: { addWindow: vi.fn(async () => {}) }, + }, + }; +}; + +const setClipboard = (writeText: (t: string) => Promise) => { + Object.defineProperty(window.navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); +}; + +const labels = () => + Array.from(getOpenMenu()?.querySelectorAll(".cfl-menu-item") || []).map((e) => e.textContent); + +beforeEach(() => { + toasts.length = 0; + setNodeTypes([{ type: "Evidence", format: EVD }]); + stubGraph(); + document.body.innerHTML = ""; +}); +afterEach(() => { + closeMenu(); + setNodeTypes([]); +}); + +describe("payload", () => { + it("builds the cited sentence", async () => { + expect((await latexForTitle(NODE_TITLE)).latex).toBe( + "The tube pinched \\autocite{vasan2020mechanical}.", + ); + }); + + it("refuses a page that is not a discourse node, and says why", async () => { + const out = await latexForTitle("Meeting notes"); + expect(out.latex).toBe(""); + expect(out.warning).toContain("not a discourse node"); + }); + + it("drops the type marker and the citekey brackets from a hyperlink label", () => { + expect(labelForTitle("[[EVD]] - The tube pinched - [[@vasan2020mechanical]]")).toBe( + "The tube pinched - @vasan2020mechanical", + ); + }); + + it("falls back to the unwrapped title for a non-node", () => { + expect(labelForTitle("[[Some page]]")).toBe("Some page"); + }); +}); + +describe("menuItemsFor", () => { + it("offers the full set by default", () => { + expect(menuItemsFor(NODE_TITLE).map((i) => ("label" in i ? i.label : "—"))).toEqual([ + "Copy for LaTeX", + "Copy as hyperlink", + "—", + "Jump to page", + "Open in sidebar", + ]); + }); + + it("drops Jump to page when asked", () => { + expect( + menuItemsFor(NODE_TITLE, { omitJumpToPage: true }).some( + (i) => "label" in i && i.label === "Jump to page", + ), + ).toBe(false); + }); +}); + +describe("openMenu", () => { + it("scopes its class to this prototype rather than borrowing Roam's", () => { + const menu = openMenu(NODE_TITLE, 10, 10); + expect(menu.className).toContain(ROOT_CLASS); + expect(menu.className).not.toContain("bp3-"); + }); + + it("replaces an already-open menu instead of stacking a second one", () => { + openMenu(NODE_TITLE, 10, 10); + openMenu(NODE_TITLE, 20, 20); + expect(document.querySelectorAll(`.${ROOT_CLASS}`).length).toBe(1); + }); + + it("closes on Escape", () => { + openMenu(NODE_TITLE, 10, 10); + handleKeydown(new KeyboardEvent("keydown", { key: "Escape" })); + expect(getOpenMenu()).toBeNull(); + }); +}); + +describe("dismissal", () => { + it("keeps the menu open for a press on one of its own items", () => { + const menu = openMenu(NODE_TITLE, 10, 10); + expect(shouldCloseOnPointer(menu.querySelector(".cfl-menu-item"))).toBe(false); + }); + + it("closes on a press anywhere else", () => { + openMenu(NODE_TITLE, 10, 10); + handlePointer(new MouseEvent("mousedown")); + expect(getOpenMenu()).toBeNull(); + }); + + it("treats any press as closable when nothing is open", () => { + expect(shouldCloseOnPointer(document.body)).toBe(true); + }); +}); + +describe("copyText", () => { + it("uses the clipboard API when it works", async () => { + const written: string[] = []; + setClipboard(async (t) => { + written.push(t); + }); + expect(await copyText("hello")).toBe(true); + expect(written).toEqual(["hello"]); + }); + + /* execCommand is the only path that works when the document is not focused, + * which is exactly the case right after a contextmenu in some browsers. */ + it("falls back to execCommand when the clipboard API rejects", async () => { + setClipboard(async () => { + throw new Error("not focused"); + }); + const exec = vi.fn(() => true); + (document as unknown as Record).execCommand = exec; + expect(await copyText("hello")).toBe(true); + expect(exec).toHaveBeenCalledWith("copy"); + }); + + /* A failed copy used to resolve normally with no toast, so the user + * pasted whatever was on the clipboard before. */ + it("reports failure when both paths fail", async () => { + setClipboard(async () => { + throw new Error("no"); + }); + (document as unknown as Record).execCommand = () => false; + expect(await copyText("hello")).toBe(false); + }); +}); + +describe("clicking an item", () => { + const clickItem = async (text: string) => { + const item = Array.from(getOpenMenu()?.querySelectorAll(".cfl-menu-item") || []).find( + (e) => e.textContent === text, + ) as HTMLElement; + item.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await new Promise((r) => setTimeout(r, 0)); + }; + + it("copies the LaTeX sentence", async () => { + const written: string[] = []; + setClipboard(async (t) => { + written.push(t); + }); + openMenu(NODE_TITLE, 10, 10); + await clickItem("Copy for LaTeX"); + expect(written).toEqual(["The tube pinched \\autocite{vasan2020mechanical}."]); + }); + + it("toasts rather than copying when there is nothing usable", async () => { + const written: string[] = []; + setClipboard(async (t) => { + written.push(t); + }); + openMenu("Meeting notes", 10, 10); + await clickItem("Copy for LaTeX"); + expect(written).toEqual([]); + expect(toasts[0]).toContain("not a discourse node"); + }); + + it("toasts when the clipboard write fails", async () => { + setClipboard(async () => { + throw new Error("no"); + }); + (document as unknown as Record).execCommand = () => false; + openMenu(NODE_TITLE, 10, 10); + await clickItem("Copy for LaTeX"); + expect(toasts).toContain(CLIPBOARD_FAIL_MESSAGE); + }); +}); + +describe("handleContextMenu", () => { + const rightClickOn = (el: Element) => { + const e = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); + Object.defineProperty(e, "target", { value: el }); + handleContextMenu(e); + return e; + }; + + const headingFor = (title: string, { inMainWindow = true } = {}) => { + document.body.innerHTML = `

x

`; + return document.querySelector("h1") as HTMLElement; + }; + + it("opens on a node's own heading and suppresses the native menu", () => { + const e = rightClickOn(headingFor(NODE_TITLE)); + expect(labels()).toContain("Copy for LaTeX"); + expect(e.defaultPrevented).toBe(true); + }); + + it("drops Jump to page on the page you are already on", () => { + rightClickOn(headingFor(NODE_TITLE)); + expect(labels()).toEqual(["Copy for LaTeX", "Copy as hyperlink", "Open in sidebar"]); + }); + + it("keeps it on a sidebar heading, where it still goes somewhere", () => { + rightClickOn(headingFor(NODE_TITLE, { inMainWindow: false })); + expect(labels()).toEqual([ + "Copy for LaTeX", + "Copy as hyperlink", + "Jump to page", + "Open in sidebar", + ]); + }); + + /* A heading's parked answer can be a page behind. */ + it("leaves the native menu alone when the parked title is not a node", () => { + const e = rightClickOn(headingFor("Meeting notes")); + expect(getOpenMenu()).toBeNull(); + expect(e.defaultPrevented).toBe(false); + }); + + it("opens the full menu on an inline reference", () => { + document.body.innerHTML = `
x
`; + rightClickOn(document.querySelector("span") as HTMLElement); + expect(labels()).toEqual([ + "Copy for LaTeX", + "Copy as hyperlink", + "Jump to page", + "Open in sidebar", + ]); + }); + + it("ignores a right-click on ordinary text", () => { + document.body.innerHTML = `

plain

`; + const e = rightClickOn(document.querySelector("p") as HTMLElement); + expect(getOpenMenu()).toBeNull(); + expect(e.defaultPrevented).toBe(false); + }); +}); diff --git a/prototypes/copy-for-latex/tests/nodeFormat.spec.ts b/prototypes/copy-for-latex/tests/nodeFormat.spec.ts new file mode 100644 index 0000000..275be94 --- /dev/null +++ b/prototypes/copy-for-latex/tests/nodeFormat.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { formatToRegex, parseNodeTitle } from "~/nodeFormat"; + +const EVD = "[[EVD]] - {content} - {Source}"; +const QUE = "[[QUE]] - {content}"; + +describe("parseNodeTitle", () => { + it("splits content and source", () => { + expect( + parseNodeTitle( + "[[EVD]] - Uniform constriction forces led to pinched deformation - @vasan2020mechanical", + EVD, + ), + ).toEqual({ + content: "Uniform constriction forces led to pinched deformation", + source: "@vasan2020mechanical", + }); + }); + + it("leaves source empty for a format without a source slot", () => { + expect(parseNodeTitle("[[QUE]] - How isotropic is membrane curvature in 3D?", QUE)).toEqual({ + content: "How isotropic is membrane curvature in 3D?", + source: "", + }); + }); + + it("yields nothing for a title that does not match", () => { + expect(parseNodeTitle("Some ordinary page", EVD)).toEqual({ content: "", source: "" }); + }); + + it("has no format to match against when the format is empty", () => { + expect(parseNodeTitle("anything", "")).toEqual({ content: "", source: "" }); + }); +}); + +describe("formatToRegex", () => { + /* Mirrors the plugin's own expression builder. Agreeing with the plugin + * about where content ends matters more than being cleverer than it. */ + it("escapes only the five characters the plugin escapes", () => { + const { regex } = formatToRegex("[[EVD]] - {content}"); + expect(regex.test("[[EVD]] - anything at all")).toBe(true); + }); + + it("captures lazily, so the last separator wins the source slot", () => { + expect(parseNodeTitle("[[EVD]] - A finding - with an aside - @key2020", EVD)).toEqual({ + content: "A finding", + source: "with an aside - @key2020", + }); + }); + + it("matches across newlines", () => { + expect(parseNodeTitle("[[QUE]] - two\nlines", QUE).content).toBe("two\nlines"); + }); + + it("names placeholders case-insensitively, so {Source} fills source", () => { + expect(formatToRegex(EVD).names).toEqual(["content", "source"]); + }); +}); diff --git a/prototypes/copy-for-latex/tests/styles.spec.ts b/prototypes/copy-for-latex/tests/styles.spec.ts new file mode 100644 index 0000000..3ece9f7 --- /dev/null +++ b/prototypes/copy-for-latex/tests/styles.spec.ts @@ -0,0 +1,138 @@ +/* The extension must carry its own stylesheet. + * + * Roam injects a published `extension.css` when it loads an extension from a + * URL, but nothing injects it when the module is pulled in with `import()` + * from a `roam/js` block — which is how preview builds get tested. The menu + * is `position: fixed`, so without its rules it renders as a static, unstyled + * list at the end of : present, clickable, and invisible in practice. + * That is exactly how this shipped the first time. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MENU_CSS } from "~/styles"; +import { openMenu, closeMenu } from "~/menu"; +import { setNodeTypes } from "~/graph"; + +const NODE_TITLE = "[[EVD]] - The tube pinched - @vasan2020mechanical"; + +afterEach(() => { + closeMenu(); + setNodeTypes([]); + document.head.querySelectorAll("style").forEach((s) => s.remove()); +}); + +describe("the stylesheet the bundle carries", () => { + it("makes the menu float, which is the part that cannot be optional", () => { + setNodeTypes([{ type: "Evidence", format: "[[EVD]] - {content} - {Source}" }]); + const style = document.createElement("style"); + style.textContent = MENU_CSS; + document.head.appendChild(style); + + const menu = openMenu(NODE_TITLE, 10, 10); + expect(getComputedStyle(menu).position).toBe("fixed"); + }); + + it("scopes every rule to this prototype so it cannot restyle the graph", () => { + const selectors = MENU_CSS.split("}") + .map((chunk) => chunk.split("{")[0]?.trim()) + .filter((s): s is string => Boolean(s)); + expect(selectors.length).toBeGreaterThan(0); + for (const selector of selectors) { + expect(selector).toContain("roam-prototype-copy-for-latex"); + } + }); +}); + +describe("onload", () => { + const stubRoam = async () => { + (window as unknown as Record).roamAlphaAPI = { + data: { async: { q: vi.fn(async () => []) } }, + ui: { commandPalette: { addCommand: vi.fn(), removeCommand: vi.fn() } }, + platform: { isPC: true }, + }; + /* runExtension writes a React 17 shim onto window.React the moment it is + * called, which is at module scope. Roam provides that global; jsdom does + * not, and an empty object is all the assignment needs. */ + (window as unknown as Record).React = {}; + }; + + it("injects the stylesheet itself, and removes it on unload", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + await stubRoam(); + const before = document.head.querySelectorAll("style").length; + + const extension = (await import("~/index")).default; + await extension.onload({ + extensionAPI: undefined as never, + extension: { version: "test" } as never, + }); + /* runExtension's onload is typed as returning void: it starts the async + * run and does not await it, and only registers the unload callback once + * that promise resolves. Let it settle before asking about cleanup. */ + await new Promise((resolve) => setTimeout(resolve, 0)); + + const during = document.head.querySelectorAll("style").length; + expect(during).toBeGreaterThan(before); + expect( + Array.from(document.head.querySelectorAll("style")).some((s) => + s.textContent?.includes("roam-prototype-copy-for-latex"), + ), + ).toBe(true); + + await extension.onunload?.(); + expect( + Array.from(document.head.querySelectorAll("style")).some((s) => + s.textContent?.includes("roam-prototype-copy-for-latex"), + ), + ).toBe(false); + }); +}); + +describe("load failure", () => { + /* runExtension's own failure path loses the error: in production it posts to + * SamePage and shows a generic toast, and it reads extensionAPI.settings + * while doing so — which is undefined when the module is imported from a + * roam/js block, so the reporter throws over the top of the real error. + * Anything that goes wrong at load has to be caught before it gets there. */ + it("names the missing capability instead of failing deep in a helper", async () => { + const errors: unknown[] = []; + vi.spyOn(console, "error").mockImplementation((...a) => void errors.push(a)); + (window as unknown as Record).React = {}; + (window as unknown as Record).roamAlphaAPI = { + graph: { name: "g" }, + data: {}, // no async.q: an older Roam, or a stubbed one + }; + // runExtension skips onload entirely once its id is in this set + delete (window as unknown as Record).roamjs; + + const extension = (await import("~/index")).default; + await extension.onload({ + extensionAPI: undefined as never, + extension: { version: "test" } as never, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(errors.length).toBeGreaterThan(0); + // Errors do not survive JSON.stringify, so read the message directly. + const messages = errors.flat().map((e) => (e instanceof Error ? e.message : String(e))); + expect(messages.join(" ")).toContain("data.async.q"); + }); + + it("does not rethrow, so runExtension's broken reporter is never reached", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + (window as unknown as Record).React = {}; + (window as unknown as Record).roamAlphaAPI = { graph: { name: "g" }, data: {} }; + delete (window as unknown as Record).roamjs; + const extension = (await import("~/index")).default; + // extensionAPI undefined is precisely the case that crashed the reporter + await expect( + (async () => { + extension.onload({ + extensionAPI: undefined as never, + extension: { version: "test" } as never, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + })(), + ).resolves.toBeUndefined(); + }); +}); diff --git a/prototypes/copy-for-latex/tsconfig.json b/prototypes/copy-for-latex/tsconfig.json new file mode 100644 index 0000000..a3469d4 --- /dev/null +++ b/prototypes/copy-for-latex/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../packages/extension-base/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": [ + "./src/*" + ] + } + }, + "include": [ + "src", + "tests", + "vitest.config.ts" + ] +} diff --git a/prototypes/copy-for-latex/vitest.config.ts b/prototypes/copy-for-latex/vitest.config.ts new file mode 100644 index 0000000..f0d175e --- /dev/null +++ b/prototypes/copy-for-latex/vitest.config.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // The generated tsconfig declares a "~/*" path alias and the esbuild CLI + // honours it, but the generated vitest config does not, so a test that + // imports the way the source does fails to resolve. Mirrored here. + resolve: { + alias: { + "~": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + restoreMocks: true, + // Spec files, not test files. The repository's root `pnpm test` runs a + // bare `node --test`, whose default discovery walks the whole tree and + // matches any dot-test dot-ts file. Prototype tests named that way get + // picked up by Node's runner, which cannot resolve vitest or the "~" + // alias, so the root command fails. Node's patterns do not include + // dot-spec, which keeps the two runners out of each other's way. + // The tidier fix is `node --test test/` at the root, but that is a + // shared-tooling change, so it is reported rather than made here. + include: ["tests/**/*.spec.ts"], + }, +});