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.
- package/README.md +85 -25
- package/dist/api.d.ts +25 -5
- package/dist/api.js +22 -2
- package/dist/commands/diagram.d.ts +1 -0
- package/dist/commands/diagram.js +7 -1
- package/dist/commands/dump.d.ts +15 -2
- package/dist/commands/dump.js +18 -4
- package/dist/commands/miro-sync.d.ts +3 -0
- package/dist/commands/miro-sync.js +8 -0
- package/dist/commands/render.d.ts +4 -0
- package/dist/commands/render.js +64 -9
- package/dist/commands/schemas.d.ts +2 -2
- package/dist/commands/schemas.js +117 -24
- package/dist/commands/show.d.ts +1 -0
- package/dist/commands/show.js +7 -1
- package/dist/commands/spaces.d.ts +3 -1
- package/dist/commands/spaces.js +11 -4
- package/dist/commands/validate-file.js +20 -5
- package/dist/commands/validate.js +54 -21
- package/dist/config.d.ts +26 -13
- package/dist/config.js +104 -31
- package/dist/filter/augment-nodes.d.ts +3 -1
- package/dist/filter/augment-nodes.js +12 -7
- package/dist/filter/filter-nodes.js +1 -1
- package/dist/index.js +51 -35
- package/dist/load-space-graph.d.ts +42 -0
- package/dist/load-space-graph.js +53 -0
- package/dist/plugins/index.js +3 -1
- package/dist/plugins/loader.js +4 -4
- package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
- package/dist/plugins/markdown/extract-content-links.js +123 -0
- package/dist/plugins/markdown/index.js +5 -5
- package/dist/plugins/markdown/parse-embedded.d.ts +6 -0
- package/dist/plugins/markdown/parse-embedded.js +50 -7
- package/dist/plugins/markdown/read-space.js +41 -5
- package/dist/plugins/markdown/render-directory.d.ts +11 -0
- package/dist/plugins/markdown/render-directory.js +130 -0
- package/dist/plugins/mermaid/index.d.ts +2 -0
- package/dist/plugins/mermaid/index.js +14 -0
- package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -15
- package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
- package/dist/plugins/miro/index.d.ts +2 -0
- package/dist/plugins/miro/index.js +27 -0
- package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
- package/dist/{integrations → plugins}/miro/layout.js +3 -16
- package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
- package/dist/plugins/miro/sync.d.ts +7 -0
- package/dist/{integrations → plugins}/miro/sync.js +22 -39
- package/dist/plugins/util.d.ts +48 -1
- package/dist/plugins/util.js +143 -0
- package/dist/read/resolve-graph-edges.d.ts +1 -1
- package/dist/read/resolve-graph-edges.js +78 -27
- package/dist/read/wikilink-utils.d.ts +17 -0
- package/dist/read/wikilink-utils.js +40 -0
- package/dist/render/render.d.ts +4 -1
- package/dist/render/render.js +20 -16
- package/dist/schema/evaluate-rule.js +1 -0
- package/dist/schema/meta-schemas.d.ts +13 -0
- package/dist/schema/meta-schemas.js +50 -0
- package/dist/schema/metadata-contract.d.ts +343 -187
- package/dist/schema/metadata-contract.js +68 -30
- package/dist/schema/schema.d.ts +17 -9
- package/dist/schema/schema.js +283 -200
- package/dist/schema/validate-graph.d.ts +2 -1
- package/dist/schema/validate-graph.js +40 -23
- package/dist/schema/validate-rules.d.ts +5 -1
- package/dist/schema/validate-rules.js +20 -1
- package/dist/schemas/_knowledge_wiki.json +21 -21
- package/dist/schemas/_ost_strict.json +2 -2
- package/dist/schemas/_sctx_base.json +2 -2
- package/dist/schemas/_strategy_general.json +38 -38
- package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
- package/dist/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
- package/dist/schemas/knowledge_wiki.json +10 -9
- package/dist/schemas/strategy_general.json +15 -14
- package/dist/schemas/strict_ost.json +17 -16
- package/dist/space-context.d.ts +27 -0
- package/dist/space-context.js +46 -0
- package/dist/space-graph.d.ts +9 -7
- package/dist/space-graph.js +7 -5
- package/dist/testing.d.ts +33 -0
- package/dist/testing.js +72 -0
- package/dist/types.d.ts +101 -8
- package/dist/validate.d.ts +15 -3
- package/dist/validate.js +55 -20
- package/docs/concepts.md +37 -4
- package/docs/config.md +10 -0
- package/docs/rules.md +49 -22
- package/docs/schemas.md +110 -35
- package/package.json +13 -8
- package/schemas/_knowledge_wiki.json +21 -21
- package/schemas/_ost_strict.json +2 -2
- package/schemas/_sctx_base.json +2 -2
- package/schemas/_strategy_general.json +38 -38
- package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
- package/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
- package/schemas/knowledge_wiki.json +10 -9
- package/schemas/strategy_general.json +15 -14
- package/schemas/strict_ost.json +17 -16
- package/dist/integrations/miro/sync.d.ts +0 -8
- package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
- package/schemas/generated/_structured_context_schema_meta.json +0 -191
- /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
- /package/dist/{integrations → plugins}/miro/cache.js +0 -0
- /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
- /package/dist/{integrations → plugins}/miro/client.js +0 -0
- /package/dist/{integrations → plugins}/miro/styles.js +0 -0
|
@@ -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 '../../
|
|
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
|
|
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 '../../
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
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 (!
|
|
17
|
-
|
|
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 (!
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
return;
|
|
22
|
+
return 'No space nodes found.';
|
|
33
23
|
}
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
73
|
-
console.log(`Saved
|
|
57
|
+
context.callbacks?.persistConfig?.({ frameId });
|
|
58
|
+
console.log(`Saved frameId to miro plugin config`);
|
|
74
59
|
}
|
|
75
60
|
}
|
|
76
61
|
else {
|
|
77
|
-
frameId =
|
|
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
|
-
|
|
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
|
}
|
package/dist/plugins/util.d.ts
CHANGED
|
@@ -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<
|
|
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[];
|
package/dist/plugins/util.js
CHANGED
|
@@ -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
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
156
|
-
for (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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"
|