structured-context 0.11.0 → 0.13.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 (138) hide show
  1. package/README.md +52 -198
  2. package/dist/api.d.ts +17 -15
  3. package/dist/api.js +12 -9
  4. package/dist/commands/diagram.d.ts +1 -0
  5. package/dist/commands/diagram.js +7 -1
  6. package/dist/commands/docs.js +1 -0
  7. package/dist/commands/dump.d.ts +15 -2
  8. package/dist/commands/dump.js +18 -4
  9. package/dist/commands/providers.d.ts +4 -0
  10. package/dist/commands/providers.js +52 -0
  11. package/dist/commands/render.d.ts +4 -0
  12. package/dist/commands/render.js +64 -9
  13. package/dist/commands/schemas.d.ts +2 -2
  14. package/dist/commands/schemas.js +133 -22
  15. package/dist/commands/show.d.ts +1 -0
  16. package/dist/commands/show.js +7 -1
  17. package/dist/commands/spaces.d.ts +3 -1
  18. package/dist/commands/spaces.js +11 -2
  19. package/dist/commands/validate-file.d.ts +3 -0
  20. package/dist/commands/validate-file.js +24 -6
  21. package/dist/commands/validate.d.ts +4 -0
  22. package/dist/commands/validate.js +79 -23
  23. package/dist/config.d.ts +25 -1
  24. package/dist/config.js +106 -12
  25. package/dist/filter/augment-nodes.d.ts +3 -1
  26. package/dist/filter/augment-nodes.js +12 -7
  27. package/dist/filter/filter-nodes.js +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +96 -31
  30. package/dist/judge/context.d.ts +20 -0
  31. package/dist/judge/context.js +105 -0
  32. package/dist/judge/factory.d.ts +44 -0
  33. package/dist/judge/factory.js +148 -0
  34. package/dist/judge/index.d.ts +13 -0
  35. package/dist/judge/index.js +8 -0
  36. package/dist/judge/judge-node.d.ts +27 -0
  37. package/dist/judge/judge-node.js +122 -0
  38. package/dist/judge/prompt.d.ts +10 -0
  39. package/dist/judge/prompt.js +92 -0
  40. package/dist/judge/provider/anthropic.d.ts +17 -0
  41. package/dist/judge/provider/anthropic.js +43 -0
  42. package/dist/judge/provider/fake.d.ts +26 -0
  43. package/dist/judge/provider/fake.js +56 -0
  44. package/dist/judge/provider/jev.d.ts +37 -0
  45. package/dist/judge/provider/jev.js +169 -0
  46. package/dist/judge/provider/openai-decisions.d.ts +21 -0
  47. package/dist/judge/provider/openai-decisions.js +84 -0
  48. package/dist/judge/provider/openai.d.ts +20 -0
  49. package/dist/judge/provider/openai.js +53 -0
  50. package/dist/judge/provider/per-rule.d.ts +10 -0
  51. package/dist/judge/provider/per-rule.js +18 -0
  52. package/dist/judge/trim.d.ts +27 -0
  53. package/dist/judge/trim.js +62 -0
  54. package/dist/judge/types.d.ts +92 -0
  55. package/dist/judge/types.js +9 -0
  56. package/dist/judge/verdicts.d.ts +24 -0
  57. package/dist/judge/verdicts.js +82 -0
  58. package/dist/load-space-graph.d.ts +7 -5
  59. package/dist/load-space-graph.js +19 -8
  60. package/dist/plugins/loader.js +3 -3
  61. package/dist/plugins/markdown/extract-content-links.js +8 -29
  62. package/dist/plugins/markdown/index.js +8 -2
  63. package/dist/plugins/markdown/parse-embedded.d.ts +8 -0
  64. package/dist/plugins/markdown/parse-embedded.js +195 -24
  65. package/dist/plugins/markdown/read-space.js +42 -14
  66. package/dist/plugins/markdown/render-directory.d.ts +11 -0
  67. package/dist/plugins/markdown/render-directory.js +130 -0
  68. package/dist/plugins/markdown/template-sync.js +3 -4
  69. package/dist/plugins/markdown/util.d.ts +26 -5
  70. package/dist/plugins/markdown/util.js +56 -15
  71. package/dist/plugins/mermaid/render-mermaid.js +2 -1
  72. package/dist/plugins/miro/sync.js +7 -6
  73. package/dist/plugins/util.d.ts +39 -1
  74. package/dist/plugins/util.js +139 -0
  75. package/dist/read/resolve-graph-edges.d.ts +1 -1
  76. package/dist/read/resolve-graph-edges.js +46 -27
  77. package/dist/read/wikilink-utils.d.ts +17 -0
  78. package/dist/read/wikilink-utils.js +40 -0
  79. package/dist/render/render.d.ts +3 -1
  80. package/dist/render/render.js +2 -2
  81. package/dist/schema/evaluate-rule.d.ts +33 -2
  82. package/dist/schema/evaluate-rule.js +58 -4
  83. package/dist/schema/gating.d.ts +69 -0
  84. package/dist/schema/gating.js +253 -0
  85. package/dist/schema/meta-schemas.d.ts +13 -0
  86. package/dist/schema/meta-schemas.js +50 -0
  87. package/dist/schema/metadata-contract.d.ts +952 -183
  88. package/dist/schema/metadata-contract.js +177 -33
  89. package/dist/schema/rule-filter.d.ts +12 -0
  90. package/dist/schema/rule-filter.js +46 -0
  91. package/dist/schema/rule-severity.d.ts +13 -0
  92. package/dist/schema/rule-severity.js +33 -0
  93. package/dist/schema/schema.d.ts +14 -8
  94. package/dist/schema/schema.js +308 -199
  95. package/dist/schema/validate-graph.d.ts +2 -1
  96. package/dist/schema/validate-graph.js +40 -23
  97. package/dist/schema/validate-rules.d.ts +16 -3
  98. package/dist/schema/validate-rules.js +91 -29
  99. package/dist/schemas/_knowledge_wiki.json +24 -24
  100. package/dist/schemas/_okf.json +172 -0
  101. package/dist/schemas/_ost_strict.json +17 -8
  102. package/dist/schemas/_sctx_base.json +3 -3
  103. package/dist/schemas/_strategy_general.json +38 -38
  104. package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  105. package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  106. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
  107. package/dist/schemas/knowledge_wiki.json +10 -9
  108. package/dist/schemas/okf.json +8 -0
  109. package/dist/schemas/strategy_general.json +15 -14
  110. package/dist/schemas/strict_ost.json +17 -16
  111. package/dist/space-context.d.ts +5 -2
  112. package/dist/space-context.js +9 -4
  113. package/dist/space-graph.d.ts +9 -7
  114. package/dist/space-graph.js +7 -5
  115. package/dist/testing.d.ts +33 -0
  116. package/dist/testing.js +72 -0
  117. package/dist/types.d.ts +64 -16
  118. package/dist/validate.d.ts +62 -9
  119. package/dist/validate.js +229 -114
  120. package/docs/concepts.md +65 -9
  121. package/docs/config.md +78 -1
  122. package/docs/rules.md +168 -50
  123. package/docs/schemas.md +152 -52
  124. package/package.json +17 -11
  125. package/schemas/_knowledge_wiki.json +24 -24
  126. package/schemas/_okf.json +172 -0
  127. package/schemas/_ost_strict.json +17 -8
  128. package/schemas/_sctx_base.json +3 -3
  129. package/schemas/_strategy_general.json +38 -38
  130. package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
  131. package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  132. package/schemas/generated/latest/_structured_context_schema_meta.json +400 -0
  133. package/schemas/knowledge_wiki.json +10 -9
  134. package/schemas/okf.json +8 -0
  135. package/schemas/strategy_general.json +15 -14
  136. package/schemas/strict_ost.json +17 -16
  137. package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
  138. package/schemas/generated/_structured_context_schema_meta.json +0 -191
@@ -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
  },
@@ -8,6 +8,8 @@ type StackEntry = {
8
8
  nodeType: string;
9
9
  /** Preferred wikilink key used when this heading acts as a parent. */
10
10
  refTarget: string;
11
+ /** Node whose section this heading opens; absent for untyped placeholders and the page's own entry. */
12
+ node?: BaseNode;
11
13
  };
12
14
  /** Detect a bare wikilink `[[...]]` and return the inner target, or undefined. */
13
15
  export declare function isWikilink(text: string): string | undefined;
@@ -74,11 +76,17 @@ export interface ExtractEmbeddedOptions {
74
76
  * Example: { "record_type": "type" } renames `record_type` to `type` in extracted data.
75
77
  */
76
78
  fieldMap?: Record<string, string>;
79
+ /** File path of the containing document, for populating node.source. */
80
+ file?: string;
81
+ /** Line offset of the body within the source file (e.g. frontmatter lines). */
82
+ lineOffset?: number;
77
83
  }
78
84
  export interface ExtractEmbeddedResult {
79
85
  nodes: BaseNode[];
80
86
  preambleNodeCount: number;
81
87
  terminatedHeadings: string[];
88
+ preambleContent?: string;
89
+ preambleFields?: Record<string, unknown>;
82
90
  }
83
91
  /**
84
92
  * Extract space nodes from markdown body text.
@@ -1,10 +1,9 @@
1
- import { load as yamlLoad } from 'js-yaml';
2
1
  import { toString as mdastToString } from 'mdast-util-to-string';
3
2
  import remarkGfm from 'remark-gfm';
4
3
  import remarkParse from 'remark-parse';
5
4
  import { unified } from 'unified';
6
5
  import { extractLinksFromAstNode } from './extract-content-links';
7
- import { applyFieldMap, coerceDates } from './util';
6
+ import { applyFieldMap, parseYaml } from './util';
8
7
  /** Type values that identify a space_on_a_page container (not themselves space nodes). */
9
8
  export const ON_A_PAGE_TYPES = ['ost_on_a_page', 'space_on_a_page'];
10
9
  const DEFAULT_STATUS = 'identified';
@@ -141,16 +140,91 @@ export function defaultNodeType(stack, hierarchy) {
141
140
  }
142
141
  return hierarchy[idx + 1];
143
142
  }
144
- function appendContent(node, text) {
145
- if (!text)
143
+ /** Start and end offsets of an mdast node. Throws when the parser gave no position. */
144
+ function offsetsOf(node) {
145
+ const start = node.position?.start.offset;
146
+ const end = node.position?.end.offset;
147
+ if (start === undefined || end === undefined) {
148
+ throw new Error(`Markdown ${node.type} node has no source position; cannot slice its content`);
149
+ }
150
+ return { start, end };
151
+ }
152
+ function addRange(claims, node, start, end) {
153
+ if (end <= start)
146
154
  return;
147
- const existing = node.schemaData.content;
148
- node.schemaData.content = existing ? `${existing}\n${text}` : text;
155
+ const ranges = claims.ranges.get(node) ?? [];
156
+ const last = ranges[ranges.length - 1];
157
+ if (last && last.end === start)
158
+ last.end = end;
159
+ else
160
+ ranges.push({ start, end });
161
+ claims.ranges.set(node, ranges);
162
+ }
163
+ /**
164
+ * Give a node a block of source, plus the blank lines between `from` and the block's `start`.
165
+ * Those lines are skipped when a consumed block just before already kept its own.
166
+ */
167
+ function claim(claims, node, from, start, end) {
168
+ addRange(claims, node, claims.gapKept.delete(node) ? start : from, end);
169
+ }
170
+ /**
171
+ * Take a block out of a node's content (it was consumed as a node, field or other syntax).
172
+ * The blank lines before it stay, so the content on either side does not run together.
173
+ */
174
+ function consume(claims, node, from, start) {
175
+ addRange(claims, node, claims.gapKept.delete(node) ? start : from, start);
176
+ claims.gapKept.add(node);
177
+ }
178
+ /** Sets each node's content from its claimed source. Leading blank lines and trailing whitespace are dropped. */
179
+ function applyClaims(claims, body) {
180
+ for (const [node, ranges] of claims.ranges) {
181
+ let content = ranges
182
+ .map((r) => body.slice(r.start, r.end))
183
+ .join('')
184
+ .replace(/^(?:[ \t]*\r?\n)+/, '')
185
+ .trimEnd();
186
+ const indent = claims.contentIndent.get(node);
187
+ if (indent) {
188
+ const strip = new RegExp(`^[ \\t]{1,${indent}}`);
189
+ content = content
190
+ .split(/(?<=\n)/)
191
+ .map((line) => line.replace(strip, ''))
192
+ .join('');
193
+ }
194
+ if (!content)
195
+ continue;
196
+ node.schemaData.content = content;
197
+ node.content = content;
198
+ }
199
+ }
200
+ /** Source lines (with terminators) of a range, as absolute offsets. */
201
+ function sourceLines(body, range) {
202
+ const lines = [];
203
+ const re = /[^\n]*\n|[^\n]+$/g;
204
+ const text = body.slice(range.start, range.end);
205
+ for (const m of text.matchAll(re)) {
206
+ lines.push({
207
+ start: range.start + m.index,
208
+ end: range.start + m.index + m[0].length,
209
+ text: m[0].replace(/\r?\n$/, ''),
210
+ });
211
+ }
212
+ return lines;
213
+ }
214
+ /** Whether a line holds only inline fields: bracketed `[key:: value]` pieces, or one `key:: value`. */
215
+ function isFieldOnlyLine(line) {
216
+ const { cleanText, fields } = extractBracketedFields(line);
217
+ const hasBracketed = Object.keys(fields).length > 0;
218
+ if (cleanText === '')
219
+ return hasBracketed;
220
+ return /^[a-zA-Z][a-zA-Z0-9_-]*:: *.*$/.test(cleanText);
149
221
  }
150
- function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend) {
222
+ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset, walk) {
223
+ const itemEnd = offsetsOf(item).end;
151
224
  const firstPara = item.children.find((c) => c.type === 'paragraph');
152
225
  if (!firstPara) {
153
- appendContent(contentTarget, `- ${mdastToString(item)}`);
226
+ claim(walk.claims, contentTarget, walk.pos, offsetsOf(item).start, itemEnd);
227
+ walk.pos = itemEnd;
154
228
  return;
155
229
  }
156
230
  const rawText = mdastToString(firstPara);
@@ -159,11 +233,15 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
159
233
  if (wikiTarget) {
160
234
  const linkRef = `[[${wikiTarget}]]`;
161
235
  if (parentFieldAppend) {
236
+ consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
162
237
  appendParentField(parentFieldAppend.node, parentFieldAppend.field, linkRef);
238
+ walk.pos = itemEnd;
163
239
  return;
164
240
  }
165
241
  if (activeNodeFieldAppend) {
242
+ consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
166
243
  appendParentField(activeNodeFieldAppend.node, activeNodeFieldAppend.field, linkRef);
244
+ walk.pos = itemEnd;
167
245
  return;
168
246
  }
169
247
  // No field append context — fall through to node creation or content append
@@ -185,6 +263,11 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
185
263
  schemaData.parent = parentRef;
186
264
  if (summary)
187
265
  schemaData.summary = summary;
266
+ const line = item.position?.start.line ? item.position.start.line + (lineOffset ?? 0) : undefined;
267
+ const column = item.position?.start.column;
268
+ const source = file
269
+ ? { file, ...(line !== undefined ? { line } : {}), ...(column !== undefined ? { column } : {}) }
270
+ : undefined;
188
271
  const linkTargets = buildLinkTargets(title);
189
272
  const newNode = {
190
273
  label: makeLabel(title),
@@ -192,24 +275,39 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
192
275
  schemaData,
193
276
  linkTargets,
194
277
  type,
278
+ ...(schemaData.content !== undefined ? { content: schemaData.content } : {}),
195
279
  contentLinks: [],
280
+ ...(source ? { source } : {}),
196
281
  };
197
282
  nodes.push(newNode);
283
+ const contentColumn = firstPara.position?.start.column;
284
+ if (contentColumn !== undefined)
285
+ walk.claims.contentIndent.set(newNode, contentColumn - 1);
198
286
  if (parentFieldAppend) {
199
287
  appendParentField(parentFieldAppend.node, parentFieldAppend.field, `[[${linkTargets[0] ?? title}]]`);
200
288
  }
201
289
  const nestedParentRef = `[[${linkTargets[0] ?? title}]]`;
290
+ consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
291
+ walk.pos = offsetsOf(firstPara).end;
202
292
  for (const child of item.children) {
293
+ if (child === firstPara)
294
+ continue;
203
295
  if (child.type === 'list') {
204
296
  for (const subItem of child.children) {
205
- processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType);
297
+ processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset, walk);
206
298
  }
207
299
  }
300
+ else {
301
+ const { start, end } = offsetsOf(child);
302
+ claim(walk.claims, newNode, walk.pos, start, end);
303
+ walk.pos = end;
304
+ }
208
305
  }
209
306
  }
210
307
  else {
211
- appendContent(contentTarget, `- ${rawText}`);
308
+ claim(walk.claims, contentTarget, walk.pos, offsetsOf(item).start, itemEnd);
212
309
  }
310
+ walk.pos = itemEnd;
213
311
  }
214
312
  /**
215
313
  * Extract space nodes from markdown body text.
@@ -240,7 +338,7 @@ export function extractEmbeddedNodes(body, options) {
240
338
  const stack = !isOnAPageMode && pageTitle !== undefined
241
339
  ? [{ depth: 0, title: pageTitle, nodeType: pageType, refTarget: pageTitle }]
242
340
  : [];
243
- let parseState = 'preamble';
341
+ let parseState = isOnAPageMode ? 'preamble' : 'active';
244
342
  let preambleNodeCount = 0;
245
343
  const terminatedHeadings = [];
246
344
  /**
@@ -409,7 +507,32 @@ export function extractEmbeddedNodes(body, options) {
409
507
  }
410
508
  let grouping = null;
411
509
  let activeNode = rootNode;
510
+ const claims = { ranges: new Map(), gapKept: new Set(), contentIndent: new Map() };
511
+ /** End of the previous top-level block; a block's span starts here so the blank lines before it stay with it. */
512
+ let prevEnd = 0;
412
513
  for (const child of tree.children) {
514
+ const { start: blockStart, end: blockEnd } = offsetsOf(child);
515
+ const spanStart = prevEnd;
516
+ prevEnd = blockEnd;
517
+ /** Give a node this block as source content, minus any `removed` ranges inside it. */
518
+ const claimBlock = (node, removed = []) => {
519
+ let from = blockStart;
520
+ let first = true;
521
+ for (const gap of removed) {
522
+ if (first)
523
+ claim(claims, node, spanStart, blockStart, gap.start);
524
+ else
525
+ addRange(claims, node, from, gap.start);
526
+ first = false;
527
+ from = gap.end;
528
+ }
529
+ if (first)
530
+ claim(claims, node, spanStart, blockStart, blockEnd);
531
+ else
532
+ addRange(claims, node, from, blockEnd);
533
+ };
534
+ /** Take this whole block out of a node's content, keeping the blank lines before it. */
535
+ const consumeBlock = (node) => consume(claims, node, spanStart, blockStart);
413
536
  if (parseState === 'done') {
414
537
  if (child.type === 'heading') {
415
538
  const rawTitle = mdastToString(child);
@@ -427,8 +550,11 @@ export function extractEmbeddedNodes(body, options) {
427
550
  if (child.type === 'heading') {
428
551
  const heading = child;
429
552
  const depth = heading.depth;
430
- if (depth > 5)
553
+ if (depth > 5) {
554
+ if (parseState === 'active')
555
+ claimBlock(activeNode);
431
556
  continue;
557
+ }
432
558
  parseState = 'active';
433
559
  const rawText = mdastToString(heading);
434
560
  const { cleanText: afterBracketed, fields: rawInlineFields } = extractBracketedFields(rawText);
@@ -446,6 +572,10 @@ export function extractEmbeddedNodes(body, options) {
446
572
  stack.push({ depth, title, nodeType: '', refTarget: title });
447
573
  // Discard any pending grouping (untyped heading has no implied type)
448
574
  grouping = null;
575
+ // An untyped heading is part of the body of the nearest enclosing node. Popping above removed every
576
+ // node at the same or a higher level, so those sections end here; deeper nodes' sections continue.
577
+ activeNode = stack.findLast((entry) => entry.node)?.node ?? rootNode;
578
+ claimBlock(activeNode);
449
579
  continue;
450
580
  }
451
581
  if (isOnAPageMode && stack.length > 0) {
@@ -467,6 +597,11 @@ export function extractEmbeddedNodes(body, options) {
467
597
  };
468
598
  if (parentRef)
469
599
  schemaData.parent = parentRef;
600
+ const line = heading.position?.start.line ? heading.position.start.line + (options.lineOffset ?? 0) : undefined;
601
+ const column = heading.position?.start.column;
602
+ const source = options.file
603
+ ? { file: options.file, ...(line !== undefined ? { line } : {}), ...(column !== undefined ? { column } : {}) }
604
+ : undefined;
470
605
  const linkTargets = buildHeadingLinkTargets(rawText, title, anchor);
471
606
  const headingNode = {
472
607
  label: makeLabel(title),
@@ -474,11 +609,13 @@ export function extractEmbeddedNodes(body, options) {
474
609
  schemaData,
475
610
  linkTargets,
476
611
  type,
612
+ ...(schemaData.content !== undefined ? { content: schemaData.content } : {}),
477
613
  contentLinks: [],
614
+ ...(source ? { source } : {}),
478
615
  };
479
616
  // Push to stack BEFORE resolving semantic parent — stack[-2] is the correct parent.
480
617
  const refTarget = linkTargets[0] ?? title;
481
- stack.push({ depth, title, nodeType: type, refTarget });
618
+ stack.push({ depth, title, nodeType: type, refTarget, node: headingNode });
482
619
  // If this match came from a relationship/hierarchy and no explicit type was given,
483
620
  // create a grouping — delay adding the node until we see the following content.
484
621
  // Discard any previous grouping (not flushed — original agnostic-parse behaviour).
@@ -516,14 +653,16 @@ export function extractEmbeddedNodes(body, options) {
516
653
  const activeNodeFieldAppendArg = isParentLevelMatch && semanticParent.node
517
654
  ? { node: semanticParent.node, field: definition.field }
518
655
  : undefined;
656
+ const walk = { claims, pos: spanStart };
519
657
  for (const item of list.children) {
520
- processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg);
658
+ processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset, walk);
521
659
  }
522
660
  grouping = null;
523
661
  }
524
662
  else {
663
+ const walk = { claims, pos: spanStart };
525
664
  for (const item of list.children) {
526
- processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap);
665
+ processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset, walk);
527
666
  }
528
667
  }
529
668
  }
@@ -605,6 +744,15 @@ export function extractEmbeddedNodes(body, options) {
605
744
  schemaData[mappedColName] = cellContent;
606
745
  }
607
746
  }
747
+ const line = row.position?.start.line ? row.position.start.line + (options.lineOffset ?? 0) : undefined;
748
+ const column = row.position?.start.column;
749
+ const source = options.file
750
+ ? {
751
+ file: options.file,
752
+ ...(line !== undefined ? { line } : {}),
753
+ ...(column !== undefined ? { column } : {}),
754
+ }
755
+ : undefined;
608
756
  const linkTargets = buildListItemLinkTargets(title);
609
757
  const rowNode = {
610
758
  label: makeLabel(title),
@@ -613,6 +761,7 @@ export function extractEmbeddedNodes(body, options) {
613
761
  linkTargets,
614
762
  type: rowTypeStr,
615
763
  contentLinks: [],
764
+ ...(source ? { source } : {}),
616
765
  };
617
766
  nodes.push(rowNode);
618
767
  if (tableParentFieldAppend) {
@@ -622,7 +771,7 @@ export function extractEmbeddedNodes(body, options) {
622
771
  grouping = null;
623
772
  }
624
773
  else {
625
- appendContent(activeNode, mdastToString(child));
774
+ claimBlock(activeNode);
626
775
  }
627
776
  }
628
777
  }
@@ -636,37 +785,59 @@ export function extractEmbeddedNodes(body, options) {
636
785
  if (child.type === 'paragraph') {
637
786
  const rawText = mdastToString(child);
638
787
  const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText);
639
- const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
788
+ const { fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
640
789
  const allFields = applyFieldMap({ ...unbracketedFields, ...bracketedFields }, fieldMap);
641
790
  if ('type' in allFields) {
642
791
  throw new Error(`Type override via paragraph field is not supported at "${activeNode.schemaData.title ?? activeNode.label}". ` +
643
792
  `Put [type:: ${allFields.type}] directly in the heading text.`);
644
793
  }
645
794
  Object.assign(activeNode.schemaData, allFields);
646
- if (remainingText)
647
- appendContent(activeNode, remainingText);
795
+ // Lines holding only fields are consumed as fields; any other line, including prose that mentions a field, is content.
796
+ const lines = sourceLines(body, { start: blockStart, end: blockEnd });
797
+ const fieldLines = lines.filter((l) => isFieldOnlyLine(l.text));
798
+ if (fieldLines.length === lines.length) {
799
+ consumeBlock(activeNode);
800
+ }
801
+ else {
802
+ // Each field line goes with its terminator; a last line without one takes the preceding line break instead.
803
+ claimBlock(activeNode, fieldLines.map((l) => {
804
+ if (/\n$/.test(body.slice(l.start, l.end)))
805
+ return { start: l.start, end: l.end };
806
+ return { start: l.start - (body[l.start - 2] === '\r' ? 2 : 1), end: l.end };
807
+ }));
808
+ }
648
809
  activeNode.contentLinks ??= [];
649
810
  activeNode.contentLinks.push(...extractLinksFromAstNode(child));
650
811
  }
651
812
  else if (child.type === 'code' && child.lang?.trim() === 'yaml') {
652
813
  const code = child;
653
- const parsed = yamlLoad(code.value);
814
+ const parsed = parseYaml(code.value);
654
815
  if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
655
- Object.assign(activeNode.schemaData, coerceDates(applyFieldMap(parsed, fieldMap)));
816
+ Object.assign(activeNode.schemaData, applyFieldMap(parsed, fieldMap));
817
+ consumeBlock(activeNode);
656
818
  }
657
819
  else if (Array.isArray(parsed)) {
658
820
  throw new Error(`YAML block must be an object at "${activeNode.label}".`);
659
821
  }
660
822
  else {
661
- appendContent(activeNode, code.value);
823
+ claimBlock(activeNode);
662
824
  }
663
825
  }
664
826
  else {
665
- appendContent(activeNode, mdastToString(child));
827
+ claimBlock(activeNode);
666
828
  activeNode.contentLinks ??= [];
667
829
  activeNode.contentLinks.push(...extractLinksFromAstNode(child));
668
830
  }
669
831
  }
670
832
  }
671
- return { nodes, preambleNodeCount, terminatedHeadings };
833
+ applyClaims(claims, body);
834
+ const preambleContent = rootNode.schemaData.content;
835
+ const { type: _rootType, content: _rootContent, ...preambleFields } = rootNode.schemaData;
836
+ return {
837
+ nodes,
838
+ preambleNodeCount,
839
+ terminatedHeadings,
840
+ preambleContent,
841
+ preambleFields: Object.keys(preambleFields).length > 0 ? preambleFields : undefined,
842
+ };
672
843
  }
@@ -5,12 +5,12 @@ import matter from 'gray-matter';
5
5
  import { extractSchemaTypeNames } from '../../schema/schema';
6
6
  import { extractLinksFromBody, extractLinksFromFrontmatter, getEdgeFieldNames } from './extract-content-links';
7
7
  import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
8
- import { applyFieldMap, coerceDates, inferTypeFromPath } from './util';
8
+ import { applyFieldMap, inferTypeFromPath, MATTER_OPTIONS } from './util';
9
9
  export function readSpaceOnAPage(context) {
10
10
  const { space, resolvedSchemaPath, schema: { metadata }, } = context;
11
11
  const filePath = resolve(space.path);
12
12
  const raw = readFileSync(filePath, 'utf-8');
13
- const { data: frontmatter, content: body } = matter(raw);
13
+ const { data: frontmatter, content: body } = matter(raw, MATTER_OPTIONS);
14
14
  const pageType = frontmatter.type;
15
15
  if (pageType !== undefined && !ON_A_PAGE_TYPES.includes(pageType)) {
16
16
  throw new Error(`Expected a space_on_a_page file but got type "${pageType}" in ${filePath}. ` +
@@ -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,
@@ -55,32 +60,38 @@ export async function readSpaceDirectory(context, options) {
55
60
  const content = readFileSync(join(directory, file), 'utf-8');
56
61
  let parsed;
57
62
  try {
58
- parsed = matter(content);
63
+ parsed = matter(content, MATTER_OPTIONS);
59
64
  }
60
65
  catch (err) {
61
- // gray-matter caches parsed YAML and has a known bug where a caught exception
62
- // corrupts its internal cache, causing subsequent parses to silently return {}.
63
- // Clear the cache after any parse error to avoid stale state.
64
- // See: https://github.com/jonschlinkert/gray-matter/issues/166
65
- matter.clearCache();
66
+ const yamlErr = err;
67
+ const line = yamlErr.mark ? yamlErr.mark.line + 1 : undefined;
68
+ const column = yamlErr.mark ? yamlErr.mark.column + 1 : undefined;
66
69
  parseIssues.push({
67
70
  file,
68
71
  severity: 'error',
69
72
  type: 'parse',
70
73
  message: err instanceof Error ? err.message : String(err),
74
+ ...(line !== undefined ? { line } : {}),
75
+ ...(column !== undefined ? { column } : {}),
71
76
  });
72
77
  continue;
73
78
  }
74
79
  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' });
80
+ parseIssues.push({
81
+ file,
82
+ severity: 'warning',
83
+ type: 'no-type',
84
+ message: 'No front-matter or type specified',
85
+ line: 1,
86
+ });
76
87
  continue;
77
88
  }
78
- const data = coerceDates(applyFieldMap(parsed.data, fieldMap));
89
+ const data = applyFieldMap(parsed.data, fieldMap);
79
90
  if (!data.type && typeInferenceCfg && knownTypes) {
80
91
  data.type = inferTypeFromPath(file, typeInferenceCfg, knownTypes, context.schema.metadata.typeAliases);
81
92
  }
82
93
  if (!data.type) {
83
- parseIssues.push({ file, severity: 'warning', type: 'no-type' });
94
+ parseIssues.push({ file, severity: 'warning', type: 'no-type', line: 1 });
84
95
  continue;
85
96
  }
86
97
  if (ON_A_PAGE_TYPES.includes(data.type) && !options?.includeOnAPageFiles) {
@@ -89,21 +100,35 @@ export async function readSpaceDirectory(context, options) {
89
100
  const pageType = data.type;
90
101
  const fileBase = basename(file, '.md');
91
102
  const title = data.title ?? fileBase;
92
- nodes.push({
103
+ const fmMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
104
+ const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
105
+ const pageNode = {
93
106
  label: file,
94
107
  title,
95
108
  schemaData: { title, ...data },
96
109
  linkTargets: [title, fileBase],
97
110
  type: pageType,
111
+ source: { file, line: 1 },
112
+ ...(data.content !== undefined ? { content: data.content } : {}),
98
113
  contentLinks: [...extractLinksFromFrontmatter(data, edgeFields), ...extractLinksFromBody(parsed.content)],
99
- });
114
+ };
100
115
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
101
- const { nodes: embedded, terminatedHeadings } = extractEmbeddedNodes(parsed.content, {
116
+ const { nodes: embedded, preambleContent, preambleFields, terminatedHeadings, } = extractEmbeddedNodes(parsed.content, {
102
117
  pageTitle: fileBase,
103
118
  pageType,
104
119
  metadata,
105
120
  fieldMap,
121
+ file,
122
+ lineOffset,
106
123
  });
124
+ if (preambleFields) {
125
+ Object.assign(pageNode.schemaData, preambleFields);
126
+ }
127
+ if (preambleContent) {
128
+ pageNode.schemaData.content = preambleContent;
129
+ pageNode.content = preambleContent;
130
+ }
131
+ nodes.push(pageNode);
107
132
  nodes.push(...embedded);
108
133
  for (const heading of terminatedHeadings) {
109
134
  parseIssues.push({
@@ -114,6 +139,9 @@ export async function readSpaceDirectory(context, options) {
114
139
  });
115
140
  }
116
141
  }
142
+ else {
143
+ nodes.push(pageNode);
144
+ }
117
145
  }
118
146
  return { nodes, parseIssues, diagnostics: { kind: 'directory' } };
119
147
  }
@@ -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;