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.
- package/README.md +85 -25
- package/dist/api.d.ts +25 -5
- package/dist/api.js +22 -2
- package/dist/commands/diagram.d.ts +1 -0
- package/dist/commands/diagram.js +7 -1
- package/dist/commands/dump.d.ts +15 -2
- package/dist/commands/dump.js +18 -4
- package/dist/commands/miro-sync.d.ts +3 -0
- package/dist/commands/miro-sync.js +8 -0
- package/dist/commands/render.d.ts +4 -0
- package/dist/commands/render.js +64 -9
- package/dist/commands/schemas.d.ts +2 -2
- package/dist/commands/schemas.js +117 -24
- package/dist/commands/show.d.ts +1 -0
- package/dist/commands/show.js +7 -1
- package/dist/commands/spaces.d.ts +3 -1
- package/dist/commands/spaces.js +11 -4
- package/dist/commands/validate-file.js +20 -5
- package/dist/commands/validate.js +54 -21
- package/dist/config.d.ts +26 -13
- package/dist/config.js +104 -31
- package/dist/filter/augment-nodes.d.ts +3 -1
- package/dist/filter/augment-nodes.js +12 -7
- package/dist/filter/filter-nodes.js +1 -1
- package/dist/index.js +51 -35
- package/dist/load-space-graph.d.ts +42 -0
- package/dist/load-space-graph.js +53 -0
- package/dist/plugins/index.js +3 -1
- package/dist/plugins/loader.js +4 -4
- package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
- package/dist/plugins/markdown/extract-content-links.js +123 -0
- package/dist/plugins/markdown/index.js +5 -5
- package/dist/plugins/markdown/parse-embedded.d.ts +6 -0
- package/dist/plugins/markdown/parse-embedded.js +50 -7
- package/dist/plugins/markdown/read-space.js +41 -5
- package/dist/plugins/markdown/render-directory.d.ts +11 -0
- package/dist/plugins/markdown/render-directory.js +130 -0
- package/dist/plugins/mermaid/index.d.ts +2 -0
- package/dist/plugins/mermaid/index.js +14 -0
- package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -15
- package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
- package/dist/plugins/miro/index.d.ts +2 -0
- package/dist/plugins/miro/index.js +27 -0
- package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
- package/dist/{integrations → plugins}/miro/layout.js +3 -16
- package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
- package/dist/plugins/miro/sync.d.ts +7 -0
- package/dist/{integrations → plugins}/miro/sync.js +22 -39
- package/dist/plugins/util.d.ts +48 -1
- package/dist/plugins/util.js +143 -0
- package/dist/read/resolve-graph-edges.d.ts +1 -1
- package/dist/read/resolve-graph-edges.js +78 -27
- package/dist/read/wikilink-utils.d.ts +17 -0
- package/dist/read/wikilink-utils.js +40 -0
- package/dist/render/render.d.ts +4 -1
- package/dist/render/render.js +20 -16
- package/dist/schema/evaluate-rule.js +1 -0
- package/dist/schema/meta-schemas.d.ts +13 -0
- package/dist/schema/meta-schemas.js +50 -0
- package/dist/schema/metadata-contract.d.ts +343 -187
- package/dist/schema/metadata-contract.js +68 -30
- package/dist/schema/schema.d.ts +17 -9
- package/dist/schema/schema.js +283 -200
- package/dist/schema/validate-graph.d.ts +2 -1
- package/dist/schema/validate-graph.js +40 -23
- package/dist/schema/validate-rules.d.ts +5 -1
- package/dist/schema/validate-rules.js +20 -1
- package/dist/schemas/_knowledge_wiki.json +21 -21
- package/dist/schemas/_ost_strict.json +2 -2
- package/dist/schemas/_sctx_base.json +2 -2
- package/dist/schemas/_strategy_general.json +38 -38
- package/dist/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
- package/dist/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
- package/dist/schemas/knowledge_wiki.json +10 -9
- package/dist/schemas/strategy_general.json +15 -14
- package/dist/schemas/strict_ost.json +17 -16
- package/dist/space-context.d.ts +27 -0
- package/dist/space-context.js +46 -0
- package/dist/space-graph.d.ts +9 -7
- package/dist/space-graph.js +7 -5
- package/dist/testing.d.ts +33 -0
- package/dist/testing.js +72 -0
- package/dist/types.d.ts +101 -8
- package/dist/validate.d.ts +15 -3
- package/dist/validate.js +55 -20
- package/docs/concepts.md +37 -4
- package/docs/config.md +10 -0
- package/docs/rules.md +49 -22
- package/docs/schemas.md +110 -35
- package/package.json +13 -8
- package/schemas/_knowledge_wiki.json +21 -21
- package/schemas/_ost_strict.json +2 -2
- package/schemas/_sctx_base.json +2 -2
- package/schemas/_strategy_general.json +38 -38
- package/schemas/generated/0.12.0/_structured_context_schema_meta.json +229 -0
- package/schemas/generated/latest/_structured_context_schema_meta.json +229 -0
- package/schemas/knowledge_wiki.json +10 -9
- package/schemas/strategy_general.json +15 -14
- package/schemas/strict_ost.json +17 -16
- package/dist/integrations/miro/sync.d.ts +0 -8
- package/dist/schemas/generated/_structured_context_schema_meta.json +0 -191
- package/schemas/generated/_structured_context_schema_meta.json +0 -191
- /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
- /package/dist/{integrations → plugins}/miro/cache.js +0 -0
- /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
- /package/dist/{integrations → plugins}/miro/client.js +0 -0
- /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 {
|
|
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: '
|
|
42
|
+
{ name: 'directory', description: 'Markdown space directory with one file per node' },
|
|
43
43
|
],
|
|
44
|
-
render(
|
|
44
|
+
render(context, graph, { format, data }) {
|
|
45
45
|
if (format === 'bullets')
|
|
46
46
|
return renderBullets(graph);
|
|
47
|
-
if (format === '
|
|
48
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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,14 @@
|
|
|
1
|
+
import { PLUGIN_PREFIX } from '../util';
|
|
2
|
+
import { renderMermaid } from './render-mermaid';
|
|
3
|
+
export const mermaidPlugin = {
|
|
4
|
+
name: `${PLUGIN_PREFIX}mermaid`,
|
|
5
|
+
configSchema: { type: 'object', additionalProperties: false },
|
|
6
|
+
render: {
|
|
7
|
+
formats: [{ name: 'graph', description: 'Mermaid graph TD diagram' }],
|
|
8
|
+
render(_context, graph, { format }) {
|
|
9
|
+
if (format === 'graph')
|
|
10
|
+
return renderMermaid(graph);
|
|
11
|
+
throw new Error(`Unknown mermaid render format: "${format}"`);
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
const LEVEL_PALETTE = [
|
|
2
|
+
{ fill: '#ff9999', stroke: '#ff0000' },
|
|
3
|
+
{ fill: '#99ccff', stroke: '#0066cc' },
|
|
4
|
+
{ fill: '#99ff99', stroke: '#00cc00' },
|
|
5
|
+
{ fill: '#ffcc99', stroke: '#cc9900' },
|
|
6
|
+
{ fill: '#cc99ff', stroke: '#6600cc' },
|
|
7
|
+
{ fill: '#ff99cc', stroke: '#cc0066' },
|
|
8
|
+
{ fill: '#99ffff', stroke: '#009999' },
|
|
9
|
+
{ fill: '#ffff99', stroke: '#cccc00' },
|
|
10
|
+
];
|
|
1
11
|
function escapeMermaidString(str) {
|
|
2
12
|
return str.replace(/"/g, '"');
|
|
3
13
|
}
|
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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}"]:::${
|
|
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;
|