structured-context 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +22 -3
  2. package/dist/api.d.ts +21 -3
  3. package/dist/api.js +19 -2
  4. package/dist/commands/diagram.js +1 -1
  5. package/dist/commands/miro-sync.d.ts +3 -0
  6. package/dist/commands/miro-sync.js +8 -0
  7. package/dist/commands/schemas.js +3 -2
  8. package/dist/commands/spaces.js +0 -2
  9. package/dist/commands/validate-file.js +17 -5
  10. package/dist/commands/validate.js +16 -0
  11. package/dist/config.d.ts +8 -12
  12. package/dist/config.js +24 -22
  13. package/dist/index.js +13 -21
  14. package/dist/load-space-graph.d.ts +40 -0
  15. package/dist/load-space-graph.js +42 -0
  16. package/dist/plugins/index.js +3 -1
  17. package/dist/plugins/loader.js +2 -2
  18. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  19. package/dist/plugins/markdown/extract-content-links.js +144 -0
  20. package/dist/plugins/markdown/index.js +1 -7
  21. package/dist/plugins/markdown/parse-embedded.js +9 -0
  22. package/dist/plugins/markdown/read-space.js +3 -0
  23. package/dist/plugins/mermaid/index.d.ts +2 -0
  24. package/dist/plugins/mermaid/index.js +14 -0
  25. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -16
  26. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  27. package/dist/plugins/miro/index.d.ts +2 -0
  28. package/dist/plugins/miro/index.js +27 -0
  29. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  30. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  31. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  32. package/dist/plugins/miro/sync.d.ts +7 -0
  33. package/dist/{integrations → plugins}/miro/sync.js +23 -41
  34. package/dist/plugins/util.d.ts +9 -0
  35. package/dist/plugins/util.js +4 -0
  36. package/dist/read/resolve-graph-edges.js +32 -0
  37. package/dist/render/render.d.ts +1 -0
  38. package/dist/render/render.js +20 -16
  39. package/dist/schema/evaluate-rule.js +1 -0
  40. package/dist/schema/schema.d.ts +3 -1
  41. package/dist/schema/schema.js +6 -2
  42. package/dist/schemas/_knowledge_wiki.json +1 -1
  43. package/dist/space-context.d.ts +24 -0
  44. package/dist/space-context.js +41 -0
  45. package/dist/types.d.ts +52 -0
  46. package/dist/validate.d.ts +12 -3
  47. package/dist/validate.js +37 -12
  48. package/docs/concepts.md +23 -0
  49. package/docs/config.md +2 -0
  50. package/package.json +1 -1
  51. package/schemas/_knowledge_wiki.json +1 -1
  52. package/dist/integrations/miro/sync.d.ts +0 -8
  53. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  54. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  55. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  56. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  57. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
package/README.md CHANGED
@@ -335,13 +335,26 @@ sctx schemas show strategy_general --mermaid-erd
335
335
  sctx miro-sync <space> [--new-frame <title>] [--dry-run] [--verbose]
336
336
  ```
337
337
 
338
- Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `miroBoardId` set in the space's config entry.
338
+ Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `boardId` set in the `miro` plugin config for the space.
339
339
 
340
- - `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `miroFrameId` back to the config file
340
+ - `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `frameId` back to the miro plugin config
341
341
  - `--dry-run` — show what would change without touching Miro
342
342
  - `--verbose` / `-v` — detailed per-card and per-connector output
343
343
 
344
- On subsequent runs, the cached `miroFrameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates.
344
+ Configure the miro plugin in the space's config entry:
345
+
346
+ ```json
347
+ {
348
+ "plugins": {
349
+ "miro": {
350
+ "boardId": "your-board-id",
351
+ "frameId": "your-frame-id"
352
+ }
353
+ }
354
+ }
355
+ ```
356
+
357
+ On subsequent runs, the cached `frameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates.
345
358
 
346
359
  Sync is one-way (OST → Miro) and scoped to a single frame. Only cards and connectors created by this tool within that frame are managed — everything else on the board is left untouched. Card content and connectors are overwritten or recreated to match the markdown source; any edits made directly in Miro to managed cards will be lost on the next sync. Existing card positions are not changed.
347
360
 
@@ -357,6 +370,12 @@ Keeps Obsidian template files in sync with schema examples:
357
370
  - `templatePrefix` in `plugins.markdown` config (default blank) sets a naming convention for templates (`{templatePrefix}{type}.md`). This will be used to check existing filenames, and create new templates with `--create-missing`.
358
371
  - `--dry-run` previews changes without writing files
359
372
 
373
+ ## Programmatic API
374
+
375
+ The package exposes a library entry point at `structured-context/api` for embedding the tooling
376
+ in other applications. See
377
+ [docs/api.md](docs/api.md) for the full reference and usage examples.
378
+
360
379
  ## Development
361
380
 
362
381
  ```bash
package/dist/api.d.ts CHANGED
@@ -3,13 +3,31 @@
3
3
  *
4
4
  * Import from this module to use structured-context as a library:
5
5
  *
6
- * import { validateFile } from 'structured-context/api';
6
+ * import { loadConfig, loadSpaceGraph, validateSpace, validateFile } from 'structured-context/api';
7
7
  * import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/api';
8
+ *
9
+ * Common patterns:
10
+ *
11
+ * const config = loadConfig();
12
+ * const graph = await loadSpaceGraph('my-space', config, { filter: 'my-view' });
13
+ *
14
+ * // Read once, then assemble a graph and validate from the same read and context:
15
+ * const ctx = createSpaceContext('my-space', config);
16
+ * const readResult = await readSpace(ctx);
17
+ * const graph = await assembleSpaceGraph(ctx, { readResult });
18
+ * const validation = await validateSpace(ctx, { readResult });
8
19
  */
9
20
  export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
10
21
  export type { Config, SpaceConfig } from './config';
22
+ export { loadConfig, setConfigPath } from './config';
23
+ export { PLUGIN_PREFIX } from './constants';
24
+ export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
11
25
  export type { ParseHook, ParseResult, PluginContext, RenderFormat, RenderHook, RenderOptions, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
26
+ export { readSpace } from './read/read-space';
12
27
  export type { SharedEmbeddingFields } from './schema/metadata-contract';
28
+ export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
29
+ export { createSpaceContext, SpaceNotFoundError } from './space-context';
13
30
  export type { SpaceGraph } from './space-graph';
14
- export type { BaseNode, EdgeDefinition, FileNotInSpaceResult, FileValidationResult, HierarchyLevel, ParseIssue, Relationship, SchemaMetadata, SchemaWithMetadata, SpaceContext, SpaceNode, UnresolvedRef, ValidateFileOutput, } from './types';
15
- export { validateFile } from './validate';
31
+ export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationResult, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, SchemaMetadata, SchemaWithMetadata, SpaceContext, SpaceNode, UnresolvedRef, ValidateFileOutput, } from './types';
32
+ export type { ValidationResult } from './validate';
33
+ export { validateFile, validateSpace } from './validate';
package/dist/api.js CHANGED
@@ -3,7 +3,24 @@
3
3
  *
4
4
  * Import from this module to use structured-context as a library:
5
5
  *
6
- * import { validateFile } from 'structured-context/api';
6
+ * import { loadConfig, loadSpaceGraph, validateSpace, validateFile } from 'structured-context/api';
7
7
  * import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/api';
8
+ *
9
+ * Common patterns:
10
+ *
11
+ * const config = loadConfig();
12
+ * const graph = await loadSpaceGraph('my-space', config, { filter: 'my-view' });
13
+ *
14
+ * // Read once, then assemble a graph and validate from the same read and context:
15
+ * const ctx = createSpaceContext('my-space', config);
16
+ * const readResult = await readSpace(ctx);
17
+ * const graph = await assembleSpaceGraph(ctx, { readResult });
18
+ * const validation = await validateSpace(ctx, { readResult });
8
19
  */
9
- export { validateFile } from './validate';
20
+ export { loadConfig, setConfigPath } from './config';
21
+ export { PLUGIN_PREFIX } from './constants';
22
+ export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
23
+ export { readSpace } from './read/read-space';
24
+ export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
25
+ export { createSpaceContext, SpaceNotFoundError } from './space-context';
26
+ export { validateFile, validateSpace } from './validate';
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from 'node:fs';
2
2
  import { executeRender } from '../render/render';
3
3
  export async function diagram(context, options) {
4
- const result = await executeRender('markdown.mermaid', context, { filter: options.filter });
4
+ const result = await executeRender('mermaid.graph', context, { filter: options.filter });
5
5
  if (options.output) {
6
6
  writeFileSync(options.output, result);
7
7
  console.log(`Mermaid diagram written to ${options.output}`);
@@ -0,0 +1,3 @@
1
+ import type { SyncOptions } from '../plugins/miro/sync';
2
+ import type { SpaceContext } from '../types';
3
+ export declare function miroSyncCommand(context: SpaceContext, options: SyncOptions): Promise<void>;
@@ -0,0 +1,8 @@
1
+ import { executeRender } from '../render/render';
2
+ export async function miroSyncCommand(context, options) {
3
+ const summary = await executeRender('sctx-miro:board', context, {
4
+ filter: undefined,
5
+ data: options,
6
+ });
7
+ console.log(summary);
8
+ }
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { loadConfig, resolveSchema } from '../config';
4
4
  import { bundledSchemasDir, extractEntityInfo, loadSchema, readRawSchema } from '../schema/schema';
5
- import { mergeVariantProperties } from '../schema/schema-refs';
5
+ import { mergeVariantProperties, resolveRef } from '../schema/schema-refs';
6
6
  function isBundledPath(schemaPath) {
7
7
  return dirname(schemaPath) === bundledSchemasDir;
8
8
  }
@@ -21,6 +21,7 @@ function extractRefs(obj, refs) {
21
21
  function extractEntities(oneOf, schemaRefRegistry, schema) {
22
22
  return oneOf.map((entry) => {
23
23
  const entryObj = entry;
24
+ const resolvedObj = resolveRef(entryObj, schema, schemaRefRegistry) ?? entryObj;
24
25
  const { properties, required } = mergeVariantProperties(entryObj, schema, schemaRefRegistry);
25
26
  const typeDef = properties.type;
26
27
  let types = [];
@@ -30,7 +31,7 @@ function extractEntities(oneOf, schemaRefRegistry, schema) {
30
31
  types = typeDef.enum.map(String);
31
32
  return {
32
33
  types,
33
- description: typeof entryObj.description === 'string' ? entryObj.description : undefined,
34
+ description: typeof resolvedObj.description === 'string' ? resolvedObj.description : undefined,
34
35
  properties: Object.keys(properties).filter((k) => k !== 'type'),
35
36
  required: required.filter((r) => r !== 'type'),
36
37
  };
@@ -14,8 +14,6 @@ export function listSpaces() {
14
14
  console.log(` path: ${space.path}`);
15
15
  const schema = space.schema ?? config.schema;
16
16
  console.log(` schema: ${schema ? basename(schema) : '(none)'}`);
17
- if (space.miroBoardId)
18
- console.log(` miro: configured`);
19
17
  const plugins = space.plugins ?? {};
20
18
  if (Object.keys(plugins).length > 0) {
21
19
  console.log(' plugins:');
@@ -21,19 +21,31 @@ export async function validateFileCommand(filePath, options = {}) {
21
21
  else {
22
22
  printHumanReadable(result);
23
23
  }
24
- return Object.keys(result.errors).length > 0 ? 1 : 0;
24
+ return result.errorCount > 0 ? 1 : 0;
25
25
  }
26
26
  function printHumanReadable(result) {
27
27
  const reset = '\x1b[0m';
28
28
  const green = '\x1b[32m';
29
29
  const red = '\x1b[31m';
30
- if (result.errorCount === 0) {
30
+ const yellow = '\x1b[33m';
31
+ if (result.errorCount === 0 && result.warningCount === 0) {
31
32
  console.log(`${green}✓${reset} ${result.label} (space: ${result.space})`);
32
33
  return;
33
34
  }
34
- console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`);
35
- for (const { kind, message } of Object.values(result.errors)) {
36
- console.log(` [${kind}] ${message}`);
35
+ if (result.errorCount > 0) {
36
+ console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`);
37
+ for (const { kind, message } of Object.values(result.errors)) {
38
+ console.log(` [${kind}] ${message}`);
39
+ }
40
+ }
41
+ else {
42
+ console.log(`\n${green}✓${reset} ${result.label} (space: ${result.space})`);
43
+ }
44
+ if (result.warningCount > 0) {
45
+ console.log(`\n ${yellow}${result.warningCount} warning(s):${reset}`);
46
+ for (const { kind, message } of Object.values(result.warnings)) {
47
+ console.log(` ${yellow}[${kind}]${reset} ${message}`);
48
+ }
37
49
  }
38
50
  console.log('');
39
51
  }
@@ -47,6 +47,7 @@ export async function validate(context, options = {}) {
47
47
  errors: errorsByFile,
48
48
  orphanCount: result.orphans.length,
49
49
  parseIssues: result.parseIssues,
50
+ unresolvedContentLinks: result.unresolvedContentLinks,
50
51
  }, null, 2));
51
52
  return errorCount > 0 ? 1 : 0;
52
53
  }
@@ -81,6 +82,7 @@ export async function validate(context, options = {}) {
81
82
  console.log(fmt(' Rule violations', result.ruleViolations.length, true));
82
83
  console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
83
84
  console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
85
+ console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
84
86
  const parseIssueErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
85
87
  console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
86
88
  if (result.orphans.length > 0) {
@@ -88,6 +90,20 @@ export async function validate(context, options = {}) {
88
90
  for (const node of result.orphans)
89
91
  console.log(` ${node.label}`);
90
92
  }
93
+ if (result.unresolvedContentLinks.length > 0) {
94
+ console.log(`\nUnresolved content links (may be outside-space or broken):`);
95
+ const byFile = new Map();
96
+ for (const { file, target } of result.unresolvedContentLinks) {
97
+ if (!byFile.has(file))
98
+ byFile.set(file, []);
99
+ byFile.get(file).push(target);
100
+ }
101
+ for (const [file, targets] of byFile) {
102
+ console.log(` ${file}:`);
103
+ for (const target of targets)
104
+ console.log(` ${target}`);
105
+ }
106
+ }
91
107
  if (result.parseIssues.length > 0) {
92
108
  console.log(`\nExcluded during parsing:`);
93
109
  for (const issue of result.parseIssues) {
package/dist/config.d.ts CHANGED
@@ -2,14 +2,18 @@ export type SpaceConfig = {
2
2
  name: string;
3
3
  path: string;
4
4
  schema?: string;
5
- miroBoardId?: string;
6
- miroFrameId?: string;
7
5
  /** Plugin name → plugin config map. Overrides top-level plugins when set. */
8
6
  plugins?: Record<string, Record<string, unknown>>;
9
7
  /** Named filter views for this space. Keys are view names; values contain the filter expression. */
10
8
  views?: Record<string, {
11
9
  expression: string;
12
10
  }>;
11
+ /**
12
+ * Directory of the config file that defined this space, used to anchor relative plugin-config
13
+ * paths. Set by loadConfig; absent for a hand-assembled Config, whose callers must pass an
14
+ * explicit configDir to createSpaceContext. Not persisted by updateSpaceField.
15
+ */
16
+ sourceDir?: string;
13
17
  };
14
18
  export type Config = {
15
19
  spaces: SpaceConfig[];
@@ -20,19 +24,11 @@ export type Config = {
20
24
  export declare function setConfigPath(path: string | undefined): void;
21
25
  /** Get all config file paths that were loaded (main config + included configs). */
22
26
  export declare function getConfigSourceFiles(): Set<string>;
23
- /**
24
- * Get the directory of the config file that defines a given space.
25
- */
26
- export declare function getSpaceConfigDir(spaceName: string): string;
27
27
  export declare function configPath(): string;
28
28
  export declare function loadConfig(): Config;
29
29
  /** Get the full space config entry by name. Throws if not found. */
30
30
  export declare function getSpaceConfig(name: string, config: Config): SpaceConfig;
31
31
  /** Resolve schema path: CLI arg > space-level config > global config. Throws if none configured. */
32
32
  export declare function resolveSchema(config: Config, space?: SpaceConfig): string;
33
- type StringFields<T> = {
34
- [K in keyof T]: T[K] extends string | undefined ? K : never;
35
- }[keyof T];
36
- /** Update a string field on a space entry and persist config. */
37
- export declare function updateSpaceField(spaceName: string, field: StringFields<SpaceConfig>, value: string): void;
38
- export {};
33
+ /** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
34
+ export declare function updateSpaceField(spaceName: string, field: string, value: string, plugin?: string): void;
package/dist/config.js CHANGED
@@ -4,7 +4,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import Ajv from 'ajv';
5
5
  import JSON5 from 'json5';
6
6
  import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants';
7
- import { normalizePluginName } from './plugins/util';
7
+ import { normalizePluginName, shortenPluginName } from './plugins/util';
8
8
  import { bundledSchemasDir } from './schema/schema';
9
9
  const CONFIG_SCHEMA = {
10
10
  type: 'object',
@@ -17,8 +17,6 @@ const CONFIG_SCHEMA = {
17
17
  name: { type: 'string', pattern: '^[a-z0-9_-]+$' },
18
18
  path: { type: 'string' },
19
19
  schema: { type: 'string' },
20
- miroBoardId: { type: 'string' },
21
- miroFrameId: { type: 'string' },
22
20
  plugins: { type: 'object', additionalProperties: { type: 'object' } },
23
21
  views: {
24
22
  type: 'object',
@@ -37,7 +35,7 @@ const CONFIG_SCHEMA = {
37
35
  schema: { type: 'string' },
38
36
  includeSpacesFrom: { type: 'array', items: { type: 'string' } },
39
37
  },
40
- required: ['spaces'],
38
+ anyOf: [{ required: ['spaces'] }, { required: ['includeSpacesFrom'] }],
41
39
  additionalProperties: false,
42
40
  };
43
41
  let _configPathOverride;
@@ -54,18 +52,6 @@ export function getConfigSourceFiles() {
54
52
  }
55
53
  return new Set(_spaceSourceFiles.values());
56
54
  }
57
- /**
58
- * Get the directory of the config file that defines a given space.
59
- */
60
- export function getSpaceConfigDir(spaceName) {
61
- if (_spaceSourceFiles.size === 0) {
62
- loadConfig();
63
- }
64
- const spaceConfigPath = _spaceSourceFiles.get(spaceName);
65
- if (!spaceConfigPath)
66
- throw new Error('Space config path not found');
67
- return dirname(spaceConfigPath);
68
- }
69
55
  export function configPath() {
70
56
  if (_configPathOverride) {
71
57
  return _configPathOverride;
@@ -130,6 +116,8 @@ function _loadConfig(path) {
130
116
  if (!validate(config)) {
131
117
  throw new Error(`Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
132
118
  }
119
+ if (!config.spaces)
120
+ config.spaces = [];
133
121
  return config;
134
122
  }
135
123
  export function loadConfig() {
@@ -141,8 +129,10 @@ export function loadConfig() {
141
129
  const config = _loadConfig(path);
142
130
  _spaceSourceFiles.clear();
143
131
  // Track which spaces come from the main config file
132
+ const mainSourceDir = dirname(resolve(path));
144
133
  for (const space of config.spaces) {
145
134
  _spaceSourceFiles.set(space.name, path);
135
+ space.sourceDir = mainSourceDir;
146
136
  }
147
137
  // Load includeSpacesFrom configs and merge their spaces in, with later entries taking precedence over earlier ones
148
138
  if (config.includeSpacesFrom) {
@@ -154,8 +144,10 @@ export function loadConfig() {
154
144
  throw new Error(`Included config contains spaces with duplicate names: ${resolvedIncludePath}`);
155
145
  }
156
146
  // Track which spaces come from this included config file
147
+ const includeSourceDir = dirname(resolvedIncludePath);
157
148
  for (const space of includedConfig.spaces) {
158
149
  _spaceSourceFiles.set(space.name, resolvedIncludePath);
150
+ space.sourceDir = includeSourceDir;
159
151
  }
160
152
  config.spaces.push(...includedConfig.spaces);
161
153
  }
@@ -178,17 +170,27 @@ export function resolveSchema(config, space) {
178
170
  }
179
171
  return schema;
180
172
  }
181
- /** Update a string field on a space entry and persist config. */
182
- export function updateSpaceField(spaceName, field, value) {
173
+ /** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
174
+ export function updateSpaceField(spaceName, field, value, plugin) {
183
175
  const sourcePath = _spaceSourceFiles.get(spaceName);
184
- if (!sourcePath) {
176
+ if (!sourcePath)
185
177
  throw new Error(`Space "${spaceName}" not found in any config file`);
186
- }
187
178
  const config = _loadConfig(sourcePath);
188
179
  const space = config.spaces?.find((s) => s.name === spaceName);
189
- if (!space) {
180
+ if (!space)
190
181
  throw new Error(`Unknown space config: "${spaceName}". Check config.`);
182
+ if (plugin !== undefined) {
183
+ if (!space.plugins)
184
+ space.plugins = {};
185
+ const normalized = normalizePluginName(plugin);
186
+ const shortName = shortenPluginName(normalized);
187
+ const existingKey = Object.keys(space.plugins).find((k) => k === normalized || k === shortName) ?? normalized;
188
+ if (!space.plugins[existingKey])
189
+ space.plugins[existingKey] = {};
190
+ space.plugins[existingKey][field] = value;
191
+ }
192
+ else {
193
+ space[field] = value;
191
194
  }
192
- space[field] = value;
193
195
  writeFileSync(sourcePath, `${JSON5.stringify(config, null, 2)}\n`);
194
196
  }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { Command } from 'commander';
4
4
  import { diagram } from './commands/diagram';
5
5
  import { docs } from './commands/docs';
6
6
  import { dump } from './commands/dump';
7
+ import { miroSyncCommand } from './commands/miro-sync';
7
8
  import { listPlugins } from './commands/plugins';
8
9
  import { render, renderList } from './commands/render';
9
10
  import { listSchemas, showSchema } from './commands/schemas';
@@ -12,29 +13,20 @@ import { listSpaces } from './commands/spaces';
12
13
  import { templateSync } from './commands/template-sync';
13
14
  import { validate, watchValidate } from './commands/validate';
14
15
  import { validateFileCommand } from './commands/validate-file';
15
- import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config';
16
+ import { loadConfig, setConfigPath } from './config';
16
17
  import { CLI_NAME } from './constants';
17
- import { miroSync } from './integrations/miro/sync';
18
- import { loadSchema } from './schema/schema';
18
+ import { createSpaceContext, SpaceNotFoundError } from './space-context';
19
19
  export function buildSpaceContext(spaceName) {
20
- const config = loadConfig();
21
- const space = config.spaces.find((s) => s.name === spaceName);
22
- if (!space) {
23
- console.error(`Error: Unknown space "${spaceName}"`);
24
- process.exit(1);
20
+ try {
21
+ return createSpaceContext(spaceName, loadConfig());
22
+ }
23
+ catch (err) {
24
+ if (err instanceof SpaceNotFoundError) {
25
+ console.error(`Error: ${err.message}`);
26
+ process.exit(1);
27
+ }
28
+ throw err;
25
29
  }
26
- const resolvedSchemaPath = resolveSchema(config, space);
27
- const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath);
28
- const configDir = getSpaceConfigDir(space.name);
29
- return {
30
- space,
31
- config,
32
- resolvedSchemaPath,
33
- schema,
34
- schemaRefRegistry,
35
- schemaValidator,
36
- configDir,
37
- };
38
30
  }
39
31
  const require = createRequire(import.meta.url);
40
32
  const packageJson = require('../package.json');
@@ -97,7 +89,7 @@ program
97
89
  .option('--new-frame <title>', 'Create a new frame on the board and sync into it')
98
90
  .option('--dry-run', 'Show what would change without touching Miro')
99
91
  .option('-v, --verbose', 'Detailed output')
100
- .action((spaceName, options) => miroSync(buildSpaceContext(spaceName), options));
92
+ .action((spaceName, options) => miroSyncCommand(buildSpaceContext(spaceName), options));
101
93
  program
102
94
  .command('template-sync')
103
95
  .description('Sync schema examples to templates')
@@ -0,0 +1,40 @@
1
+ import type { Config } from './config';
2
+ import { type SpaceGraph } from './space-graph';
3
+ import type { ReadSpaceResult, SpaceContext } from './types';
4
+ export interface AssembleSpaceGraphOptions {
5
+ /** Named view (resolved via space.views) or a raw filter DSL expression. */
6
+ filter?: string;
7
+ /** Pre-loaded read result to assemble from, avoiding a re-read of the space. */
8
+ readResult?: ReadSpaceResult;
9
+ }
10
+ /**
11
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes that
12
+ * fail schema validation, build the hierarchy graph, and apply an optional
13
+ * view/filter expression.
14
+ *
15
+ * Nodes that fail schema validation are silently excluded so the graph is
16
+ * well-formed enough to render and traverse. This is NOT a validation pass —
17
+ * schema-valid nodes may still have broken references, rule violations, or
18
+ * duplicates. Use validateSpace for a full validation report.
19
+ *
20
+ * Shared by the render pipeline and the public loadSpaceGraph API so their
21
+ * graph-assembly behaviour cannot drift.
22
+ */
23
+ export declare function assembleSpaceGraph(context: SpaceContext, options?: AssembleSpaceGraphOptions): Promise<SpaceGraph>;
24
+ export interface LoadSpaceGraphOptions extends AssembleSpaceGraphOptions {
25
+ /**
26
+ * Directory to anchor relative plugin paths against. Required when config is
27
+ * hand-assembled (has no source file); see {@link createSpaceContext}.
28
+ */
29
+ configDir?: string;
30
+ }
31
+ /**
32
+ * Load a named space and return its assembled, filtered SpaceGraph.
33
+ *
34
+ * Convenience entry point over createSpaceContext + assembleSpaceGraph: pass a
35
+ * space name, config, and optional filter. Pass options.readResult to reuse an
36
+ * already-loaded read (e.g. when also calling validateSpace) and skip re-reading.
37
+ * When you already hold a SpaceContext, call assembleSpaceGraph directly to avoid
38
+ * rebuilding (and recompiling the schema for) the context.
39
+ */
40
+ export declare function loadSpaceGraph(spaceName: string, config: Config, options?: LoadSpaceGraphOptions): Promise<SpaceGraph>;
@@ -0,0 +1,42 @@
1
+ import { filterNodes } from './filter/filter-nodes';
2
+ import { readSpace } from './read/read-space';
3
+ import { createSpaceContext } from './space-context';
4
+ import { buildSpaceGraph } from './space-graph';
5
+ /**
6
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes that
7
+ * fail schema validation, build the hierarchy graph, and apply an optional
8
+ * view/filter expression.
9
+ *
10
+ * Nodes that fail schema validation are silently excluded so the graph is
11
+ * well-formed enough to render and traverse. This is NOT a validation pass —
12
+ * schema-valid nodes may still have broken references, rule violations, or
13
+ * duplicates. Use validateSpace for a full validation report.
14
+ *
15
+ * Shared by the render pipeline and the public loadSpaceGraph API so their
16
+ * graph-assembly behaviour cannot drift.
17
+ */
18
+ export async function assembleSpaceGraph(context, options = {}) {
19
+ const { nodes: allNodes } = options.readResult ?? (await readSpace(context));
20
+ const { schemaValidator } = context;
21
+ const validNodes = allNodes.filter((node) => schemaValidator(node.schemaData));
22
+ const levels = context.schema.metadata.hierarchy?.levels ?? [];
23
+ let graph = buildSpaceGraph(validNodes, levels);
24
+ if (options.filter) {
25
+ const expression = context.space.views?.[options.filter]?.expression ?? options.filter;
26
+ graph = await filterNodes(expression, graph);
27
+ }
28
+ return graph;
29
+ }
30
+ /**
31
+ * Load a named space and return its assembled, filtered SpaceGraph.
32
+ *
33
+ * Convenience entry point over createSpaceContext + assembleSpaceGraph: pass a
34
+ * space name, config, and optional filter. Pass options.readResult to reuse an
35
+ * already-loaded read (e.g. when also calling validateSpace) and skip re-reading.
36
+ * When you already hold a SpaceContext, call assembleSpaceGraph directly to avoid
37
+ * rebuilding (and recompiling the schema for) the context.
38
+ */
39
+ export async function loadSpaceGraph(spaceName, config, options = {}) {
40
+ const context = createSpaceContext(spaceName, config, { configDir: options.configDir });
41
+ return assembleSpaceGraph(context, options);
42
+ }
@@ -1,3 +1,5 @@
1
1
  import { markdownPlugin } from './markdown';
2
+ import { mermaidPlugin } from './mermaid';
3
+ import { miroPlugin } from './miro';
2
4
  /** All built-in plugins, in default load order. */
3
- export const builtinPlugins = [markdownPlugin];
5
+ export const builtinPlugins = [markdownPlugin, mermaidPlugin, miroPlugin];
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import Ajv, {} from 'ajv';
4
4
  import { getConfigSourceFiles } from '../config';
5
5
  import { builtinPlugins } from '.';
6
- import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX } from './util';
6
+ import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, shortenPluginName, } from './util';
7
7
  /**
8
8
  * Walk a plugin's configSchema and resolve any string fields annotated with
9
9
  * format:'path' relative to configDir.
@@ -92,7 +92,7 @@ export async function loadPlugins(pluginMap, configDir) {
92
92
  }
93
93
  // Built-in plugins: always loaded, config taken from map if declared (with or without prefix)
94
94
  for (const builtin of builtinPlugins) {
95
- const rawConfig = pluginMap[builtin.name] ?? pluginMap[builtin.name.slice(PLUGIN_PREFIX.length)] ?? {};
95
+ const rawConfig = pluginMap[builtin.name] ?? pluginMap[shortenPluginName(builtin.name)] ?? {};
96
96
  const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir);
97
97
  const validate = ajv.compile(builtin.configSchema);
98
98
  if (!validate(pluginConfig)) {
@@ -0,0 +1,23 @@
1
+ import type { Node } from 'mdast';
2
+ import type { ContentLink, SchemaMetadata } from '../../types';
3
+ /**
4
+ * Recursively walk an mdast subtree and collect all links.
5
+ * Handles standard markdown link/image nodes and scans text nodes for wikilinks.
6
+ */
7
+ export declare function extractLinksFromAstNode(node: Node): ContentLink[];
8
+ /**
9
+ * Extract all links from a raw markdown body string.
10
+ * Parses the full document and collects links from every node in the tree.
11
+ */
12
+ export declare function extractLinksFromBody(body: string): ContentLink[];
13
+ /**
14
+ * Build the set of field names that serve as graph edges (hierarchy + relationship fields).
15
+ * Used to exclude edge fields from frontmatter link extraction.
16
+ */
17
+ export declare function getEdgeFieldNames(metadata: SchemaMetadata): Set<string>;
18
+ /**
19
+ * Extract links from frontmatter data fields, excluding known graph edge fields.
20
+ * Parses each string value as markdown (via mdast) to keep link extraction consistent
21
+ * with body content parsing — handles wikilinks, markdown links, images, and bare URLs.
22
+ */
23
+ export declare function extractLinksFromFrontmatter(data: Record<string, unknown>, edgeFields: Set<string>): ContentLink[];