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/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/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/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 7bbad0a..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, @@ -11,6 +12,39 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultPrototypesRoot = path.join(root, "prototypes"); const sourceExtension = /\.[cm]?[jt]sx?$/; +await init; + +export const assertNoRoamJsDefaultImports = (source, label) => { + 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 = + normalizedClause.startsWith("{") && + /(?:\{|,)\s*default\s+as\b/.test(normalizedClause); + if ( + (normalizedClause.startsWith("{") && !hasNamedDefault) || + normalizedClause.startsWith("*") + ) { + continue; + } + throw new Error( + `${label} default-imports ${imported.n}; use a named export from a roamjs-components barrel`, + ); + } +}; + const isFile = async (target) => { try { return (await stat(target)).isFile(); @@ -19,27 +53,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 +111,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..1c9e022 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,69 @@ 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( + [ + "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( + 'import Alert, { render } from "roamjs-components/components/Toast";', + "sample-prototype/src/index.ts", + ), + /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";', + '// 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", + ), + ); +});