From c4f976289e5f2ddcda360600c054aef2edfe3221 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Nov 2026 01:27:47 +0000 Subject: [PATCH 1/2] feat: self-contained HTML page of a space's map views A page host (src/page) shows the snapshots carried inline in its page in the shared Viewer. scripts/build-page.ts bundles it with React and inlines it, with the CSS, into the template dist/page.html, so a page opens offline. fillPage writes a title and snapshots into the template, escaping `<` so data can't end the script element. snapshotOf snapshots a space from a context and an already assembled (e.g. filtered) graph; exportSnapshot now uses it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- AGENTS.md | 2 +- docs/architecture.md | 3 +- packages/map-views/package.json | 2 +- packages/map-views/scripts/build-page.ts | 48 +++++++++++++++++++ packages/map-views/src/node/page.ts | 21 ++++++++ packages/map-views/src/node/snapshot.ts | 20 ++++++-- packages/map-views/src/page/main.tsx | 24 ++++++++++ .../map-views/tests/module-boundaries.test.ts | 4 +- packages/map-views/tests/page.test.ts | 44 +++++++++++++++++ 9 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 packages/map-views/scripts/build-page.ts create mode 100644 packages/map-views/src/node/page.ts create mode 100644 packages/map-views/src/page/main.tsx create mode 100644 packages/map-views/tests/page.test.ts diff --git a/AGENTS.md b/AGENTS.md index 86cf7b7..21375eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Declarative map views of structured-context spaces: the definition format, the r - `packages/map-views`: the published library, `structured-context-map-views`. Its `src/index.ts` is the public surface; add an export only when a host needs it. - `packages/map-views/src/core`: framework-agnostic. Definitions, parameters, graph helpers and the projections. No React, DOM, Node or runtime structured-context imports (type-only is fine, and `structured-context/types` is browser-safe); `tests/module-boundaries.test.ts` enforces this. - `packages/map-views/src/layouts`: React components and the SVG edge overlay. Don't import host code from here. -- `packages/map-views/src/viewer`: the page-filling viewer that hosts share. It knows nothing about any one host: anything host-specific (such as card labels) is an option the host passes. Hosts (`src/artifact`) are imported by nothing else. +- `packages/map-views/src/viewer`: the page-filling viewer that hosts share. It knows nothing about any one host: anything host-specific (such as card labels) is an option the host passes. Hosts (`src/artifact`, `src/page`) are imported by nothing else. - `packages/map-views/src/node`: Node-only definition discovery (`loadMapViewDefinitions`), shipped as its own entry point (`structured-context-map-views/node`). Core and layouts must not import it. - `apps/navigator`: the standalone host (private package: a Vite app plus dev-server middleware calling the structured-context API). It consumes the library like any other host. - `packages/map-views/map-views/*.json5`: definitions bundled with the library, only for structured-context's bundled schemas (`$id` `sctx-map-view://core/`). `bun run generate:definitions` embeds them as `src/node/bundled-definitions.ts`; a test fails if that is stale. Plugins ship their own; personal ones go in a `map-views/` folder next to the structured-context config. diff --git a/docs/architecture.md b/docs/architecture.md index a17a796..f9d7458 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,7 @@ flowchart LR | `packages/map-views/src/core` | Definition parsing and validation, parameter resolution, graph helpers, and the projections (`buildTree`, `buildLandscape`, `buildXYPlot`) | Types from structured-context only. No React, DOM or Node | | `packages/map-views/src/layouts` | `MapView`, the layout components, `NodeCard`, `EdgeOverlay`, the parameter controls (`useParameterControls`, the typed inputs and the default `ParameterControls` layout), styles | core, React | | `packages/map-views/src/viewer` | `Viewer`: a page-filling viewer for space snapshots (space and map view switcher, parameter controls, the map view, a side panel with a node's fields and validation results) and its page styles. Hosts pass the snapshots and options such as card labels | core, layouts, React, `structured-context/browser` | -| `packages/map-views/src/node` | `loadMapViewDefinitions`: finding definitions in the library (embedded at build time from `packages/map-views/map-views/`), plugins and config folders; `exportSnapshot` and the `export` bin, which snapshot a space for browser-only hosts | core, Node, structured-context | +| `packages/map-views/src/node` | `loadMapViewDefinitions`: finding definitions in the library (embedded at build time from `packages/map-views/map-views/`), plugins and config folders; `exportSnapshot`, `snapshotOf` and the `export` bin, which snapshot a space for browser-only hosts; `fillPage`, which writes snapshots into the self-contained page | core, Node, structured-context | | `apps/navigator` | The standalone host: a Vite app plus dev-server middleware calling the structured-context API | Anything | `packages/map-views/src/index.ts` is the published entry point and re-exports the few things hosts need (modules import each other directly, with no barrel files); `src/node` is the second entry point, and `src/node/cli.ts` is the bin. The library build puts the projections and annotations in a chunk of their own, so bundling just those from the package root leaves out React and ajv. `packages/map-views/tests/module-boundaries.test.ts` enforces the core, layouts and viewer rules, including that none imports the Node module or a host. Core runs anywhere (browser, Node, worker) and is tested without a DOM, against fixtures captured from spaces (`packages/map-views/tests/fixtures/`, written by `packages/map-views/scripts/capture-fixtures.ts`). The public sample space in `samples/launch-playbook/` is the source of `launch-playbook.json`. @@ -57,4 +57,5 @@ Hosts theme map views through nine variables: `--mv-color-surface`, `--mv-color- - **Navigator** (`apps/navigator`): dev-server middleware serves spaces (from the structured-context config), the definitions `loadMapViewDefinitions` finds, and, when a map view asks for them, validation results (`validateSpace`, expression rules only), as JSON. The chosen space, map view and parameter values live in the query string, so a reload restores them. Selecting a node opens a side panel with its fields, content and validation results. - **Claude artifact** (`packages/map-views/src/artifact`, shipped as `dist/artifact.js` with its page `dist/artifact.html`): a host with no server. The publishing agent runs the `export` command and writes each space to the artifact's database (`spaces/`: `schemaId`, and `graph`, `validation` and `definitions` as JSON strings). The page lists that collection and shows it in the `Viewer`. It declares only the `db` capability and shows a message when the database is unavailable, empty or slow (10 s). Claude's artifact viewer allows scripts only from a few CDNs and blocks external stylesheets, so `scripts/build-artifact-page.ts` writes a page that carries the CSS inline and loads `dist/artifact.js` (pinned to this package's version) and its dependencies from jsDelivr through an import map; `react` and `react-dom` are pinned to one version so the page runs one React. The host names cards `[] · <view>` through the `Viewer`'s `cardLabel` option, where the index is the node's position in the stored graph's `nodes` array, because the artifact viewer's comment mode reads a comment's location from it (cut at about 45 characters, too short for a node id), so a comment tells an agent which node it concerns. Refreshing the data is writing the documents again; the page doesn't change. +- **Self-contained page** (`packages/map-views/src/page`, built into the template `dist/page.html` by `scripts/build-page.ts`): one HTML file with the page host, React and the CSS inlined, so it opens offline. `fillPage` writes a title and the snapshots into the template as a JSON script element, with `<` escaped so no string in the data can end it. The page shows them in the `Viewer`. - **Obsidian plugin** ([obsidian-structured-context](https://github.com/mindsocket/obsidian-structured-context)): renders map views in a pane, builds the data in-process, opens the source note on selection and keeps parameter values in the view state. diff --git a/packages/map-views/package.json b/packages/map-views/package.json index e39aa0d..66054ad 100644 --- a/packages/map-views/package.json +++ b/packages/map-views/package.json @@ -40,7 +40,7 @@ "generate": "bun run generate:schemas && bun run generate:definitions", "generate:schemas": "bun scripts/generate-schemas.ts", "generate:definitions": "bun scripts/generate-definitions.ts", - "build": "bun run clean && bun run generate && bun --bun vite build && tsc -p tsconfig.build.json && tailwindcss -i src/layouts/styles.css -o dist/styles.css && bun scripts/build-artifact-page.ts", + "build": "bun run clean && bun run generate && bun --bun vite build && tsc -p tsconfig.build.json && tailwindcss -i src/layouts/styles.css -o dist/styles.css && bun scripts/build-page.ts && bun scripts/build-artifact-page.ts", "typecheck": "tsc --noEmit", "capture-fixtures": "bun scripts/capture-fixtures.ts", "test": "bun test", diff --git a/packages/map-views/scripts/build-page.ts b/packages/map-views/scripts/build-page.ts new file mode 100644 index 0000000..7e6bb19 --- /dev/null +++ b/packages/map-views/scripts/build-page.ts @@ -0,0 +1,48 @@ +/** + * Writes dist/page.html: the template for a self-contained page, with the page host (`src/page/main.tsx`), + * React and everything else it imports bundled and inlined, and the CSS inlined. `fillPage` writes a title and + * the snapshots into it. Run after the library build, which writes dist/styles.css. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PAGE_SLOTS } from '../src/node/page'; + +const root = join(import.meta.dir, '..'); + +const result = await Bun.build({ + entrypoints: [join(root, 'src/page/main.tsx')], + target: 'browser', + format: 'esm', + minify: true, + define: { 'process.env.NODE_ENV': '"production"' }, +}); +if (!result.success) throw new AggregateError(result.logs, 'Bundling the page host failed'); +if (result.outputs.length !== 1) throw new Error(`Expected one bundle, got ${result.outputs.length}.`); +// A script element ends at the first `</script`, wherever it appears +const js = (await result.outputs[0]!.text()).replaceAll('</script', '<\\/script'); + +const css = [join(root, 'dist/styles.css'), join(root, 'src/viewer/viewer.css')] + .map((f) => readFileSync(f, 'utf8')) + .join('\n') + .replaceAll('</style', '<\\/style'); + +const page = `<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>${PAGE_SLOTS.title} + + + +
+ + + + +`; +writeFileSync(join(root, 'dist/page.html'), page); +console.log(`dist/page.html: ${(page.length / 1024).toFixed(0)} KB`); diff --git a/packages/map-views/src/node/page.ts b/packages/map-views/src/node/page.ts new file mode 100644 index 0000000..1941185 --- /dev/null +++ b/packages/map-views/src/node/page.ts @@ -0,0 +1,21 @@ +import type { SpaceSnapshot } from '../core/snapshot'; + +/** Where `fillPage` writes the page's title and snapshots in the template `scripts/build-page.ts` builds. */ +export const PAGE_SLOTS = { title: '', snapshots: '' } as const; + +/** + * A self-contained HTML page showing `snapshots` in the viewer: `template` (`dist/page.html`) with the title + * and the snapshots written in. The page needs no network. + */ +export function fillPage(template: string, snapshots: SpaceSnapshot[], title: string): string { + for (const slot of Object.values(PAGE_SLOTS)) { + if (template.split(slot).length !== 2) throw new Error(`The page template must hold ${slot} exactly once.`); + } + // `<` escaped, so no string in the data can close the script element or open a comment + const json = JSON.stringify(snapshots).replaceAll('<', '\\u003c'); + return template.replace(PAGE_SLOTS.title, () => escapeHtml(title)).replace(PAGE_SLOTS.snapshots, () => json); +} + +function escapeHtml(text: string): string { + return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/packages/map-views/src/node/snapshot.ts b/packages/map-views/src/node/snapshot.ts index 9df64ad..f384a35 100644 --- a/packages/map-views/src/node/snapshot.ts +++ b/packages/map-views/src/node/snapshot.ts @@ -3,6 +3,8 @@ import { type Config, createSpaceContext, exportSpaceGraph, + type SpaceContext, + type SpaceGraph, validateSpace, } from 'structured-context/api'; import { mapViewsForSchema } from '../core/definition'; @@ -26,14 +28,26 @@ export async function exportSnapshot( validateOptions: ExportValidateOptions = {}, ): Promise { const context = createSpaceContext(spaceName, config); - const graph = await assembleSpaceGraph(context, {}); + return snapshotOf(context, await assembleSpaceGraph(context, {}), configDir, validateOptions); +} + +/** + * Node-only: snapshot a space from a context and a graph already assembled for it, such as a filtered one. + * Validation covers the whole space, so issues about nodes outside a filtered graph arrive unattached. + */ +export async function snapshotOf( + context: SpaceContext, + graph: SpaceGraph, + configDir?: string, + validateOptions: ExportValidateOptions = {}, +): Promise { const schemaId = context.schema.$id; const validation = await validateSpace(context, validateOptions); return { - space: spaceName, + space: context.space.name, schemaId, graph: exportSpaceGraph(graph.nodes.values(), context.schema.metadata), validation: { issues: validation.issues }, - definitions: mapViewsForSchema(loadMapViewDefinitions(config, { configDir }), schemaId), + definitions: mapViewsForSchema(loadMapViewDefinitions(context.config, { configDir }), schemaId), }; } diff --git a/packages/map-views/src/page/main.tsx b/packages/map-views/src/page/main.tsx new file mode 100644 index 0000000..c27eb4b --- /dev/null +++ b/packages/map-views/src/page/main.tsx @@ -0,0 +1,24 @@ +import { createRoot } from 'react-dom/client'; +import type { SpaceSnapshot } from '../core/snapshot'; +import { Viewer } from '../viewer/Viewer'; + +/** + * The host for a self-contained HTML page: shows the snapshots carried inline in the page, in the + * ``; +const sample = join(import.meta.dir, '../../../samples/launch-playbook'); + +const carried = (html: string) => JSON.parse(/id="s">(.*)<\/script>/s.exec(html)![1]!); + +describe('fillPage', () => { + test('writes the title and snapshots, escaped so data cannot close the script or title', () => { + const snapshot = { + space: 'a', snapshots: '' } as const; -/** - * A self-contained HTML page showing `snapshots` in the viewer: `template` (`dist/page.html`) with the title - * and the snapshots written in. The page needs no network. - */ +/** `template` (`dist/page.html`) with the title and snapshots written in. */ export function fillPage(template: string, snapshots: SpaceSnapshot[], title: string): string { for (const slot of Object.values(PAGE_SLOTS)) { if (template.split(slot).length !== 2) throw new Error(`The page template must hold ${slot} exactly once.`); } - // `<` escaped, so no string in the data can close the script element or open a comment + // Escaping `<` stops a string in the data ending the script element const json = JSON.stringify(snapshots).replaceAll('<', '\\u003c'); return template.replace(PAGE_SLOTS.title, () => escapeHtml(title)).replace(PAGE_SLOTS.snapshots, () => json); } diff --git a/packages/map-views/src/node/snapshot.ts b/packages/map-views/src/node/snapshot.ts index f384a35..9df64ad 100644 --- a/packages/map-views/src/node/snapshot.ts +++ b/packages/map-views/src/node/snapshot.ts @@ -3,8 +3,6 @@ import { type Config, createSpaceContext, exportSpaceGraph, - type SpaceContext, - type SpaceGraph, validateSpace, } from 'structured-context/api'; import { mapViewsForSchema } from '../core/definition'; @@ -28,26 +26,14 @@ export async function exportSnapshot( validateOptions: ExportValidateOptions = {}, ): Promise { const context = createSpaceContext(spaceName, config); - return snapshotOf(context, await assembleSpaceGraph(context, {}), configDir, validateOptions); -} - -/** - * Node-only: snapshot a space from a context and a graph already assembled for it, such as a filtered one. - * Validation covers the whole space, so issues about nodes outside a filtered graph arrive unattached. - */ -export async function snapshotOf( - context: SpaceContext, - graph: SpaceGraph, - configDir?: string, - validateOptions: ExportValidateOptions = {}, -): Promise { + const graph = await assembleSpaceGraph(context, {}); const schemaId = context.schema.$id; const validation = await validateSpace(context, validateOptions); return { - space: context.space.name, + space: spaceName, schemaId, graph: exportSpaceGraph(graph.nodes.values(), context.schema.metadata), validation: { issues: validation.issues }, - definitions: mapViewsForSchema(loadMapViewDefinitions(context.config, { configDir }), schemaId), + definitions: mapViewsForSchema(loadMapViewDefinitions(config, { configDir }), schemaId), }; } diff --git a/packages/map-views/src/page/main.tsx b/packages/map-views/src/page/main.tsx index c27eb4b..901cc29 100644 --- a/packages/map-views/src/page/main.tsx +++ b/packages/map-views/src/page/main.tsx @@ -2,11 +2,7 @@ import { createRoot } from 'react-dom/client'; import type { SpaceSnapshot } from '../core/snapshot'; import { Viewer } from '../viewer/Viewer'; -/** - * The host for a self-contained HTML page: shows the snapshots carried inline in the page, in the - * ``; -const sample = join(import.meta.dir, '../../../samples/launch-playbook'); const carried = (html: string) => JSON.parse(/id="s">(.*)<\/script>/s.exec(html)![1]!); @@ -29,16 +25,3 @@ describe('fillPage', () => { expect(() => fillPage('', [], 'x')).toThrow(PAGE_SLOTS.title); }); }); - -describe('snapshotOf', () => { - test('snapshots the graph it is given', async () => { - const config = loadConfig({ startDir: sample }); - const context = createSpaceContext('launch-playbook', config); - const graph = await assembleSpaceGraph(context, { filter: "WHERE resolvedType = 'Phases'" }); - const snapshot = await snapshotOf(context, graph, sample); - expect(snapshot.space).toBe('launch-playbook'); - expect(snapshot.graph.nodes.length).toBe(graph.nodes.size); - expect(snapshot.graph.nodes.length).toBeGreaterThan(0); - expect(snapshot.graph.nodes.length).toBeLessThan((await assembleSpaceGraph(context)).nodes.size); - }); -});