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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`). `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.
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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/<space>`: `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 `[<index>] <title> · <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.
2 changes: 1 addition & 1 deletion packages/map-views/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packages/map-views/scripts/build-page.ts
Original file line number Diff line number Diff line change
@@ -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}</title>
<style>
${css}
</style>
</head>
<body>
<div id="root"></div>
<script type="application/json" id="sctx-snapshots">${PAGE_SLOTS.snapshots}</script>
<script type="module">${js}</script>
</body>
</html>
`;
writeFileSync(join(root, 'dist/page.html'), page);
console.log(`dist/page.html: ${(page.length / 1024).toFixed(0)} KB`);
17 changes: 17 additions & 0 deletions packages/map-views/src/node/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { SpaceSnapshot } from '../core/snapshot';

export const PAGE_SLOTS = { title: '<!--sctx-title-->', snapshots: '<!--sctx-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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
20 changes: 20 additions & 0 deletions packages/map-views/src/page/main.tsx
Original file line number Diff line number Diff line change
@@ -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(<Viewer spaces={readSnapshots()} />);
} catch (err) {
root.render(<p className="viewer-message">{err instanceof Error ? err.message : String(err)}</p>);
}
4 changes: 2 additions & 2 deletions packages/map-views/tests/module-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});

Expand Down
27 changes: 27 additions & 0 deletions packages/map-views/tests/page.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<title>${PAGE_SLOTS.title}</title><script type="application/json" id="s">${PAGE_SLOTS.snapshots}</script>`;

const carried = (html: string) => JSON.parse(/id="s">(.*)<\/script>/s.exec(html)![1]!);

describe('fillPage', () => {
Comment thread
mindsocket marked this conversation as resolved.
Resolved
test('writes the title and snapshots, escaped so data cannot close the script or title', () => {
const snapshot = {
space: 'a</script><!--',
schemaId: undefined,
graph: {},
validation: { issues: [] },
definitions: [],
};
const html = fillPage(template, [snapshot as unknown as SpaceSnapshot], '<b>Plan</b> & $&');
expect(html).toContain('<title>&lt;b&gt;Plan&lt;/b&gt; &amp; $&amp;</title>');
expect(html.match(/<\/script>/g)).toHaveLength(1);
expect(carried(html)).toEqual([{ ...snapshot, schemaId: undefined }]);
});

test('rejects a template without each slot exactly once', () => {
expect(() => fillPage('<title></title>', [], 'x')).toThrow(PAGE_SLOTS.title);
});
});
Loading