diff --git a/AGENTS.md b/AGENTS.md index 21375eb..26cdbe0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Declarative map views of structured-context spaces: the definition format, the r - `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. +- `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 the config root's `.sctx/map-views/` (structured-context's `resourceDir`). - `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. - `samples/launch-playbook/`: a public sample space. `packages/map-views/tests/fixtures/launch-playbook.json` is captured from it with `bun run capture-fixtures launch-playbook=../../samples/launch-playbook/.sctx.json` in `packages/map-views`. diff --git a/README.md b/README.md index f13aff5..46f3393 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Declarative visualisations of [structured-context](https://github.com/mindsocket/structured-context) spaces. -A **map view** is a short JSON5 definition that projects every space of one schema into a visual form: a tree, nested containers, or an XY plot. The same space can be shown several ways, and readers can switch settings such as orientation or grouping through parameters the definition declares. Definitions are found the way structured-context finds plugin schemas: bundled here, in plugins, or in a `map-views/` folder next to your config. +A **map view** is a short JSON5 definition that projects every space of one schema into a visual form: a tree, nested containers, or an XY plot. The same space can be shown several ways, and readers can switch settings such as orientation or grouping through parameters the definition declares. Definitions are found the way structured-context finds plugin schemas: bundled here, in plugins, or in the `.sctx/map-views/` folder beside your content. ```json5 { diff --git a/apps/navigator/package.json b/apps/navigator/package.json index 0d3d6e6..a6a82ee 100644 --- a/apps/navigator/package.json +++ b/apps/navigator/package.json @@ -12,7 +12,7 @@ "dependencies": { "react": "^19.3.0", "react-dom": "^19.3.0", - "structured-context": "^0.23.0", + "structured-context": "^0.24.0", "structured-context-map-views": "workspace:*" }, "devDependencies": { diff --git a/bun.lock b/bun.lock index bdf9881..4a1bae7 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ "dependencies": { "react": "^19.3.0", "react-dom": "^19.3.0", - "structured-context": "^0.23.0", + "structured-context": "^0.24.0", "structured-context-map-views": "workspace:*", }, "devDependencies": { @@ -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", }, @@ -50,14 +50,14 @@ "@vitejs/plugin-react": "^6.1.1", "react": "^19.3.0", "react-dom": "^19.3.0", - "structured-context": "^0.23.0", + "structured-context": "^0.24.0", "tailwindcss": "^4.3.3", "vite": "^8.3.2", }, "peerDependencies": { "react": "^19.3.0", "react-dom": "^19.3.0", - "structured-context": "^0.23.0", + "structured-context": "^0.24.0", }, }, }, @@ -518,7 +518,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"], diff --git a/docs/definitions.md b/docs/definitions.md index 69ee622..24aae2e 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -124,8 +124,8 @@ Definitions are found the way structured-context finds plugin schemas, and each |---|---| | Bundled with this library (`map-views/`), for structured-context's bundled schemas | `sctx-map-view://core/` | | A plugin: the folder named by `"sctx": { "mapViews": "…" }` in its `package.json`, else its `map-views/` folder | `sctx-map-view:///` (the plugin's name without `sctx-`) | -| A `map-views/` folder next to a structured-context config file (including files pulled in by `includeSpacesFrom`) | `sctx-map-view://` | +| The `.sctx/map-views/` folder of a structured-context config root, or `map-views/` beside the global config ([resources folder](https://github.com/mindsocket/structured-context/blob/main/docs/config.md#resources-folder)), including configs pulled in by `includeSpacesFrom` | `sctx-map-view://` | -Plugins are those in a config folder's `plugins/`, plus those a space or the config names (in `plugins`, or as the prefix of `schema`). Two definitions with the same `$id` are an error: to change a definition, write another with its own `$id`. +Plugins are those in a config root's `.sctx/plugins/`, plus those a space or the config names (in `plugins`, or as the prefix of `schema`). Two definitions with the same `$id` are an error: to change a definition, write another with its own `$id`. A host lists the definitions whose `schema` matches the space's schema `$id` (`mapViewsForSchema`). diff --git a/packages/map-views/package.json b/packages/map-views/package.json index 66054ad..d16e087 100644 --- a/packages/map-views/package.json +++ b/packages/map-views/package.json @@ -52,7 +52,7 @@ "peerDependencies": { "react": "^19.3.0", "react-dom": "^19.3.0", - "structured-context": "^0.23.0" + "structured-context": "^0.24.0" }, "dependencies": { "ajv": "^8.20.0", @@ -68,7 +68,7 @@ "@vitejs/plugin-react": "^6.1.1", "react": "^19.3.0", "react-dom": "^19.3.0", - "structured-context": "^0.23.0", + "structured-context": "^0.24.0", "tailwindcss": "^4.3.3", "vite": "^8.3.2" } diff --git a/packages/map-views/src/node/cli.ts b/packages/map-views/src/node/cli.ts index 279f58c..5b01b20 100644 --- a/packages/map-views/src/node/cli.ts +++ b/packages/map-views/src/node/cli.ts @@ -1,5 +1,4 @@ #!/usr/bin/env bun -import { dirname, resolve } from 'node:path'; import { Command } from 'commander'; import { loadConfig, resolveConfigPath, SctxError, setConfigPath } from 'structured-context/api'; import packageJson from '../../package.json' with { type: 'json' }; @@ -29,7 +28,7 @@ program setConfigPath(program.opts().config); try { const config = loadConfig(); - const configDir = dirname(resolve(resolveConfigPath().path)); + const configDir = resolveConfigPath().root; const result = await exportSnapshot(spaceName, config, configDir, options); process.stdout.write(`${JSON.stringify(result)}\n`); } catch (err) { diff --git a/packages/map-views/src/node/index.ts b/packages/map-views/src/node/index.ts index 7763a5a..41f4715 100644 --- a/packages/map-views/src/node/index.ts +++ b/packages/map-views/src/node/index.ts @@ -1,7 +1,7 @@ /// import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { type Config, PLUGIN_PREFIX, resolveConfigPath, resolvePluginDir } from 'structured-context/api'; +import { join, resolve } from 'node:path'; +import { type Config, PLUGIN_PREFIX, resolveConfigPath, resolvePluginDir, resourceDir } from 'structured-context/api'; import { type MapViewDefinition, parseMapViewDefinition } from '../core/definition'; import { BUNDLED_DEFINITIONS } from './bundled-definitions'; @@ -18,7 +18,7 @@ const PACKAGE_NAME = 'structured-context-map-views'; type Source = { kind: 'core' } | { kind: 'plugin'; plugin: string } | { kind: 'config' }; export type LoadMapViewDefinitionsOptions = { - /** Directory of the main config file. Defaults to where structured-context finds it. */ + /** Root of the main config file (`resolveConfigPath().root`). Defaults to where structured-context finds it. */ configDir?: string; }; @@ -27,7 +27,7 @@ export type LoadMapViewDefinitionsOptions = { * - this package's embedded definitions (`sctx-map-view://core/`), for structured-context's bundled schemas; * - each plugin found from a config folder or named by a space (`sctx-map-view:///`): the * folder named by `"sctx": { "mapViews": "…" }` in its package.json, else its `map-views/` folder; - * - a `map-views/` folder next to each config file (`sctx-map-view://`). + * - the `map-views` resources folder of each config root (`sctx-map-view://`). * * Each definition's `$id` must be in the namespace of where it was found, and ids must be unique. * Throws on the first invalid definition. @@ -36,7 +36,7 @@ export function loadMapViewDefinitions( config: Config, options: LoadMapViewDefinitionsOptions = {}, ): MapViewDefinition[] { - const configDir = options.configDir ?? dirname(resolve(resolveConfigPath().path)); + const configDir = options.configDir ?? resolveConfigPath().root; const configDirs = [...new Set([configDir, ...config.spaces.flatMap((s) => (s.sourceDir ? [s.sourceDir] : []))])]; const folders: { dir: string; source: Source }[] = [ @@ -44,7 +44,10 @@ export function loadMapViewDefinitions( const definitions = pluginDefinitionsDir(dir); return definitions ? [{ dir: definitions, source: { kind: 'plugin', plugin } as const }] : []; }), - ...configDirs.map((dir) => ({ dir: join(dir, DEFINITIONS_DIR), source: { kind: 'config' } as const })), + ...configDirs.flatMap((dir) => { + const definitions = resourceDir(dir, DEFINITIONS_DIR); + return definitions ? [{ dir: definitions, source: { kind: 'config' } as const }] : []; + }), ]; const foundAt = new Map(); @@ -72,7 +75,7 @@ export function loadMapViewDefinitions( return definitions; } -/** Plugin short name → root directory, for plugins in a config folder's `plugins/` or named by the config. */ +/** Plugin short name → root directory, for plugins in a config root's plugins folder or named by the config. */ function pluginDirs(config: Config, configDir: string, configDirs: string[]): [string, string][] { const found = new Map(); const add = (name: string, fromDir: string) => { @@ -83,8 +86,8 @@ function pluginDirs(config: Config, configDir: string, configDirs: string[]): [s }; for (const dir of configDirs) { - const plugins = join(dir, CONFIG_PLUGINS_DIR); - if (!isDirectory(plugins)) continue; + const plugins = resourceDir(dir, CONFIG_PLUGINS_DIR); + if (!plugins) continue; for (const entry of readdirSync(plugins) .filter((e) => e.startsWith(PLUGIN_PREFIX)) .sort()) diff --git a/packages/map-views/tests/annotations.test.ts b/packages/map-views/tests/annotations.test.ts index 35b61e4..20f1e9e 100644 --- a/packages/map-views/tests/annotations.test.ts +++ b/packages/map-views/tests/annotations.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test'; import { buildSpaceGraph } from 'structured-context/api'; -import { makeSpaceNode } from 'structured-context/testing'; import { VALIDATION_ISSUE_KINDS } from 'structured-context/types'; import { annotationsFrom, filterAnnotations, type NodeAnnotation, worstSeverity } from '../src/core/annotations'; +import { makeSpaceNode } from './builders'; const graph = buildSpaceGraph([makeSpaceNode('A', 'Thing'), makeSpaceNode('B', 'Thing')], undefined); const idOf = (title: string) => [...graph.nodes.values()].find((n) => n.title === title)!.id; diff --git a/packages/map-views/tests/builders.ts b/packages/map-views/tests/builders.ts new file mode 100644 index 0000000..1cafa1c --- /dev/null +++ b/packages/map-views/tests/builders.ts @@ -0,0 +1,58 @@ +import type { + BaseNode, + Hierarchy, + HierarchyLevel, + NodeId, + ResolvedParentRef, + SpaceNode, +} from 'structured-context/types'; + +export const makeParentRef = (id: string, overrides: Partial = {}): ResolvedParentRef => { + const kind = overrides.kind ?? 'hierarchy'; + return { + id: id as NodeId, + field: 'parent', + kind, + ...(kind === 'hierarchy' ? { hierarchy: 'main' } : {}), + selfRef: false, + fieldOn: 'child', + ...overrides, + }; +}; + +export const makeLevel = (type: string, overrides: Partial = {}): HierarchyLevel => ({ + type, + field: 'parent', + fieldOn: 'child', + multiple: false, + selfRef: false, + ...overrides, +}); + +export const makeHierarchy = (levels: HierarchyLevel[], overrides: Partial = {}): Hierarchy => ({ + name: 'main', + levels, + ...overrides, +}); + +const makeNode = (title: string, type: string, extra: Record, linkTargets?: string[]): BaseNode => ({ + id: title as NodeId, + title, + schemaData: { title, type, ...extra }, + linkTargets: linkTargets ?? [title], + type, +}); + +export const makeSpaceNode = ( + title: string, + type: string, + extra: Record = {}, + linkTargets?: string[], + resolvedParents: ResolvedParentRef[] = [], + resolvedLinks: SpaceNode['resolvedLinks'] = [], +): SpaceNode => ({ + ...makeNode(title, type, extra, linkTargets), + resolvedType: type, + resolvedParents, + resolvedLinks, +}); diff --git a/packages/map-views/tests/load-definitions.test.ts b/packages/map-views/tests/load-definitions.test.ts index ae5a5e2..3e6a279 100644 --- a/packages/map-views/tests/load-definitions.test.ts +++ b/packages/map-views/tests/load-definitions.test.ts @@ -23,10 +23,10 @@ const ids = (config: Config, configDir = join(root, 'config')) => describe('loadMapViewDefinitions', () => { test('finds bundled, plugin and config folder definitions', () => { - write('config/plugins/sctx-conventional/map-views/a.json5', definition('sctx-map-view://conventional/a')); - write('config/plugins/sctx-declared/package.json', JSON.stringify({ sctx: { mapViews: 'views' } })); - write('config/plugins/sctx-declared/views/b.json5', definition('sctx-map-view://declared/b')); - write('config/map-views/c.json5', definition('sctx-map-view://c')); + write('config/.sctx/plugins/sctx-conventional/map-views/a.json5', definition('sctx-map-view://conventional/a')); + write('config/.sctx/plugins/sctx-declared/package.json', JSON.stringify({ sctx: { mapViews: 'views' } })); + write('config/.sctx/plugins/sctx-declared/views/b.json5', definition('sctx-map-view://declared/b')); + write('config/.sctx/map-views/c.json5', definition('sctx-map-view://c')); expect(ids(noSpaces)).toEqual([ 'sctx-map-view://core/strategy-tree', 'sctx-map-view://conventional/a', @@ -37,7 +37,7 @@ describe('loadMapViewDefinitions', () => { test('includes plugins named by a space and the folders of included configs', () => { write('included/node_modules/sctx-named/map-views/a.json5', definition('sctx-map-view://named/a')); - write('included/map-views/b.json5', definition('sctx-map-view://b')); + write('included/.sctx/map-views/b.json5', definition('sctx-map-view://b')); const config: Config = { spaces: [ { @@ -52,18 +52,26 @@ describe('loadMapViewDefinitions', () => { }); test.each([ - ['config/map-views/a.json5', 'sctx-map-view://core/a', /Use the form "sctx-map-view:\/\/"/], - ['config/plugins/sctx-p/map-views/a.json5', 'sctx-map-view://a', /Use the form "sctx-map-view:\/\/p\/"/], - ['config/plugins/sctx-p/map-views/a.json5', 'sctx-map-view://q/a', /Use the form "sctx-map-view:\/\/p\/"/], - ['config/plugins/sctx-core/map-views/a.json5', 'sctx-map-view://core/a', /namespace violation/], + ['config/.sctx/map-views/a.json5', 'sctx-map-view://core/a', /Use the form "sctx-map-view:\/\/"/], + [ + 'config/.sctx/plugins/sctx-p/map-views/a.json5', + 'sctx-map-view://a', + /Use the form "sctx-map-view:\/\/p\/"/, + ], + [ + 'config/.sctx/plugins/sctx-p/map-views/a.json5', + 'sctx-map-view://q/a', + /Use the form "sctx-map-view:\/\/p\/"/, + ], + ['config/.sctx/plugins/sctx-core/map-views/a.json5', 'sctx-map-view://core/a', /namespace violation/], ])('rejects %s with $id %s', (path, id, message) => { write(path, definition(id)); expect(() => ids(noSpaces)).toThrow(message); }); test('rejects two definitions with the same id', () => { - write('config/map-views/a.json5', definition('sctx-map-view://a')); - write('included/map-views/a.json5', definition('sctx-map-view://a')); + write('config/.sctx/map-views/a.json5', definition('sctx-map-view://a')); + write('included/.sctx/map-views/a.json5', definition('sctx-map-view://a')); const config: Config = { spaces: [{ name: 's', path: '.', sourceDir: join(root, 'included') }] }; expect(() => ids(config)).toThrow(/"sctx-map-view:\/\/a" is defined by both/); }); diff --git a/packages/map-views/tests/tree.test.ts b/packages/map-views/tests/tree.test.ts index 6453c0c..4371f32 100644 --- a/packages/map-views/tests/tree.test.ts +++ b/packages/map-views/tests/tree.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { buildSpaceGraph } from 'structured-context/api'; -import { makeHierarchy, makeLevel, makeParentRef, makeSpaceNode } from 'structured-context/testing'; import { buildTree, type TreeOccurrence, treeLinks } from '../src/core/tree'; +import { makeHierarchy, makeLevel, makeParentRef, makeSpaceNode } from './builders'; import { fixtureGraph } from './fixture-graph'; import sample from './fixtures/launch-playbook.json';