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 @@ -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/<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.
- `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 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/<version>/` 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`.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 1 addition & 1 deletion apps/navigator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` |
| A plugin: the folder named by `"sctx": { "mapViews": "…" }` in its `package.json`, else its `map-views/` folder | `sctx-map-view://<plugin>/<name>` (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://<name>` |
| 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://<name>` |

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`).
4 changes: 2 additions & 2 deletions packages/map-views/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
Expand Down
3 changes: 1 addition & 2 deletions packages/map-views/src/node/cli.ts
Original file line number Diff line number Diff line change
@@ -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' };
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 12 additions & 9 deletions packages/map-views/src/node/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="node" />
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';

Expand All @@ -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;
};

Expand All @@ -27,7 +27,7 @@ export type LoadMapViewDefinitionsOptions = {
* - this package's embedded definitions (`sctx-map-view://core/<name>`), for structured-context's bundled schemas;
* - each plugin found from a config folder or named by a space (`sctx-map-view://<plugin>/<name>`): 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://<name>`).
* - the `map-views` resources folder of each config root (`sctx-map-view://<name>`).
*
* 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.
Expand All @@ -36,15 +36,18 @@ 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 }[] = [
...pluginDirs(config, configDir, configDirs).flatMap(([plugin, dir]) => {
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<string, string>();
Expand Down Expand Up @@ -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<string, string>();
const add = (name: string, fromDir: string) => {
Expand All @@ -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())
Expand Down
2 changes: 1 addition & 1 deletion packages/map-views/tests/annotations.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
58 changes: 58 additions & 0 deletions packages/map-views/tests/builders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type {
BaseNode,
Hierarchy,
HierarchyLevel,
NodeId,
ResolvedParentRef,
SpaceNode,
} from 'structured-context/types';

export const makeParentRef = (id: string, overrides: Partial<ResolvedParentRef> = {}): 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> = {}): HierarchyLevel => ({
type,
field: 'parent',
fieldOn: 'child',
multiple: false,
selfRef: false,
...overrides,
});

export const makeHierarchy = (levels: HierarchyLevel[], overrides: Partial<Hierarchy> = {}): Hierarchy => ({
name: 'main',
levels,
...overrides,
});

const makeNode = (title: string, type: string, extra: Record<string, unknown>, 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<string, unknown> = {},
linkTargets?: string[],
resolvedParents: ResolvedParentRef[] = [],
resolvedLinks: SpaceNode['resolvedLinks'] = [],
): SpaceNode => ({
...makeNode(title, type, extra, linkTargets),
resolvedType: type,
resolvedParents,
resolvedLinks,
});
30 changes: 19 additions & 11 deletions packages/map-views/tests/load-definitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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: [
{
Expand All @@ -52,18 +52,26 @@ describe('loadMapViewDefinitions', () => {
});

test.each([
['config/map-views/a.json5', 'sctx-map-view://core/a', /Use the form "sctx-map-view:\/\/<name>"/],
['config/plugins/sctx-p/map-views/a.json5', 'sctx-map-view://a', /Use the form "sctx-map-view:\/\/p\/<name>"/],
['config/plugins/sctx-p/map-views/a.json5', 'sctx-map-view://q/a', /Use the form "sctx-map-view:\/\/p\/<name>"/],
['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:\/\/<name>"/],
[
'config/.sctx/plugins/sctx-p/map-views/a.json5',
'sctx-map-view://a',
/Use the form "sctx-map-view:\/\/p\/<name>"/,
],
[
'config/.sctx/plugins/sctx-p/map-views/a.json5',
'sctx-map-view://q/a',
/Use the form "sctx-map-view:\/\/p\/<name>"/,
],
['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/);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/map-views/tests/tree.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
Loading