structured-context 0.12.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.
- package/README.md +29 -216
- package/dist/api.d.ts +17 -17
- package/dist/api.js +12 -12
- package/dist/commands/docs.js +1 -0
- package/dist/commands/providers.d.ts +4 -0
- package/dist/commands/providers.js +52 -0
- package/dist/commands/schemas.js +21 -2
- package/dist/commands/validate-file.d.ts +3 -0
- package/dist/commands/validate-file.js +17 -2
- package/dist/commands/validate.d.ts +4 -0
- package/dist/commands/validate.js +42 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +26 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +59 -18
- package/dist/judge/context.d.ts +20 -0
- package/dist/judge/context.js +105 -0
- package/dist/judge/factory.d.ts +44 -0
- package/dist/judge/factory.js +148 -0
- package/dist/judge/index.d.ts +13 -0
- package/dist/judge/index.js +8 -0
- package/dist/judge/judge-node.d.ts +27 -0
- package/dist/judge/judge-node.js +122 -0
- package/dist/judge/prompt.d.ts +10 -0
- package/dist/judge/prompt.js +92 -0
- package/dist/judge/provider/anthropic.d.ts +17 -0
- package/dist/judge/provider/anthropic.js +43 -0
- package/dist/judge/provider/fake.d.ts +26 -0
- package/dist/judge/provider/fake.js +56 -0
- package/dist/judge/provider/jev.d.ts +37 -0
- package/dist/judge/provider/jev.js +169 -0
- package/dist/judge/provider/openai-decisions.d.ts +21 -0
- package/dist/judge/provider/openai-decisions.js +84 -0
- package/dist/judge/provider/openai.d.ts +20 -0
- package/dist/judge/provider/openai.js +53 -0
- package/dist/judge/provider/per-rule.d.ts +10 -0
- package/dist/judge/provider/per-rule.js +18 -0
- package/dist/judge/trim.d.ts +27 -0
- package/dist/judge/trim.js +62 -0
- package/dist/judge/types.d.ts +92 -0
- package/dist/judge/types.js +9 -0
- package/dist/judge/verdicts.d.ts +24 -0
- package/dist/judge/verdicts.js +82 -0
- package/dist/load-space-graph.d.ts +3 -3
- package/dist/load-space-graph.js +4 -4
- package/dist/plugins/loader.js +1 -1
- package/dist/plugins/markdown/parse-embedded.d.ts +2 -0
- package/dist/plugins/markdown/parse-embedded.js +161 -24
- package/dist/plugins/markdown/read-space.js +4 -9
- package/dist/plugins/markdown/render-directory.js +5 -5
- package/dist/plugins/markdown/template-sync.js +3 -4
- package/dist/plugins/markdown/util.d.ts +26 -5
- package/dist/plugins/markdown/util.js +56 -15
- package/dist/render/render.d.ts +1 -1
- package/dist/render/render.js +1 -1
- package/dist/schema/evaluate-rule.d.ts +33 -2
- package/dist/schema/evaluate-rule.js +58 -4
- package/dist/schema/gating.d.ts +69 -0
- package/dist/schema/gating.js +253 -0
- package/dist/schema/metadata-contract.d.ts +625 -12
- package/dist/schema/metadata-contract.js +110 -4
- package/dist/schema/rule-filter.d.ts +12 -0
- package/dist/schema/rule-filter.js +46 -0
- package/dist/schema/rule-severity.d.ts +13 -0
- package/dist/schema/rule-severity.js +33 -0
- package/dist/schema/schema.js +31 -1
- package/dist/schema/validate-rules.d.ts +17 -8
- package/dist/schema/validate-rules.js +91 -48
- package/dist/schemas/_knowledge_wiki.json +4 -4
- package/dist/schemas/_okf.json +172 -0
- package/dist/schemas/_ost_strict.json +15 -6
- package/dist/schemas/_sctx_base.json +1 -1
- package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
- package/dist/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
- package/dist/schemas/okf.json +8 -0
- package/dist/space-context.d.ts +2 -2
- package/dist/space-context.js +3 -3
- package/dist/space-graph.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/types.d.ts +11 -4
- package/dist/validate.d.ts +59 -9
- package/dist/validate.js +225 -120
- package/docs/concepts.md +52 -6
- package/docs/config.md +70 -1
- package/docs/rules.md +140 -49
- package/docs/schemas.md +43 -18
- package/package.json +6 -5
- package/schemas/_knowledge_wiki.json +4 -4
- package/schemas/_okf.json +172 -0
- package/schemas/_ost_strict.json +15 -6
- package/schemas/_sctx_base.json +1 -1
- package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
- package/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
- package/schemas/okf.json +8 -0
|
@@ -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,
|
|
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,18 +140,91 @@ export function defaultNodeType(stack, hierarchy) {
|
|
|
141
140
|
}
|
|
142
141
|
return hierarchy[idx + 1];
|
|
143
142
|
}
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
151
199
|
}
|
|
152
|
-
|
|
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);
|
|
221
|
+
}
|
|
222
|
+
function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset, walk) {
|
|
223
|
+
const itemEnd = offsetsOf(item).end;
|
|
153
224
|
const firstPara = item.children.find((c) => c.type === 'paragraph');
|
|
154
225
|
if (!firstPara) {
|
|
155
|
-
|
|
226
|
+
claim(walk.claims, contentTarget, walk.pos, offsetsOf(item).start, itemEnd);
|
|
227
|
+
walk.pos = itemEnd;
|
|
156
228
|
return;
|
|
157
229
|
}
|
|
158
230
|
const rawText = mdastToString(firstPara);
|
|
@@ -161,11 +233,15 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
|
|
|
161
233
|
if (wikiTarget) {
|
|
162
234
|
const linkRef = `[[${wikiTarget}]]`;
|
|
163
235
|
if (parentFieldAppend) {
|
|
236
|
+
consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
|
|
164
237
|
appendParentField(parentFieldAppend.node, parentFieldAppend.field, linkRef);
|
|
238
|
+
walk.pos = itemEnd;
|
|
165
239
|
return;
|
|
166
240
|
}
|
|
167
241
|
if (activeNodeFieldAppend) {
|
|
242
|
+
consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
|
|
168
243
|
appendParentField(activeNodeFieldAppend.node, activeNodeFieldAppend.field, linkRef);
|
|
244
|
+
walk.pos = itemEnd;
|
|
169
245
|
return;
|
|
170
246
|
}
|
|
171
247
|
// No field append context — fall through to node creation or content append
|
|
@@ -204,21 +280,34 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
|
|
|
204
280
|
...(source ? { source } : {}),
|
|
205
281
|
};
|
|
206
282
|
nodes.push(newNode);
|
|
283
|
+
const contentColumn = firstPara.position?.start.column;
|
|
284
|
+
if (contentColumn !== undefined)
|
|
285
|
+
walk.claims.contentIndent.set(newNode, contentColumn - 1);
|
|
207
286
|
if (parentFieldAppend) {
|
|
208
287
|
appendParentField(parentFieldAppend.node, parentFieldAppend.field, `[[${linkTargets[0] ?? title}]]`);
|
|
209
288
|
}
|
|
210
289
|
const nestedParentRef = `[[${linkTargets[0] ?? title}]]`;
|
|
290
|
+
consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
|
|
291
|
+
walk.pos = offsetsOf(firstPara).end;
|
|
211
292
|
for (const child of item.children) {
|
|
293
|
+
if (child === firstPara)
|
|
294
|
+
continue;
|
|
212
295
|
if (child.type === 'list') {
|
|
213
296
|
for (const subItem of child.children) {
|
|
214
|
-
processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset);
|
|
297
|
+
processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset, walk);
|
|
215
298
|
}
|
|
216
299
|
}
|
|
300
|
+
else {
|
|
301
|
+
const { start, end } = offsetsOf(child);
|
|
302
|
+
claim(walk.claims, newNode, walk.pos, start, end);
|
|
303
|
+
walk.pos = end;
|
|
304
|
+
}
|
|
217
305
|
}
|
|
218
306
|
}
|
|
219
307
|
else {
|
|
220
|
-
|
|
308
|
+
claim(walk.claims, contentTarget, walk.pos, offsetsOf(item).start, itemEnd);
|
|
221
309
|
}
|
|
310
|
+
walk.pos = itemEnd;
|
|
222
311
|
}
|
|
223
312
|
/**
|
|
224
313
|
* Extract space nodes from markdown body text.
|
|
@@ -418,7 +507,32 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
418
507
|
}
|
|
419
508
|
let grouping = null;
|
|
420
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;
|
|
421
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);
|
|
422
536
|
if (parseState === 'done') {
|
|
423
537
|
if (child.type === 'heading') {
|
|
424
538
|
const rawTitle = mdastToString(child);
|
|
@@ -436,8 +550,11 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
436
550
|
if (child.type === 'heading') {
|
|
437
551
|
const heading = child;
|
|
438
552
|
const depth = heading.depth;
|
|
439
|
-
if (depth > 5)
|
|
553
|
+
if (depth > 5) {
|
|
554
|
+
if (parseState === 'active')
|
|
555
|
+
claimBlock(activeNode);
|
|
440
556
|
continue;
|
|
557
|
+
}
|
|
441
558
|
parseState = 'active';
|
|
442
559
|
const rawText = mdastToString(heading);
|
|
443
560
|
const { cleanText: afterBracketed, fields: rawInlineFields } = extractBracketedFields(rawText);
|
|
@@ -455,6 +572,10 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
455
572
|
stack.push({ depth, title, nodeType: '', refTarget: title });
|
|
456
573
|
// Discard any pending grouping (untyped heading has no implied type)
|
|
457
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);
|
|
458
579
|
continue;
|
|
459
580
|
}
|
|
460
581
|
if (isOnAPageMode && stack.length > 0) {
|
|
@@ -494,7 +615,7 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
494
615
|
};
|
|
495
616
|
// Push to stack BEFORE resolving semantic parent — stack[-2] is the correct parent.
|
|
496
617
|
const refTarget = linkTargets[0] ?? title;
|
|
497
|
-
stack.push({ depth, title, nodeType: type, refTarget });
|
|
618
|
+
stack.push({ depth, title, nodeType: type, refTarget, node: headingNode });
|
|
498
619
|
// If this match came from a relationship/hierarchy and no explicit type was given,
|
|
499
620
|
// create a grouping — delay adding the node until we see the following content.
|
|
500
621
|
// Discard any previous grouping (not flushed — original agnostic-parse behaviour).
|
|
@@ -532,14 +653,16 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
532
653
|
const activeNodeFieldAppendArg = isParentLevelMatch && semanticParent.node
|
|
533
654
|
? { node: semanticParent.node, field: definition.field }
|
|
534
655
|
: undefined;
|
|
656
|
+
const walk = { claims, pos: spanStart };
|
|
535
657
|
for (const item of list.children) {
|
|
536
|
-
processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset);
|
|
658
|
+
processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset, walk);
|
|
537
659
|
}
|
|
538
660
|
grouping = null;
|
|
539
661
|
}
|
|
540
662
|
else {
|
|
663
|
+
const walk = { claims, pos: spanStart };
|
|
541
664
|
for (const item of list.children) {
|
|
542
|
-
processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset);
|
|
665
|
+
processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset, walk);
|
|
543
666
|
}
|
|
544
667
|
}
|
|
545
668
|
}
|
|
@@ -648,7 +771,7 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
648
771
|
grouping = null;
|
|
649
772
|
}
|
|
650
773
|
else {
|
|
651
|
-
|
|
774
|
+
claimBlock(activeNode);
|
|
652
775
|
}
|
|
653
776
|
}
|
|
654
777
|
}
|
|
@@ -662,38 +785,52 @@ export function extractEmbeddedNodes(body, options) {
|
|
|
662
785
|
if (child.type === 'paragraph') {
|
|
663
786
|
const rawText = mdastToString(child);
|
|
664
787
|
const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText);
|
|
665
|
-
const {
|
|
788
|
+
const { fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
|
|
666
789
|
const allFields = applyFieldMap({ ...unbracketedFields, ...bracketedFields }, fieldMap);
|
|
667
790
|
if ('type' in allFields) {
|
|
668
791
|
throw new Error(`Type override via paragraph field is not supported at "${activeNode.schemaData.title ?? activeNode.label}". ` +
|
|
669
792
|
`Put [type:: ${allFields.type}] directly in the heading text.`);
|
|
670
793
|
}
|
|
671
794
|
Object.assign(activeNode.schemaData, allFields);
|
|
672
|
-
|
|
673
|
-
|
|
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
|
+
}
|
|
674
809
|
activeNode.contentLinks ??= [];
|
|
675
810
|
activeNode.contentLinks.push(...extractLinksFromAstNode(child));
|
|
676
811
|
}
|
|
677
812
|
else if (child.type === 'code' && child.lang?.trim() === 'yaml') {
|
|
678
813
|
const code = child;
|
|
679
|
-
const parsed =
|
|
814
|
+
const parsed = parseYaml(code.value);
|
|
680
815
|
if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
|
|
681
|
-
Object.assign(activeNode.schemaData,
|
|
816
|
+
Object.assign(activeNode.schemaData, applyFieldMap(parsed, fieldMap));
|
|
817
|
+
consumeBlock(activeNode);
|
|
682
818
|
}
|
|
683
819
|
else if (Array.isArray(parsed)) {
|
|
684
820
|
throw new Error(`YAML block must be an object at "${activeNode.label}".`);
|
|
685
821
|
}
|
|
686
822
|
else {
|
|
687
|
-
|
|
823
|
+
claimBlock(activeNode);
|
|
688
824
|
}
|
|
689
825
|
}
|
|
690
826
|
else {
|
|
691
|
-
|
|
827
|
+
claimBlock(activeNode);
|
|
692
828
|
activeNode.contentLinks ??= [];
|
|
693
829
|
activeNode.contentLinks.push(...extractLinksFromAstNode(child));
|
|
694
830
|
}
|
|
695
831
|
}
|
|
696
832
|
}
|
|
833
|
+
applyClaims(claims, body);
|
|
697
834
|
const preambleContent = rootNode.schemaData.content;
|
|
698
835
|
const { type: _rootType, content: _rootContent, ...preambleFields } = rootNode.schemaData;
|
|
699
836
|
return {
|
|
@@ -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,
|
|
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}. ` +
|
|
@@ -60,14 +60,9 @@ export async function readSpaceDirectory(context, options) {
|
|
|
60
60
|
const content = readFileSync(join(directory, file), 'utf-8');
|
|
61
61
|
let parsed;
|
|
62
62
|
try {
|
|
63
|
-
parsed = matter(content);
|
|
63
|
+
parsed = matter(content, MATTER_OPTIONS);
|
|
64
64
|
}
|
|
65
65
|
catch (err) {
|
|
66
|
-
// gray-matter caches parsed YAML and has a known bug where a caught exception
|
|
67
|
-
// corrupts its internal cache, causing subsequent parses to silently return {}.
|
|
68
|
-
// Clear the cache after any parse error to avoid stale state.
|
|
69
|
-
// See: https://github.com/jonschlinkert/gray-matter/issues/166
|
|
70
|
-
matter.clearCache();
|
|
71
66
|
const yamlErr = err;
|
|
72
67
|
const line = yamlErr.mark ? yamlErr.mark.line + 1 : undefined;
|
|
73
68
|
const column = yamlErr.mark ? yamlErr.mark.column + 1 : undefined;
|
|
@@ -91,7 +86,7 @@ export async function readSpaceDirectory(context, options) {
|
|
|
91
86
|
});
|
|
92
87
|
continue;
|
|
93
88
|
}
|
|
94
|
-
const data =
|
|
89
|
+
const data = applyFieldMap(parsed.data, fieldMap);
|
|
95
90
|
if (!data.type && typeInferenceCfg && knownTypes) {
|
|
96
91
|
data.type = inferTypeFromPath(file, typeInferenceCfg, knownTypes, context.schema.metadata.typeAliases);
|
|
97
92
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { posix } from 'node:path';
|
|
2
2
|
import matter from 'gray-matter';
|
|
3
|
-
import { getMarkdownConfig } from '
|
|
4
|
-
import {
|
|
3
|
+
import { getMarkdownConfig } from '../../index.js';
|
|
4
|
+
import { invertFieldMap, MATTER_OPTIONS } from './util';
|
|
5
5
|
/**
|
|
6
6
|
* Sanitize a node title into a valid filesystem filename.
|
|
7
7
|
* Strips Obsidian/OS forbidden characters: \ / : * ? " < > | # ^ [ ]
|
|
@@ -112,11 +112,11 @@ export function renderMarkdownDirectory(context, graph, options) {
|
|
|
112
112
|
const typeKey = reverseFieldMap.type ?? 'type';
|
|
113
113
|
frontmatterData[typeKey] = nodeType;
|
|
114
114
|
}
|
|
115
|
-
const
|
|
115
|
+
const sortedData = sortFrontmatterKeys(frontmatterData);
|
|
116
116
|
const body = (node.content ?? '').trim();
|
|
117
117
|
let fileContent;
|
|
118
|
-
if (Object.keys(
|
|
119
|
-
fileContent = matter.stringify(body ? `\n${body}\n` : '',
|
|
118
|
+
if (Object.keys(sortedData).length > 0) {
|
|
119
|
+
fileContent = matter.stringify(body ? `\n${body}\n` : '', sortedData, MATTER_OPTIONS);
|
|
120
120
|
}
|
|
121
121
|
else {
|
|
122
122
|
fileContent = body ? `${body}\n` : '';
|
|
@@ -2,9 +2,8 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import fg from 'fast-glob';
|
|
4
4
|
import matter from 'gray-matter';
|
|
5
|
-
import yaml from 'js-yaml';
|
|
6
5
|
import { mergeVariantProperties, resolveRef } from '../../schema/schema-refs';
|
|
7
|
-
import { invertFieldMap } from './util';
|
|
6
|
+
import { dumpYaml, invertFieldMap, MATTER_OPTIONS } from './util';
|
|
8
7
|
// Fields derived from the filesystem — present at validation time but not written to frontmatter
|
|
9
8
|
const DERIVED_FIELDS = new Set(['title', 'content']);
|
|
10
9
|
function enumPlaceholder(def) {
|
|
@@ -76,7 +75,7 @@ export function generateNewContent(nodeType, variant, schema, schemaRefRegistry,
|
|
|
76
75
|
const toCanonicalKey = (k) => fieldMap[k] ?? k;
|
|
77
76
|
const exampleWithPlaceholders = withEnumPlaceholders(example, properties, schema, schemaRefRegistry);
|
|
78
77
|
const remappedExample = Object.fromEntries(Object.entries(exampleWithPlaceholders).map(([k, v]) => [toFileKey(k), v]));
|
|
79
|
-
let frontmatterYaml =
|
|
78
|
+
let frontmatterYaml = dumpYaml(remappedExample, { lineWidth: -1 }).trim();
|
|
80
79
|
// Append property descriptions as comments
|
|
81
80
|
// YAML lines use file field names; look up properties using canonical key.
|
|
82
81
|
const lines = frontmatterYaml.split('\n');
|
|
@@ -206,7 +205,7 @@ export async function templateSync(context, options) {
|
|
|
206
205
|
continue;
|
|
207
206
|
}
|
|
208
207
|
const body = content.slice(fmMatch[0].length);
|
|
209
|
-
const parsed = matter(content);
|
|
208
|
+
const parsed = matter(content, MATTER_OPTIONS);
|
|
210
209
|
const nodeType = parsed.data.type;
|
|
211
210
|
if (!nodeType) {
|
|
212
211
|
continue;
|
|
@@ -1,11 +1,32 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type DumpOptions, type Schema } from 'js-yaml';
|
|
2
|
+
import type { TypeInferenceConfig } from '../../index.js';
|
|
2
3
|
export declare function inferTypeFromPath(filePath: string, config: TypeInferenceConfig, knownTypes: Set<string>, typeAliases: Record<string, string> | undefined): string | undefined;
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* YAML schema for frontmatter and embedded YAML blocks: YAML 1.2 Core schema extended with
|
|
6
|
+
* YAML 1.1 merge keys (`<<`), collections (binary, omap, pairs, set), and custom Date scalar
|
|
7
|
+
* handling that avoids js-yaml's timestamp type. Dates and datetimes stay as the strings
|
|
8
|
+
* written (e.g. `2026-03-31`, `2026-06-20T22:53:05+10:00`) rather than becoming Date objects,
|
|
9
|
+
* which lose precision and offset and are not valid JSON.
|
|
7
10
|
*/
|
|
8
|
-
export declare
|
|
11
|
+
export declare const YAML_SCHEMA: Schema;
|
|
12
|
+
export declare function parseYaml(text: string): unknown;
|
|
13
|
+
export type DumpYamlOptions = DumpOptions & {
|
|
14
|
+
replacer?: (key: string, value: unknown) => unknown;
|
|
15
|
+
};
|
|
16
|
+
/** Dumps with YAML_SCHEMA. Date values (e.g. from non-markdown plugins) are written as ISO 8601 strings. */
|
|
17
|
+
export declare function dumpYaml(data: unknown, options?: DumpYamlOptions): string;
|
|
18
|
+
/**
|
|
19
|
+
* gray-matter options using YAML_SCHEMA. Passing options also bypasses gray-matter's parse cache,
|
|
20
|
+
* which is corrupted by a caught parse error (https://github.com/jonschlinkert/gray-matter/issues/166).
|
|
21
|
+
*/
|
|
22
|
+
export declare const MATTER_OPTIONS: {
|
|
23
|
+
engines: {
|
|
24
|
+
yaml: {
|
|
25
|
+
parse: (text: string) => object;
|
|
26
|
+
stringify: (data: object) => string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
9
30
|
/**
|
|
10
31
|
* Apply field remapping to a data object.
|
|
11
32
|
* Renames keys according to fieldMap (file field name → canonical field name).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { posix } from 'node:path';
|
|
2
|
+
import { binaryTag, CORE_SCHEMA, defineScalarTag, dump, load, mergeTag, omapTag, pairsTag, setTag, visit, } from 'js-yaml';
|
|
2
3
|
export function inferTypeFromPath(filePath, config, knownTypes, typeAliases) {
|
|
3
4
|
if (config.mode === 'off')
|
|
4
5
|
return undefined;
|
|
@@ -37,26 +38,66 @@ export function inferTypeFromPath(filePath, config, knownTypes, typeAliases) {
|
|
|
37
38
|
}
|
|
38
39
|
return undefined;
|
|
39
40
|
}
|
|
41
|
+
const dateTag = defineScalarTag('tag:yaml.org,2002:timestamp', {
|
|
42
|
+
identify: (data) => data instanceof Date,
|
|
43
|
+
represent: (data) => data.toISOString(),
|
|
44
|
+
resolve: (source) => source,
|
|
45
|
+
});
|
|
40
46
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
47
|
+
* YAML schema for frontmatter and embedded YAML blocks: YAML 1.2 Core schema extended with
|
|
48
|
+
* YAML 1.1 merge keys (`<<`), collections (binary, omap, pairs, set), and custom Date scalar
|
|
49
|
+
* handling that avoids js-yaml's timestamp type. Dates and datetimes stay as the strings
|
|
50
|
+
* written (e.g. `2026-03-31`, `2026-06-20T22:53:05+10:00`) rather than becoming Date objects,
|
|
51
|
+
* which lose precision and offset and are not valid JSON.
|
|
44
52
|
*/
|
|
45
|
-
export
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
export const YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag, binaryTag, omapTag, pairsTag, setTag, dateTag);
|
|
54
|
+
export function parseYaml(text) {
|
|
55
|
+
return load(text, { schema: YAML_SCHEMA });
|
|
56
|
+
}
|
|
57
|
+
function applyReplacer(key, value, replacer) {
|
|
58
|
+
const replaced = replacer(key, value);
|
|
59
|
+
if (Array.isArray(replaced)) {
|
|
60
|
+
return replaced.map((item, index) => applyReplacer(String(index), item, replacer));
|
|
61
|
+
}
|
|
62
|
+
if (replaced !== null && typeof replaced === 'object' && Object.getPrototypeOf(replaced) === Object.prototype) {
|
|
63
|
+
const res = {};
|
|
64
|
+
for (const [k, v] of Object.entries(replaced)) {
|
|
65
|
+
const vReplaced = applyReplacer(k, v, replacer);
|
|
66
|
+
if (vReplaced !== undefined) {
|
|
67
|
+
res[k] = vReplaced;
|
|
68
|
+
}
|
|
56
69
|
}
|
|
70
|
+
return res;
|
|
57
71
|
}
|
|
58
|
-
return
|
|
72
|
+
return replaced;
|
|
73
|
+
}
|
|
74
|
+
/** Dumps with YAML_SCHEMA. Date values (e.g. from non-markdown plugins) are written as ISO 8601 strings. */
|
|
75
|
+
export function dumpYaml(data, options) {
|
|
76
|
+
const input = options?.replacer ? applyReplacer('', data, options.replacer) : data;
|
|
77
|
+
const { replacer: _ignored, transform, ...dumpOpts } = options ?? {};
|
|
78
|
+
return dump(input, {
|
|
79
|
+
...dumpOpts,
|
|
80
|
+
schema: YAML_SCHEMA,
|
|
81
|
+
transform: (docs) => {
|
|
82
|
+
visit(docs, (node) => {
|
|
83
|
+
if (node.kind === 'scalar' && (node.tag === '!!timestamp' || node.tag === 'tag:yaml.org,2002:timestamp')) {
|
|
84
|
+
node.tagged = false;
|
|
85
|
+
node.tag = 'tag:yaml.org,2002:str';
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
transform?.(docs);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
59
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* gray-matter options using YAML_SCHEMA. Passing options also bypasses gray-matter's parse cache,
|
|
94
|
+
* which is corrupted by a caught parse error (https://github.com/jonschlinkert/gray-matter/issues/166).
|
|
95
|
+
*/
|
|
96
|
+
export const MATTER_OPTIONS = {
|
|
97
|
+
engines: {
|
|
98
|
+
yaml: { parse: (text) => parseYaml(text), stringify: (data) => dumpYaml(data) },
|
|
99
|
+
},
|
|
100
|
+
};
|
|
60
101
|
/**
|
|
61
102
|
* Apply field remapping to a data object.
|
|
62
103
|
* Renames keys according to fieldMap (file field name → canonical field name).
|
package/dist/render/render.d.ts
CHANGED
package/dist/render/render.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SpaceNode } from '../types';
|
|
1
|
+
import type { Rule, RuleViolation, SpaceNode } from '../types';
|
|
2
2
|
/** Evaluation context for JSONata expressions */
|
|
3
3
|
export interface EvalContext {
|
|
4
4
|
/** All nodes in the space (flattened) */
|
|
@@ -12,13 +12,26 @@ export interface EvalContext {
|
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
14
|
* Evaluate a JSONata expression against a context.
|
|
15
|
-
* Returns the result of the expression (boolean for rule checks).
|
|
15
|
+
* Returns the result of the expression (boolean for rule checks). Throws {@link ExpressionError} if it fails.
|
|
16
16
|
*
|
|
17
17
|
* @param expr - JSONata expression string
|
|
18
18
|
* @param context - Evaluation context with $, $$, parent, $lookup
|
|
19
19
|
* @returns Result of expression evaluation
|
|
20
20
|
*/
|
|
21
21
|
export declare function evaluateExpression(expr: string, context: EvalContext): Promise<boolean | string | number>;
|
|
22
|
+
/** A JSONata expression could not be compiled or evaluated. */
|
|
23
|
+
export declare class ExpressionError extends Error {
|
|
24
|
+
readonly expression: string;
|
|
25
|
+
constructor(expression: string, cause: unknown);
|
|
26
|
+
}
|
|
27
|
+
/** Result of evaluating a rule's `expression` or `when`: a value, or the error that stopped it (never thrown). */
|
|
28
|
+
export type ExpressionOutcome = {
|
|
29
|
+
value: boolean | string | number;
|
|
30
|
+
} | {
|
|
31
|
+
error: string;
|
|
32
|
+
};
|
|
33
|
+
/** Like {@link evaluateExpression} but reports a failure as data, so callers can turn it into a violation. */
|
|
34
|
+
export declare function tryEvaluateExpression(expr: string, context: EvalContext): Promise<ExpressionOutcome>;
|
|
22
35
|
/**
|
|
23
36
|
* Build an evaluation context for a given node.
|
|
24
37
|
*
|
|
@@ -28,3 +41,21 @@ export declare function evaluateExpression(expr: string, context: EvalContext):
|
|
|
28
41
|
* @returns Evaluation context for the node
|
|
29
42
|
*/
|
|
30
43
|
export declare function buildEvalContext(node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<string, SpaceNode>): EvalContext;
|
|
44
|
+
/** The violation reported when a rule's `expression` or `when` cannot be evaluated: an authoring error, so always `error`. */
|
|
45
|
+
export declare function expressionErrorViolation(rule: Rule, file: string, error: string): RuleViolation;
|
|
46
|
+
/** The outcome of a rule's `expression` on one node. */
|
|
47
|
+
export type ExpressionRuleResult = {
|
|
48
|
+
passed: true;
|
|
49
|
+
}
|
|
50
|
+
/** `failed` is the expression returning something other than true; otherwise it could not be evaluated. */
|
|
51
|
+
| {
|
|
52
|
+
passed: false;
|
|
53
|
+
violation: RuleViolation;
|
|
54
|
+
failed: boolean;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Evaluate a rule's `expression` against a node (`file` empty and `node` a sentinel for a global rule). A failure
|
|
58
|
+
* reports the rule's severity; a rule with a semantic part names the expression in the evidence, so the
|
|
59
|
+
* violation is not mistaken for a verdict. An evaluation error reports `error` and is not a `failed` result.
|
|
60
|
+
*/
|
|
61
|
+
export declare function evaluateRuleExpression(rule: Rule, node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<string, SpaceNode>, file?: string): Promise<ExpressionRuleResult>;
|