structured-context 0.10.0 → 0.12.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 (107) hide show
  1. package/README.md +85 -25
  2. package/dist/api.d.ts +25 -5
  3. package/dist/api.js +22 -2
  4. package/dist/commands/diagram.d.ts +1 -0
  5. package/dist/commands/diagram.js +7 -1
  6. package/dist/commands/dump.d.ts +15 -2
  7. package/dist/commands/dump.js +18 -4
  8. package/dist/commands/miro-sync.d.ts +3 -0
  9. package/dist/commands/miro-sync.js +8 -0
  10. package/dist/commands/render.d.ts +4 -0
  11. package/dist/commands/render.js +64 -9
  12. package/dist/commands/schemas.d.ts +2 -2
  13. package/dist/commands/schemas.js +117 -24
  14. package/dist/commands/show.d.ts +1 -0
  15. package/dist/commands/show.js +7 -1
  16. package/dist/commands/spaces.d.ts +3 -1
  17. package/dist/commands/spaces.js +11 -4
  18. package/dist/commands/validate-file.js +20 -5
  19. package/dist/commands/validate.js +54 -21
  20. package/dist/config.d.ts +26 -13
  21. package/dist/config.js +104 -31
  22. package/dist/filter/augment-nodes.d.ts +3 -1
  23. package/dist/filter/augment-nodes.js +12 -7
  24. package/dist/filter/filter-nodes.js +1 -1
  25. package/dist/index.js +51 -35
  26. package/dist/load-space-graph.d.ts +42 -0
  27. package/dist/load-space-graph.js +53 -0
  28. package/dist/plugins/index.js +3 -1
  29. package/dist/plugins/loader.js +4 -4
  30. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  31. package/dist/plugins/markdown/extract-content-links.js +123 -0
  32. package/dist/plugins/markdown/index.js +5 -5
  33. package/dist/plugins/markdown/parse-embedded.d.ts +6 -0
  34. package/dist/plugins/markdown/parse-embedded.js +50 -7
  35. package/dist/plugins/markdown/read-space.js +41 -5
  36. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  37. package/dist/plugins/markdown/render-directory.js +130 -0
  38. package/dist/plugins/mermaid/index.d.ts +2 -0
  39. package/dist/plugins/mermaid/index.js +14 -0
  40. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -15
  41. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  42. package/dist/plugins/miro/index.d.ts +2 -0
  43. package/dist/plugins/miro/index.js +27 -0
  44. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  45. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  46. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  47. package/dist/plugins/miro/sync.d.ts +7 -0
  48. package/dist/{integrations → plugins}/miro/sync.js +22 -39
  49. package/dist/plugins/util.d.ts +48 -1
  50. package/dist/plugins/util.js +143 -0
  51. package/dist/read/resolve-graph-edges.d.ts +1 -1
  52. package/dist/read/resolve-graph-edges.js +78 -27
  53. package/dist/read/wikilink-utils.d.ts +17 -0
  54. package/dist/read/wikilink-utils.js +40 -0
  55. package/dist/render/render.d.ts +4 -1
  56. package/dist/render/render.js +20 -16
  57. package/dist/schema/evaluate-rule.js +1 -0
  58. package/dist/schema/meta-schemas.d.ts +13 -0
  59. package/dist/schema/meta-schemas.js +50 -0
  60. package/dist/schema/metadata-contract.d.ts +343 -187
  61. package/dist/schema/metadata-contract.js +68 -30
  62. package/dist/schema/schema.d.ts +17 -9
  63. package/dist/schema/schema.js +283 -200
  64. package/dist/schema/validate-graph.d.ts +2 -1
  65. package/dist/schema/validate-graph.js +40 -23
  66. package/dist/schema/validate-rules.d.ts +5 -1
  67. package/dist/schema/validate-rules.js +20 -1
  68. package/dist/schemas/_knowledge_wiki.json +21 -21
  69. package/dist/schemas/_ost_strict.json +2 -2
  70. package/dist/schemas/_sctx_base.json +2 -2
  71. package/dist/schemas/_strategy_general.json +38 -38
  72. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  73. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  74. package/dist/schemas/knowledge_wiki.json +10 -9
  75. package/dist/schemas/strategy_general.json +15 -14
  76. package/dist/schemas/strict_ost.json +17 -16
  77. package/dist/space-context.d.ts +27 -0
  78. package/dist/space-context.js +46 -0
  79. package/dist/space-graph.d.ts +9 -7
  80. package/dist/space-graph.js +7 -5
  81. package/dist/testing.d.ts +33 -0
  82. package/dist/testing.js +72 -0
  83. package/dist/types.d.ts +101 -8
  84. package/dist/validate.d.ts +15 -3
  85. package/dist/validate.js +55 -20
  86. package/docs/concepts.md +37 -4
  87. package/docs/config.md +10 -0
  88. package/docs/rules.md +49 -22
  89. package/docs/schemas.md +110 -35
  90. package/package.json +13 -8
  91. package/schemas/_knowledge_wiki.json +21 -21
  92. package/schemas/_ost_strict.json +2 -2
  93. package/schemas/_sctx_base.json +2 -2
  94. package/schemas/_strategy_general.json +38 -38
  95. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  96. package/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  97. package/schemas/knowledge_wiki.json +10 -9
  98. package/schemas/strategy_general.json +15 -14
  99. package/schemas/strict_ost.json +17 -16
  100. package/dist/integrations/miro/sync.d.ts +0 -8
  101. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  102. package/schemas/generated/_structured_context_schema_meta.json +0 -191
  103. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  104. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  105. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  106. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  107. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
package/dist/config.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
- import Ajv from 'ajv';
4
+ import Ajv2020 from 'ajv/dist/2020';
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, resolvePluginSchemasDir, resolveSpacePluginSchemas, 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;
@@ -101,13 +87,10 @@ function resolveRelativePaths(config, configDir) {
101
87
  const relSchema = (p) => {
102
88
  if (!p || isAbsolute(p) || isUrl(p))
103
89
  return p;
104
- if (!p.includes('/') && !p.includes('\\')) {
105
- const localPath = resolve(configDir, p);
106
- if (existsSync(localPath))
107
- return localPath;
108
- return join(bundledSchemasDir, p);
90
+ if (p.startsWith('./') || p.startsWith('../')) {
91
+ return resolve(configDir, p);
109
92
  }
110
- return resolve(configDir, p);
93
+ return p;
111
94
  };
112
95
  return {
113
96
  ...config,
@@ -125,11 +108,13 @@ function _loadConfig(path) {
125
108
  throw new Error(`Config file not found: ${path}`);
126
109
  }
127
110
  const config = JSON5.parse(readFileSync(path, 'utf-8'));
128
- const ajv = new Ajv();
111
+ const ajv = new Ajv2020();
129
112
  const validate = ajv.compile(CONFIG_SCHEMA);
130
113
  if (!validate(config)) {
131
114
  throw new Error(`Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
132
115
  }
116
+ if (!config.spaces)
117
+ config.spaces = [];
133
118
  return config;
134
119
  }
135
120
  export function loadConfig() {
@@ -141,8 +126,10 @@ export function loadConfig() {
141
126
  const config = _loadConfig(path);
142
127
  _spaceSourceFiles.clear();
143
128
  // Track which spaces come from the main config file
129
+ const mainSourceDir = dirname(resolve(path));
144
130
  for (const space of config.spaces) {
145
131
  _spaceSourceFiles.set(space.name, path);
132
+ space.sourceDir = mainSourceDir;
146
133
  }
147
134
  // Load includeSpacesFrom configs and merge their spaces in, with later entries taking precedence over earlier ones
148
135
  if (config.includeSpacesFrom) {
@@ -154,8 +141,10 @@ export function loadConfig() {
154
141
  throw new Error(`Included config contains spaces with duplicate names: ${resolvedIncludePath}`);
155
142
  }
156
143
  // Track which spaces come from this included config file
144
+ const includeSourceDir = dirname(resolvedIncludePath);
157
145
  for (const space of includedConfig.spaces) {
158
146
  _spaceSourceFiles.set(space.name, resolvedIncludePath);
147
+ space.sourceDir = includeSourceDir;
159
148
  }
160
149
  config.spaces.push(...includedConfig.spaces);
161
150
  }
@@ -170,25 +159,109 @@ export function getSpaceConfig(name, config) {
170
159
  }
171
160
  return space;
172
161
  }
162
+ /**
163
+ * Resolve a schema identifier or path to an absolute file path.
164
+ *
165
+ * Precedence:
166
+ * 1. Absolute path or URL → used directly.
167
+ * 2. Explicit relative path (./... or ../...) → resolved relative to configDir.
168
+ * 3. Plugin-qualified path (e.g. "sctx-wardley-mapping/wardley_map.json" or "wardley-mapping/wardley_map.json"):
169
+ * resolved against the matching loaded plugin's schemasDir.
170
+ * 4. Simple filename without directory (e.g. "wardley_map.json" or "strategy_general.json"):
171
+ * - configDir/filename if it exists
172
+ * - plugin.schemasDir/filename for any loaded plugin with schemasDir
173
+ * - bundledSchemasDir/filename if it exists
174
+ * - fallback: configDir/filename
175
+ */
176
+ export function resolveSchemaPath(rawSchema, configDir, space, config) {
177
+ if (!rawSchema) {
178
+ throw new Error('No schema configured. Set "schema" in the space config or at the top level of the config file.');
179
+ }
180
+ if (isAbsolute(rawSchema) || isUrl(rawSchema)) {
181
+ return rawSchema;
182
+ }
183
+ if (rawSchema.startsWith('./') || rawSchema.startsWith('../')) {
184
+ return resolve(configDir, rawSchema);
185
+ }
186
+ if (rawSchema.includes('/') || rawSchema.includes('\\')) {
187
+ const normalizedSlash = rawSchema.replace(/\\/g, '/');
188
+ const slashIdx = normalizedSlash.indexOf('/');
189
+ const prefix = normalizedSlash.slice(0, slashIdx);
190
+ const subPath = normalizedSlash.slice(slashIdx + 1);
191
+ const pluginSchemasDir = resolvePluginSchemasDir(prefix, configDir);
192
+ if (pluginSchemasDir) {
193
+ const candidate = resolve(pluginSchemasDir, subPath);
194
+ if (existsSync(candidate)) {
195
+ return candidate;
196
+ }
197
+ }
198
+ // Check if relative to configDir exists
199
+ const localCandidate = resolve(configDir, rawSchema);
200
+ if (existsSync(localCandidate)) {
201
+ return localCandidate;
202
+ }
203
+ // If plugin schemas dir was found, return candidate under it
204
+ if (pluginSchemasDir) {
205
+ return resolve(pluginSchemasDir, subPath);
206
+ }
207
+ return localCandidate;
208
+ }
209
+ // Simple filename without directory:
210
+ // 1. configDir/filename
211
+ const localCandidate = resolve(configDir, rawSchema);
212
+ if (existsSync(localCandidate)) {
213
+ return localCandidate;
214
+ }
215
+ // 2. Plugin schemas from declared space / config plugins
216
+ if (space || config) {
217
+ const pluginSources = resolveSpacePluginSchemas(space, config ?? { spaces: [] }, configDir);
218
+ for (const source of pluginSources) {
219
+ const pluginCandidate = resolve(source.schemasDir, rawSchema);
220
+ if (existsSync(pluginCandidate)) {
221
+ return pluginCandidate;
222
+ }
223
+ }
224
+ }
225
+ // 3. Bundled schemas
226
+ const bundledCandidate = join(bundledSchemasDir, rawSchema);
227
+ if (existsSync(bundledCandidate)) {
228
+ return bundledCandidate;
229
+ }
230
+ return localCandidate;
231
+ }
173
232
  /** Resolve schema path: CLI arg > space-level config > global config. Throws if none configured. */
174
- export function resolveSchema(config, space) {
233
+ export function resolveSchema(config, space, options) {
175
234
  const schema = space?.schema ?? config.schema;
176
235
  if (!schema) {
177
236
  throw new Error('No schema configured. Set "schema" in the space config or at the top level of the config file.');
178
237
  }
238
+ const configDir = options?.configDir ?? space?.sourceDir;
239
+ if (configDir) {
240
+ return resolveSchemaPath(schema, configDir, space, config);
241
+ }
179
242
  return schema;
180
243
  }
181
- /** Update a string field on a space entry and persist config. */
182
- export function updateSpaceField(spaceName, field, value) {
244
+ /** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
245
+ export function updateSpaceField(spaceName, field, value, plugin) {
183
246
  const sourcePath = _spaceSourceFiles.get(spaceName);
184
- if (!sourcePath) {
247
+ if (!sourcePath)
185
248
  throw new Error(`Space "${spaceName}" not found in any config file`);
186
- }
187
249
  const config = _loadConfig(sourcePath);
188
250
  const space = config.spaces?.find((s) => s.name === spaceName);
189
- if (!space) {
251
+ if (!space)
190
252
  throw new Error(`Unknown space config: "${spaceName}". Check config.`);
253
+ if (plugin !== undefined) {
254
+ if (!space.plugins)
255
+ space.plugins = {};
256
+ const normalized = normalizePluginName(plugin);
257
+ const shortName = shortenPluginName(normalized);
258
+ const existingKey = Object.keys(space.plugins).find((k) => k === normalized || k === shortName) ?? normalized;
259
+ if (!space.plugins[existingKey])
260
+ space.plugins[existingKey] = {};
261
+ space.plugins[existingKey][field] = value;
262
+ }
263
+ else {
264
+ space[field] = value;
191
265
  }
192
- space[field] = value;
193
266
  writeFileSync(sourcePath, `${JSON5.stringify(config, null, 2)}\n`);
194
267
  }
@@ -3,6 +3,8 @@ import type { SpaceNode } from '../types';
3
3
  export type EdgeMetadata = {
4
4
  _field: string;
5
5
  _source: 'hierarchy' | 'relationship';
6
+ /** Hierarchy name, present only for hierarchy edges. */
7
+ _hierarchy?: string;
6
8
  _selfRef: boolean;
7
9
  };
8
10
  /** A flat node representation augmented with ancestor and descendant traversal arrays. */
@@ -18,6 +20,6 @@ export type AugmentedFlatNode = Record<string, unknown> & {
18
20
  *
19
21
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by title.
20
22
  * - descendants: BFS via childrenIndex, nearest first, deduplicated by title.
21
- * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _selfRef).
23
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
22
24
  */
23
25
  export declare function augmentNode(node: SpaceNode, nodeIndex: ReadonlyMap<string, SpaceNode>, childrenIndex: ReadonlyMap<string, readonly SpaceNode[]>): AugmentedFlatNode;
@@ -1,3 +1,12 @@
1
+ /** Edge metadata for the resolvedParents entry connecting two nodes. */
2
+ function edgeMetadata(ref) {
3
+ return {
4
+ _field: ref.field,
5
+ _source: ref.source,
6
+ ...(ref.hierarchy !== undefined ? { _hierarchy: ref.hierarchy } : {}),
7
+ _selfRef: ref.selfRef,
8
+ };
9
+ }
1
10
  /** Flatten a SpaceNode's data fields for use in an augmented representation. */
2
11
  function flattenData(node) {
3
12
  return {
@@ -13,7 +22,7 @@ function flattenData(node) {
13
22
  *
14
23
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by title.
15
24
  * - descendants: BFS via childrenIndex, nearest first, deduplicated by title.
16
- * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _selfRef).
25
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
17
26
  */
18
27
  export function augmentNode(node, nodeIndex, childrenIndex) {
19
28
  const ancestors = buildAncestors(node, nodeIndex);
@@ -44,9 +53,7 @@ function buildAncestors(node, nodeIndex) {
44
53
  visited.add(title);
45
54
  result.push({
46
55
  ...flattenData(item.node),
47
- _field: item.ref.field,
48
- _source: item.ref.source,
49
- _selfRef: item.ref.selfRef,
56
+ ...edgeMetadata(item.ref),
50
57
  });
51
58
  // Continue BFS upward
52
59
  for (const ref of item.node.resolvedParents) {
@@ -78,9 +85,7 @@ function buildDescendants(node, childrenIndex) {
78
85
  visited.add(title);
79
86
  result.push({
80
87
  ...flattenData(item.childNode),
81
- _field: item.ref.field,
82
- _source: item.ref.source,
83
- _selfRef: item.ref.selfRef,
88
+ ...edgeMetadata(item.ref),
84
89
  });
85
90
  const grandchildren = childrenIndex.get(title) ?? [];
86
91
  for (const grandchild of grandchildren) {
@@ -69,5 +69,5 @@ export async function filterNodes(expression, graph) {
69
69
  else {
70
70
  matchedNodes = matched;
71
71
  }
72
- return buildSpaceGraph(matchedNodes, graph.levels);
72
+ return buildSpaceGraph(matchedNodes, graph.hierarchy);
73
73
  }
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { createRequire } from 'node:module';
3
+ import { format } from 'node:util';
3
4
  import { Command } from 'commander';
4
5
  import { diagram } from './commands/diagram';
5
6
  import { docs } from './commands/docs';
6
7
  import { dump } from './commands/dump';
8
+ import { miroSyncCommand } from './commands/miro-sync';
7
9
  import { listPlugins } from './commands/plugins';
8
10
  import { render, renderList } from './commands/render';
9
11
  import { listSchemas, showSchema } from './commands/schemas';
@@ -12,29 +14,25 @@ import { listSpaces } from './commands/spaces';
12
14
  import { templateSync } from './commands/template-sync';
13
15
  import { validate, watchValidate } from './commands/validate';
14
16
  import { validateFileCommand } from './commands/validate-file';
15
- import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config';
17
+ import { loadConfig, setConfigPath } from './config';
16
18
  import { CLI_NAME } from './constants';
17
- import { miroSync } from './integrations/miro/sync';
18
- import { loadSchema } from './schema/schema';
19
+ import { createSpaceContext, SpaceNotFoundError } from './space-context';
20
+ // Once node:process is loaded (vfile, via unified, loads it), Bun's console.log drops piped output
21
+ // past 64KB when the reader is slow. process.stdout.write delivers all of it before exit.
22
+ console.log = (...args) => {
23
+ process.stdout.write(`${format(...args)}\n`);
24
+ };
19
25
  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);
26
+ try {
27
+ return createSpaceContext(spaceName, loadConfig());
28
+ }
29
+ catch (err) {
30
+ if (err instanceof SpaceNotFoundError) {
31
+ console.error(`Error: ${err.message}`);
32
+ process.exit(1);
33
+ }
34
+ throw err;
25
35
  }
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
36
  }
39
37
  const require = createRequire(import.meta.url);
40
38
  const packageJson = require('../package.json');
@@ -53,8 +51,7 @@ program
53
51
  .argument('<path>', 'Path to the file to validate')
54
52
  .option('--json', 'Output results as JSON (machine-readable, for hooks)')
55
53
  .action(async (filePath, options) => {
56
- const exitCode = await validateFileCommand(filePath, { json: options.json });
57
- process.exit(exitCode);
54
+ process.exitCode = await validateFileCommand(filePath, { json: options.json });
58
55
  });
59
56
  program
60
57
  .command('validate')
@@ -68,8 +65,8 @@ program
68
65
  await watchValidate(context);
69
66
  }
70
67
  else {
71
- const exitCode = await validate(context, { json: options.json });
72
- process.exit(exitCode);
68
+ // exitCode rather than process.exit(): exiting straight away can cut off large piped output
69
+ process.exitCode = await validate(context, { json: options.json });
73
70
  }
74
71
  });
75
72
  program
@@ -77,19 +74,23 @@ program
77
74
  .description('Generate mermaid diagram from space')
78
75
  .argument('<space-name>', 'Space name')
79
76
  .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
77
+ .option('--hierarchy <name>', 'Hierarchy to render (default: the main hierarchy)')
80
78
  .option('-o, --output <path>', 'Output file path (default: stdout)')
81
- .action((spaceName, options) => diagram(buildSpaceContext(spaceName), options));
79
+ .action(async (spaceName, options) => diagram(buildSpaceContext(spaceName), options));
82
80
  program
83
81
  .command('show')
84
82
  .description('Print space graph as a bullet list')
85
83
  .argument('<space-name>', 'Space name')
86
84
  .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
87
- .action((spaceName, options) => show(buildSpaceContext(spaceName), options));
85
+ .option('--hierarchy <name>', 'Hierarchy to render (default: the main hierarchy)')
86
+ .action(async (spaceName, options) => show(buildSpaceContext(spaceName), options));
88
87
  program
89
88
  .command('dump')
90
89
  .description('Dump parsed space nodes as JSON')
91
90
  .argument('<space-name>', 'Space name')
92
- .action((spaceName) => dump(buildSpaceContext(spaceName)));
91
+ .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
92
+ .option('--hierarchy <name>', 'Hierarchy used to evaluate --filter (default: the main hierarchy)')
93
+ .action(async (spaceName, options) => dump(buildSpaceContext(spaceName), options));
93
94
  program
94
95
  .command('miro-sync')
95
96
  .description('Sync space to a Miro board')
@@ -97,16 +98,16 @@ program
97
98
  .option('--new-frame <title>', 'Create a new frame on the board and sync into it')
98
99
  .option('--dry-run', 'Show what would change without touching Miro')
99
100
  .option('-v, --verbose', 'Detailed output')
100
- .action((spaceName, options) => miroSync(buildSpaceContext(spaceName), options));
101
+ .action(async (spaceName, options) => miroSyncCommand(buildSpaceContext(spaceName), options));
101
102
  program
102
103
  .command('template-sync')
103
104
  .description('Sync schema examples to templates')
104
105
  .argument('<space-name>', 'Space name')
105
106
  .option('--create-missing', 'Create missing template files for schema types')
106
107
  .option('--dry-run', 'Preview changes without writing files')
107
- .action((spaceName, options) => {
108
+ .action(async (spaceName, options) => {
108
109
  const context = buildSpaceContext(spaceName);
109
- templateSync(context, options);
110
+ await templateSync(context, options);
110
111
  });
111
112
  program
112
113
  .command('plugins')
@@ -116,13 +117,14 @@ const spacesCmd = new Command('spaces').description('List configured spaces');
116
117
  spacesCmd
117
118
  .command('list', { isDefault: true })
118
119
  .description('List all configured spaces and their paths')
119
- .action(listSpaces);
120
+ .option('--json', 'Output as JSON: config files, and each space with its path and schema')
121
+ .action((options) => listSpaces(options));
120
122
  program.addCommand(spacesCmd);
121
123
  const schemasCmd = new Command('schemas').alias('schema').description('List and inspect schemas');
122
124
  schemasCmd
123
125
  .command('list', { isDefault: true })
124
126
  .description('List available schemas')
125
- .action(() => listSchemas());
127
+ .action(async () => await listSchemas());
126
128
  schemasCmd
127
129
  .command('show')
128
130
  .description('Show schema structure (or raw JSON with --raw, or Mermaid ERD with --mermaid-erd)')
@@ -130,7 +132,7 @@ schemasCmd
130
132
  .option('--space <name>', 'Resolve schema from space config')
131
133
  .option('--raw', 'Output raw JSON file content')
132
134
  .option('--mermaid-erd', 'Output Mermaid Entity Relationship Diagram')
133
- .action((file, options) => showSchema(file, {
135
+ .action(async (file, options) => await showSchema(file, {
134
136
  space: options.space,
135
137
  raw: options.raw ?? false,
136
138
  mermaidErd: options.mermaidErd ?? false,
@@ -149,11 +151,25 @@ renderCmd
149
151
  const context = spaceName ? buildSpaceContext(spaceName) : undefined;
150
152
  await renderList(context);
151
153
  });
154
+ function parseDataOptions(val, prev = {}) {
155
+ const eqIdx = val.indexOf('=');
156
+ if (eqIdx >= 0) {
157
+ prev[val.slice(0, eqIdx).trim()] = val.slice(eqIdx + 1).trim();
158
+ }
159
+ else {
160
+ prev[val.trim()] = 'true';
161
+ }
162
+ return prev;
163
+ }
152
164
  renderCmd
153
165
  .argument('<space-name>', 'Space name')
154
- .argument('<format>', 'Render format (e.g. markdown.bullets)')
166
+ .argument('<format>', 'Render format (e.g. markdown.bullets, markdown.directory)')
155
167
  .option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
156
- .option('-o, --output <path>', 'Output file path (default: stdout)')
168
+ .option('--hierarchy <name>', 'Hierarchy to render (default: the main hierarchy)')
169
+ .option('-o, --output <path>', 'Output file or directory path (default: stdout)')
170
+ .option('--out <path>', 'Output directory or file path')
171
+ .option('-f, --force', 'Overwrite existing files when writing to disk')
172
+ .option('-d, --data <key=value...>', 'Format-specific key=value options', parseDataOptions)
157
173
  .action(async (spaceName, format, options) => {
158
174
  await render(buildSpaceContext(spaceName), format, options);
159
175
  });
@@ -0,0 +1,42 @@
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
+ /** Name of the hierarchy to structure the graph by. Defaults to the schema's main hierarchy. */
8
+ hierarchy?: string;
9
+ /** Pre-loaded read result to assemble from, avoiding a re-read of the space. */
10
+ readResult?: ReadSpaceResult;
11
+ }
12
+ /**
13
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes that
14
+ * fail schema validation, build the graph for the selected hierarchy (the main
15
+ * hierarchy by default), and apply an optional view/filter expression.
16
+ *
17
+ * Nodes that fail schema validation are silently excluded so the graph is
18
+ * well-formed enough to render and traverse. This is NOT a validation pass —
19
+ * schema-valid nodes may still have broken references, rule violations, or
20
+ * duplicates. Use validateSpace for a full validation report.
21
+ *
22
+ * Shared by the render pipeline and the public loadSpaceGraph API so their
23
+ * graph-assembly behaviour cannot drift.
24
+ */
25
+ export declare function assembleSpaceGraph(context: SpaceContext, options?: AssembleSpaceGraphOptions): Promise<SpaceGraph>;
26
+ export interface LoadSpaceGraphOptions extends AssembleSpaceGraphOptions {
27
+ /**
28
+ * Directory to anchor relative plugin paths against. Required when config is
29
+ * hand-assembled (has no source file); see {@link createSpaceContext}.
30
+ */
31
+ configDir?: string;
32
+ }
33
+ /**
34
+ * Load a named space and return its assembled, filtered SpaceGraph.
35
+ *
36
+ * Convenience entry point over createSpaceContext + assembleSpaceGraph: pass a
37
+ * space name, config, and optional filter. Pass options.readResult to reuse an
38
+ * already-loaded read (e.g. when also calling validateSpace) and skip re-reading.
39
+ * When you already hold a SpaceContext, call assembleSpaceGraph directly to avoid
40
+ * rebuilding (and recompiling the schema for) the context.
41
+ */
42
+ export declare function loadSpaceGraph(spaceName: string, config: Config, options?: LoadSpaceGraphOptions): Promise<SpaceGraph>;
@@ -0,0 +1,53 @@
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 graph for the selected hierarchy (the main
8
+ * hierarchy by default), and apply an optional 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
+ let graph = buildSpaceGraph(validNodes, selectHierarchy(context.schema.metadata, options.hierarchy));
23
+ if (options.filter) {
24
+ const expression = context.space.views?.[options.filter]?.expression ?? options.filter;
25
+ graph = await filterNodes(expression, graph);
26
+ }
27
+ return graph;
28
+ }
29
+ /** Resolve a hierarchy by name, or the main hierarchy when no name is given. */
30
+ function selectHierarchy(metadata, name) {
31
+ if (name === undefined)
32
+ return metadata.hierarchy;
33
+ const hierarchies = metadata.hierarchies ?? [];
34
+ const hierarchy = hierarchies.find((h) => h.name === name);
35
+ if (!hierarchy) {
36
+ const available = hierarchies.map((h) => h.name).join(', ');
37
+ throw new Error(`Unknown hierarchy: "${name}".${available ? ` Available: ${available}` : ' The schema defines no hierarchies.'}`);
38
+ }
39
+ return hierarchy;
40
+ }
41
+ /**
42
+ * Load a named space and return its assembled, filtered SpaceGraph.
43
+ *
44
+ * Convenience entry point over createSpaceContext + assembleSpaceGraph: pass a
45
+ * space name, config, and optional filter. Pass options.readResult to reuse an
46
+ * already-loaded read (e.g. when also calling validateSpace) and skip re-reading.
47
+ * When you already hold a SpaceContext, call assembleSpaceGraph directly to avoid
48
+ * rebuilding (and recompiling the schema for) the context.
49
+ */
50
+ export async function loadSpaceGraph(spaceName, config, options = {}) {
51
+ const context = createSpaceContext(spaceName, config, { configDir: options.configDir });
52
+ return assembleSpaceGraph(context, options);
53
+ }
@@ -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];
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, join, resolve } from 'node:path';
3
- import Ajv, {} from 'ajv';
3
+ import Ajv2020 from 'ajv/dist/2020';
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.
@@ -71,7 +71,7 @@ export async function discoverPlugins() {
71
71
  */
72
72
  export async function loadPlugins(pluginMap, configDir) {
73
73
  const builtinsByName = new Map(builtinPlugins.map((p) => [p.name, p]));
74
- const ajv = new Ajv();
74
+ const ajv = new Ajv2020();
75
75
  ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
76
76
  const loaded = [];
77
77
  // External plugins: entries in the map that are not built-in names
@@ -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[];