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 @@ -14,6 +14,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"`.
- Support both documented loading modes: Roam's developer-extension URL loader and the `roam/js` ESM loader. Treat `args.extensionAPI` as optional and provide a fallback or clearly gate features that require it; `window.roamAlphaAPI` remains available in both modes.
- 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The repository includes instructions for the assistant in [AGENTS.md](AGENTS.md)

Public source repository for Discourse Graphs' installable Roam developer-extension prototypes.

Each prototype lives in `prototypes/<prototype>/`. Development uses ordinary feature branches and pull requests. Build artifacts are intentionally public so Roam can load them with **Load Developer Extensions from URL**.
Each prototype lives in `prototypes/<prototype>/`. Development uses ordinary feature branches and pull requests. Build artifacts are intentionally public so Roam can load them either with **Load Developer Extensions from URL** or from a `roam/js` code block. Each prototype README documents both methods.

## Release URLs

Expand Down Expand Up @@ -68,6 +68,8 @@ It may also contain:
- `extension.css`
- `CHANGELOG.md`

URL loading is the full developer-extension environment: Roam supplies `extensionAPI`, loads `extension.css`, and owns unloading. The documented `roam/js` loader imports the same `extension.js`, supplies no `extensionAPI`, and therefore handles cache busting, stylesheet loading, and replacement unloading itself. Prototype behavior that depends on extension settings or other `extensionAPI` capabilities must feature-detect them; global `window.roamAlphaAPI` capabilities remain available in both modes.

## Repository layout

```text
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 @@ -26,4 +26,6 @@ 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.

Generated prototypes document two supported loading modes. Roam's developer-extension URL loader supplies `extensionAPI` and manages the release stylesheet. A `roam/js` code block can import the same ESM artifact, but it cannot manufacture Roam's extension-scoped API; its loader passes `extensionAPI: undefined` and manages the stylesheet and replacement lifecycle. Prototype code must treat extension-scoped capabilities as optional when it supports both modes.

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.
2 changes: 1 addition & 1 deletion packages/extension-base/skills/react-rendering/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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.
With URL loading, 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. The documented `roam/js` loader manages `extension.css`, but has no extension API, so feature-detect extension-scoped capabilities and explicitly clean up any fallback registrations. DOM nodes, observers, event listeners, intervals, and custom registered components remain the extension's responsibility in both modes.

Use the supported Roam renderers when the UI is fundamentally Roam content:

Expand Down
75 changes: 73 additions & 2 deletions packages/extension-base/template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,81 @@ Internal prototype for evaluation by Discourse Graphs.

- Document the prototype's user-visible behavior here.

## Install
## Install from a URL

Load this developer-extension URL in Roam:
In Roam, use **Load Developer Extensions from URL** with:

```text
https://discoursegraphs.com/releases/prototypes/__PROTOTYPE_NAME__/
```

Roam supplies the extension API, loads `extension.css`, and unloads the extension in this mode.

## Load from roam/js

Paste this loader into a `roam/js` code block. To test a pull-request preview, change only `baseUrl` to the preview release directory posted on the pull request.

```javascript
(async () => {
const baseUrl =
"https://discoursegraphs.com/releases/prototypes/__PROTOTYPE_NAME__";
const globalKey = "__roamPrototype:__PROTOTYPE_NAME__";
const loadKey = `${globalKey}:load`;

const previousLoad = window[loadKey] ?? Promise.resolve();
const currentLoad = previousLoad.catch(() => {}).then(async () => {
const version = Date.now();
const previous = window[globalKey];
const previousExtension = previous?.extension ?? previous;

// Import and validate the replacement before unloading a working copy.
const module = await import(`${baseUrl}/extension.js?v=${version}`);
Comment thread
mdroidian marked this conversation as resolved.
const extension = module.default;
if (!extension?.onload || !extension?.onunload) {
throw new Error("The loaded module is not a Roam extension.");
}

if (previousExtension?.onunload) {
await previousExtension.onunload();
}
previous?.stylesheet?.remove();
delete window[globalKey];

const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = `${baseUrl}/extension.css?v=${version}`;
stylesheet.dataset.roamPrototype = "__PROTOTYPE_NAME__";

try {
document.head.appendChild(stylesheet);
await extension.onload({
extensionAPI: undefined,
extension: { version: "roam/js" },
});
window[globalKey] = { extension, stylesheet };
} catch (error) {
try {
await extension.onunload();
} catch (cleanupError) {
console.error(
"Could not clean up the failed __PROTOTYPE_TITLE__ load:",
cleanupError,
);
}
stylesheet.remove();
throw error;
Comment thread
mdroidian marked this conversation as resolved.
}
});

window[loadKey] = currentLoad;
try {
await currentLoad;
} finally {
if (window[loadKey] === currentLoad) delete window[loadKey];
}
})().catch((error) => {
console.error("Could not load __PROTOTYPE_TITLE__:", error);
});
```

A `roam/js` block can use global `window.roamAlphaAPI` capabilities, but Roam does not provide the extension-scoped `extensionAPI` through this loading path. Features that require extension settings or other `extensionAPI` methods are available only with URL loading unless the prototype provides a fallback.
46 changes: 33 additions & 13 deletions packages/extension-base/template/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,42 @@ import { render as renderToast } from "roamjs-components/components/Toast";
import { runExtension } from "roamjs-components/util";
import "./styles.css";

export default runExtension(async () => {
if (process.env.NODE_ENV === "development") {
const reportRoamJsLoadFailure = (error: unknown) => {
console.error("Failed to load the __PROTOTYPE_TITLE__ prototype from roam/js.", error);
try {
renderToast({
id: "__PROTOTYPE_NAME__-loaded",
content: __LOAD_MESSAGE_JSON__,
intent: "success",
timeout: 800,
id: "__PROTOTYPE_NAME__-error",
content: "Failed to load __PROTOTYPE_TITLE__. See the developer console for details.",
intent: "danger",
});
} catch (toastError) {
console.error("Could not display the __PROTOTYPE_TITLE__ failure toast.", toastError);
}
};

// Add prototype behavior here. Register every observer, listener, command,
// timer, and mounted element for cleanup when the extension unloads.
export default runExtension(async (args) => {
try {
if (process.env.NODE_ENV === "development") {
renderToast({
id: "__PROTOTYPE_NAME__-loaded",
content: __LOAD_MESSAGE_JSON__,
intent: "success",
timeout: 800,
});
}

return {
unload: () => {
// Remove anything that is not returned through runExtension's registry.
},
};
// Add prototype behavior here. Register every observer, listener, command,
// timer, and mounted element for cleanup when the extension unloads.
// args.extensionAPI is available with URL loading and undefined from roam/js.

return {
unload: () => {
// Remove anything that is not returned through runExtension's registry.
},
};
} catch (error) {
if (args.extensionAPI) throw error;
reportRoamJsLoadFailure(error);
return {};
Comment thread
mdroidian marked this conversation as resolved.
}
});
75 changes: 73 additions & 2 deletions prototypes/loaded-dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,81 @@ Internal prototype for evaluation by Discourse Graphs.
- Opens a Blueprint alert when the extension loads.
- Confirms that the extension loaded successfully with a single **Got it** action.

## Install
## Install from a URL

Load this developer-extension URL in Roam:
In Roam, use **Load Developer Extensions from URL** with:

```text
https://discoursegraphs.com/releases/prototypes/loaded-dialog/
```

Roam supplies the extension API, loads `extension.css`, and unloads the extension in this mode.

## Load from roam/js

Paste this loader into a `roam/js` code block. To test a pull-request preview, change only `baseUrl` to the preview release directory posted on the pull request.

```javascript
(async () => {
const baseUrl =
"https://discoursegraphs.com/releases/prototypes/loaded-dialog";
const globalKey = "__roamPrototype:loaded-dialog";
const loadKey = `${globalKey}:load`;

const previousLoad = window[loadKey] ?? Promise.resolve();
const currentLoad = previousLoad.catch(() => {}).then(async () => {
const version = Date.now();
const previous = window[globalKey];
const previousExtension = previous?.extension ?? previous;

// Import and validate the replacement before unloading a working copy.
const module = await import(`${baseUrl}/extension.js?v=${version}`);
const extension = module.default;
if (!extension?.onload || !extension?.onunload) {
throw new Error("The loaded module is not a Roam extension.");
}

if (previousExtension?.onunload) {
await previousExtension.onunload();
}
previous?.stylesheet?.remove();
delete window[globalKey];

const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.href = `${baseUrl}/extension.css?v=${version}`;
stylesheet.dataset.roamPrototype = "loaded-dialog";

try {
document.head.appendChild(stylesheet);
await extension.onload({
extensionAPI: undefined,
extension: { version: "roam/js" },
});
window[globalKey] = { extension, stylesheet };
} catch (error) {
try {
await extension.onunload();
} catch (cleanupError) {
console.error(
"Could not clean up the failed Loaded Dialog load:",
cleanupError,
);
}
stylesheet.remove();
throw error;
}
});

window[loadKey] = currentLoad;
try {
await currentLoad;
} finally {
if (window[loadKey] === currentLoad) delete window[loadKey];
}
})().catch((error) => {
console.error("Could not load Loaded Dialog:", error);
});
```

A `roam/js` block can use global `window.roamAlphaAPI` capabilities, but Roam does not provide the extension-scoped `extensionAPI` through this loading path. Features that require extension settings or other `extensionAPI` methods are available only with URL loading unless the prototype provides a fallback.
29 changes: 24 additions & 5 deletions prototypes/loaded-dialog/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import { render as renderAlert } from "roamjs-components/components/SimpleAlert";
import { render as renderToast } from "roamjs-components/components/Toast";
import { runExtension } from "roamjs-components/util";
import "./styles.css";

export default runExtension(async () => {
await renderAlert({
content: "Loaded Dialog has loaded successfully.",
confirmText: "Got it",
});
const reportRoamJsLoadFailure = (error: unknown) => {
console.error("Failed to load Loaded Dialog from roam/js.", error);
try {
renderToast({
id: "loaded-dialog-error",
content: "Failed to load Loaded Dialog. See the developer console for details.",
intent: "danger",
});
} catch (toastError) {
console.error("Could not display the Loaded Dialog failure toast.", toastError);
}
};

export default runExtension(async (args) => {
try {
await renderAlert({
content: "Loaded Dialog has loaded successfully.",
confirmText: "Got it",
});
} catch (error) {
if (args.extensionAPI) throw error;
reportRoamJsLoadFailure(error);
}
});
11 changes: 11 additions & 0 deletions test/create-prototype.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ test("creates a complete prototype with catalog dependencies", async () => {
entry,
/import runExtension from "roamjs-components\/util\/runExtension";/,
);
assert.match(entry, /if \(args\.extensionAPI\) throw error;/);
const readme = await readFile(path.join(result.destination, "README.md"), "utf8");
assert.match(readme, /Load Developer Extensions from URL/);
assert.match(readme, /extensionAPI: undefined/);
assert.match(readme, /extension\.css\?v=/);
assert.match(readme, /previousExtension\?\.onunload/);
assert.match(readme, /const loadKey = `\$\{globalKey\}:load`/);
assert.match(readme, /window\[loadKey\] = currentLoad/);
const loader = /```javascript\r?\n([\s\S]*?)\r?\n```/.exec(readme)?.[1];
assert.ok(loader, "generated README should contain a roam/js loader");
assert.doesNotThrow(() => new Function(loader));
});
});

Expand Down
Loading