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