Skip to content

Add the Copy for LaTeX prototype - #11

Open
mattakamatsu wants to merge 4 commits into
mainfrom
copy-for-latex
Open

Add the Copy for LaTeX prototype#11
mattakamatsu wants to merge 4 commits into
mainfrom
copy-for-latex

Conversation

@mattakamatsu

Copy link
Copy Markdown

Copy a discourse node out of Roam as a LaTeX sentence carrying its own citation, or as a hyperlink, from a right-click. Ported from a standalone roam/js prototype that has been in real use.

Preview URL once CI publishes:

https://discoursegraphs.com/releases/prototypes/previews/copy-for-latex/copy-for-latex/

What the repo's rules changed

The port was not a copy. Three rules in AGENTS.md changed the design, and the reasoning is worth recording:

Async reads collide with preventDefault. roamAlphaAPI.q is banned, so every read is async. But preventDefault must be called synchronously during the contextmenu event or the native menu wins, and resolving a page heading's title needs an await. Headings are therefore resolved ahead of time by the mutation observer and the answer parked on the element, keyed to the page uid so navigation invalidates it — Roam reuses the heading element across pages, so an unkeyed cache would answer for the page you were on before. A synchronous reconstruction from the DOM covers the gap before the observer catches up.

Parameterized Datalog removed a real hazard, not just a style nit. The old version interpolated titles into query strings and needed backslash-then-quote escaping to survive them. Node titles in these graphs contain LaTeX ($$\frac{a}{b}$$), and an unescaped backslash is an invalid Clojure string escape that throws. Passing inputs through :in deletes that whole class of bug; the escaping helper is gone.

Prototype-scoped CSS instead of Roam's Blueprint classes. The menu no longer borrows bp3-menu, so styling this owns does not track Roam's Blueprint version. One selector still does track the host: .bp3-dark for dark mode, because there is no more semantic hook for it.

Three things worth fixing in the repo

Each cost time and will cost the next person the same. All three are reported rather than fixed here, since they are shared tooling.

  1. pnpm create:prototype -- --name ... fails. Both README.md and CONTRIBUTING.md document the -- separator, but pnpm 10.15.1 — the version this repo pins — forwards it to the script, which exits with Unknown argument: --. Dropping the -- works.

  2. The root pnpm test breaks as soon as a prototype has tests. It runs a bare node --test, whose default discovery walks the whole tree and matches any *.test.ts. Node's runner then tries to execute prototype tests it cannot resolve (vitest, the ~/ alias) and the root command fails. This prototype works around it by naming its files *.spec.ts, which Node's patterns do not match. The tidier fix is node --test test/ at the root.

  3. The generated vitest.config.ts does not resolve the ~/ alias its own tsconfig.json declares. The esbuild CLI honours it, so source builds fine and only tests fail. Worth adding to packages/extension-base/template.

Validation

pnpm test (31 root + 106 prototype), pnpm build, and pnpm prepare:artifacts all pass. Typechecked separately with TypeScript 5.6; the repo has no typecheck step, since esbuild strips types without checking them.

One @ts-expect-error remains, on the sidebar addWindow call: 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 it starts failing the day roamjs-components corrects the type.

🤖 Generated with Claude Code

Ports the standalone roam/js prototype into this repository. Copy a
discourse node as a LaTeX sentence carrying its own citation, or as a
hyperlink, from a right-click.

The port is not a copy. Three repository rules changed the design:

- Graph reads move to roamAlphaAPI.data.async.*, so every read is async.
  That collides with the right-click: preventDefault has to be called
  synchronously or the native menu wins, but resolving a page heading's
  title now needs an await. Headings are therefore resolved ahead of time
  by the observer and the answer parked on the element, keyed to the page
  uid so navigation invalidates it. A synchronous reconstruction from the
  DOM covers the gap before the observer catches up.
- Datalog inputs are parameterized rather than interpolated, which removes
  the string-escaping the old version needed. Node titles here really do
  contain LaTeX, and an unescaped backslash is an invalid Clojure string
  escape that throws.
- The menu uses prototype-scoped classes instead of Roam's Blueprint menu
  classes, so styling this owns does not track Roam's Blueprint version.

Also new since the standalone version: the menu opens on a node's own page
heading, not only on inline references. Reading the heading's text is not
enough, because a node title contains page references whose brackets Roam
renders as separate elements, so the text reads "EVD - x - @y" and matches
no node format. The page uid is authoritative; the DOM is the fallback.

106 tests, ported from the standalone suite and run under vitest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Roam prototype previews

The preview deployment is ready. Paste a URL below into Load Developer Extensions from URL in Roam:

View publishing details

mattakamatsu and others added 3 commits August 18, 2026 22:53
Roam injects a published extension.css when it loads an extension from a
URL. Nothing injects it when the module is pulled in with import() from a
roam/js block, which is how preview builds actually get tested.

The menu is position: fixed, and that rule lived only in the stylesheet.
Loaded through a roam/js block it therefore became a static, unstyled list
appended to the end of <body>: created, positioned nowhere, clickable in
principle and invisible in practice. Reported from real use.

Structural CSS for a floating menu is behavior, not theming, so it now
lives with the code. src/styles.ts holds the rules, index.ts injects them
with roamjs addStyle and removes them on unload. dist/extension.css is no
longer emitted, which the artifact contract allows.

Adds three regression tests, each verified against the defect it guards:
onload must inject the rules and unload must remove them; the carried CSS
must make the menu float; and every rule must stay scoped to this
prototype so it cannot restyle the graph.

Also documents both load paths in the README, including the loader block
and the warning that any prototype shipping CSS separately will look
broken when imported this way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Loading through a roam/js block produced "Cannot read properties of
undefined (reading 'settings')" and nothing else. That error is not the
fault: it is runExtension's failure reporter crashing on top of the real
one.

runExtension's production catch never logs the error. It posts the message
to SamePage and shows a generic "Failed to load" toast, and while building
that report it reads args.extensionAPI.settings.getAll(). extensionAPI is
undefined whenever the module is loaded by import() from a roam/js block
rather than by Roam itself, so the reporter throws its own TypeError and
the original error is gone. Any block-loaded prototype hits this.

So errors are caught here, where the message still exists: logged with the
error object and shown in a toast that does not time out. Nothing is
rethrown, so the broken reporter is never reached.

Adds an explicit capability check, since the most likely real cause is a
Roam build without roamAlphaAPI.data.async.q. A missing capability now
names itself instead of surfacing as a TypeError deep in a helper.

Also gives the observer's fire-and-forget heading resolution a catch, so a
failure there is attributed rather than appearing as a bare unhandled
rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The real error, once it stopped being swallowed, was
"(0, wo.default) is not a function" on the addStyle call.

roamjs-components is CommonJS. This repository builds with esbuild in ESM
format, and its __toESM helper is emitted in Node-interop mode: when
isNodeMode is set it defines `default` on the wrapper as the entire module
object, and __copyProps then skips the module's own `default` because the
key already exists. So

    import addStyle from "roamjs-components/dom/addStyle";

binds `{ default: fn }` rather than `fn`, and calling it throws. Every
default import from that package has this shape. Named imports are
unaffected, which is why the generated template's `{ render }` and
`{ runExtension }` work and nothing warns you.

Vitest cannot catch it: it resolves CommonJS with ordinary interop, so the
same code passes every unit test and fails only in the built bundle. The
new tests/interop.spec.ts therefore checks the source text, and names the
offending line when it fires.

The stylesheet is now injected by six lines of local code, which also
removes a dependency from the load path.

Hardens the failure reporter as well. It called renderToast, which needs
Blueprint and a lazily-loaded Roam global and so can throw on its own; when
it did, the original error was lost again and the symptom reverted to the
misleading "reading 'settings'". The console now gets the error first and
unconditionally, with the toast attempted separately.

Verified against the built bundle in a jsdom harness with the host globals
stubbed: the buggy build reproduces the reported error and the fixed build
loads, injects its styles, marks references, and cleans up on unload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mattakamatsu

Copy link
Copy Markdown
Author

A repo-wide trap worth fixing upstream: default imports from roamjs-components are broken

This cost three deploy cycles to find, so recording the mechanism.

roamjs-components is CommonJS. The shared build emits ESM, and esbuild's __toESM helper is emitted in Node-interop mode:

var ee = (e, t, r) => (r = e != null ? To(So(e)) : {},
  Ro(t || !e || !e.__esModule ? Jt(r, "default", { value: e, enumerable: true }) : r, e));
//        ^ isNodeMode = 1, so "default" is set to the WHOLE module object

__copyProps then skips the module's own default because the key already exists on the target. So:

import addStyle from "roamjs-components/dom/addStyle";
addStyle(css);   // TypeError: (0, wo.default) is not a function

addStyle binds { default: fn }, not fn. Every default import from that package has this shape. Named imports are unaffected, which is exactly why loaded-dialog and the generated template work — they only use { render } and { runExtension }, so nothing surfaces the problem until a prototype reaches for a default export.

Vitest does not catch it. It resolves CommonJS with ordinary interop, so the identical code passes every unit test and fails only in the built artifact.

Two things would help every future prototype:

  1. A note in AGENTS.md / packages/extension-base/skills to use named imports from roamjs-components, with the reason.
  2. Something that exercises the built bundle. Unit tests here test the source; nothing tests the artifact that actually ships. A jsdom smoke test that imports dist/extension.js with the host globals stubbed (window.TSLib, React, ReactDOM, Blueprint.Core, Nanoid, RoamLazy) and calls onload catches this class immediately — that is how this was finally diagnosed.

Related, and arguably the reason it took so long: runExtension's failure path destroys the error it is reporting. In production it never logs the error; it posts the message to SamePage and shows a generic toast, and while building that report it reads args.extensionAPI.settings.getAll(). extensionAPI is undefined whenever a prototype is loaded via import() from a roam/js block — the documented way to test previews — so the reporter throws its own TypeError: Cannot read properties of undefined (reading 'settings') on top of the real one, and the actual cause is gone. Prototypes have to catch their own load errors to see anything at all.

@mdroidian

Copy link
Copy Markdown
Member

A repo-wide trap worth fixing upstream: default imports from roamjs-components are broken

This cost three deploy cycles to find, so recording the mechanism.

roamjs-components is CommonJS. The shared build emits ESM, and esbuild's __toESM helper is emitted in Node-interop mode:

var ee = (e, t, r) => (r = e != null ? To(So(e)) : {},
  Ro(t || !e || !e.__esModule ? Jt(r, "default", { value: e, enumerable: true }) : r, e));
//        ^ isNodeMode = 1, so "default" is set to the WHOLE module object

__copyProps then skips the module's own default because the key already exists on the target. So:

import addStyle from "roamjs-components/dom/addStyle";
addStyle(css);   // TypeError: (0, wo.default) is not a function

addStyle binds { default: fn }, not fn. Every default import from that package has this shape. Named imports are unaffected, which is exactly why loaded-dialog and the generated template work — they only use { render } and { runExtension }, so nothing surfaces the problem until a prototype reaches for a default export.

Vitest does not catch it. It resolves CommonJS with ordinary interop, so the identical code passes every unit test and fails only in the built artifact.

Two things would help every future prototype:

  1. A note in AGENTS.md / packages/extension-base/skills to use named imports from roamjs-components, with the reason.
  2. Something that exercises the built bundle. Unit tests here test the source; nothing tests the artifact that actually ships. A jsdom smoke test that imports dist/extension.js with the host globals stubbed (window.TSLib, React, ReactDOM, Blueprint.Core, Nanoid, RoamLazy) and calls onload catches this class immediately — that is how this was finally diagnosed.

Related, and arguably the reason it took so long: runExtension's failure path destroys the error it is reporting. In production it never logs the error; it posts the message to SamePage and shows a generic toast, and while building that report it reads args.extensionAPI.settings.getAll(). extensionAPI is undefined whenever a prototype is loaded via import() from a roam/js block — the documented way to test previews — so the reporter throws its own TypeError: Cannot read properties of undefined (reading 'settings') on top of the real one, and the actual cause is gone. Prototypes have to catch their own load errors to see anything at all.

Fixed in
#14
#15
RoamJS/roamjs-components#64

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants