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..a94ad5c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,8 +17,8 @@ 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/viewer` | `Viewer`, the page-filling UI that hosts share. What is specific to a host comes in as an option | 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; `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`): a host that needs no server or network. The build inlines it, React and the CSS into the template `dist/page.html`, and `fillPage` writes snapshots into it. - **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..f6a02cf --- /dev/null +++ b/packages/map-views/scripts/build-page.ts @@ -0,0 +1,44 @@ +/** Writes dist/page.html, the page template with its JS and CSS inlined. Run after the library build. */ + +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..af6bb5e --- /dev/null +++ b/packages/map-views/src/node/page.ts @@ -0,0 +1,17 @@ +import type { SpaceSnapshot } from '../core/snapshot'; + +export const PAGE_SLOTS = { title: '', snapshots: '' } as const; + +/** `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.`); + } + // 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); +} + +function escapeHtml(text: string): string { + return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} diff --git a/packages/map-views/src/page/main.tsx b/packages/map-views/src/page/main.tsx new file mode 100644 index 0000000..901cc29 --- /dev/null +++ b/packages/map-views/src/page/main.tsx @@ -0,0 +1,20 @@ +import { createRoot } from 'react-dom/client'; +import type { SpaceSnapshot } from '../core/snapshot'; +import { Viewer } from '../viewer/Viewer'; + +/** The host for a self-contained page: shows the snapshots the page carries. */ + +function readSnapshots(): SpaceSnapshot[] { + const text = document.getElementById('sctx-snapshots')?.textContent?.trim(); + if (!text) throw new Error('This page carries no space snapshot.'); + const snapshots = JSON.parse(text) as SpaceSnapshot[]; + if (!Array.isArray(snapshots) || snapshots.length === 0) throw new Error('This page carries no space snapshot.'); + return snapshots; +} + +const root = createRoot(document.getElementById('root')!); +try { + root.render(); +} catch (err) { + root.render(

{err instanceof Error ? err.message : String(err)}

); +} diff --git a/packages/map-views/tests/module-boundaries.test.ts b/packages/map-views/tests/module-boundaries.test.ts index e9d26bc..80c2fda 100644 --- a/packages/map-views/tests/module-boundaries.test.ts +++ b/packages/map-views/tests/module-boundaries.test.ts @@ -39,9 +39,9 @@ describe('module boundaries', () => { expect(importsIn('viewer').filter(({ specifier }) => !allowed.test(specifier))).toEqual([]); }); - test('no module imports the artifact host', () => { + test('no module imports a host', () => { const imports = ['core', 'layouts', 'node', 'viewer'].flatMap(importsIn); - expect(imports.filter(({ specifier }) => /^(\.\.\/)+artifact(\/|$)/.test(specifier))).toEqual([]); + expect(imports.filter(({ specifier }) => /^(\.\.\/)+(artifact|page)(\/|$)/.test(specifier))).toEqual([]); }); }); diff --git a/packages/map-views/tests/page.test.ts b/packages/map-views/tests/page.test.ts new file mode 100644 index 0000000..adf6fe6 --- /dev/null +++ b/packages/map-views/tests/page.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import type { SpaceSnapshot } from '../src/core/snapshot'; +import { fillPage, PAGE_SLOTS } from '../src/node/page'; + +const template = `${PAGE_SLOTS.title}`; + +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