From 5215107343d84b09d5d544cdda46513a8f924774 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 19 Aug 2026 09:13:04 -0600 Subject: [PATCH 1/4] Prevent roamjs-components default import failures --- AGENTS.md | 1 + packages/extension-base/README.md | 2 + .../skills/react-rendering/SKILL.md | 7 +++ scripts/validate-prototypes.mjs | 33 +++++++++--- test/starter-integration.test.mjs | 54 +++++++++++++++++++ test/validate-prototypes.test.mjs | 34 +++++++++++- 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80393db..a8efcec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ This is a pnpm monorepo for public, installable Roam developer-extension artifac - Keep prototype code inside `prototypes/` and shared convention material inside `packages/extension-base`. - Import `roamjs-components` directly. `packages/extension-base` is build tooling, configuration, and a template, not a browser runtime API. +- With this repository's ESM build, never default-import a published CommonJS subpath from `roamjs-components`. Import named exports from package barrels instead, such as `import { addStyle } from "roamjs-components/dom"`. - Read the relevant guidance under `packages/extension-base/skills` before using Roam graph writes, commands, navigation, or React rendering. - For new graph reads, prefer `await window.roamAlphaAPI.data.async.*`. Never use legacy top-level aliases such as `roamAlphaAPI.q`, `roamAlphaAPI.pull`, or `roamAlphaAPI.createBlock`. - Keep `runExtension` as the lifecycle wrapper. Dispose observers, listeners, commands, timers, and mounted UI when the extension unloads. diff --git a/packages/extension-base/README.md b/packages/extension-base/README.md index aad7e77..f64c960 100644 --- a/packages/extension-base/README.md +++ b/packages/extension-base/README.md @@ -24,4 +24,6 @@ The starter imports the lifecycle wrapper as a named export: import { runExtension } from "roamjs-components/util"; ``` +Use the same named-barrel form for every `roamjs-components` import. The package is published as TypeScript-compiled CommonJS, while prototypes are emitted as ESM. A default import from a published subpath can therefore bind the CommonJS export object (`{ default: fn }`) instead of the function. For example, use `import { addStyle } from "roamjs-components/dom"`, not a default import from `roamjs-components/dom/addStyle`. Prototype validation enforces this boundary. + Its production error reporting to SamePage is behavior inside `roamjs-components`, independent of which bundler produced `extension.js`; reports include the graph name and extension settings. Never store credentials or sensitive data in extension settings. diff --git a/packages/extension-base/skills/react-rendering/SKILL.md b/packages/extension-base/skills/react-rendering/SKILL.md index fb90f47..b286e52 100644 --- a/packages/extension-base/skills/react-rendering/SKILL.md +++ b/packages/extension-base/skills/react-rendering/SKILL.md @@ -7,6 +7,13 @@ description: Render React or Roam-aware UI in a prototype while respecting host Prefer existing `roamjs-components` primitives for Roam-consistent controls, dialogs, toasts, settings, and observers. Scope prototype CSS under a unique class such as `.roam-prototype-` so it cannot restyle the graph globally. +Import those primitives as named exports from package barrels. This repository emits ESM, but `roamjs-components` is published as TypeScript-compiled CommonJS; a default import from a published subpath can bind `{ default: fn }` instead of the function. For example: + +```ts +import { addStyle } from "roamjs-components/dom"; +import { runExtension } from "roamjs-components/util"; +``` + Roam automatically injects and removes a published `extension.css`. The extension API also automatically cleans up its commands, slash commands, settings panel, and experimental AI tools. DOM nodes, observers, event listeners, intervals, and custom registered components remain the extension's responsibility. Use the supported Roam renderers when the UI is fundamentally Roam content: diff --git a/scripts/validate-prototypes.mjs b/scripts/validate-prototypes.mjs index 7bbad0a..9ef9735 100644 --- a/scripts/validate-prototypes.mjs +++ b/scripts/validate-prototypes.mjs @@ -10,6 +10,18 @@ import { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultPrototypesRoot = path.join(root, "prototypes"); const sourceExtension = /\.[cm]?[jt]sx?$/; +const roamJsImport = /\bimport\s+([\s\S]*?)\s+from\s+["'](roamjs-components(?:\/[^"']*)?)["']/g; + +export const assertNoRoamJsDefaultImports = (source, label) => { + for (const match of source.matchAll(roamJsImport)) { + const clause = match[1].trim(); + if (clause.startsWith("type ")) continue; + if (clause.startsWith("{") || clause.startsWith("*")) continue; + throw new Error( + `${label} default-imports ${match[2]}; use a named export from a roamjs-components barrel`, + ); + } +}; const isFile = async (target) => { try { @@ -19,27 +31,32 @@ const isFile = async (target) => { } }; -const validateSourceDirectory = async (directory, prototype, prototypesRoot) => { +const validateSourceDirectory = async (directory, label, sourceRoot = directory) => { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const target = path.join(directory, entry.name); if (entry.isDirectory()) { - await validateSourceDirectory(target, prototype, prototypesRoot); + await validateSourceDirectory(target, label, sourceRoot); } else if (entry.isFile() && sourceExtension.test(entry.name)) { const source = await readFile(target, "utf8"); - assertAllowedEnvironmentReferences( - source, - `${prototype}/${path.relative(path.join(prototypesRoot, prototype), target)}`, - ); + const sourceLabel = `${label}/${path.relative(sourceRoot, target)}`; + assertAllowedEnvironmentReferences(source, sourceLabel); + assertNoRoamJsDefaultImports(source, sourceLabel); } else if (!entry.isFile()) { - throw new Error(`${prototype} source contains a non-file entry: ${entry.name}`); + throw new Error(`${label} source contains a non-file entry: ${entry.name}`); } } }; export const validatePrototypes = async ({ prototypesRoot = defaultPrototypesRoot, + templateSourceRoot = path.join(root, "packages", "extension-base", "template", "src"), } = {}) => { + await validateSourceDirectory( + templateSourceRoot, + "packages/extension-base/template", + templateSourceRoot, + ); const entries = await readDirectoryIfExists(prototypesRoot, { withFileTypes: true, }); @@ -72,7 +89,7 @@ export const validatePrototypes = async ({ throw new Error(`${prototype} is missing src/index.ts`); } - await validateSourceDirectory(sourceDirectory, prototype, prototypesRoot); + await validateSourceDirectory(sourceDirectory, prototype, sourceDirectory); packageCount += 1; } diff --git a/test/starter-integration.test.mjs b/test/starter-integration.test.mjs index cbe5ce8..61959ea 100644 --- a/test/starter-integration.test.mjs +++ b/test/starter-integration.test.mjs @@ -107,6 +107,60 @@ test( await readFile(path.join(destination, "dist", "extension.js"), "utf8"), /process\.env\.(?:PACKAGE_NAME|ROAMJS_VERSION|VERSION)/, ); + + const smokeTest = path.join(destination, "artifact-smoke.mjs"); + await writeFile( + smokeTest, + `import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const roamRequire = createRequire(require.resolve("roamjs-components/package.json")); +const TSLib = roamRequire("tslib"); + +globalThis.window = globalThis; +globalThis.HTMLElement = class HTMLElement {}; +const body = new EventTarget(); +const head = { appendChild: () => {} }; +globalThis.document = { + body, + createElement: () => ({ remove: () => {}, setAttribute: () => {} }), + getElementById: () => null, + getElementsByTagName: () => [head], +}; +window.React = {}; +window.ReactDOM = {}; +window.TSLib = TSLib; +window.Blueprint = { Core: {}, DateTime: {}, Select: {} }; +window.RoamLazy = {}; +window.Nanoid = { nanoid: () => "smoke-test" }; +globalThis.localStorage = { getItem: () => null }; +let errorReports = 0; +globalThis.fetch = async () => { + errorReports += 1; + return { ok: true, status: 204 }; +}; +window.roamAlphaAPI = { + graph: { name: "artifact-smoke" }, + ui: { commandPalette: { removeCommand: () => {} } }, +}; + +const extension = (await import("./dist/extension.js")).default; +if (typeof extension?.onload !== "function" || typeof extension?.onunload !== "function") { + throw new Error("Built artifact does not expose the Roam extension lifecycle"); +} +extension.onload({ + extensionAPI: { settings: { getAll: () => ({}) } }, + extension: { version: "artifact-smoke" }, +}); +await new Promise((resolve) => setTimeout(resolve, 0)); +extension.onunload(); +if (errorReports) { + throw new Error("Built artifact reported a lifecycle failure"); +} +`, + "utf8", + ); + run(["--dir", destination, "exec", "node", smokeTest], repoRoot); } finally { await rm(repoRoot, { recursive: true, force: true }); } diff --git a/test/validate-prototypes.test.mjs b/test/validate-prototypes.test.mjs index 95e77bf..afa910c 100644 --- a/test/validate-prototypes.test.mjs +++ b/test/validate-prototypes.test.mjs @@ -5,7 +5,10 @@ import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { createPrototype } from "../scripts/create-prototype.mjs"; -import { validatePrototypes } from "../scripts/validate-prototypes.mjs"; +import { + assertNoRoamJsDefaultImports, + validatePrototypes, +} from "../scripts/validate-prototypes.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -40,3 +43,32 @@ test("validates the generated dev script convention", async () => { await rm(root, { recursive: true, force: true }); } }); + +test("rejects default imports from published roamjs-components CommonJS subpaths", () => { + assert.throws( + () => + assertNoRoamJsDefaultImports( + 'import addStyle from "roamjs-components/dom/addStyle";', + "sample-prototype/src/index.ts", + ), + /default-imports roamjs-components\/dom\/addStyle/, + ); + assert.throws( + () => + assertNoRoamJsDefaultImports( + 'import Alert, { render } from "roamjs-components/components/Toast";', + "sample-prototype/src/index.ts", + ), + /default-imports roamjs-components\/components\/Toast/, + ); + assert.doesNotThrow(() => + assertNoRoamJsDefaultImports( + [ + 'import { addStyle } from "roamjs-components/dom";', + 'import { runExtension } from "roamjs-components/util";', + 'import type { OnloadArgs } from "roamjs-components/types";', + ].join("\n"), + "sample-prototype/src/index.ts", + ), + ); +}); From d46f38833af2d2e2327abbdc606210bb7e2ee60d Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Thu, 20 Aug 2026 22:47:09 -0600 Subject: [PATCH 2/4] Harden roamjs import validation --- scripts/validate-prototypes.mjs | 6 ++++-- test/validate-prototypes.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/validate-prototypes.mjs b/scripts/validate-prototypes.mjs index 9ef9735..2443510 100644 --- a/scripts/validate-prototypes.mjs +++ b/scripts/validate-prototypes.mjs @@ -10,13 +10,15 @@ import { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultPrototypesRoot = path.join(root, "prototypes"); const sourceExtension = /\.[cm]?[jt]sx?$/; -const roamJsImport = /\bimport\s+([\s\S]*?)\s+from\s+["'](roamjs-components(?:\/[^"']*)?)["']/g; +const roamJsImport = /\bimport\s+([^"';]*?)\s+from\s+["'](roamjs-components(?:\/[^"']*)?)["']/g; export const assertNoRoamJsDefaultImports = (source, label) => { for (const match of source.matchAll(roamJsImport)) { const clause = match[1].trim(); if (clause.startsWith("type ")) continue; - if (clause.startsWith("{") || clause.startsWith("*")) continue; + const hasNamedDefault = + clause.startsWith("{") && /(?:\{|,)\s*default\s+as\b/.test(clause); + if ((clause.startsWith("{") && !hasNamedDefault) || clause.startsWith("*")) continue; throw new Error( `${label} default-imports ${match[2]}; use a named export from a roamjs-components barrel`, ); diff --git a/test/validate-prototypes.test.mjs b/test/validate-prototypes.test.mjs index afa910c..1aa3d12 100644 --- a/test/validate-prototypes.test.mjs +++ b/test/validate-prototypes.test.mjs @@ -61,9 +61,29 @@ test("rejects default imports from published roamjs-components CommonJS subpaths ), /default-imports roamjs-components\/components\/Toast/, ); + assert.throws( + () => + assertNoRoamJsDefaultImports( + 'import { default as addStyle } from "roamjs-components/dom/addStyle";', + "sample-prototype/src/index.ts", + ), + /default-imports roamjs-components\/dom\/addStyle/, + ); + assert.throws( + () => + assertNoRoamJsDefaultImports( + [ + 'import { map } from "lodash";', + 'import addStyle from "roamjs-components/dom/addStyle";', + ].join("\n"), + "sample-prototype/src/index.ts", + ), + /default-imports roamjs-components\/dom\/addStyle/, + ); assert.doesNotThrow(() => assertNoRoamJsDefaultImports( [ + 'import React from "react";', 'import { addStyle } from "roamjs-components/dom";', 'import { runExtension } from "roamjs-components/util";', 'import type { OnloadArgs } from "roamjs-components/types";', From a351cb5b15db1857badd00d5e34d01f1fd8b46b2 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Thu, 20 Aug 2026 22:58:27 -0600 Subject: [PATCH 3/4] Ignore non-code RoamJS import examples --- scripts/validate-prototypes.mjs | 47 +++++++++++++++++++++++++++++++ test/validate-prototypes.test.mjs | 6 ++++ 2 files changed, 53 insertions(+) diff --git a/scripts/validate-prototypes.mjs b/scripts/validate-prototypes.mjs index 2443510..8aa66fc 100644 --- a/scripts/validate-prototypes.mjs +++ b/scripts/validate-prototypes.mjs @@ -12,8 +12,55 @@ const defaultPrototypesRoot = path.join(root, "prototypes"); const sourceExtension = /\.[cm]?[jt]sx?$/; const roamJsImport = /\bimport\s+([^"';]*?)\s+from\s+["'](roamjs-components(?:\/[^"']*)?)["']/g; +const maskCommentsAndStrings = (source) => { + const masked = source.split(""); + let state = "code"; + const mask = (index) => { + if (masked[index] !== "\n" && masked[index] !== "\r") masked[index] = " "; + }; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + const next = source[index + 1]; + if (state === "code") { + if (character === "'" || character === '"' || character === "`") { + state = character; + mask(index); + } else if (character === "/" && next === "/") { + state = "line-comment"; + mask(index); + mask(++index); + } else if (character === "/" && next === "*") { + state = "block-comment"; + mask(index); + mask(++index); + } + } else if (state === "line-comment") { + if (character === "\n" || character === "\r") state = "code"; + else mask(index); + } else if (state === "block-comment") { + mask(index); + if (character === "*" && next === "/") { + mask(++index); + state = "code"; + } + } else { + mask(index); + if (character === "\\") { + if (next !== undefined) mask(++index); + } else if (character === state) { + state = "code"; + } + } + } + + return masked.join(""); +}; + export const assertNoRoamJsDefaultImports = (source, label) => { + const codeMask = maskCommentsAndStrings(source); for (const match of source.matchAll(roamJsImport)) { + if (codeMask.slice(match.index, match.index + 6) !== "import") continue; const clause = match[1].trim(); if (clause.startsWith("type ")) continue; const hasNamedDefault = diff --git a/test/validate-prototypes.test.mjs b/test/validate-prototypes.test.mjs index 1aa3d12..9a5acec 100644 --- a/test/validate-prototypes.test.mjs +++ b/test/validate-prototypes.test.mjs @@ -87,6 +87,12 @@ test("rejects default imports from published roamjs-components CommonJS subpaths 'import { addStyle } from "roamjs-components/dom";', 'import { runExtension } from "roamjs-components/util";', 'import type { OnloadArgs } from "roamjs-components/types";', + '// import addStyle from "roamjs-components/dom/addStyle";', + '/* import addStyle from "roamjs-components/dom/addStyle"; */', + "const example = 'import addStyle from \"roamjs-components/dom/addStyle\";'", + "const multilineExample = `", + 'import addStyle from "roamjs-components/dom/addStyle";', + "`;", ].join("\n"), "sample-prototype/src/index.ts", ), From 406e81e2ec5a4d5b07dac8a32cd4b2c8bb0c2b0c Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Thu, 20 Aug 2026 23:06:00 -0600 Subject: [PATCH 4/4] Parse actual imports in prototype validation --- package.json | 3 +- pnpm-lock.yaml | 3 ++ scripts/validate-prototypes.mjs | 79 ++++++++++--------------------- test/validate-prototypes.test.mjs | 11 +++++ 4 files changed, 42 insertions(+), 54 deletions(-) diff --git a/package.json b/package.json index 37cb355..1acedc3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "validate:prototypes": "node scripts/validate-prototypes.mjs" }, "dependencies": { - "@vercel/blob": "^2.8.0" + "@vercel/blob": "^2.8.0", + "es-module-lexer": "^2.3.1" }, "engines": { "node": ">=22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce84dc..142b294 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@vercel/blob': specifier: ^2.8.0 version: 2.8.0 + es-module-lexer: + specifier: ^2.3.1 + version: 2.3.1 packages/extension-base: dependencies: diff --git a/scripts/validate-prototypes.mjs b/scripts/validate-prototypes.mjs index 8aa66fc..e0dfefc 100644 --- a/scripts/validate-prototypes.mjs +++ b/scripts/validate-prototypes.mjs @@ -1,6 +1,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { init, parse } from "es-module-lexer"; import { assertAllowedEnvironmentReferences, assertPrototypeName, @@ -10,64 +11,36 @@ import { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultPrototypesRoot = path.join(root, "prototypes"); const sourceExtension = /\.[cm]?[jt]sx?$/; -const roamJsImport = /\bimport\s+([^"';]*?)\s+from\s+["'](roamjs-components(?:\/[^"']*)?)["']/g; -const maskCommentsAndStrings = (source) => { - const masked = source.split(""); - let state = "code"; - const mask = (index) => { - if (masked[index] !== "\n" && masked[index] !== "\r") masked[index] = " "; - }; - - for (let index = 0; index < source.length; index += 1) { - const character = source[index]; - const next = source[index + 1]; - if (state === "code") { - if (character === "'" || character === '"' || character === "`") { - state = character; - mask(index); - } else if (character === "/" && next === "/") { - state = "line-comment"; - mask(index); - mask(++index); - } else if (character === "/" && next === "*") { - state = "block-comment"; - mask(index); - mask(++index); - } - } else if (state === "line-comment") { - if (character === "\n" || character === "\r") state = "code"; - else mask(index); - } else if (state === "block-comment") { - mask(index); - if (character === "*" && next === "/") { - mask(++index); - state = "code"; - } - } else { - mask(index); - if (character === "\\") { - if (next !== undefined) mask(++index); - } else if (character === state) { - state = "code"; - } - } - } - - return masked.join(""); -}; +await init; export const assertNoRoamJsDefaultImports = (source, label) => { - const codeMask = maskCommentsAndStrings(source); - for (const match of source.matchAll(roamJsImport)) { - if (codeMask.slice(match.index, match.index + 6) !== "import") continue; - const clause = match[1].trim(); - if (clause.startsWith("type ")) continue; + const [imports] = parse(source); + for (const imported of imports) { + if ( + imported.d !== -1 || + !imported.n?.match(/^roamjs-components(?:\/|$)/) + ) { + continue; + } + const statement = source.slice(imported.ss, imported.se); + const clause = /^\s*import\s+([\s\S]*?)\s+from\s+["']/.exec(statement)?.[1]; + if (!clause) continue; + const normalizedClause = clause + .replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, "") + .trim(); + if (normalizedClause.startsWith("type ")) continue; const hasNamedDefault = - clause.startsWith("{") && /(?:\{|,)\s*default\s+as\b/.test(clause); - if ((clause.startsWith("{") && !hasNamedDefault) || clause.startsWith("*")) continue; + normalizedClause.startsWith("{") && + /(?:\{|,)\s*default\s+as\b/.test(normalizedClause); + if ( + (normalizedClause.startsWith("{") && !hasNamedDefault) || + normalizedClause.startsWith("*") + ) { + continue; + } throw new Error( - `${label} default-imports ${match[2]}; use a named export from a roamjs-components barrel`, + `${label} default-imports ${imported.n}; use a named export from a roamjs-components barrel`, ); } }; diff --git a/test/validate-prototypes.test.mjs b/test/validate-prototypes.test.mjs index 9a5acec..1c9e022 100644 --- a/test/validate-prototypes.test.mjs +++ b/test/validate-prototypes.test.mjs @@ -53,6 +53,17 @@ test("rejects default imports from published roamjs-components CommonJS subpaths ), /default-imports roamjs-components\/dom\/addStyle/, ); + assert.throws( + () => + assertNoRoamJsDefaultImports( + [ + "const quotes = /['\"]/;", + 'import addStyle from "roamjs-components/dom/addStyle";', + ].join("\n"), + "sample-prototype/src/index.ts", + ), + /default-imports roamjs-components\/dom\/addStyle/, + ); assert.throws( () => assertNoRoamJsDefaultImports(