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
@@ -0,0 +1,2 @@
1
+ import { type StructuredContextPlugin } from '../../api';
2
+ export declare const miroPlugin: StructuredContextPlugin;
@@ -0,0 +1,27 @@
1
+ import { PLUGIN_PREFIX } from '../../api';
2
+ import { miroSync } from './sync';
3
+ export const miroPlugin = {
4
+ name: `${PLUGIN_PREFIX}miro`,
5
+ configSchema: {
6
+ type: 'object',
7
+ properties: {
8
+ boardId: { type: 'string' },
9
+ frameId: { type: 'string' },
10
+ },
11
+ additionalProperties: false,
12
+ },
13
+ render: {
14
+ formats: [{ name: 'board', description: 'Sync space to Miro board' }],
15
+ async render(context, graph, { format, data }) {
16
+ if (format === 'board') {
17
+ const options = {
18
+ newFrame: data?.newFrame,
19
+ dryRun: data?.dryRun,
20
+ verbose: data?.verbose,
21
+ };
22
+ return await miroSync(context, graph, options);
23
+ }
24
+ throw new Error(`Unknown miro render format: "${format}"`);
25
+ },
26
+ },
27
+ };
@@ -1,4 +1,4 @@
1
- import type { HierarchyLevel, SpaceNode } from '../../types';
1
+ import type { HierarchyLevel, SpaceNode } from '../../api';
2
2
  export declare const CARD_WIDTH = 320;
3
3
  export interface LayoutResult {
4
4
  positions: Map<string, {
@@ -25,4 +25,4 @@ export interface LayoutResult {
25
25
  export declare function layoutNewCards(newNodes: SpaceNode[], existingPositions: Map<string, {
26
26
  x: number;
27
27
  y: number;
28
- }>, hierarchyLevels: HierarchyLevel[]): LayoutResult;
28
+ }>, hierarchyLevels: readonly HierarchyLevel[]): LayoutResult;
@@ -3,18 +3,6 @@ const CARD_HEIGHT = 160;
3
3
  const H_GAP = 40;
4
4
  const V_GAP = 60;
5
5
  const FRAME_PADDING = 60;
6
- /**
7
- * DEPRECATED - likely not needed after migration to render plugin and SpaceGraph
8
- * Build a depth map from hierarchy levels.
9
- * The position in the hierarchy array determines the depth.
10
- */
11
- function buildDepthMap(hierarchyLevels) {
12
- const depthMap = new Map();
13
- for (const [i, level] of hierarchyLevels.entries()) {
14
- depthMap.set(level.type, i);
15
- }
16
- return depthMap;
17
- }
18
6
  /**
19
7
  * Compute positions for new cards only. Existing cards keep their Miro positions.
20
8
  * New cards are laid out in rows grouped by OST type depth, starting below
@@ -25,8 +13,6 @@ function buildDepthMap(hierarchyLevels) {
25
13
  * Returns positions and a bounding box covering all cards (for frame sizing).
26
14
  */
27
15
  export function layoutNewCards(newNodes, existingPositions, hierarchyLevels) {
28
- // Build depth map from hierarchy levels (position in hierarchy = depth)
29
- const depthMap = buildDepthMap(hierarchyLevels);
30
16
  // Find the lowest y among existing cards
31
17
  let lowestY = 0;
32
18
  for (const pos of existingPositions.values()) {
@@ -35,10 +21,11 @@ export function layoutNewCards(newNodes, existingPositions, hierarchyLevels) {
35
21
  }
36
22
  }
37
23
  const startY = existingPositions.size > 0 ? lowestY + V_GAP * 2 : 0;
38
- // Group new nodes by depth
24
+ // Group new nodes by depth (position in hierarchy levels array)
39
25
  const byDepth = new Map();
40
26
  for (const node of newNodes) {
41
- const depth = depthMap.get(node.schemaData.type) ?? depthMap.size;
27
+ const idx = hierarchyLevels.findIndex((l) => l.type === node.schemaData.type);
28
+ const depth = idx === -1 ? hierarchyLevels.length : idx;
42
29
  if (!byDepth.has(depth))
43
30
  byDepth.set(depth, []);
44
31
  byDepth.get(depth)?.push(node);
@@ -1,4 +1,4 @@
1
- import type { HierarchyLevel, SpaceNode } from '../../types';
1
+ import type { HierarchyLevel, SpaceNode } from '../../api';
2
2
  /**
3
3
  * Get card color based on type and hierarchy levels.
4
4
  * Colors are assigned from palette based on level position.
@@ -6,6 +6,6 @@ import type { HierarchyLevel, SpaceNode } from '../../types';
6
6
  * @param type - The node's type string
7
7
  * @param hierarchyLevels - Hierarchy levels from metadata
8
8
  */
9
- export declare function getCardColor(type: string, hierarchyLevels: HierarchyLevel[]): string;
9
+ export declare function getCardColor(type: string, hierarchyLevels: readonly HierarchyLevel[]): string;
10
10
  export declare function buildCardTitle(node: SpaceNode): string;
11
11
  export declare function buildCardDescription(node: SpaceNode): string;
@@ -0,0 +1,7 @@
1
+ import type { PluginContext, SpaceGraph } from '../../api';
2
+ export interface SyncOptions {
3
+ newFrame?: string;
4
+ dryRun?: boolean;
5
+ verbose?: boolean;
6
+ }
7
+ export declare function miroSync(context: PluginContext, graph: SpaceGraph, options: SyncOptions): Promise<string>;
@@ -1,43 +1,28 @@
1
- import { updateSpaceField } from '../../config';
2
- import { readSpace } from '../../read/read-space';
3
- import { buildSpaceGraph } from '../../space-graph';
4
1
  import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache';
5
2
  import { MiroClient, MiroNotFoundError } from './client';
6
3
  import { CARD_WIDTH, layoutNewCards } from './layout';
7
4
  import { buildCardDescription, buildCardTitle, getCardColor } from './styles';
8
- export async function miroSync(context, options) {
5
+ export async function miroSync(context, graph, options) {
9
6
  const token = process.env.MIRO_TOKEN;
10
- if (!token) {
11
- console.error('MIRO_TOKEN environment variable is required');
12
- process.exit(1);
13
- }
14
- const { space, schema: { metadata }, } = context;
7
+ if (!token)
8
+ throw new Error('MIRO_TOKEN environment variable is required');
9
+ const { space } = context;
10
+ const boardId = context.pluginConfig.boardId;
11
+ const configuredFrameId = context.pluginConfig.frameId;
15
12
  // 1. Resolve board
16
- if (!space.miroBoardId) {
17
- console.error(`No miroBoardId configured for space "${space.name}".`);
18
- console.error('Add miroBoardId to the space entry in config.');
19
- process.exit(1);
20
- }
21
- const boardId = space.miroBoardId;
13
+ if (!boardId)
14
+ throw new Error(`No boardId configured for space "${space.name}". Add boardId to the miro plugin config in the space entry.`);
22
15
  // 2. Resolve frame
23
- if (!space.miroFrameId && !options.newFrame) {
24
- console.error('No miroFrameId in space config. Pass --new-frame "Title" to create one.');
25
- process.exit(1);
26
- }
27
- // 3. Load space nodes (load before creating frame so we can calculate size)
28
- let nodes;
29
- ({ nodes } = await readSpace(context));
16
+ if (!configuredFrameId && !options.newFrame)
17
+ throw new Error('No frameId in miro plugin config. Pass --new-frame "Title" to create one.');
18
+ // 3. Filter to hierarchy nodes only (graph already built by caller)
19
+ const levels = graph.hierarchy?.levels ?? [];
20
+ const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title));
30
21
  if (nodes.length === 0) {
31
- console.log('No space nodes found.');
32
- return;
22
+ return 'No space nodes found.';
33
23
  }
34
- // Filter to hierarchy nodes only
35
- const levels = metadata.hierarchy?.levels ?? [];
36
- const { nonHierarchy, hierarchyTitles: hierarchyNodeTitles } = buildSpaceGraph(nodes, levels);
37
- // Filter nodes to only hierarchy nodes
38
- nodes = nodes.filter((n) => hierarchyNodeTitles.has(n.title));
39
- if (options.verbose && nonHierarchy.length > 0) {
40
- console.log(`Excluded ${nonHierarchy.length} non-hierarchy nodes from sync`);
24
+ if (options.verbose && graph.nonHierarchy.length > 0) {
25
+ console.log(`Excluded ${graph.nonHierarchy.length} non-hierarchy nodes from sync`);
41
26
  }
42
27
  const client = new MiroClient(boardId, token);
43
28
  let frameId;
@@ -69,12 +54,12 @@ export async function miroSync(context, options) {
69
54
  });
70
55
  frameId = frame.id;
71
56
  console.log(`Created frame "${options.newFrame}" (${frameId}) - size: ${finalFrameWidth}x${finalFrameHeight}`);
72
- updateSpaceField(space.name, 'miroFrameId', frameId);
73
- console.log(`Saved miroFrameId to config`);
57
+ context.callbacks?.persistConfig?.({ frameId });
58
+ console.log(`Saved frameId to miro plugin config`);
74
59
  }
75
60
  }
76
61
  else {
77
- frameId = space.miroFrameId;
62
+ frameId = configuredFrameId;
78
63
  }
79
64
  // 4. Load cache
80
65
  const cache = loadCache(boardId, frameId);
@@ -272,8 +257,8 @@ export async function miroSync(context, options) {
272
257
  const desiredEdges = new Map();
273
258
  for (const node of nodes) {
274
259
  const childTitle = node.title;
275
- for (const { title: parentTitle, source } of node.resolvedParents) {
276
- if (source !== 'hierarchy')
260
+ for (const { title: parentTitle, source, hierarchy } of node.resolvedParents) {
261
+ if (source !== 'hierarchy' || hierarchy !== graph.hierarchy?.name)
277
262
  continue;
278
263
  // Both endpoints must have verified cards on the board
279
264
  if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
@@ -341,7 +326,5 @@ export async function miroSync(context, options) {
341
326
  if (!options.dryRun)
342
327
  saveCache(cache);
343
328
  // Summary
344
- console.log(`\n${prefix}Sync complete:`);
345
- console.log(` Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged`);
346
- console.log(` Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`);
329
+ return `\n${prefix}Sync complete:\n Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged\n Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`;
347
330
  }
@@ -1,13 +1,22 @@
1
1
  import type { AnySchemaObject } from 'ajv';
2
+ import type { Config, SpaceConfig } from '../config';
3
+ import type { PluginSchemaSource } from '../schema/schema';
2
4
  import type { SpaceGraph } from '../space-graph';
3
5
  import type { BaseNode, ParseIssue, SpaceContext } from '../types';
4
6
  export declare const PLUGIN_PREFIX = "sctx-";
5
7
  export declare const CONFIG_PLUGINS_DIR = "plugins";
6
8
  /** Normalize a plugin name to its canonical prefixed form. */
7
9
  export declare function normalizePluginName(name: string): string;
10
+ /** Shorten a plugin name to its non-prefixed form. */
11
+ export declare function shortenPluginName(name: string): string;
8
12
  export type PluginContext = SpaceContext & {
9
13
  /** Validated config for this plugin invocation. */
10
14
  pluginConfig: Record<string, unknown>;
15
+ /** Optional callback functions for plugins to interact with the system. */
16
+ callbacks?: {
17
+ /** Persist plugin config updates to the config file. */
18
+ persistConfig?: (updates: Record<string, string>) => void;
19
+ };
11
20
  };
12
21
  export type ParseResult = {
13
22
  nodes: BaseNode[];
@@ -33,7 +42,24 @@ export type RenderFormat = {
33
42
  export type RenderOptions = {
34
43
  /** The format name being rendered (e.g. 'bullets', 'mermaid'). */
35
44
  format: string;
45
+ /** Plugin-specific extra data (e.g. CLI flags forwarded by a convenience command). */
46
+ data?: Record<string, unknown>;
36
47
  };
48
+ /** A single file generated as part of a multi-file render output. */
49
+ export type RenderFileOutput = {
50
+ /** Relative path of the file within the destination directory (e.g. 'goals/My Goal.md'). */
51
+ path: string;
52
+ /** File content to write. */
53
+ content: string;
54
+ };
55
+ /** Multi-file render output generated by a render hook. */
56
+ export type RenderMultiFileOutput = {
57
+ files: RenderFileOutput[];
58
+ };
59
+ /** Render hook output: either a single string (stdout/single file) or multiple files. */
60
+ export type RenderResult = string | RenderMultiFileOutput;
61
+ /** Type guard for multi-file render result. */
62
+ export declare function isRenderMultiFileOutput(result: RenderResult): result is RenderMultiFileOutput;
37
63
  /**
38
64
  * The render hook on a plugin: declares available formats and handles rendering.
39
65
  * Unlike parse/templateSync (first-responder), render hooks are additive —
@@ -41,7 +67,7 @@ export type RenderOptions = {
41
67
  */
42
68
  export type RenderHook = {
43
69
  formats: RenderFormat[];
44
- render: (context: PluginContext, graph: SpaceGraph, options: RenderOptions) => Promise<string> | string;
70
+ render: (context: PluginContext, graph: SpaceGraph, options: RenderOptions) => Promise<RenderResult> | RenderResult;
45
71
  };
46
72
  /**
47
73
  * Plugin contract:
@@ -54,7 +80,28 @@ export type StructuredContextPlugin = {
54
80
  /** JSON Schema used to validate the plugin's config block. Fields with `format: 'path'`
55
81
  * are resolved relative to the config directory by `resolveConfigPaths` in the loader. */
56
82
  configSchema: AnySchemaObject;
83
+ /** Optional directory containing JSON schemas contributed by this plugin. */
84
+ schemasDir?: string;
57
85
  parse?: ParseHook;
58
86
  templateSync?: TemplateSyncHook;
59
87
  render?: RenderHook;
60
88
  };
89
+ /**
90
+ * Synchronously locate the root directory of an external plugin.
91
+ * Resolution order:
92
+ * 1. Config-adjacent: {configDir}/plugins/{name} or {configDir}/plugins/sctx-{name}
93
+ * 2. Node module: resolve from configDir using standard module resolution
94
+ */
95
+ export declare function resolvePluginDir(pluginName: string, configDir: string): string | undefined;
96
+ /**
97
+ * Synchronously locate the schemas directory for a plugin.
98
+ * Convention:
99
+ * 1. package.json "sctx": { "schemas": "..." }
100
+ * 2. Convention: <pluginDir>/schemas
101
+ * 3. Fallback for test fixtures: {configDir}/plugins/schemas
102
+ */
103
+ export declare function resolvePluginSchemasDir(pluginName: string, configDir: string): string | undefined;
104
+ /**
105
+ * Synchronously collect all plugin schema sources available to a space.
106
+ */
107
+ export declare function resolveSpacePluginSchemas(space: SpaceConfig | undefined, config: Config, configDir: string): PluginSchemaSource[];
@@ -1,3 +1,6 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
1
4
  import { PLUGIN_PREFIX as _PLUGIN_PREFIX } from '../constants';
2
5
  export const PLUGIN_PREFIX = _PLUGIN_PREFIX;
3
6
  export const CONFIG_PLUGINS_DIR = 'plugins';
@@ -5,3 +8,143 @@ export const CONFIG_PLUGINS_DIR = 'plugins';
5
8
  export function normalizePluginName(name) {
6
9
  return name.startsWith(PLUGIN_PREFIX) ? name : `${PLUGIN_PREFIX}${name}`;
7
10
  }
11
+ /** Shorten a plugin name to its non-prefixed form. */
12
+ export function shortenPluginName(name) {
13
+ return name.startsWith(PLUGIN_PREFIX) ? name.slice(PLUGIN_PREFIX.length) : name;
14
+ }
15
+ /** Type guard for multi-file render result. */
16
+ export function isRenderMultiFileOutput(result) {
17
+ return typeof result === 'object' && result !== null && Array.isArray(result.files);
18
+ }
19
+ /**
20
+ * Synchronously locate the root directory of an external plugin.
21
+ * Resolution order:
22
+ * 1. Config-adjacent: {configDir}/plugins/{name} or {configDir}/plugins/sctx-{name}
23
+ * 2. Node module: resolve from configDir using standard module resolution
24
+ */
25
+ export function resolvePluginDir(pluginName, configDir) {
26
+ const normalized = normalizePluginName(pluginName);
27
+ const short = shortenPluginName(pluginName);
28
+ for (const candidate of [normalized, short]) {
29
+ const localDir = resolve(configDir, CONFIG_PLUGINS_DIR, candidate);
30
+ if (existsSync(localDir)) {
31
+ try {
32
+ if (statSync(localDir).isDirectory()) {
33
+ return localDir;
34
+ }
35
+ }
36
+ catch { }
37
+ }
38
+ }
39
+ // Node module resolution from configDir
40
+ for (const candidate of [normalized, short]) {
41
+ try {
42
+ const req = createRequire(join(configDir, 'package.json'));
43
+ const pkgPath = req.resolve(`${candidate}/package.json`);
44
+ return dirname(pkgPath);
45
+ }
46
+ catch {
47
+ const nmCandidate = resolve(configDir, 'node_modules', candidate);
48
+ if (existsSync(nmCandidate)) {
49
+ try {
50
+ if (statSync(nmCandidate).isDirectory()) {
51
+ return nmCandidate;
52
+ }
53
+ }
54
+ catch { }
55
+ }
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ /**
61
+ * Synchronously locate the schemas directory for a plugin.
62
+ * Convention:
63
+ * 1. package.json "sctx": { "schemas": "..." }
64
+ * 2. Convention: <pluginDir>/schemas
65
+ * 3. Fallback for test fixtures: {configDir}/plugins/schemas
66
+ */
67
+ export function resolvePluginSchemasDir(pluginName, configDir) {
68
+ const dir = resolvePluginDir(pluginName, configDir);
69
+ if (dir) {
70
+ // 1. package.json sctx.schemas
71
+ const pkgJsonPath = join(dir, 'package.json');
72
+ if (existsSync(pkgJsonPath)) {
73
+ try {
74
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
75
+ if (pkg.sctx?.schemas && typeof pkg.sctx.schemas === 'string') {
76
+ const customSchemas = resolve(dir, pkg.sctx.schemas);
77
+ if (existsSync(customSchemas) && statSync(customSchemas).isDirectory()) {
78
+ return customSchemas;
79
+ }
80
+ }
81
+ }
82
+ catch { }
83
+ }
84
+ // 2. Convention: <pluginDir>/schemas
85
+ const conventionDir = join(dir, 'schemas');
86
+ if (existsSync(conventionDir)) {
87
+ try {
88
+ if (statSync(conventionDir).isDirectory()) {
89
+ return conventionDir;
90
+ }
91
+ }
92
+ catch { }
93
+ }
94
+ }
95
+ // 3. Check config-adjacent plugins/schemas as fallback (e.g. test fixtures)
96
+ const fallbackSchemas = resolve(configDir, CONFIG_PLUGINS_DIR, 'schemas');
97
+ if (existsSync(fallbackSchemas)) {
98
+ try {
99
+ if (statSync(fallbackSchemas).isDirectory()) {
100
+ return fallbackSchemas;
101
+ }
102
+ }
103
+ catch { }
104
+ }
105
+ return undefined;
106
+ }
107
+ /**
108
+ * Synchronously collect all plugin schema sources available to a space.
109
+ */
110
+ export function resolveSpacePluginSchemas(space, config, configDir) {
111
+ const pluginSources = [];
112
+ const seenPlugins = new Set();
113
+ const maybeAddPlugin = (name) => {
114
+ const shortName = shortenPluginName(name);
115
+ if (seenPlugins.has(shortName))
116
+ return;
117
+ seenPlugins.add(shortName);
118
+ const schemasDir = resolvePluginSchemasDir(name, configDir);
119
+ if (schemasDir) {
120
+ pluginSources.push({
121
+ pluginName: shortName,
122
+ schemasDir,
123
+ });
124
+ }
125
+ };
126
+ // 1. Plugin referenced by space.schema prefix if any (e.g. sctx-wardley-mapping/wardley_map.json)
127
+ const rawSchema = space?.schema ?? config.schema;
128
+ if (rawSchema && !isAbsolute(rawSchema) && !rawSchema.startsWith('.')) {
129
+ const normalized = rawSchema.replace(/\\/g, '/');
130
+ const slashIdx = normalized.indexOf('/');
131
+ if (slashIdx !== -1) {
132
+ const prefix = normalized.slice(0, slashIdx);
133
+ maybeAddPlugin(prefix);
134
+ }
135
+ }
136
+ // 2. Plugins configured on space
137
+ if (space?.plugins) {
138
+ for (const name of Object.keys(space.plugins)) {
139
+ maybeAddPlugin(name);
140
+ }
141
+ }
142
+ // 3. Plugins configured globally
143
+ const globalPlugins = config.plugins;
144
+ if (globalPlugins) {
145
+ for (const name of Object.keys(globalPlugins)) {
146
+ maybeAddPlugin(name);
147
+ }
148
+ }
149
+ return pluginSources;
150
+ }
@@ -1,7 +1,7 @@
1
1
  import type { BaseNode, SchemaMetadata, SpaceNode, UnresolvedRef } from '../types';
2
2
  /**
3
3
  * Enrich parsed nodes into SpaceNodes by applying type alias resolution and resolving
4
- * parent links using the hierarchy levels and relationships from schema metadata.
4
+ * parent links using the levels of every hierarchy and the relationships from schema metadata.
5
5
  *
6
6
  * Returns the enriched nodes and any unresolved refs (broken/invalid wikilinks).
7
7
  */
@@ -21,19 +21,27 @@ function getRefs(rawField, multiple) {
21
21
  * @param nodesByType Map of node type to nodes
22
22
  * @param targetIndex Map of link targets to nodes
23
23
  * @param edge The edge definition (child type, parent type, field, fieldOn, multiple)
24
- * @param source Whether this edge comes from hierarchy.levels or relationships
24
+ * @param source Whether this edge comes from hierarchy levels or relationships
25
+ * @param hierarchy Name of the hierarchy the edge belongs to (hierarchy edges only)
25
26
  * @param selfRef Whether child and parent are the same node type
26
27
  * @param unresolvedRefs Array to push broken/invalid link entries into
27
28
  * @param typeAliases Optional type aliases for resolution
28
29
  */
29
- function resolveEdge(nodesByType, targetIndex, edge, source, selfRef, unresolvedRefs, typeAliases) {
30
+ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef, unresolvedRefs, typeAliases) {
30
31
  const { type: rawChildType, parent: rawParentType, field, fieldOn, multiple } = edge;
31
32
  const childType = resolveNodeType(rawChildType, typeAliases);
32
33
  const parentType = resolveNodeType(rawParentType, typeAliases);
33
34
  function pushParentRef(childNode, parentTitle) {
34
35
  // Deduplicate by (field, title) — same parent via different fields is intentional
35
36
  if (!childNode.resolvedParents.some((r) => r.field === field && r.title === parentTitle)) {
36
- childNode.resolvedParents.push({ title: parentTitle, field, fieldOn, source, selfRef });
37
+ childNode.resolvedParents.push({
38
+ title: parentTitle,
39
+ field,
40
+ fieldOn,
41
+ source,
42
+ ...(hierarchy !== undefined ? { hierarchy } : {}),
43
+ selfRef,
44
+ });
37
45
  }
38
46
  }
39
47
  function checkShape(ownerNode, rawField) {
@@ -54,6 +62,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, selfRef, unresolved
54
62
  field,
55
63
  reason: 'invalid_shape',
56
64
  message: `Field "${field}" must be a wikilink string, got ${typeof rawField}`,
65
+ source: ownerNode.source,
57
66
  });
58
67
  return false;
59
68
  }
@@ -69,6 +78,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, selfRef, unresolved
69
78
  field,
70
79
  reason: 'not_found',
71
80
  message: `Link target "${target}" in field "${field}" not found`,
81
+ source: ownerNode.source,
72
82
  });
73
83
  return null;
74
84
  }
@@ -79,6 +89,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, selfRef, unresolved
79
89
  field,
80
90
  reason: 'ambiguous',
81
91
  message: `Link target "${target}" in field "${field}" is ambiguous (matches multiple nodes)`,
92
+ source: ownerNode.source,
82
93
  });
83
94
  return null;
84
95
  }
@@ -126,14 +137,41 @@ function resolveEdge(nodesByType, targetIndex, edge, source, selfRef, unresolved
126
137
  }
127
138
  }
128
139
  }
140
+ /**
141
+ * Classify a ContentLink into a ResolvedContentLink by determining its location.
142
+ *
143
+ * Wikilinks are resolved against the target index: if found, location is 'node',
144
+ * otherwise 'internal' (in the source system but not a space node).
145
+ * Markdown links are classified by URL scheme: external (http/https), system (file://),
146
+ * protocol (other scheme), or internal (relative path / no scheme).
147
+ */
148
+ function classifyContentLink(link, targetIndex) {
149
+ const { linkSyntax, target, text, action, anchor } = link;
150
+ const base = { text, target, action, ...(anchor !== undefined ? { anchor } : {}) };
151
+ if (linkSyntax === 'wikilink') {
152
+ // Try the full target#anchor key first, then the bare target — either means it's a space node.
153
+ const lookupKey = anchor !== undefined ? `${target}#${anchor}` : target;
154
+ const inIndex = targetIndex.has(lookupKey) || (anchor !== undefined && targetIndex.has(target));
155
+ return { ...base, location: inIndex ? 'node' : 'internal' };
156
+ }
157
+ // Markdown link: classify by URL scheme.
158
+ const colonIdx = target.indexOf('://');
159
+ if (colonIdx > 0) {
160
+ const scheme = target.slice(0, colonIdx).toLowerCase();
161
+ const location = scheme === 'https' || scheme === 'http' ? 'external' : scheme === 'file' ? 'system' : 'protocol';
162
+ return { ...base, location };
163
+ }
164
+ // Relative path or fragment — in the source system but not a resolvable space node.
165
+ return { ...base, location: 'internal' };
166
+ }
129
167
  /**
130
168
  * Enrich parsed nodes into SpaceNodes by applying type alias resolution and resolving
131
- * parent links using the hierarchy levels and relationships from schema metadata.
169
+ * parent links using the levels of every hierarchy and the relationships from schema metadata.
132
170
  *
133
171
  * Returns the enriched nodes and any unresolved refs (broken/invalid wikilinks).
134
172
  */
135
173
  export function resolveGraphEdges(nodes, metadata) {
136
- const levels = metadata.hierarchy?.levels ?? [];
174
+ const hierarchies = metadata.hierarchies ?? [];
137
175
  const relationships = metadata.relationships ?? [];
138
176
  const typeAliases = metadata.typeAliases;
139
177
  const unresolvedRefs = [];
@@ -141,6 +179,7 @@ export function resolveGraphEdges(nodes, metadata) {
141
179
  ...n,
142
180
  resolvedType: resolveNodeType(n.type, typeAliases),
143
181
  resolvedParents: [],
182
+ resolvedLinks: [],
144
183
  }));
145
184
  const targetIndex = buildTargetIndex(spaceNodes);
146
185
  // Build nodesByType map
@@ -152,27 +191,35 @@ export function resolveGraphEdges(nodes, metadata) {
152
191
  }
153
192
  nodesByType.get(type).push(node);
154
193
  }
155
- // 1. Process hierarchy levels
156
- for (let i = 0; i < levels.length; i++) {
157
- const level = levels[i];
158
- // Regular relationship (child type → parent type)
159
- if (i > 0) {
160
- const parentLevel = levels[i - 1];
161
- resolveEdge(nodesByType, targetIndex, {
162
- type: level.type,
163
- parent: parentLevel.type,
164
- field: level.field,
165
- fieldOn: level.fieldOn,
166
- multiple: level.multiple,
167
- }, 'hierarchy', false, unresolvedRefs, typeAliases);
168
- }
169
- // Same-type relationship (child type → same type) via primary field
170
- if (level.selfRef) {
171
- resolveEdge(nodesByType, targetIndex, { type: level.type, parent: level.type, field: level.field, fieldOn: level.fieldOn, multiple: level.multiple }, 'hierarchy', true, unresolvedRefs, typeAliases);
172
- }
173
- // Same-type relationship via explicit selfRefField
174
- if (level.selfRefField) {
175
- resolveEdge(nodesByType, targetIndex, { type: level.type, parent: level.type, field: level.selfRefField, fieldOn: 'child', multiple: false }, 'hierarchy', true, unresolvedRefs, typeAliases);
194
+ // 1. Process the levels of each hierarchy
195
+ for (const { name, levels } of hierarchies) {
196
+ for (let i = 0; i < levels.length; i++) {
197
+ const level = levels[i];
198
+ // Regular relationship (child type → parent type)
199
+ if (i > 0) {
200
+ const parentLevel = levels[i - 1];
201
+ resolveEdge(nodesByType, targetIndex, {
202
+ type: level.type,
203
+ parent: parentLevel.type,
204
+ field: level.field,
205
+ fieldOn: level.fieldOn,
206
+ multiple: level.multiple,
207
+ }, 'hierarchy', name, false, unresolvedRefs, typeAliases);
208
+ }
209
+ // Same-type relationship (child type → same type) via primary field
210
+ if (level.selfRef) {
211
+ resolveEdge(nodesByType, targetIndex, {
212
+ type: level.type,
213
+ parent: level.type,
214
+ field: level.field,
215
+ fieldOn: level.fieldOn,
216
+ multiple: level.multiple,
217
+ }, 'hierarchy', name, true, unresolvedRefs, typeAliases);
218
+ }
219
+ // Same-type relationship via explicit selfRefField
220
+ if (level.selfRefField) {
221
+ resolveEdge(nodesByType, targetIndex, { type: level.type, parent: level.type, field: level.selfRefField, fieldOn: 'child', multiple: false }, 'hierarchy', name, true, unresolvedRefs, typeAliases);
222
+ }
176
223
  }
177
224
  }
178
225
  // 2. Process additional relationships
@@ -184,7 +231,11 @@ export function resolveGraphEdges(nodes, metadata) {
184
231
  fieldOn: rel.fieldOn,
185
232
  multiple: rel.multiple,
186
233
  };
187
- resolveEdge(nodesByType, targetIndex, edge, 'relationship', rel.type === rel.parent, unresolvedRefs, typeAliases);
234
+ resolveEdge(nodesByType, targetIndex, edge, 'relationship', undefined, rel.type === rel.parent, unresolvedRefs, typeAliases);
235
+ }
236
+ // 3. Resolve content links — classify each raw ContentLink into a ResolvedContentLink.
237
+ for (const node of spaceNodes) {
238
+ node.resolvedLinks = (node.contentLinks ?? []).map((link) => classifyContentLink(link, targetIndex));
188
239
  }
189
240
  // Deduplicate by (label, field, ref) — the same broken link may be encountered across
190
241
  // multiple resolveEdge calls (e.g. selfRef + regular hierarchy share the same field).
@@ -1,4 +1,21 @@
1
1
  import type { BaseNode } from '../types';
2
+ export interface ParsedWikilink {
3
+ target: string;
4
+ anchor?: string;
5
+ displayText?: string;
6
+ }
7
+ /**
8
+ * Format a wikilink string.
9
+ * formatWikilink('My Goal') -> '[[My Goal]]'
10
+ * formatWikilink('My Goal', 'Custom Display') -> '[[My Goal|Custom Display]]'
11
+ */
12
+ export declare function formatWikilink(target: string, alias?: string): string;
13
+ /**
14
+ * Parse a wikilink or link inner string into target, anchor, and displayText.
15
+ * Handles: [[target]], [[target#anchor]], [[target|alias]], [[target#anchor|alias]]
16
+ * Also handles bare forms without enclosing brackets.
17
+ */
18
+ export declare function parseWikilink(wikilink: string): ParsedWikilink;
2
19
  /**
3
20
  * Extract the lookup key from a wikilink string such as:
4
21
  * [[Personal Vision]] → "Personal Vision"