diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c528ce..490c7db 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,7 @@ jobs: with: bun-version: latest - run: bun install --frozen-lockfile + - run: bun run --cwd packages/map-views build:library - run: bun run lint - run: bun run typecheck - run: bun run test diff --git a/AGENTS.md b/AGENTS.md index 21375eb..add07f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Declarative map views of structured-context spaces: the definition format, the r - `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`, `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. +- `packages/sctx-map-views`: the structured-context render plugin (format `map-views.html`), bundling the library's page template and `/node` entry. It reads everything through the render callbacks and imports structured-context for types only. - `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. - `packages/map-views/src/core/format/*.ts`: the definition format's JSON Schemas (`as const`), the source of truth. Types derive from them (`FromSchema`); `bun run generate:schemas` writes `schemas/generated/latest/` at the repo root (so the published URLs stay stable), and a test fails if those are stale; `--release` (run when cutting a release) also freezes `schemas/generated//` if the format changed. `$id`s are the published https URLs (`schema-url.ts`). Use absolute `${SCHEMA_URL}/…` refs: `FromSchema` doesn't resolve relative refs inside referenced schemas. diff --git a/README.md b/README.md index f13aff5..33231d3 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This repo holds: - **The library** (`packages/map-views`, package `structured-context-map-views`): React components that render a map view inside any host app, using the browser's own layout engine plus an SVG overlay for connectors. - **An `export` command** (`structured-context-map-views export `): a JSON snapshot of a space (graph, validation results, map view definitions) for rendering a map view in a host that can't run structured-context. - **A Claude artifact host** (`dist/artifact.html`, `dist/artifact.js`): publishes a space's map views as a private artifact whose data lives in the artifact database. +- **A render plugin** (`packages/sctx-map-views`, package `sctx-map-views`): `sctx render map-views.html` writes a space and its map views as one self-contained interactive HTML file. - **The navigator** (`apps/navigator`, not published): a standalone app for browsing your spaces through their map views. Pre-1.0. Docs: diff --git a/bun.lock b/bun.lock index bdf9881..d00d81d 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/map-views": { "name": "structured-context-map-views", - "version": "0.5.0", + "version": "0.6.0", "bin": { "structured-context-map-views": "dist/cli.js", }, @@ -60,6 +60,17 @@ "structured-context": "^0.23.0", }, }, + "packages/sctx-map-views": { + "name": "sctx-map-views", + "version": "0.0.0", + "devDependencies": { + "structured-context": "^0.24.0", + "structured-context-map-views": "workspace:*", + }, + "peerDependencies": { + "structured-context": "^0.24.0", + }, + }, }, "packages": { "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], @@ -510,6 +521,8 @@ "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "sctx-map-views": ["sctx-map-views@workspace:packages/sctx-map-views"], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], @@ -518,7 +531,7 @@ "strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="], - "structured-context": ["structured-context@0.23.0", "", { "dependencies": { "ajv": "^8.20.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "js-yaml": "^5.4.3", "json-schema-to-ts": "^3.1.1", "json5": "^2.2.3", "jsonata": "^2.2.1", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "typescript": "^5" }, "bin": { "sctx": "dist/index.js" } }, "sha512-KWriaOPuyYW2kDoYEjDYvyHm7s1bUQqQ6QT8c2fRUDRbKzS65mFJhc3o9e7TpdFLwXi6VM3NFZEKszww9ZoYPQ=="], + "structured-context": ["structured-context@0.24.0", "", { "dependencies": { "ajv": "^8.20.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "js-yaml": "^5.4.3", "json-schema-to-ts": "^3.1.1", "json5": "^2.2.3", "jsonata": "^2.2.1", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "typescript": "^5" }, "bin": { "sctx": "dist/index.js" } }, "sha512-Bo+XOIwIgkaQyOeWugRKNvdoe/O6LH/hartIHU7Ifq0BaUNIi4Tuq1pnYnaZELtFjcAnQ9pAh/wBOzfIw5OCnw=="], "structured-context-map-views": ["structured-context-map-views@workspace:packages/map-views"], @@ -580,6 +593,10 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "structured-context-map-views/structured-context": ["structured-context@0.23.0", "", { "dependencies": { "ajv": "^8.20.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "js-yaml": "^5.4.3", "json-schema-to-ts": "^3.1.1", "json5": "^2.2.3", "jsonata": "^2.2.1", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "typescript": "^5" }, "bin": { "sctx": "dist/index.js" } }, "sha512-KWriaOPuyYW2kDoYEjDYvyHm7s1bUQqQ6QT8c2fRUDRbKzS65mFJhc3o9e7TpdFLwXi6VM3NFZEKszww9ZoYPQ=="], + + "structured-context-navigator/structured-context": ["structured-context@0.23.0", "", { "dependencies": { "ajv": "^8.20.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "js-yaml": "^5.4.3", "json-schema-to-ts": "^3.1.1", "json5": "^2.2.3", "jsonata": "^2.2.1", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "typescript": "^5" }, "bin": { "sctx": "dist/index.js" } }, "sha512-KWriaOPuyYW2kDoYEjDYvyHm7s1bUQqQ6QT8c2fRUDRbKzS65mFJhc3o9e7TpdFLwXi6VM3NFZEKszww9ZoYPQ=="], + "@tailwindcss/node/lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], diff --git a/docs/api.md b/docs/api.md index 607cfe0..77da2f6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -72,6 +72,10 @@ Map views take structured-context's `SpaceGraph`. A Node host (such as the Obsid `dist/artifact.html` is a page that needs no edits: publish it as a Claude artifact declaring the `db` capability, then write each space as `spaces/` from the `export` document: `{ schemaId, graph, validation, definitions }`, the last three as JSON strings. The page lists the `spaces` collection, so one artifact can show several spaces, and refreshing is writing the documents again. The page pins this package's version (loaded from jsDelivr) and carries the styles inline. Comments on a card report its accessible name, `[] · <view>`; the index is the node's position in the stored graph's `nodes` array. +## Rendering to an HTML file + +`sctx-map-views` is a structured-context plugin. Name it in a space's `plugins`, or put it in the config root's plugins folder, and `sctx render <space> map-views.html -o map.html` writes one HTML file with the page, its scripts and the space's data inlined. Definitions come from the same places as everywhere else. Validation runs with the `-d` options `semantic`, `depth`, `ruleFilter` (comma-separated) and `includeGated`. + ## Validation results `annotationsFrom(input, graph)` attaches structured-context's validation issues to the graph's nodes. Pass it a `ValidationResult` or the output of `sctx validate --json` as it is. It returns the `annotations` to pass to `MapView`, and the `unattached` issues, which have no node in the graph. The definition's `nodes.validation` and `nodes.validationSources` choose which annotations show. diff --git a/docs/architecture.md b/docs/architecture.md index a94ad5c..3a661d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,6 +19,7 @@ flowchart LR | `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`, 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 | +| `packages/sctx-map-views` | The structured-context render plugin: format `map-views.html`. Takes the graph, validation issues and definition folders from the render callbacks, and writes them into the page template | `structured-context-map-views` and its `/node` entry, structured-context API types | | `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,5 +58,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. +- **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. The `sctx-map-views` plugin bundles that template. - **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 66054ad..3f1f555 100644 --- a/packages/map-views/package.json +++ b/packages/map-views/package.json @@ -33,14 +33,16 @@ "types": "./dist/types/node/index.d.ts", "default": "./dist/node.js" }, - "./styles.css": "./dist/styles.css" + "./styles.css": "./dist/styles.css", + "./page.html": "./dist/page.html" }, "scripts": { "clean": "rm -rf dist", "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-page.ts && bun scripts/build-artifact-page.ts", + "build:library": "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", + "build": "bun run build:library && 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/src/node/index.ts b/packages/map-views/src/node/index.ts index 7763a5a..82aa29f 100644 --- a/packages/map-views/src/node/index.ts +++ b/packages/map-views/src/node/index.ts @@ -15,7 +15,7 @@ const DEFINITIONS_DIR = 'map-views'; const CONFIG_PLUGINS_DIR = 'plugins'; const PACKAGE_NAME = 'structured-context-map-views'; -type Source = { kind: 'core' } | { kind: 'plugin'; plugin: string } | { kind: 'config' }; +export type DefinitionSource = { kind: 'core' } | { kind: 'plugin'; plugin: string } | { kind: 'config' }; export type LoadMapViewDefinitionsOptions = { /** Directory of the main config file. Defaults to where structured-context finds it. */ @@ -39,7 +39,7 @@ export function loadMapViewDefinitions( const configDir = options.configDir ?? dirname(resolve(resolveConfigPath().path)); const configDirs = [...new Set([configDir, ...config.spaces.flatMap((s) => (s.sourceDir ? [s.sourceDir] : []))])]; - const folders: { dir: string; source: Source }[] = [ + const folders: { dir: string; source: DefinitionSource }[] = [ ...pluginDirs(config, configDir, configDirs).flatMap(([plugin, dir]) => { const definitions = pluginDefinitionsDir(dir); return definitions ? [{ dir: definitions, source: { kind: 'plugin', plugin } as const }] : []; @@ -47,9 +47,17 @@ export function loadMapViewDefinitions( ...configDirs.map((dir) => ({ dir: join(dir, DEFINITIONS_DIR), source: { kind: 'config' } as const })), ]; + return readDefinitionFolders(folders); +} + +/** + * Definitions in `folders` and this package's embedded ones, each checked against the namespace of its source. + * Throws on the first invalid definition or a repeated id. + */ +export function readDefinitionFolders(folders: { dir: string; source: DefinitionSource }[]): MapViewDefinition[] { const foundAt = new Map<string, string>(); const definitions: MapViewDefinition[] = []; - const add = (label: string, text: string, source: Source) => { + const add = (label: string, text: string, source: DefinitionSource) => { const definition = parseMapViewDefinition(label, text); assertIdNamespace(definition.id, source, label); const existing = foundAt.get(definition.id); @@ -111,7 +119,7 @@ function pluginDefinitionsDir(pluginDir: string): string | undefined { return join(pluginDir, DEFINITIONS_DIR); } -function assertIdNamespace(id: string, source: Source, path: string): void { +function assertIdNamespace(id: string, source: DefinitionSource, path: string): void { const segments = id.slice(SCHEME.length).split('/'); const expected = source.kind === 'core' @@ -131,3 +139,5 @@ function assertIdNamespace(id: string, source: Source, path: string): void { function isDirectory(path: string): boolean { return existsSync(path) && statSync(path).isDirectory(); } + +export { fillPage } from './page'; diff --git a/packages/sctx-map-views/package.json b/packages/sctx-map-views/package.json new file mode 100644 index 0000000..704c04b --- /dev/null +++ b/packages/sctx-map-views/package.json @@ -0,0 +1,40 @@ +{ + "name": "sctx-map-views", + "version": "0.0.0", + "description": "structured-context render plugin: a space's map views as one self-contained interactive HTML file.", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/mindsocket/structured-context-map-views.git", + "directory": "packages/sctx-map-views" + }, + "keywords": [ + "structured-context", + "sctx", + "plugin", + "map-view" + ], + "files": [ + "dist/" + ], + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "clean": "rm -rf dist", + "build": "bun run clean && bun run --cwd ../map-views build:library && bun scripts/build.ts && tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "peerDependencies": { + "structured-context": "^0.24.0" + }, + "devDependencies": { + "structured-context": "^0.24.0", + "structured-context-map-views": "workspace:*" + } +} diff --git a/packages/sctx-map-views/scripts/build.ts b/packages/sctx-map-views/scripts/build.ts new file mode 100644 index 0000000..59a4837 --- /dev/null +++ b/packages/sctx-map-views/scripts/build.ts @@ -0,0 +1,13 @@ +/** Bundles the plugin into dist/index.js: the map views library and the page template are inlined, structured-context is not. */ + +import { join } from 'node:path'; + +const root = join(import.meta.dir, '..'); +const result = await Bun.build({ + entrypoints: [join(root, 'src/index.ts')], + outdir: join(root, 'dist'), + target: 'node', + format: 'esm', + external: ['structured-context', 'structured-context/*'], +}); +if (!result.success) throw new AggregateError(result.logs, 'Bundling the plugin failed'); diff --git a/packages/sctx-map-views/src/html.d.ts b/packages/sctx-map-views/src/html.d.ts new file mode 100644 index 0000000..5bcd649 --- /dev/null +++ b/packages/sctx-map-views/src/html.d.ts @@ -0,0 +1,4 @@ +declare module 'structured-context-map-views/page.html' { + const text: string; + export default text; +} diff --git a/packages/sctx-map-views/src/index.ts b/packages/sctx-map-views/src/index.ts new file mode 100644 index 0000000..714bcdc --- /dev/null +++ b/packages/sctx-map-views/src/index.ts @@ -0,0 +1,13 @@ +import type { StructuredContextPlugin } from 'structured-context/api'; +import { renderHtml } from './render'; + +const plugin: StructuredContextPlugin = { + name: 'sctx-map-views', + configSchema: { type: 'object', additionalProperties: false }, + render: { + formats: [{ name: 'html', description: 'Self-contained interactive HTML page of the space and its map views' }], + render: (context, _graph, { data }) => renderHtml(context, data), + }, +}; + +export default plugin; diff --git a/packages/sctx-map-views/src/render.ts b/packages/sctx-map-views/src/render.ts new file mode 100644 index 0000000..f12cd9d --- /dev/null +++ b/packages/sctx-map-views/src/render.ts @@ -0,0 +1,43 @@ +import type { PluginContext } from 'structured-context/api'; +import { mapViewsForSchema } from 'structured-context-map-views'; +import { fillPage, readDefinitionFolders } from 'structured-context-map-views/node'; +import pageTemplate from 'structured-context-map-views/page.html' with { type: 'text' }; + +const DEFINITIONS = 'map-views'; + +type Snapshot = Parameters<typeof fillPage>[1][number]; + +/** The `-d` options that reach validation, with the same names as the `validate` command's flags. */ +function validateOptions(data: Record<string, unknown> | undefined) { + const { semantic, depth, ruleFilter, includeGated } = data ?? {}; + return { + semantic: semantic === 'true' ? true : (semantic as string | undefined), + depth: depth === undefined ? undefined : Number(depth), + ruleFilter: typeof ruleFilter === 'string' ? ruleFilter.split(',') : undefined, + includeGated: includeGated === 'true' ? true : undefined, + }; +} + +export async function renderHtml(context: PluginContext, data: Record<string, unknown> | undefined): Promise<string> { + const { validate, exportGraph, resourceDirs } = context.callbacks ?? {}; + if (!validate || !exportGraph || !resourceDirs) + throw new Error('This renderer needs a structured-context version that passes render callbacks.'); + + const schemaId = context.schema.$id; + const snapshot: Snapshot = { + space: context.space.name, + schemaId, + graph: exportGraph(), + validation: { issues: await validate(validateOptions(data)) }, + definitions: mapViewsForSchema( + readDefinitionFolders( + resourceDirs(DEFINITIONS).map(({ dir, plugin }) => ({ + dir, + source: plugin ? { kind: 'plugin', plugin } : { kind: 'config' }, + })), + ), + schemaId, + ), + }; + return fillPage(pageTemplate, [snapshot], context.space.name); +} diff --git a/packages/sctx-map-views/tests/render.test.ts b/packages/sctx-map-views/tests/render.test.ts new file mode 100644 index 0000000..dc0b8c3 --- /dev/null +++ b/packages/sctx-map-views/tests/render.test.ts @@ -0,0 +1,66 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { PluginContext } from 'structured-context/api'; +import plugin from '../src/index'; + +const DEFINITION = + "{ $id: 'sctx-map-view://mine', schema: 'sctx://test', regions: [{ id: 'main', layout: { type: 'tree' } }] }"; + +let dir: string; +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'sctx-map-views-')); + mkdirSync(join(dir, 'map-views')); + writeFileSync(join(dir, 'map-views', 'mine.json5'), DEFINITION); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +function context(overrides: Partial<NonNullable<PluginContext['callbacks']>> = {}) { + const validateCalls: unknown[] = []; + const callbacks = { + validate: async (options: unknown) => { + validateCalls.push(options); + return []; + }, + exportGraph: () => ({ nodes: [], hierarchy: undefined, hierarchies: [] }), + resourceDirs: () => [{ dir: join(dir, 'map-views') }], + ...overrides, + }; + return { + context: { space: { name: 'demo' }, schema: { $id: 'sctx://test' }, callbacks } as unknown as PluginContext, + validateCalls, + }; +} + +const render = (ctx: PluginContext, data?: Record<string, unknown>) => + plugin.render!.render(ctx, {} as never, { format: 'html', data }) as Promise<string>; + +const snapshotsOf = (page: string) => + JSON.parse(/id="sctx-snapshots">(.*?)<\/script>/s.exec(page)![1]!) as { + space: string; + definitions: { id: string }[]; + }[]; + +describe('map-views.html', () => { + test('writes the space, its issues and the definitions of its schema into one page', async () => { + const page = await render(context().context); + const [snapshot] = snapshotsOf(page); + expect(snapshot!.space).toBe('demo'); + expect(snapshot!.definitions.map((d) => d.id)).toEqual(['sctx-map-view://mine']); + }); + + test('passes validation options from -d', async () => { + const { context: ctx, validateCalls } = context(); + await render(ctx, { semantic: 'true', depth: '2', ruleFilter: 'severity=error,type=goal' }); + expect(validateCalls).toEqual([ + { semantic: true, depth: 2, ruleFilter: ['severity=error', 'type=goal'], includeGated: undefined }, + ]); + }); + + test('fails when core passes no callbacks', async () => { + const { context: ctx } = context(); + delete (ctx as { callbacks?: unknown }).callbacks; + await expect(render(ctx)).rejects.toThrow(/callbacks/); + }); +}); diff --git a/packages/sctx-map-views/tsconfig.build.json b/packages/sctx-map-views/tsconfig.build.json new file mode 100644 index 0000000..d248e78 --- /dev/null +++ b/packages/sctx-map-views/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist/types", + "types": [] + }, + "include": ["src"] +} diff --git a/packages/sctx-map-views/tsconfig.json b/packages/sctx-map-views/tsconfig.json new file mode 100644 index 0000000..59b74a4 --- /dev/null +++ b/packages/sctx-map-views/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "tests", "scripts"] +} diff --git a/scripts/git-hooks/check-pushed-tree.sh b/scripts/git-hooks/check-pushed-tree.sh index 5e8468b..f6a0b18 100755 --- a/scripts/git-hooks/check-pushed-tree.sh +++ b/scripts/git-hooks/check-pushed-tree.sh @@ -42,13 +42,16 @@ if ! bun install --frozen-lockfile >/dev/null 2>&1; then fi # Workspaces keep their dependencies in per-package node_modules directories. -for rel in . packages/map-views apps/navigator; do +for rel in . packages/map-views packages/sctx-map-views apps/navigator; do src="$root/$rel/node_modules" [ -d "$src" ] || src="$main_root/$rel/node_modules" [ -d "$src" ] && ln -s "$src" "$wt/$rel/node_modules" done cd "$wt" || exit 1 +# sctx-map-views imports the library's build output. +bun run --cwd packages/map-views build:library >/dev/null || exit 1 + status=0 echo "checking $(git rev-parse --short HEAD), not the working tree"