Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This is a pnpm monorepo for public, installable Roam developer-extension artifac

- Keep prototype code inside `prototypes/<slug>` 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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/extension-base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions packages/extension-base/skills/react-rendering/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slug>` 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:
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 47 additions & 8 deletions scripts/validate-prototypes.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Comment thread
mdroidian marked this conversation as resolved.
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();
Expand All @@ -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,
});
Expand Down Expand Up @@ -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;
}

Expand Down
54 changes: 54 additions & 0 deletions test/starter-integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
71 changes: 70 additions & 1 deletion test/validate-prototypes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "..");

Expand Down Expand Up @@ -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",
),
);
});