structured-context 0.11.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 (87) hide show
  1. package/README.md +63 -22
  2. package/dist/api.d.ts +7 -5
  3. package/dist/api.js +4 -1
  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/render.d.ts +4 -0
  9. package/dist/commands/render.js +64 -9
  10. package/dist/commands/schemas.d.ts +2 -2
  11. package/dist/commands/schemas.js +114 -22
  12. package/dist/commands/show.d.ts +1 -0
  13. package/dist/commands/show.js +7 -1
  14. package/dist/commands/spaces.d.ts +3 -1
  15. package/dist/commands/spaces.js +11 -2
  16. package/dist/commands/validate-file.js +7 -4
  17. package/dist/commands/validate.js +38 -21
  18. package/dist/config.d.ts +18 -1
  19. package/dist/config.js +81 -10
  20. package/dist/filter/augment-nodes.d.ts +3 -1
  21. package/dist/filter/augment-nodes.js +12 -7
  22. package/dist/filter/filter-nodes.js +1 -1
  23. package/dist/index.js +39 -15
  24. package/dist/load-space-graph.d.ts +4 -2
  25. package/dist/load-space-graph.js +15 -4
  26. package/dist/plugins/loader.js +2 -2
  27. package/dist/plugins/markdown/extract-content-links.js +8 -29
  28. package/dist/plugins/markdown/index.js +8 -2
  29. package/dist/plugins/markdown/parse-embedded.d.ts +6 -0
  30. package/dist/plugins/markdown/parse-embedded.js +41 -7
  31. package/dist/plugins/markdown/read-space.js +38 -5
  32. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  33. package/dist/plugins/markdown/render-directory.js +130 -0
  34. package/dist/plugins/mermaid/render-mermaid.js +2 -1
  35. package/dist/plugins/miro/sync.js +7 -6
  36. package/dist/plugins/util.d.ts +39 -1
  37. package/dist/plugins/util.js +139 -0
  38. package/dist/read/resolve-graph-edges.d.ts +1 -1
  39. package/dist/read/resolve-graph-edges.js +46 -27
  40. package/dist/read/wikilink-utils.d.ts +17 -0
  41. package/dist/read/wikilink-utils.js +40 -0
  42. package/dist/render/render.d.ts +3 -1
  43. package/dist/render/render.js +2 -2
  44. package/dist/schema/meta-schemas.d.ts +13 -0
  45. package/dist/schema/meta-schemas.js +50 -0
  46. package/dist/schema/metadata-contract.d.ts +343 -187
  47. package/dist/schema/metadata-contract.js +68 -30
  48. package/dist/schema/schema.d.ts +14 -8
  49. package/dist/schema/schema.js +277 -198
  50. package/dist/schema/validate-graph.d.ts +2 -1
  51. package/dist/schema/validate-graph.js +40 -23
  52. package/dist/schema/validate-rules.d.ts +5 -1
  53. package/dist/schema/validate-rules.js +20 -1
  54. package/dist/schemas/_knowledge_wiki.json +20 -20
  55. package/dist/schemas/_ost_strict.json +2 -2
  56. package/dist/schemas/_sctx_base.json +2 -2
  57. package/dist/schemas/_strategy_general.json +38 -38
  58. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  59. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  60. package/dist/schemas/knowledge_wiki.json +10 -9
  61. package/dist/schemas/strategy_general.json +15 -14
  62. package/dist/schemas/strict_ost.json +17 -16
  63. package/dist/space-context.d.ts +3 -0
  64. package/dist/space-context.js +7 -2
  65. package/dist/space-graph.d.ts +9 -7
  66. package/dist/space-graph.js +7 -5
  67. package/dist/testing.d.ts +33 -0
  68. package/dist/testing.js +72 -0
  69. package/dist/types.d.ts +54 -13
  70. package/dist/validate.d.ts +3 -0
  71. package/dist/validate.js +19 -9
  72. package/docs/concepts.md +14 -4
  73. package/docs/config.md +8 -0
  74. package/docs/rules.md +49 -22
  75. package/docs/schemas.md +110 -35
  76. package/package.json +13 -8
  77. package/schemas/_knowledge_wiki.json +20 -20
  78. package/schemas/_ost_strict.json +2 -2
  79. package/schemas/_sctx_base.json +2 -2
  80. package/schemas/_strategy_general.json +38 -38
  81. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  82. package/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  83. package/schemas/knowledge_wiki.json +10 -9
  84. package/schemas/strategy_general.json +15 -14
  85. package/schemas/strict_ost.json +17 -16
  86. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  87. package/schemas/generated/_structured_context_schema_meta.json +0 -191
@@ -2,30 +2,7 @@ import { toString as mdastToString } from 'mdast-util-to-string';
2
2
  import remarkGfm from 'remark-gfm';
3
3
  import remarkParse from 'remark-parse';
4
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
- }
5
+ import { parseWikilink } from '../../read/wikilink-utils';
29
6
  /**
30
7
  * Extract wikilinks (and Obsidian embed wikilinks) from a plain text string.
31
8
  * Matches [[target]], ![[target]], [[target#anchor]], [[target|alias]], etc.
@@ -35,7 +12,7 @@ function extractWikilinksFromText(text) {
35
12
  for (const match of text.matchAll(/(!?)\[\[([^\]]+)\]\]/g)) {
36
13
  const isEmbed = match[1] === '!';
37
14
  const inner = match[2];
38
- const { target, anchor, displayText } = parseWikilinkInner(inner);
15
+ const { target, anchor, displayText } = parseWikilink(inner);
39
16
  if (!target)
40
17
  continue;
41
18
  links.push({
@@ -109,10 +86,12 @@ export function extractLinksFromBody(body) {
109
86
  */
110
87
  export function getEdgeFieldNames(metadata) {
111
88
  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);
89
+ for (const hierarchy of metadata.hierarchies ?? []) {
90
+ for (const level of hierarchy.levels) {
91
+ fields.add(level.field);
92
+ if (level.selfRefField)
93
+ fields.add(level.selfRefField);
94
+ }
116
95
  }
117
96
  for (const rel of metadata.relationships ?? []) {
118
97
  fields.add(rel.field);
@@ -2,6 +2,7 @@ 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 { renderMarkdownDirectory } from './render-directory';
5
6
  import { templateSync } from './template-sync';
6
7
  const TYPE_INFERENCE_CONFIG_SCHEMA = {
7
8
  type: 'object',
@@ -36,10 +37,15 @@ export const markdownPlugin = {
36
37
  parse,
37
38
  templateSync,
38
39
  render: {
39
- formats: [{ name: 'bullets', description: 'Indented bullet list' }],
40
- render(_context, graph, { format }) {
40
+ formats: [
41
+ { name: 'bullets', description: 'Indented bullet list' },
42
+ { name: 'directory', description: 'Markdown space directory with one file per node' },
43
+ ],
44
+ render(context, graph, { format, data }) {
41
45
  if (format === 'bullets')
42
46
  return renderBullets(graph);
47
+ if (format === 'directory')
48
+ return renderMarkdownDirectory(context, graph, { format, data });
43
49
  throw new Error(`Unknown markdown render format: "${format}"`);
44
50
  },
45
51
  },
@@ -74,11 +74,17 @@ export interface ExtractEmbeddedOptions {
74
74
  * Example: { "record_type": "type" } renames `record_type` to `type` in extracted data.
75
75
  */
76
76
  fieldMap?: Record<string, string>;
77
+ /** File path of the containing document, for populating node.source. */
78
+ file?: string;
79
+ /** Line offset of the body within the source file (e.g. frontmatter lines). */
80
+ lineOffset?: number;
77
81
  }
78
82
  export interface ExtractEmbeddedResult {
79
83
  nodes: BaseNode[];
80
84
  preambleNodeCount: number;
81
85
  terminatedHeadings: string[];
86
+ preambleContent?: string;
87
+ preambleFields?: Record<string, unknown>;
82
88
  }
83
89
  /**
84
90
  * Extract space nodes from markdown body text.
@@ -145,9 +145,11 @@ function appendContent(node, text) {
145
145
  if (!text)
146
146
  return;
147
147
  const existing = node.schemaData.content;
148
- node.schemaData.content = existing ? `${existing}\n${text}` : text;
148
+ const updated = existing ? `${existing}\n${text}` : text;
149
+ node.schemaData.content = updated;
150
+ node.content = updated;
149
151
  }
150
- function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend) {
152
+ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset) {
151
153
  const firstPara = item.children.find((c) => c.type === 'paragraph');
152
154
  if (!firstPara) {
153
155
  appendContent(contentTarget, `- ${mdastToString(item)}`);
@@ -185,6 +187,11 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
185
187
  schemaData.parent = parentRef;
186
188
  if (summary)
187
189
  schemaData.summary = summary;
190
+ const line = item.position?.start.line ? item.position.start.line + (lineOffset ?? 0) : undefined;
191
+ const column = item.position?.start.column;
192
+ const source = file
193
+ ? { file, ...(line !== undefined ? { line } : {}), ...(column !== undefined ? { column } : {}) }
194
+ : undefined;
188
195
  const linkTargets = buildLinkTargets(title);
189
196
  const newNode = {
190
197
  label: makeLabel(title),
@@ -192,7 +199,9 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
192
199
  schemaData,
193
200
  linkTargets,
194
201
  type,
202
+ ...(schemaData.content !== undefined ? { content: schemaData.content } : {}),
195
203
  contentLinks: [],
204
+ ...(source ? { source } : {}),
196
205
  };
197
206
  nodes.push(newNode);
198
207
  if (parentFieldAppend) {
@@ -202,7 +211,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
202
211
  for (const child of item.children) {
203
212
  if (child.type === 'list') {
204
213
  for (const subItem of child.children) {
205
- processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType);
214
+ processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset);
206
215
  }
207
216
  }
208
217
  }
@@ -240,7 +249,7 @@ export function extractEmbeddedNodes(body, options) {
240
249
  const stack = !isOnAPageMode && pageTitle !== undefined
241
250
  ? [{ depth: 0, title: pageTitle, nodeType: pageType, refTarget: pageTitle }]
242
251
  : [];
243
- let parseState = 'preamble';
252
+ let parseState = isOnAPageMode ? 'preamble' : 'active';
244
253
  let preambleNodeCount = 0;
245
254
  const terminatedHeadings = [];
246
255
  /**
@@ -467,6 +476,11 @@ export function extractEmbeddedNodes(body, options) {
467
476
  };
468
477
  if (parentRef)
469
478
  schemaData.parent = parentRef;
479
+ const line = heading.position?.start.line ? heading.position.start.line + (options.lineOffset ?? 0) : undefined;
480
+ const column = heading.position?.start.column;
481
+ const source = options.file
482
+ ? { file: options.file, ...(line !== undefined ? { line } : {}), ...(column !== undefined ? { column } : {}) }
483
+ : undefined;
470
484
  const linkTargets = buildHeadingLinkTargets(rawText, title, anchor);
471
485
  const headingNode = {
472
486
  label: makeLabel(title),
@@ -474,7 +488,9 @@ export function extractEmbeddedNodes(body, options) {
474
488
  schemaData,
475
489
  linkTargets,
476
490
  type,
491
+ ...(schemaData.content !== undefined ? { content: schemaData.content } : {}),
477
492
  contentLinks: [],
493
+ ...(source ? { source } : {}),
478
494
  };
479
495
  // Push to stack BEFORE resolving semantic parent — stack[-2] is the correct parent.
480
496
  const refTarget = linkTargets[0] ?? title;
@@ -517,13 +533,13 @@ export function extractEmbeddedNodes(body, options) {
517
533
  ? { node: semanticParent.node, field: definition.field }
518
534
  : undefined;
519
535
  for (const item of list.children) {
520
- processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg);
536
+ processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset);
521
537
  }
522
538
  grouping = null;
523
539
  }
524
540
  else {
525
541
  for (const item of list.children) {
526
- processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap);
542
+ processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset);
527
543
  }
528
544
  }
529
545
  }
@@ -605,6 +621,15 @@ export function extractEmbeddedNodes(body, options) {
605
621
  schemaData[mappedColName] = cellContent;
606
622
  }
607
623
  }
624
+ const line = row.position?.start.line ? row.position.start.line + (options.lineOffset ?? 0) : undefined;
625
+ const column = row.position?.start.column;
626
+ const source = options.file
627
+ ? {
628
+ file: options.file,
629
+ ...(line !== undefined ? { line } : {}),
630
+ ...(column !== undefined ? { column } : {}),
631
+ }
632
+ : undefined;
608
633
  const linkTargets = buildListItemLinkTargets(title);
609
634
  const rowNode = {
610
635
  label: makeLabel(title),
@@ -613,6 +638,7 @@ export function extractEmbeddedNodes(body, options) {
613
638
  linkTargets,
614
639
  type: rowTypeStr,
615
640
  contentLinks: [],
641
+ ...(source ? { source } : {}),
616
642
  };
617
643
  nodes.push(rowNode);
618
644
  if (tableParentFieldAppend) {
@@ -668,5 +694,13 @@ export function extractEmbeddedNodes(body, options) {
668
694
  }
669
695
  }
670
696
  }
671
- return { nodes, preambleNodeCount, terminatedHeadings };
697
+ const preambleContent = rootNode.schemaData.content;
698
+ const { type: _rootType, content: _rootContent, ...preambleFields } = rootNode.schemaData;
699
+ return {
700
+ nodes,
701
+ preambleNodeCount,
702
+ terminatedHeadings,
703
+ preambleContent,
704
+ preambleFields: Object.keys(preambleFields).length > 0 ? preambleFields : undefined,
705
+ };
672
706
  }
@@ -21,10 +21,15 @@ export function readSpaceOnAPage(context) {
21
21
  throw new Error(`Schema at ${resolvedSchemaPath} must define "$metadata.hierarchy.levels" to read a space_on_a_page file.`);
22
22
  }
23
23
  const pageTitle = basename(filePath, '.md');
24
+ const fmMatch = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
25
+ const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
26
+ const fileName = basename(filePath);
24
27
  const { nodes, preambleNodeCount, terminatedHeadings } = extractEmbeddedNodes(body, {
25
28
  pageTitle,
26
29
  pageType: 'space_on_a_page',
27
30
  metadata,
31
+ file: fileName,
32
+ lineOffset,
28
33
  });
29
34
  const parseIssues = terminatedHeadings.map((heading) => ({
30
35
  file: heading,
@@ -63,16 +68,27 @@ export async function readSpaceDirectory(context, options) {
63
68
  // Clear the cache after any parse error to avoid stale state.
64
69
  // See: https://github.com/jonschlinkert/gray-matter/issues/166
65
70
  matter.clearCache();
71
+ const yamlErr = err;
72
+ const line = yamlErr.mark ? yamlErr.mark.line + 1 : undefined;
73
+ const column = yamlErr.mark ? yamlErr.mark.column + 1 : undefined;
66
74
  parseIssues.push({
67
75
  file,
68
76
  severity: 'error',
69
77
  type: 'parse',
70
78
  message: err instanceof Error ? err.message : String(err),
79
+ ...(line !== undefined ? { line } : {}),
80
+ ...(column !== undefined ? { column } : {}),
71
81
  });
72
82
  continue;
73
83
  }
74
84
  if (!parsed.data || Object.keys(parsed.data).length === 0) {
75
- parseIssues.push({ file, severity: 'warning', type: 'no-type', message: 'No front-matter or type specified' });
85
+ parseIssues.push({
86
+ file,
87
+ severity: 'warning',
88
+ type: 'no-type',
89
+ message: 'No front-matter or type specified',
90
+ line: 1,
91
+ });
76
92
  continue;
77
93
  }
78
94
  const data = coerceDates(applyFieldMap(parsed.data, fieldMap));
@@ -80,7 +96,7 @@ export async function readSpaceDirectory(context, options) {
80
96
  data.type = inferTypeFromPath(file, typeInferenceCfg, knownTypes, context.schema.metadata.typeAliases);
81
97
  }
82
98
  if (!data.type) {
83
- parseIssues.push({ file, severity: 'warning', type: 'no-type' });
99
+ parseIssues.push({ file, severity: 'warning', type: 'no-type', line: 1 });
84
100
  continue;
85
101
  }
86
102
  if (ON_A_PAGE_TYPES.includes(data.type) && !options?.includeOnAPageFiles) {
@@ -89,21 +105,35 @@ export async function readSpaceDirectory(context, options) {
89
105
  const pageType = data.type;
90
106
  const fileBase = basename(file, '.md');
91
107
  const title = data.title ?? fileBase;
92
- nodes.push({
108
+ const fmMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
109
+ const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
110
+ const pageNode = {
93
111
  label: file,
94
112
  title,
95
113
  schemaData: { title, ...data },
96
114
  linkTargets: [title, fileBase],
97
115
  type: pageType,
116
+ source: { file, line: 1 },
117
+ ...(data.content !== undefined ? { content: data.content } : {}),
98
118
  contentLinks: [...extractLinksFromFrontmatter(data, edgeFields), ...extractLinksFromBody(parsed.content)],
99
- });
119
+ };
100
120
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
101
- const { nodes: embedded, terminatedHeadings } = extractEmbeddedNodes(parsed.content, {
121
+ const { nodes: embedded, preambleContent, preambleFields, terminatedHeadings, } = extractEmbeddedNodes(parsed.content, {
102
122
  pageTitle: fileBase,
103
123
  pageType,
104
124
  metadata,
105
125
  fieldMap,
126
+ file,
127
+ lineOffset,
106
128
  });
129
+ if (preambleFields) {
130
+ Object.assign(pageNode.schemaData, preambleFields);
131
+ }
132
+ if (preambleContent) {
133
+ pageNode.schemaData.content = preambleContent;
134
+ pageNode.content = preambleContent;
135
+ }
136
+ nodes.push(pageNode);
107
137
  nodes.push(...embedded);
108
138
  for (const heading of terminatedHeadings) {
109
139
  parseIssues.push({
@@ -114,6 +144,9 @@ export async function readSpaceDirectory(context, options) {
114
144
  });
115
145
  }
116
146
  }
147
+ else {
148
+ nodes.push(pageNode);
149
+ }
117
150
  }
118
151
  return { nodes, parseIssues, diagnostics: { kind: 'directory' } };
119
152
  }
@@ -0,0 +1,11 @@
1
+ import type { SpaceGraph } from '../../space-graph';
2
+ import type { PluginContext, RenderMultiFileOutput, RenderOptions } from '../util';
3
+ /**
4
+ * Sanitize a node title into a valid filesystem filename.
5
+ * Strips Obsidian/OS forbidden characters: \ / : * ? " < > | # ^ [ ]
6
+ */
7
+ export declare function sanitizeFilename(title: string): string;
8
+ /**
9
+ * Render a space graph as a markdown directory with one file per node.
10
+ */
11
+ export declare function renderMarkdownDirectory(context: PluginContext, graph: SpaceGraph, options: RenderOptions): RenderMultiFileOutput;
@@ -0,0 +1,130 @@
1
+ import { posix } from 'node:path';
2
+ import matter from 'gray-matter';
3
+ import { getMarkdownConfig } from './index';
4
+ import { coerceDates, invertFieldMap } from './util';
5
+ /**
6
+ * Sanitize a node title into a valid filesystem filename.
7
+ * Strips Obsidian/OS forbidden characters: \ / : * ? " < > | # ^ [ ]
8
+ */
9
+ export function sanitizeFilename(title) {
10
+ const sanitized = title
11
+ .replace(/[\\/:*?"<>|#^[\]]/g, '-')
12
+ .replace(/\s+/g, ' ')
13
+ .trim()
14
+ .replace(/^[-.]+/, '')
15
+ .replace(/[-.]+$/, '');
16
+ return sanitized || 'untitled';
17
+ }
18
+ const PREFERRED_FIELD_ORDER = ['type', 'title', 'status', 'summary', 'status_tweet'];
19
+ const EDGE_FIELDS = new Set(['parent', 'dependsOn', 'flowsTo', 'anchors', 'pipelineOf']);
20
+ /**
21
+ * Format frontmatter object with preferred key ordering for Obsidian notes.
22
+ */
23
+ function sortFrontmatterKeys(data) {
24
+ const keys = Object.keys(data);
25
+ const preferred = [];
26
+ const edge = [];
27
+ const middle = [];
28
+ for (const k of PREFERRED_FIELD_ORDER) {
29
+ if (keys.includes(k))
30
+ preferred.push(k);
31
+ }
32
+ for (const k of keys) {
33
+ if (EDGE_FIELDS.has(k)) {
34
+ edge.push(k);
35
+ }
36
+ else if (!PREFERRED_FIELD_ORDER.includes(k)) {
37
+ middle.push(k);
38
+ }
39
+ }
40
+ const sorted = {};
41
+ for (const k of [...preferred, ...middle, ...edge]) {
42
+ sorted[k] = data[k];
43
+ }
44
+ return sorted;
45
+ }
46
+ /**
47
+ * Render a space graph as a markdown directory with one file per node.
48
+ */
49
+ export function renderMarkdownDirectory(context, graph, options) {
50
+ const markdownConfig = getMarkdownConfig(context.space?.plugins);
51
+ const reverseFieldMap = markdownConfig.fieldMap ? invertFieldMap(markdownConfig.fieldMap) : {};
52
+ const foldersOption = options.data?.folders;
53
+ let useFolders = false;
54
+ if (foldersOption === 'type') {
55
+ useFolders = true;
56
+ }
57
+ else if (foldersOption === 'flat') {
58
+ useFolders = false;
59
+ }
60
+ else if (markdownConfig.typeInference?.folderMap) {
61
+ useFolders = true;
62
+ }
63
+ // Build type-to-folder mapping from typeInference.folderMap if available
64
+ const folderMap = markdownConfig.typeInference?.folderMap;
65
+ const typeToFolder = new Map();
66
+ if (folderMap) {
67
+ for (const [folderPath, mappedType] of Object.entries(folderMap)) {
68
+ const cleanFolder = folderPath.replace(/\\/g, '/').replace(/\/+$/, '');
69
+ typeToFolder.set(mappedType.toLowerCase(), cleanFolder);
70
+ }
71
+ }
72
+ const files = [];
73
+ const usedPaths = new Set();
74
+ for (const node of graph.nodes.values()) {
75
+ const baseName = sanitizeFilename(node.title);
76
+ let folder = '';
77
+ if (useFolders) {
78
+ const typeKey = (node.resolvedType || node.type || '').toLowerCase();
79
+ folder = typeToFolder.get(typeKey) ?? (node.resolvedType || node.type || 'other');
80
+ }
81
+ let candidateName = `${baseName}.md`;
82
+ let counter = 2;
83
+ let relPath = folder ? posix.join(folder, candidateName) : candidateName;
84
+ while (usedPaths.has(relPath)) {
85
+ candidateName = `${baseName}-${counter}.md`;
86
+ relPath = folder ? posix.join(folder, candidateName) : candidateName;
87
+ counter++;
88
+ }
89
+ usedPaths.add(relPath);
90
+ const finalBase = candidateName.slice(0, -3);
91
+ // Prepare frontmatter data from schemaData with reverse fieldMap
92
+ const rawData = node.schemaData ?? {};
93
+ const frontmatterData = {};
94
+ for (const [key, value] of Object.entries(rawData)) {
95
+ // Body content is not written to frontmatter
96
+ if (key === 'content')
97
+ continue;
98
+ // Omit title only if it matches the final filename base
99
+ if (key === 'title' && finalBase === node.title)
100
+ continue;
101
+ const fileKey = reverseFieldMap[key] ?? key;
102
+ frontmatterData[fileKey] = value;
103
+ }
104
+ // Preserve exact title if final filename differed or disambiguation occurred
105
+ if (finalBase !== node.title && !frontmatterData.title) {
106
+ const titleKey = reverseFieldMap.title ?? 'title';
107
+ frontmatterData[titleKey] = node.title;
108
+ }
109
+ // Ensure type is in frontmatter
110
+ const nodeType = node.type || node.resolvedType;
111
+ if (nodeType && !frontmatterData.type) {
112
+ const typeKey = reverseFieldMap.type ?? 'type';
113
+ frontmatterData[typeKey] = nodeType;
114
+ }
115
+ const coercedData = coerceDates(sortFrontmatterKeys(frontmatterData));
116
+ const body = (node.content ?? '').trim();
117
+ let fileContent;
118
+ if (Object.keys(coercedData).length > 0) {
119
+ fileContent = matter.stringify(body ? `\n${body}\n` : '', coercedData);
120
+ }
121
+ else {
122
+ fileContent = body ? `${body}\n` : '';
123
+ }
124
+ files.push({
125
+ path: relPath,
126
+ content: fileContent,
127
+ });
128
+ }
129
+ return { files };
130
+ }
@@ -15,7 +15,8 @@ function safeNodeId(id) {
15
15
  return id.replace(/[^a-zA-Z0-9_-]/g, '_');
16
16
  }
17
17
  export function renderMermaid(graph) {
18
- const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet, levels } = graph;
18
+ const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph;
19
+ const levels = graph.hierarchy?.levels ?? [];
19
20
  let mmd = 'graph TD\n';
20
21
  for (let i = 0; i < levels.length; i++) {
21
22
  const level = levels[i];
@@ -16,6 +16,7 @@ export async function miroSync(context, graph, options) {
16
16
  if (!configuredFrameId && !options.newFrame)
17
17
  throw new Error('No frameId in miro plugin config. Pass --new-frame "Title" to create one.');
18
18
  // 3. Filter to hierarchy nodes only (graph already built by caller)
19
+ const levels = graph.hierarchy?.levels ?? [];
19
20
  const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title));
20
21
  if (nodes.length === 0) {
21
22
  return 'No space nodes found.';
@@ -28,7 +29,7 @@ export async function miroSync(context, graph, options) {
28
29
  let layoutOffset = null;
29
30
  if (options.newFrame) {
30
31
  // Calculate layout bounds to size the frame appropriately
31
- const { bounds } = layoutNewCards(nodes, new Map(), graph.levels);
32
+ const { bounds } = layoutNewCards(nodes, new Map(), levels);
32
33
  const frameWidth = Math.max(1600, bounds.maxX - bounds.minX);
33
34
  const frameHeight = Math.max(1200, bounds.maxY - bounds.minY);
34
35
  // Miro positions child items relative to the parent frame's top-left corner.
@@ -155,7 +156,7 @@ export async function miroSync(context, graph, options) {
155
156
  }
156
157
  }
157
158
  // Compute positions for new cards
158
- const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, graph.levels);
159
+ const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, levels);
159
160
  // 7. Create new cards
160
161
  let createdCount = 0;
161
162
  for (const node of newNodes) {
@@ -185,7 +186,7 @@ export async function miroSync(context, graph, options) {
185
186
  title: buildCardTitle(node),
186
187
  description: buildCardDescription(node),
187
188
  },
188
- style: { cardTheme: getCardColor(type, graph.levels) },
189
+ style: { cardTheme: getCardColor(type, levels) },
189
190
  position: { x: pos.x, y: pos.y, origin: 'center' },
190
191
  parent: { id: frameId },
191
192
  geometry: { width: CARD_WIDTH },
@@ -231,7 +232,7 @@ export async function miroSync(context, graph, options) {
231
232
  title: buildCardTitle(node),
232
233
  description: buildCardDescription(node),
233
234
  },
234
- style: { cardTheme: getCardColor(type, graph.levels) },
235
+ style: { cardTheme: getCardColor(type, levels) },
235
236
  position: { x: 0, y: 0, origin: 'center' },
236
237
  parent: { id: frameId },
237
238
  geometry: { width: CARD_WIDTH },
@@ -256,8 +257,8 @@ export async function miroSync(context, graph, options) {
256
257
  const desiredEdges = new Map();
257
258
  for (const node of nodes) {
258
259
  const childTitle = node.title;
259
- for (const { title: parentTitle, source } of node.resolvedParents) {
260
- if (source !== 'hierarchy')
260
+ for (const { title: parentTitle, source, hierarchy } of node.resolvedParents) {
261
+ if (source !== 'hierarchy' || hierarchy !== graph.hierarchy?.name)
261
262
  continue;
262
263
  // Both endpoints must have verified cards on the board
263
264
  if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
@@ -1,4 +1,6 @@
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-";
@@ -43,6 +45,21 @@ export type RenderOptions = {
43
45
  /** Plugin-specific extra data (e.g. CLI flags forwarded by a convenience command). */
44
46
  data?: Record<string, unknown>;
45
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;
46
63
  /**
47
64
  * The render hook on a plugin: declares available formats and handles rendering.
48
65
  * Unlike parse/templateSync (first-responder), render hooks are additive —
@@ -50,7 +67,7 @@ export type RenderOptions = {
50
67
  */
51
68
  export type RenderHook = {
52
69
  formats: RenderFormat[];
53
- render: (context: PluginContext, graph: SpaceGraph, options: RenderOptions) => Promise<string> | string;
70
+ render: (context: PluginContext, graph: SpaceGraph, options: RenderOptions) => Promise<RenderResult> | RenderResult;
54
71
  };
55
72
  /**
56
73
  * Plugin contract:
@@ -63,7 +80,28 @@ export type StructuredContextPlugin = {
63
80
  /** JSON Schema used to validate the plugin's config block. Fields with `format: 'path'`
64
81
  * are resolved relative to the config directory by `resolveConfigPaths` in the loader. */
65
82
  configSchema: AnySchemaObject;
83
+ /** Optional directory containing JSON schemas contributed by this plugin. */
84
+ schemasDir?: string;
66
85
  parse?: ParseHook;
67
86
  templateSync?: TemplateSyncHook;
68
87
  render?: RenderHook;
69
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[];