structured-context 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +85 -25
  2. package/dist/api.d.ts +25 -5
  3. package/dist/api.js +22 -2
  4. package/dist/commands/diagram.d.ts +1 -0
  5. package/dist/commands/diagram.js +7 -1
  6. package/dist/commands/dump.d.ts +15 -2
  7. package/dist/commands/dump.js +18 -4
  8. package/dist/commands/miro-sync.d.ts +3 -0
  9. package/dist/commands/miro-sync.js +8 -0
  10. package/dist/commands/render.d.ts +4 -0
  11. package/dist/commands/render.js +64 -9
  12. package/dist/commands/schemas.d.ts +2 -2
  13. package/dist/commands/schemas.js +117 -24
  14. package/dist/commands/show.d.ts +1 -0
  15. package/dist/commands/show.js +7 -1
  16. package/dist/commands/spaces.d.ts +3 -1
  17. package/dist/commands/spaces.js +11 -4
  18. package/dist/commands/validate-file.js +20 -5
  19. package/dist/commands/validate.js +54 -21
  20. package/dist/config.d.ts +26 -13
  21. package/dist/config.js +104 -31
  22. package/dist/filter/augment-nodes.d.ts +3 -1
  23. package/dist/filter/augment-nodes.js +12 -7
  24. package/dist/filter/filter-nodes.js +1 -1
  25. package/dist/index.js +51 -35
  26. package/dist/load-space-graph.d.ts +42 -0
  27. package/dist/load-space-graph.js +53 -0
  28. package/dist/plugins/index.js +3 -1
  29. package/dist/plugins/loader.js +4 -4
  30. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  31. package/dist/plugins/markdown/extract-content-links.js +123 -0
  32. package/dist/plugins/markdown/index.js +5 -5
  33. package/dist/plugins/markdown/parse-embedded.d.ts +6 -0
  34. package/dist/plugins/markdown/parse-embedded.js +50 -7
  35. package/dist/plugins/markdown/read-space.js +41 -5
  36. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  37. package/dist/plugins/markdown/render-directory.js +130 -0
  38. package/dist/plugins/mermaid/index.d.ts +2 -0
  39. package/dist/plugins/mermaid/index.js +14 -0
  40. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -15
  41. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  42. package/dist/plugins/miro/index.d.ts +2 -0
  43. package/dist/plugins/miro/index.js +27 -0
  44. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  45. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  46. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  47. package/dist/plugins/miro/sync.d.ts +7 -0
  48. package/dist/{integrations → plugins}/miro/sync.js +22 -39
  49. package/dist/plugins/util.d.ts +48 -1
  50. package/dist/plugins/util.js +143 -0
  51. package/dist/read/resolve-graph-edges.d.ts +1 -1
  52. package/dist/read/resolve-graph-edges.js +78 -27
  53. package/dist/read/wikilink-utils.d.ts +17 -0
  54. package/dist/read/wikilink-utils.js +40 -0
  55. package/dist/render/render.d.ts +4 -1
  56. package/dist/render/render.js +20 -16
  57. package/dist/schema/evaluate-rule.js +1 -0
  58. package/dist/schema/meta-schemas.d.ts +13 -0
  59. package/dist/schema/meta-schemas.js +50 -0
  60. package/dist/schema/metadata-contract.d.ts +343 -187
  61. package/dist/schema/metadata-contract.js +68 -30
  62. package/dist/schema/schema.d.ts +17 -9
  63. package/dist/schema/schema.js +283 -200
  64. package/dist/schema/validate-graph.d.ts +2 -1
  65. package/dist/schema/validate-graph.js +40 -23
  66. package/dist/schema/validate-rules.d.ts +5 -1
  67. package/dist/schema/validate-rules.js +20 -1
  68. package/dist/schemas/_knowledge_wiki.json +21 -21
  69. package/dist/schemas/_ost_strict.json +2 -2
  70. package/dist/schemas/_sctx_base.json +2 -2
  71. package/dist/schemas/_strategy_general.json +38 -38
  72. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  73. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  74. package/dist/schemas/knowledge_wiki.json +10 -9
  75. package/dist/schemas/strategy_general.json +15 -14
  76. package/dist/schemas/strict_ost.json +17 -16
  77. package/dist/space-context.d.ts +27 -0
  78. package/dist/space-context.js +46 -0
  79. package/dist/space-graph.d.ts +9 -7
  80. package/dist/space-graph.js +7 -5
  81. package/dist/testing.d.ts +33 -0
  82. package/dist/testing.js +72 -0
  83. package/dist/types.d.ts +101 -8
  84. package/dist/validate.d.ts +15 -3
  85. package/dist/validate.js +55 -20
  86. package/docs/concepts.md +37 -4
  87. package/docs/config.md +10 -0
  88. package/docs/rules.md +49 -22
  89. package/docs/schemas.md +110 -35
  90. package/package.json +13 -8
  91. package/schemas/_knowledge_wiki.json +21 -21
  92. package/schemas/_ost_strict.json +2 -2
  93. package/schemas/_sctx_base.json +2 -2
  94. package/schemas/_strategy_general.json +38 -38
  95. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  96. package/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
  97. package/schemas/knowledge_wiki.json +10 -9
  98. package/schemas/strategy_general.json +15 -14
  99. package/schemas/strict_ost.json +17 -16
  100. package/dist/integrations/miro/sync.d.ts +0 -8
  101. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  102. package/schemas/generated/_structured_context_schema_meta.json +0 -191
  103. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  104. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  105. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  106. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  107. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
@@ -0,0 +1,123 @@
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
+ import { parseWikilink } from '../../read/wikilink-utils';
6
+ /**
7
+ * Extract wikilinks (and Obsidian embed wikilinks) from a plain text string.
8
+ * Matches [[target]], ![[target]], [[target#anchor]], [[target|alias]], etc.
9
+ */
10
+ function extractWikilinksFromText(text) {
11
+ const links = [];
12
+ for (const match of text.matchAll(/(!?)\[\[([^\]]+)\]\]/g)) {
13
+ const isEmbed = match[1] === '!';
14
+ const inner = match[2];
15
+ const { target, anchor, displayText } = parseWikilink(inner);
16
+ if (!target)
17
+ continue;
18
+ links.push({
19
+ text: displayText ?? target,
20
+ target,
21
+ action: isEmbed ? 'embed' : 'link',
22
+ ...(anchor !== undefined ? { anchor } : {}),
23
+ linkSyntax: 'wikilink',
24
+ });
25
+ }
26
+ return links;
27
+ }
28
+ /**
29
+ * Recursively walk an mdast subtree and collect all links.
30
+ * Handles standard markdown link/image nodes and scans text nodes for wikilinks.
31
+ */
32
+ export function extractLinksFromAstNode(node) {
33
+ const links = [];
34
+ function walk(n) {
35
+ switch (n.type) {
36
+ case 'link': {
37
+ const linkNode = n;
38
+ links.push({
39
+ text: mdastToString(linkNode),
40
+ target: linkNode.url,
41
+ action: 'link',
42
+ linkSyntax: 'markdown',
43
+ });
44
+ // Don't recurse into children — they are display text, not link targets
45
+ break;
46
+ }
47
+ case 'image': {
48
+ const imgNode = n;
49
+ links.push({
50
+ text: imgNode.alt ?? '',
51
+ target: imgNode.url,
52
+ action: 'embed',
53
+ linkSyntax: 'markdown',
54
+ });
55
+ break;
56
+ }
57
+ case 'text': {
58
+ // Remark does not parse wikilinks natively; scan text nodes for [[...]] patterns
59
+ links.push(...extractWikilinksFromText(n.value));
60
+ break;
61
+ }
62
+ default: {
63
+ if ('children' in n) {
64
+ for (const child of n.children) {
65
+ walk(child);
66
+ }
67
+ }
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ walk(node);
73
+ return links;
74
+ }
75
+ /**
76
+ * Extract all links from a raw markdown body string.
77
+ * Parses the full document and collects links from every node in the tree.
78
+ */
79
+ export function extractLinksFromBody(body) {
80
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
81
+ return extractLinksFromAstNode(tree);
82
+ }
83
+ /**
84
+ * Build the set of field names that serve as graph edges (hierarchy + relationship fields).
85
+ * Used to exclude edge fields from frontmatter link extraction.
86
+ */
87
+ export function getEdgeFieldNames(metadata) {
88
+ const fields = new Set();
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
+ }
95
+ }
96
+ for (const rel of metadata.relationships ?? []) {
97
+ fields.add(rel.field);
98
+ }
99
+ return fields;
100
+ }
101
+ /**
102
+ * Extract links from frontmatter data fields, excluding known graph edge fields.
103
+ * Parses each string value as markdown (via mdast) to keep link extraction consistent
104
+ * with body content parsing — handles wikilinks, markdown links, images, and bare URLs.
105
+ */
106
+ export function extractLinksFromFrontmatter(data, edgeFields) {
107
+ const links = [];
108
+ for (const [key, value] of Object.entries(data)) {
109
+ if (edgeFields.has(key))
110
+ continue;
111
+ if (typeof value === 'string') {
112
+ links.push(...extractLinksFromBody(value));
113
+ }
114
+ else if (Array.isArray(value)) {
115
+ for (const item of value) {
116
+ if (typeof item === 'string') {
117
+ links.push(...extractLinksFromBody(item));
118
+ }
119
+ }
120
+ }
121
+ }
122
+ return links;
123
+ }
@@ -2,7 +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 { renderMermaid } from './render-mermaid';
5
+ import { renderMarkdownDirectory } from './render-directory';
6
6
  import { templateSync } from './template-sync';
7
7
  const TYPE_INFERENCE_CONFIG_SCHEMA = {
8
8
  type: 'object',
@@ -39,13 +39,13 @@ export const markdownPlugin = {
39
39
  render: {
40
40
  formats: [
41
41
  { name: 'bullets', description: 'Indented bullet list' },
42
- { name: 'mermaid', description: 'Mermaid graph TD diagram' },
42
+ { name: 'directory', description: 'Markdown space directory with one file per node' },
43
43
  ],
44
- render(_context, graph, { format }) {
44
+ render(context, graph, { format, data }) {
45
45
  if (format === 'bullets')
46
46
  return renderBullets(graph);
47
- if (format === 'mermaid')
48
- return renderMermaid(graph);
47
+ if (format === 'directory')
48
+ return renderMarkdownDirectory(context, graph, { format, data });
49
49
  throw new Error(`Unknown markdown render format: "${format}"`);
50
50
  },
51
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.
@@ -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'];
@@ -144,9 +145,11 @@ function appendContent(node, text) {
144
145
  if (!text)
145
146
  return;
146
147
  const existing = node.schemaData.content;
147
- 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;
148
151
  }
149
- 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) {
150
153
  const firstPara = item.children.find((c) => c.type === 'paragraph');
151
154
  if (!firstPara) {
152
155
  appendContent(contentTarget, `- ${mdastToString(item)}`);
@@ -184,6 +187,11 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
184
187
  schemaData.parent = parentRef;
185
188
  if (summary)
186
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;
187
195
  const linkTargets = buildLinkTargets(title);
188
196
  const newNode = {
189
197
  label: makeLabel(title),
@@ -191,6 +199,9 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
191
199
  schemaData,
192
200
  linkTargets,
193
201
  type,
202
+ ...(schemaData.content !== undefined ? { content: schemaData.content } : {}),
203
+ contentLinks: [],
204
+ ...(source ? { source } : {}),
194
205
  };
195
206
  nodes.push(newNode);
196
207
  if (parentFieldAppend) {
@@ -200,7 +211,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
200
211
  for (const child of item.children) {
201
212
  if (child.type === 'list') {
202
213
  for (const subItem of child.children) {
203
- 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);
204
215
  }
205
216
  }
206
217
  }
@@ -230,6 +241,7 @@ export function extractEmbeddedNodes(body, options) {
230
241
  schemaData: { type: 'space_on_a_page' },
231
242
  linkTargets: [],
232
243
  type: 'space_on_a_page',
244
+ contentLinks: [],
233
245
  };
234
246
  const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
235
247
  // In typed-page mode: stack starts with the page's own virtual entry (depth 0).
@@ -237,7 +249,7 @@ export function extractEmbeddedNodes(body, options) {
237
249
  const stack = !isOnAPageMode && pageTitle !== undefined
238
250
  ? [{ depth: 0, title: pageTitle, nodeType: pageType, refTarget: pageTitle }]
239
251
  : [];
240
- let parseState = 'preamble';
252
+ let parseState = isOnAPageMode ? 'preamble' : 'active';
241
253
  let preambleNodeCount = 0;
242
254
  const terminatedHeadings = [];
243
255
  /**
@@ -464,6 +476,11 @@ export function extractEmbeddedNodes(body, options) {
464
476
  };
465
477
  if (parentRef)
466
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;
467
484
  const linkTargets = buildHeadingLinkTargets(rawText, title, anchor);
468
485
  const headingNode = {
469
486
  label: makeLabel(title),
@@ -471,6 +488,9 @@ export function extractEmbeddedNodes(body, options) {
471
488
  schemaData,
472
489
  linkTargets,
473
490
  type,
491
+ ...(schemaData.content !== undefined ? { content: schemaData.content } : {}),
492
+ contentLinks: [],
493
+ ...(source ? { source } : {}),
474
494
  };
475
495
  // Push to stack BEFORE resolving semantic parent — stack[-2] is the correct parent.
476
496
  const refTarget = linkTargets[0] ?? title;
@@ -513,13 +533,13 @@ export function extractEmbeddedNodes(body, options) {
513
533
  ? { node: semanticParent.node, field: definition.field }
514
534
  : undefined;
515
535
  for (const item of list.children) {
516
- 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);
517
537
  }
518
538
  grouping = null;
519
539
  }
520
540
  else {
521
541
  for (const item of list.children) {
522
- 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);
523
543
  }
524
544
  }
525
545
  }
@@ -601,6 +621,15 @@ export function extractEmbeddedNodes(body, options) {
601
621
  schemaData[mappedColName] = cellContent;
602
622
  }
603
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;
604
633
  const linkTargets = buildListItemLinkTargets(title);
605
634
  const rowNode = {
606
635
  label: makeLabel(title),
@@ -608,6 +637,8 @@ export function extractEmbeddedNodes(body, options) {
608
637
  schemaData,
609
638
  linkTargets,
610
639
  type: rowTypeStr,
640
+ contentLinks: [],
641
+ ...(source ? { source } : {}),
611
642
  };
612
643
  nodes.push(rowNode);
613
644
  if (tableParentFieldAppend) {
@@ -640,6 +671,8 @@ export function extractEmbeddedNodes(body, options) {
640
671
  Object.assign(activeNode.schemaData, allFields);
641
672
  if (remainingText)
642
673
  appendContent(activeNode, remainingText);
674
+ activeNode.contentLinks ??= [];
675
+ activeNode.contentLinks.push(...extractLinksFromAstNode(child));
643
676
  }
644
677
  else if (child.type === 'code' && child.lang?.trim() === 'yaml') {
645
678
  const code = child;
@@ -656,8 +689,18 @@ export function extractEmbeddedNodes(body, options) {
656
689
  }
657
690
  else {
658
691
  appendContent(activeNode, mdastToString(child));
692
+ activeNode.contentLinks ??= [];
693
+ activeNode.contentLinks.push(...extractLinksFromAstNode(child));
659
694
  }
660
695
  }
661
696
  }
662
- 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
+ };
663
706
  }
@@ -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) {
@@ -20,10 +21,15 @@ export function readSpaceOnAPage(context) {
20
21
  throw new Error(`Schema at ${resolvedSchemaPath} must define "$metadata.hierarchy.levels" to read a space_on_a_page file.`);
21
22
  }
22
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);
23
27
  const { nodes, preambleNodeCount, terminatedHeadings } = extractEmbeddedNodes(body, {
24
28
  pageTitle,
25
29
  pageType: 'space_on_a_page',
26
30
  metadata,
31
+ file: fileName,
32
+ lineOffset,
27
33
  });
28
34
  const parseIssues = terminatedHeadings.map((heading) => ({
29
35
  file: heading,
@@ -42,6 +48,7 @@ export async function readSpaceDirectory(context, options) {
42
48
  const absoluteTemplateDir = templateDir ? resolve(templateDir) : undefined;
43
49
  const typeInferenceCfg = mdCfg.typeInference;
44
50
  const knownTypes = typeInferenceCfg?.mode !== 'off' ? extractSchemaTypeNames(context.schema, context.schemaRefRegistry) : undefined;
51
+ const edgeFields = getEdgeFieldNames(metadata);
45
52
  const files = await fg('**/*.md', { cwd: directory, followSymbolicLinks: true });
46
53
  const nodes = [];
47
54
  const parseIssues = [];
@@ -61,16 +68,27 @@ export async function readSpaceDirectory(context, options) {
61
68
  // Clear the cache after any parse error to avoid stale state.
62
69
  // See: https://github.com/jonschlinkert/gray-matter/issues/166
63
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;
64
74
  parseIssues.push({
65
75
  file,
66
76
  severity: 'error',
67
77
  type: 'parse',
68
78
  message: err instanceof Error ? err.message : String(err),
79
+ ...(line !== undefined ? { line } : {}),
80
+ ...(column !== undefined ? { column } : {}),
69
81
  });
70
82
  continue;
71
83
  }
72
84
  if (!parsed.data || Object.keys(parsed.data).length === 0) {
73
- 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
+ });
74
92
  continue;
75
93
  }
76
94
  const data = coerceDates(applyFieldMap(parsed.data, fieldMap));
@@ -78,7 +96,7 @@ export async function readSpaceDirectory(context, options) {
78
96
  data.type = inferTypeFromPath(file, typeInferenceCfg, knownTypes, context.schema.metadata.typeAliases);
79
97
  }
80
98
  if (!data.type) {
81
- parseIssues.push({ file, severity: 'warning', type: 'no-type' });
99
+ parseIssues.push({ file, severity: 'warning', type: 'no-type', line: 1 });
82
100
  continue;
83
101
  }
84
102
  if (ON_A_PAGE_TYPES.includes(data.type) && !options?.includeOnAPageFiles) {
@@ -87,20 +105,35 @@ export async function readSpaceDirectory(context, options) {
87
105
  const pageType = data.type;
88
106
  const fileBase = basename(file, '.md');
89
107
  const title = data.title ?? fileBase;
90
- 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 = {
91
111
  label: file,
92
112
  title,
93
113
  schemaData: { title, ...data },
94
114
  linkTargets: [title, fileBase],
95
115
  type: pageType,
96
- });
116
+ source: { file, line: 1 },
117
+ ...(data.content !== undefined ? { content: data.content } : {}),
118
+ contentLinks: [...extractLinksFromFrontmatter(data, edgeFields), ...extractLinksFromBody(parsed.content)],
119
+ };
97
120
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
98
- const { nodes: embedded, terminatedHeadings } = extractEmbeddedNodes(parsed.content, {
121
+ const { nodes: embedded, preambleContent, preambleFields, terminatedHeadings, } = extractEmbeddedNodes(parsed.content, {
99
122
  pageTitle: fileBase,
100
123
  pageType,
101
124
  metadata,
102
125
  fieldMap,
126
+ file,
127
+ lineOffset,
103
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);
104
137
  nodes.push(...embedded);
105
138
  for (const heading of terminatedHeadings) {
106
139
  parseIssues.push({
@@ -111,6 +144,9 @@ export async function readSpaceDirectory(context, options) {
111
144
  });
112
145
  }
113
146
  }
147
+ else {
148
+ nodes.push(pageNode);
149
+ }
114
150
  }
115
151
  return { nodes, parseIssues, diagnostics: { kind: 'directory' } };
116
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
+ }
@@ -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, '&quot;');
3
13
  }
@@ -6,19 +16,13 @@ function safeNodeId(id) {
6
16
  }
7
17
  export function renderMermaid(graph) {
8
18
  const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph;
19
+ const levels = graph.hierarchy?.levels ?? [];
9
20
  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';
21
+ for (let i = 0; i < levels.length; i++) {
22
+ const level = levels[i];
23
+ const color = LEVEL_PALETTE[i % LEVEL_PALETTE.length];
24
+ mmd += ` classDef ${level.type} fill:${color.fill},stroke:${color.stroke},stroke-width:2px\n`;
25
+ }
22
26
  const addedNodes = new Set();
23
27
  function addNodeAndChildren(node) {
24
28
  const nodeId = node.title;
@@ -26,13 +30,11 @@ export function renderMermaid(graph) {
26
30
  return;
27
31
  addedNodes.add(nodeId);
28
32
  const type = node.resolvedType;
29
- const status = node.schemaData.status;
30
33
  const priority = node.schemaData.priority;
31
34
  const label = priority ? `${nodeId} (${priority})` : nodeId;
32
- const className = `${type}_${status}`;
33
35
  const safeId = safeNodeId(nodeId);
34
36
  const escapedLabel = escapeMermaidString(label);
35
- mmd += ` ${safeId}["${escapedLabel}"]:::${className}\n`;
37
+ mmd += ` ${safeId}["${escapedLabel}"]:::${type}\n`;
36
38
  const nodeChildren = children.get(nodeId) ?? [];
37
39
  for (const child of nodeChildren) {
38
40
  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;