structured-context 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +22 -3
  2. package/dist/api.d.ts +21 -3
  3. package/dist/api.js +19 -2
  4. package/dist/commands/diagram.js +1 -1
  5. package/dist/commands/miro-sync.d.ts +3 -0
  6. package/dist/commands/miro-sync.js +8 -0
  7. package/dist/commands/schemas.js +3 -2
  8. package/dist/commands/spaces.js +0 -2
  9. package/dist/commands/validate-file.js +17 -5
  10. package/dist/commands/validate.js +16 -0
  11. package/dist/config.d.ts +8 -12
  12. package/dist/config.js +24 -22
  13. package/dist/index.js +13 -21
  14. package/dist/load-space-graph.d.ts +40 -0
  15. package/dist/load-space-graph.js +42 -0
  16. package/dist/plugins/index.js +3 -1
  17. package/dist/plugins/loader.js +2 -2
  18. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  19. package/dist/plugins/markdown/extract-content-links.js +144 -0
  20. package/dist/plugins/markdown/index.js +1 -7
  21. package/dist/plugins/markdown/parse-embedded.js +9 -0
  22. package/dist/plugins/markdown/read-space.js +3 -0
  23. package/dist/plugins/mermaid/index.d.ts +2 -0
  24. package/dist/plugins/mermaid/index.js +14 -0
  25. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -16
  26. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  27. package/dist/plugins/miro/index.d.ts +2 -0
  28. package/dist/plugins/miro/index.js +27 -0
  29. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  30. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  31. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  32. package/dist/plugins/miro/sync.d.ts +7 -0
  33. package/dist/{integrations → plugins}/miro/sync.js +23 -41
  34. package/dist/plugins/util.d.ts +9 -0
  35. package/dist/plugins/util.js +4 -0
  36. package/dist/read/resolve-graph-edges.js +32 -0
  37. package/dist/render/render.d.ts +1 -0
  38. package/dist/render/render.js +20 -16
  39. package/dist/schema/evaluate-rule.js +1 -0
  40. package/dist/schema/schema.d.ts +3 -1
  41. package/dist/schema/schema.js +6 -2
  42. package/dist/schemas/_knowledge_wiki.json +1 -1
  43. package/dist/space-context.d.ts +24 -0
  44. package/dist/space-context.js +41 -0
  45. package/dist/types.d.ts +52 -0
  46. package/dist/validate.d.ts +12 -3
  47. package/dist/validate.js +37 -12
  48. package/docs/concepts.md +23 -0
  49. package/docs/config.md +2 -0
  50. package/package.json +1 -1
  51. package/schemas/_knowledge_wiki.json +1 -1
  52. package/dist/integrations/miro/sync.d.ts +0 -8
  53. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  54. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  55. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  56. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  57. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
@@ -0,0 +1,144 @@
1
+ import { toString as mdastToString } from 'mdast-util-to-string';
2
+ import remarkGfm from 'remark-gfm';
3
+ import remarkParse from 'remark-parse';
4
+ import { unified } from 'unified';
5
+ /**
6
+ * Parse an Obsidian wikilink inner string into its components.
7
+ * Handles: [[target]], [[target#anchor]], [[target|alias]], [[target#anchor|alias]]
8
+ */
9
+ function parseWikilinkInner(inner) {
10
+ // Alias: [[target|alias]] or [[target#anchor|alias]]
11
+ const pipeIdx = inner.indexOf('|');
12
+ let core = inner;
13
+ let displayText;
14
+ if (pipeIdx >= 0) {
15
+ core = inner.slice(0, pipeIdx);
16
+ const alias = inner.slice(pipeIdx + 1).trim();
17
+ if (alias)
18
+ displayText = alias;
19
+ }
20
+ // Anchor: [[target#anchor]] or [[target#^block]]
21
+ const hashIdx = core.indexOf('#');
22
+ if (hashIdx >= 0) {
23
+ const target = core.slice(0, hashIdx).trim();
24
+ const anchor = core.slice(hashIdx + 1).trim() || undefined;
25
+ return { target, anchor, displayText };
26
+ }
27
+ return { target: core.trim(), displayText };
28
+ }
29
+ /**
30
+ * Extract wikilinks (and Obsidian embed wikilinks) from a plain text string.
31
+ * Matches [[target]], ![[target]], [[target#anchor]], [[target|alias]], etc.
32
+ */
33
+ function extractWikilinksFromText(text) {
34
+ const links = [];
35
+ for (const match of text.matchAll(/(!?)\[\[([^\]]+)\]\]/g)) {
36
+ const isEmbed = match[1] === '!';
37
+ const inner = match[2];
38
+ const { target, anchor, displayText } = parseWikilinkInner(inner);
39
+ if (!target)
40
+ continue;
41
+ links.push({
42
+ text: displayText ?? target,
43
+ target,
44
+ action: isEmbed ? 'embed' : 'link',
45
+ ...(anchor !== undefined ? { anchor } : {}),
46
+ linkSyntax: 'wikilink',
47
+ });
48
+ }
49
+ return links;
50
+ }
51
+ /**
52
+ * Recursively walk an mdast subtree and collect all links.
53
+ * Handles standard markdown link/image nodes and scans text nodes for wikilinks.
54
+ */
55
+ export function extractLinksFromAstNode(node) {
56
+ const links = [];
57
+ function walk(n) {
58
+ switch (n.type) {
59
+ case 'link': {
60
+ const linkNode = n;
61
+ links.push({
62
+ text: mdastToString(linkNode),
63
+ target: linkNode.url,
64
+ action: 'link',
65
+ linkSyntax: 'markdown',
66
+ });
67
+ // Don't recurse into children — they are display text, not link targets
68
+ break;
69
+ }
70
+ case 'image': {
71
+ const imgNode = n;
72
+ links.push({
73
+ text: imgNode.alt ?? '',
74
+ target: imgNode.url,
75
+ action: 'embed',
76
+ linkSyntax: 'markdown',
77
+ });
78
+ break;
79
+ }
80
+ case 'text': {
81
+ // Remark does not parse wikilinks natively; scan text nodes for [[...]] patterns
82
+ links.push(...extractWikilinksFromText(n.value));
83
+ break;
84
+ }
85
+ default: {
86
+ if ('children' in n) {
87
+ for (const child of n.children) {
88
+ walk(child);
89
+ }
90
+ }
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ walk(node);
96
+ return links;
97
+ }
98
+ /**
99
+ * Extract all links from a raw markdown body string.
100
+ * Parses the full document and collects links from every node in the tree.
101
+ */
102
+ export function extractLinksFromBody(body) {
103
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
104
+ return extractLinksFromAstNode(tree);
105
+ }
106
+ /**
107
+ * Build the set of field names that serve as graph edges (hierarchy + relationship fields).
108
+ * Used to exclude edge fields from frontmatter link extraction.
109
+ */
110
+ export function getEdgeFieldNames(metadata) {
111
+ const fields = new Set();
112
+ for (const level of metadata.hierarchy?.levels ?? []) {
113
+ fields.add(level.field);
114
+ if (level.selfRefField)
115
+ fields.add(level.selfRefField);
116
+ }
117
+ for (const rel of metadata.relationships ?? []) {
118
+ fields.add(rel.field);
119
+ }
120
+ return fields;
121
+ }
122
+ /**
123
+ * Extract links from frontmatter data fields, excluding known graph edge fields.
124
+ * Parses each string value as markdown (via mdast) to keep link extraction consistent
125
+ * with body content parsing — handles wikilinks, markdown links, images, and bare URLs.
126
+ */
127
+ export function extractLinksFromFrontmatter(data, edgeFields) {
128
+ const links = [];
129
+ for (const [key, value] of Object.entries(data)) {
130
+ if (edgeFields.has(key))
131
+ continue;
132
+ if (typeof value === 'string') {
133
+ links.push(...extractLinksFromBody(value));
134
+ }
135
+ else if (Array.isArray(value)) {
136
+ for (const item of value) {
137
+ if (typeof item === 'string') {
138
+ links.push(...extractLinksFromBody(item));
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return links;
144
+ }
@@ -2,7 +2,6 @@ import { statSync } from 'node:fs';
2
2
  import { PLUGIN_PREFIX } from '../util';
3
3
  import { readSpaceDirectory, readSpaceOnAPage } from './read-space';
4
4
  import { renderBullets } from './render-bullets';
5
- import { renderMermaid } from './render-mermaid';
6
5
  import { templateSync } from './template-sync';
7
6
  const TYPE_INFERENCE_CONFIG_SCHEMA = {
8
7
  type: 'object',
@@ -37,15 +36,10 @@ export const markdownPlugin = {
37
36
  parse,
38
37
  templateSync,
39
38
  render: {
40
- formats: [
41
- { name: 'bullets', description: 'Indented bullet list' },
42
- { name: 'mermaid', description: 'Mermaid graph TD diagram' },
43
- ],
39
+ formats: [{ name: 'bullets', description: 'Indented bullet list' }],
44
40
  render(_context, graph, { format }) {
45
41
  if (format === 'bullets')
46
42
  return renderBullets(graph);
47
- if (format === 'mermaid')
48
- return renderMermaid(graph);
49
43
  throw new Error(`Unknown markdown render format: "${format}"`);
50
44
  },
51
45
  },
@@ -3,6 +3,7 @@ import { toString as mdastToString } from 'mdast-util-to-string';
3
3
  import remarkGfm from 'remark-gfm';
4
4
  import remarkParse from 'remark-parse';
5
5
  import { unified } from 'unified';
6
+ import { extractLinksFromAstNode } from './extract-content-links';
6
7
  import { applyFieldMap, coerceDates } from './util';
7
8
  /** Type values that identify a space_on_a_page container (not themselves space nodes). */
8
9
  export const ON_A_PAGE_TYPES = ['ost_on_a_page', 'space_on_a_page'];
@@ -191,6 +192,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
191
192
  schemaData,
192
193
  linkTargets,
193
194
  type,
195
+ contentLinks: [],
194
196
  };
195
197
  nodes.push(newNode);
196
198
  if (parentFieldAppend) {
@@ -230,6 +232,7 @@ export function extractEmbeddedNodes(body, options) {
230
232
  schemaData: { type: 'space_on_a_page' },
231
233
  linkTargets: [],
232
234
  type: 'space_on_a_page',
235
+ contentLinks: [],
233
236
  };
234
237
  const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
235
238
  // In typed-page mode: stack starts with the page's own virtual entry (depth 0).
@@ -471,6 +474,7 @@ export function extractEmbeddedNodes(body, options) {
471
474
  schemaData,
472
475
  linkTargets,
473
476
  type,
477
+ contentLinks: [],
474
478
  };
475
479
  // Push to stack BEFORE resolving semantic parent — stack[-2] is the correct parent.
476
480
  const refTarget = linkTargets[0] ?? title;
@@ -608,6 +612,7 @@ export function extractEmbeddedNodes(body, options) {
608
612
  schemaData,
609
613
  linkTargets,
610
614
  type: rowTypeStr,
615
+ contentLinks: [],
611
616
  };
612
617
  nodes.push(rowNode);
613
618
  if (tableParentFieldAppend) {
@@ -640,6 +645,8 @@ export function extractEmbeddedNodes(body, options) {
640
645
  Object.assign(activeNode.schemaData, allFields);
641
646
  if (remainingText)
642
647
  appendContent(activeNode, remainingText);
648
+ activeNode.contentLinks ??= [];
649
+ activeNode.contentLinks.push(...extractLinksFromAstNode(child));
643
650
  }
644
651
  else if (child.type === 'code' && child.lang?.trim() === 'yaml') {
645
652
  const code = child;
@@ -656,6 +663,8 @@ export function extractEmbeddedNodes(body, options) {
656
663
  }
657
664
  else {
658
665
  appendContent(activeNode, mdastToString(child));
666
+ activeNode.contentLinks ??= [];
667
+ activeNode.contentLinks.push(...extractLinksFromAstNode(child));
659
668
  }
660
669
  }
661
670
  }
@@ -3,6 +3,7 @@ import { basename, join, resolve } from 'node:path';
3
3
  import fg from 'fast-glob';
4
4
  import matter from 'gray-matter';
5
5
  import { extractSchemaTypeNames } from '../../schema/schema';
6
+ import { extractLinksFromBody, extractLinksFromFrontmatter, getEdgeFieldNames } from './extract-content-links';
6
7
  import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
7
8
  import { applyFieldMap, coerceDates, inferTypeFromPath } from './util';
8
9
  export function readSpaceOnAPage(context) {
@@ -42,6 +43,7 @@ export async function readSpaceDirectory(context, options) {
42
43
  const absoluteTemplateDir = templateDir ? resolve(templateDir) : undefined;
43
44
  const typeInferenceCfg = mdCfg.typeInference;
44
45
  const knownTypes = typeInferenceCfg?.mode !== 'off' ? extractSchemaTypeNames(context.schema, context.schemaRefRegistry) : undefined;
46
+ const edgeFields = getEdgeFieldNames(metadata);
45
47
  const files = await fg('**/*.md', { cwd: directory, followSymbolicLinks: true });
46
48
  const nodes = [];
47
49
  const parseIssues = [];
@@ -93,6 +95,7 @@ export async function readSpaceDirectory(context, options) {
93
95
  schemaData: { title, ...data },
94
96
  linkTargets: [title, fileBase],
95
97
  type: pageType,
98
+ contentLinks: [...extractLinksFromFrontmatter(data, edgeFields), ...extractLinksFromBody(parsed.content)],
96
99
  });
97
100
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
98
101
  const { nodes: embedded, terminatedHeadings } = extractEmbeddedNodes(parsed.content, {
@@ -0,0 +1,2 @@
1
+ import type { StructuredContextPlugin } from '../util';
2
+ export declare const mermaidPlugin: StructuredContextPlugin;
@@ -0,0 +1,14 @@
1
+ import { PLUGIN_PREFIX } from '../util';
2
+ import { renderMermaid } from './render-mermaid';
3
+ export const mermaidPlugin = {
4
+ name: `${PLUGIN_PREFIX}mermaid`,
5
+ configSchema: { type: 'object', additionalProperties: false },
6
+ render: {
7
+ formats: [{ name: 'graph', description: 'Mermaid graph TD diagram' }],
8
+ render(_context, graph, { format }) {
9
+ if (format === 'graph')
10
+ return renderMermaid(graph);
11
+ throw new Error(`Unknown mermaid render format: "${format}"`);
12
+ },
13
+ },
14
+ };
@@ -1,3 +1,13 @@
1
+ const LEVEL_PALETTE = [
2
+ { fill: '#ff9999', stroke: '#ff0000' },
3
+ { fill: '#99ccff', stroke: '#0066cc' },
4
+ { fill: '#99ff99', stroke: '#00cc00' },
5
+ { fill: '#ffcc99', stroke: '#cc9900' },
6
+ { fill: '#cc99ff', stroke: '#6600cc' },
7
+ { fill: '#ff99cc', stroke: '#cc0066' },
8
+ { fill: '#99ffff', stroke: '#009999' },
9
+ { fill: '#ffff99', stroke: '#cccc00' },
10
+ ];
1
11
  function escapeMermaidString(str) {
2
12
  return str.replace(/"/g, '"');
3
13
  }
@@ -5,20 +15,13 @@ function safeNodeId(id) {
5
15
  return id.replace(/[^a-zA-Z0-9_-]/g, '_');
6
16
  }
7
17
  export function renderMermaid(graph) {
8
- const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph;
18
+ const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet, levels } = graph;
9
19
  let mmd = 'graph TD\n';
10
- mmd += ' classDef vision fill:#ff9999,stroke:#ff0000,stroke-width:2px\n';
11
- mmd += ' classDef mission fill:#99ccff,stroke:#0066cc,stroke-width:2px\n';
12
- mmd += ' classDef goal fill:#99ff99,stroke:#00cc00,stroke-width:2px\n';
13
- mmd += ' classDef opportunity fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n';
14
- mmd += ' classDef solution fill:#cc99ff,stroke:#6600cc,stroke-width:2px\n';
15
- mmd += ' classDef identified fill:#f0f0f0,stroke:#999999,stroke-dasharray: 5 5\n';
16
- mmd += ' classDef wondering fill:#fff0cc,stroke:#cccc00,stroke-dasharray: 5 5\n';
17
- mmd += ' classDef exploring fill:#ffcc99,stroke:#cc9900,stroke-dasharray: 5 5\n';
18
- mmd += ' classDef active fill:#99ff99,stroke:#00cc00,stroke-width:2px\n';
19
- mmd += ' classDef paused fill:#ffcc99,stroke:#cc9900,stroke-width:2px\n';
20
- mmd += ' classDef completed fill:#ccccff,stroke:#6666cc,stroke-width:2px\n';
21
- mmd += ' classDef archived fill:#e0e0e0,stroke:#999999,stroke-width:2px\n';
20
+ for (let i = 0; i < levels.length; i++) {
21
+ const level = levels[i];
22
+ const color = LEVEL_PALETTE[i % LEVEL_PALETTE.length];
23
+ mmd += ` classDef ${level.type} fill:${color.fill},stroke:${color.stroke},stroke-width:2px\n`;
24
+ }
22
25
  const addedNodes = new Set();
23
26
  function addNodeAndChildren(node) {
24
27
  const nodeId = node.title;
@@ -26,13 +29,11 @@ export function renderMermaid(graph) {
26
29
  return;
27
30
  addedNodes.add(nodeId);
28
31
  const type = node.resolvedType;
29
- const status = node.schemaData.status;
30
32
  const priority = node.schemaData.priority;
31
33
  const label = priority ? `${nodeId} (${priority})` : nodeId;
32
- const className = `${type}_${status}`;
33
34
  const safeId = safeNodeId(nodeId);
34
35
  const escapedLabel = escapeMermaidString(label);
35
- mmd += ` ${safeId}["${escapedLabel}"]:::${className}\n`;
36
+ mmd += ` ${safeId}["${escapedLabel}"]:::${type}\n`;
36
37
  const nodeChildren = children.get(nodeId) ?? [];
37
38
  for (const child of nodeChildren) {
38
39
  const childTitle = child.title;
@@ -1,4 +1,4 @@
1
- import type { SpaceNode } from '../../types';
1
+ import type { SpaceNode } from '../../api';
2
2
  export interface CachedNode {
3
3
  miroCardId: string;
4
4
  contentHash: string;
@@ -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,50 +1,34 @@
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 nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title));
30
20
  if (nodes.length === 0) {
31
- console.log('No space nodes found.');
32
- return;
21
+ return 'No space nodes found.';
33
22
  }
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`);
23
+ if (options.verbose && graph.nonHierarchy.length > 0) {
24
+ console.log(`Excluded ${graph.nonHierarchy.length} non-hierarchy nodes from sync`);
41
25
  }
42
26
  const client = new MiroClient(boardId, token);
43
27
  let frameId;
44
28
  let layoutOffset = null;
45
29
  if (options.newFrame) {
46
30
  // Calculate layout bounds to size the frame appropriately
47
- const { bounds } = layoutNewCards(nodes, new Map(), levels);
31
+ const { bounds } = layoutNewCards(nodes, new Map(), graph.levels);
48
32
  const frameWidth = Math.max(1600, bounds.maxX - bounds.minX);
49
33
  const frameHeight = Math.max(1200, bounds.maxY - bounds.minY);
50
34
  // Miro positions child items relative to the parent frame's top-left corner.
@@ -69,12 +53,12 @@ export async function miroSync(context, options) {
69
53
  });
70
54
  frameId = frame.id;
71
55
  console.log(`Created frame "${options.newFrame}" (${frameId}) - size: ${finalFrameWidth}x${finalFrameHeight}`);
72
- updateSpaceField(space.name, 'miroFrameId', frameId);
73
- console.log(`Saved miroFrameId to config`);
56
+ context.callbacks?.persistConfig?.({ frameId });
57
+ console.log(`Saved frameId to miro plugin config`);
74
58
  }
75
59
  }
76
60
  else {
77
- frameId = space.miroFrameId;
61
+ frameId = configuredFrameId;
78
62
  }
79
63
  // 4. Load cache
80
64
  const cache = loadCache(boardId, frameId);
@@ -171,7 +155,7 @@ export async function miroSync(context, options) {
171
155
  }
172
156
  }
173
157
  // Compute positions for new cards
174
- const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, levels);
158
+ const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, graph.levels);
175
159
  // 7. Create new cards
176
160
  let createdCount = 0;
177
161
  for (const node of newNodes) {
@@ -201,7 +185,7 @@ export async function miroSync(context, options) {
201
185
  title: buildCardTitle(node),
202
186
  description: buildCardDescription(node),
203
187
  },
204
- style: { cardTheme: getCardColor(type, levels) },
188
+ style: { cardTheme: getCardColor(type, graph.levels) },
205
189
  position: { x: pos.x, y: pos.y, origin: 'center' },
206
190
  parent: { id: frameId },
207
191
  geometry: { width: CARD_WIDTH },
@@ -247,7 +231,7 @@ export async function miroSync(context, options) {
247
231
  title: buildCardTitle(node),
248
232
  description: buildCardDescription(node),
249
233
  },
250
- style: { cardTheme: getCardColor(type, levels) },
234
+ style: { cardTheme: getCardColor(type, graph.levels) },
251
235
  position: { x: 0, y: 0, origin: 'center' },
252
236
  parent: { id: frameId },
253
237
  geometry: { width: CARD_WIDTH },
@@ -341,7 +325,5 @@ export async function miroSync(context, options) {
341
325
  if (!options.dryRun)
342
326
  saveCache(cache);
343
327
  // 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`);
328
+ return `\n${prefix}Sync complete:\n Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged\n Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`;
347
329
  }
@@ -5,9 +5,16 @@ export declare const PLUGIN_PREFIX = "sctx-";
5
5
  export declare const CONFIG_PLUGINS_DIR = "plugins";
6
6
  /** Normalize a plugin name to its canonical prefixed form. */
7
7
  export declare function normalizePluginName(name: string): string;
8
+ /** Shorten a plugin name to its non-prefixed form. */
9
+ export declare function shortenPluginName(name: string): string;
8
10
  export type PluginContext = SpaceContext & {
9
11
  /** Validated config for this plugin invocation. */
10
12
  pluginConfig: Record<string, unknown>;
13
+ /** Optional callback functions for plugins to interact with the system. */
14
+ callbacks?: {
15
+ /** Persist plugin config updates to the config file. */
16
+ persistConfig?: (updates: Record<string, string>) => void;
17
+ };
11
18
  };
12
19
  export type ParseResult = {
13
20
  nodes: BaseNode[];
@@ -33,6 +40,8 @@ export type RenderFormat = {
33
40
  export type RenderOptions = {
34
41
  /** The format name being rendered (e.g. 'bullets', 'mermaid'). */
35
42
  format: string;
43
+ /** Plugin-specific extra data (e.g. CLI flags forwarded by a convenience command). */
44
+ data?: Record<string, unknown>;
36
45
  };
37
46
  /**
38
47
  * The render hook on a plugin: declares available formats and handles rendering.
@@ -5,3 +5,7 @@ export const CONFIG_PLUGINS_DIR = 'plugins';
5
5
  export function normalizePluginName(name) {
6
6
  return name.startsWith(PLUGIN_PREFIX) ? name : `${PLUGIN_PREFIX}${name}`;
7
7
  }
8
+ /** Shorten a plugin name to its non-prefixed form. */
9
+ export function shortenPluginName(name) {
10
+ return name.startsWith(PLUGIN_PREFIX) ? name.slice(PLUGIN_PREFIX.length) : name;
11
+ }
@@ -126,6 +126,33 @@ function resolveEdge(nodesByType, targetIndex, edge, source, selfRef, unresolved
126
126
  }
127
127
  }
128
128
  }
129
+ /**
130
+ * Classify a ContentLink into a ResolvedContentLink by determining its location.
131
+ *
132
+ * Wikilinks are resolved against the target index: if found, location is 'node',
133
+ * otherwise 'internal' (in the source system but not a space node).
134
+ * Markdown links are classified by URL scheme: external (http/https), system (file://),
135
+ * protocol (other scheme), or internal (relative path / no scheme).
136
+ */
137
+ function classifyContentLink(link, targetIndex) {
138
+ const { linkSyntax, target, text, action, anchor } = link;
139
+ const base = { text, target, action, ...(anchor !== undefined ? { anchor } : {}) };
140
+ if (linkSyntax === 'wikilink') {
141
+ // Try the full target#anchor key first, then the bare target — either means it's a space node.
142
+ const lookupKey = anchor !== undefined ? `${target}#${anchor}` : target;
143
+ const inIndex = targetIndex.has(lookupKey) || (anchor !== undefined && targetIndex.has(target));
144
+ return { ...base, location: inIndex ? 'node' : 'internal' };
145
+ }
146
+ // Markdown link: classify by URL scheme.
147
+ const colonIdx = target.indexOf('://');
148
+ if (colonIdx > 0) {
149
+ const scheme = target.slice(0, colonIdx).toLowerCase();
150
+ const location = scheme === 'https' || scheme === 'http' ? 'external' : scheme === 'file' ? 'system' : 'protocol';
151
+ return { ...base, location };
152
+ }
153
+ // Relative path or fragment — in the source system but not a resolvable space node.
154
+ return { ...base, location: 'internal' };
155
+ }
129
156
  /**
130
157
  * Enrich parsed nodes into SpaceNodes by applying type alias resolution and resolving
131
158
  * parent links using the hierarchy levels and relationships from schema metadata.
@@ -141,6 +168,7 @@ export function resolveGraphEdges(nodes, metadata) {
141
168
  ...n,
142
169
  resolvedType: resolveNodeType(n.type, typeAliases),
143
170
  resolvedParents: [],
171
+ resolvedLinks: [],
144
172
  }));
145
173
  const targetIndex = buildTargetIndex(spaceNodes);
146
174
  // Build nodesByType map
@@ -186,6 +214,10 @@ export function resolveGraphEdges(nodes, metadata) {
186
214
  };
187
215
  resolveEdge(nodesByType, targetIndex, edge, 'relationship', rel.type === rel.parent, unresolvedRefs, typeAliases);
188
216
  }
217
+ // 3. Resolve content links — classify each raw ContentLink into a ResolvedContentLink.
218
+ for (const node of spaceNodes) {
219
+ node.resolvedLinks = (node.contentLinks ?? []).map((link) => classifyContentLink(link, targetIndex));
220
+ }
189
221
  // Deduplicate by (label, field, ref) — the same broken link may be encountered across
190
222
  // multiple resolveEdge calls (e.g. selfRef + regular hierarchy share the same field).
191
223
  const seen = new Set();
@@ -1,4 +1,5 @@
1
1
  import type { SpaceContext } from '../types';
2
2
  export declare function executeRender(formatName: string, context: SpaceContext, options: {
3
3
  filter?: string;
4
+ data?: Record<string, unknown>;
4
5
  }): Promise<string>;