structured-context 0.9.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +22 -3
  2. package/dist/api.d.ts +33 -0
  3. package/dist/api.js +26 -0
  4. package/dist/commands/diagram.js +1 -1
  5. package/dist/commands/dump.js +1 -1
  6. package/dist/commands/miro-sync.d.ts +3 -0
  7. package/dist/commands/miro-sync.js +8 -0
  8. package/dist/commands/schemas.js +3 -2
  9. package/dist/commands/spaces.js +0 -2
  10. package/dist/commands/validate-file.d.ts +5 -24
  11. package/dist/commands/validate-file.js +26 -108
  12. package/dist/commands/validate.d.ts +1 -12
  13. package/dist/commands/validate.js +22 -157
  14. package/dist/config.d.ts +8 -12
  15. package/dist/config.js +25 -23
  16. package/dist/index.js +15 -23
  17. package/dist/load-space-graph.d.ts +40 -0
  18. package/dist/load-space-graph.js +42 -0
  19. package/dist/plugins/index.js +3 -1
  20. package/dist/plugins/loader.js +2 -2
  21. package/dist/plugins/markdown/extract-content-links.d.ts +23 -0
  22. package/dist/plugins/markdown/extract-content-links.js +144 -0
  23. package/dist/plugins/markdown/index.d.ts +42 -32
  24. package/dist/plugins/markdown/index.js +10 -15
  25. package/dist/plugins/markdown/parse-embedded.d.ts +1 -1
  26. package/dist/plugins/markdown/parse-embedded.js +9 -0
  27. package/dist/plugins/markdown/read-space.js +6 -3
  28. package/dist/plugins/markdown/template-sync.d.ts +1 -1
  29. package/dist/plugins/markdown/template-sync.js +2 -2
  30. package/dist/plugins/mermaid/index.d.ts +2 -0
  31. package/dist/plugins/mermaid/index.js +14 -0
  32. package/dist/plugins/{markdown → mermaid}/render-mermaid.js +17 -16
  33. package/dist/{integrations → plugins}/miro/cache.d.ts +1 -1
  34. package/dist/plugins/miro/index.d.ts +2 -0
  35. package/dist/plugins/miro/index.js +27 -0
  36. package/dist/{integrations → plugins}/miro/layout.d.ts +2 -2
  37. package/dist/{integrations → plugins}/miro/layout.js +3 -16
  38. package/dist/{integrations → plugins}/miro/styles.d.ts +2 -2
  39. package/dist/plugins/miro/sync.d.ts +7 -0
  40. package/dist/{integrations → plugins}/miro/sync.js +23 -41
  41. package/dist/plugins/util.d.ts +9 -0
  42. package/dist/plugins/util.js +4 -0
  43. package/dist/read/resolve-graph-edges.js +32 -0
  44. package/dist/render/render.d.ts +1 -0
  45. package/dist/render/render.js +20 -16
  46. package/dist/schema/evaluate-rule.js +1 -0
  47. package/dist/schema/schema.d.ts +3 -1
  48. package/dist/schema/schema.js +7 -3
  49. package/dist/schemas/_knowledge_wiki.json +1 -1
  50. package/dist/space-context.d.ts +24 -0
  51. package/dist/space-context.js +41 -0
  52. package/dist/types.d.ts +72 -2
  53. package/dist/validate.d.ts +59 -0
  54. package/dist/validate.js +316 -0
  55. package/docs/concepts.md +23 -0
  56. package/docs/config.md +2 -0
  57. package/package.json +11 -5
  58. package/schemas/_knowledge_wiki.json +1 -1
  59. package/dist/integrations/miro/sync.d.ts +0 -8
  60. package/dist/plugin-api.d.ts +0 -12
  61. package/dist/plugin-api.js +0 -7
  62. /package/dist/plugins/{markdown → mermaid}/render-mermaid.d.ts +0 -0
  63. /package/dist/{integrations → plugins}/miro/cache.js +0 -0
  64. /package/dist/{integrations → plugins}/miro/client.d.ts +0 -0
  65. /package/dist/{integrations → plugins}/miro/client.js +0 -0
  66. /package/dist/{integrations → plugins}/miro/styles.js +0 -0
package/dist/config.d.ts CHANGED
@@ -2,14 +2,18 @@ export type SpaceConfig = {
2
2
  name: string;
3
3
  path: string;
4
4
  schema?: string;
5
- miroBoardId?: string;
6
- miroFrameId?: string;
7
5
  /** Plugin name → plugin config map. Overrides top-level plugins when set. */
8
6
  plugins?: Record<string, Record<string, unknown>>;
9
7
  /** Named filter views for this space. Keys are view names; values contain the filter expression. */
10
8
  views?: Record<string, {
11
9
  expression: string;
12
10
  }>;
11
+ /**
12
+ * Directory of the config file that defined this space, used to anchor relative plugin-config
13
+ * paths. Set by loadConfig; absent for a hand-assembled Config, whose callers must pass an
14
+ * explicit configDir to createSpaceContext. Not persisted by updateSpaceField.
15
+ */
16
+ sourceDir?: string;
13
17
  };
14
18
  export type Config = {
15
19
  spaces: SpaceConfig[];
@@ -20,19 +24,11 @@ export type Config = {
20
24
  export declare function setConfigPath(path: string | undefined): void;
21
25
  /** Get all config file paths that were loaded (main config + included configs). */
22
26
  export declare function getConfigSourceFiles(): Set<string>;
23
- /**
24
- * Get the directory of the config file that defines a given space.
25
- */
26
- export declare function getSpaceConfigDir(spaceName: string): string;
27
27
  export declare function configPath(): string;
28
28
  export declare function loadConfig(): Config;
29
29
  /** Get the full space config entry by name. Throws if not found. */
30
30
  export declare function getSpaceConfig(name: string, config: Config): SpaceConfig;
31
31
  /** Resolve schema path: CLI arg > space-level config > global config. Throws if none configured. */
32
32
  export declare function resolveSchema(config: Config, space?: SpaceConfig): string;
33
- type StringFields<T> = {
34
- [K in keyof T]: T[K] extends string | undefined ? K : never;
35
- }[keyof T];
36
- /** Update a string field on a space entry and persist config. */
37
- export declare function updateSpaceField(spaceName: string, field: StringFields<SpaceConfig>, value: string): void;
38
- export {};
33
+ /** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
34
+ export declare function updateSpaceField(spaceName: string, field: string, value: string, plugin?: string): void;
package/dist/config.js CHANGED
@@ -2,9 +2,9 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import Ajv from 'ajv';
5
- import { JSON5 } from 'bun';
5
+ import JSON5 from 'json5';
6
6
  import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants';
7
- import { normalizePluginName } from './plugins/util';
7
+ import { normalizePluginName, shortenPluginName } from './plugins/util';
8
8
  import { bundledSchemasDir } from './schema/schema';
9
9
  const CONFIG_SCHEMA = {
10
10
  type: 'object',
@@ -17,8 +17,6 @@ const CONFIG_SCHEMA = {
17
17
  name: { type: 'string', pattern: '^[a-z0-9_-]+$' },
18
18
  path: { type: 'string' },
19
19
  schema: { type: 'string' },
20
- miroBoardId: { type: 'string' },
21
- miroFrameId: { type: 'string' },
22
20
  plugins: { type: 'object', additionalProperties: { type: 'object' } },
23
21
  views: {
24
22
  type: 'object',
@@ -37,7 +35,7 @@ const CONFIG_SCHEMA = {
37
35
  schema: { type: 'string' },
38
36
  includeSpacesFrom: { type: 'array', items: { type: 'string' } },
39
37
  },
40
- required: ['spaces'],
38
+ anyOf: [{ required: ['spaces'] }, { required: ['includeSpacesFrom'] }],
41
39
  additionalProperties: false,
42
40
  };
43
41
  let _configPathOverride;
@@ -54,18 +52,6 @@ export function getConfigSourceFiles() {
54
52
  }
55
53
  return new Set(_spaceSourceFiles.values());
56
54
  }
57
- /**
58
- * Get the directory of the config file that defines a given space.
59
- */
60
- export function getSpaceConfigDir(spaceName) {
61
- if (_spaceSourceFiles.size === 0) {
62
- loadConfig();
63
- }
64
- const spaceConfigPath = _spaceSourceFiles.get(spaceName);
65
- if (!spaceConfigPath)
66
- throw new Error('Space config path not found');
67
- return dirname(spaceConfigPath);
68
- }
69
55
  export function configPath() {
70
56
  if (_configPathOverride) {
71
57
  return _configPathOverride;
@@ -130,6 +116,8 @@ function _loadConfig(path) {
130
116
  if (!validate(config)) {
131
117
  throw new Error(`Invalid config in ${path}: ${JSON.stringify(validate.errors)}`);
132
118
  }
119
+ if (!config.spaces)
120
+ config.spaces = [];
133
121
  return config;
134
122
  }
135
123
  export function loadConfig() {
@@ -141,8 +129,10 @@ export function loadConfig() {
141
129
  const config = _loadConfig(path);
142
130
  _spaceSourceFiles.clear();
143
131
  // Track which spaces come from the main config file
132
+ const mainSourceDir = dirname(resolve(path));
144
133
  for (const space of config.spaces) {
145
134
  _spaceSourceFiles.set(space.name, path);
135
+ space.sourceDir = mainSourceDir;
146
136
  }
147
137
  // Load includeSpacesFrom configs and merge their spaces in, with later entries taking precedence over earlier ones
148
138
  if (config.includeSpacesFrom) {
@@ -154,8 +144,10 @@ export function loadConfig() {
154
144
  throw new Error(`Included config contains spaces with duplicate names: ${resolvedIncludePath}`);
155
145
  }
156
146
  // Track which spaces come from this included config file
147
+ const includeSourceDir = dirname(resolvedIncludePath);
157
148
  for (const space of includedConfig.spaces) {
158
149
  _spaceSourceFiles.set(space.name, resolvedIncludePath);
150
+ space.sourceDir = includeSourceDir;
159
151
  }
160
152
  config.spaces.push(...includedConfig.spaces);
161
153
  }
@@ -178,17 +170,27 @@ export function resolveSchema(config, space) {
178
170
  }
179
171
  return schema;
180
172
  }
181
- /** Update a string field on a space entry and persist config. */
182
- export function updateSpaceField(spaceName, field, value) {
173
+ /** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
174
+ export function updateSpaceField(spaceName, field, value, plugin) {
183
175
  const sourcePath = _spaceSourceFiles.get(spaceName);
184
- if (!sourcePath) {
176
+ if (!sourcePath)
185
177
  throw new Error(`Space "${spaceName}" not found in any config file`);
186
- }
187
178
  const config = _loadConfig(sourcePath);
188
179
  const space = config.spaces?.find((s) => s.name === spaceName);
189
- if (!space) {
180
+ if (!space)
190
181
  throw new Error(`Unknown space config: "${spaceName}". Check config.`);
182
+ if (plugin !== undefined) {
183
+ if (!space.plugins)
184
+ space.plugins = {};
185
+ const normalized = normalizePluginName(plugin);
186
+ const shortName = shortenPluginName(normalized);
187
+ const existingKey = Object.keys(space.plugins).find((k) => k === normalized || k === shortName) ?? normalized;
188
+ if (!space.plugins[existingKey])
189
+ space.plugins[existingKey] = {};
190
+ space.plugins[existingKey][field] = value;
191
+ }
192
+ else {
193
+ space[field] = value;
191
194
  }
192
- space[field] = value;
193
195
  writeFileSync(sourcePath, `${JSON5.stringify(config, null, 2)}\n`);
194
196
  }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { Command } from 'commander';
4
4
  import { diagram } from './commands/diagram';
5
5
  import { docs } from './commands/docs';
6
6
  import { dump } from './commands/dump';
7
+ import { miroSyncCommand } from './commands/miro-sync';
7
8
  import { listPlugins } from './commands/plugins';
8
9
  import { render, renderList } from './commands/render';
9
10
  import { listSchemas, showSchema } from './commands/schemas';
@@ -11,30 +12,21 @@ import { show } from './commands/show';
11
12
  import { listSpaces } from './commands/spaces';
12
13
  import { templateSync } from './commands/template-sync';
13
14
  import { validate, watchValidate } from './commands/validate';
14
- import { validateFile } from './commands/validate-file';
15
- import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config';
15
+ import { validateFileCommand } from './commands/validate-file';
16
+ import { loadConfig, setConfigPath } from './config';
16
17
  import { CLI_NAME } from './constants';
17
- import { miroSync } from './integrations/miro/sync';
18
- import { loadSchema } from './schema/schema';
18
+ import { createSpaceContext, SpaceNotFoundError } from './space-context';
19
19
  export function buildSpaceContext(spaceName) {
20
- const config = loadConfig();
21
- const space = config.spaces.find((s) => s.name === spaceName);
22
- if (!space) {
23
- console.error(`Error: Unknown space "${spaceName}"`);
24
- process.exit(1);
20
+ try {
21
+ return createSpaceContext(spaceName, loadConfig());
22
+ }
23
+ catch (err) {
24
+ if (err instanceof SpaceNotFoundError) {
25
+ console.error(`Error: ${err.message}`);
26
+ process.exit(1);
27
+ }
28
+ throw err;
25
29
  }
26
- const resolvedSchemaPath = resolveSchema(config, space);
27
- const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath);
28
- const configDir = getSpaceConfigDir(space.name);
29
- return {
30
- space,
31
- config,
32
- resolvedSchemaPath,
33
- schema,
34
- schemaRefRegistry,
35
- schemaValidator,
36
- configDir,
37
- };
38
30
  }
39
31
  const require = createRequire(import.meta.url);
40
32
  const packageJson = require('../package.json');
@@ -53,7 +45,7 @@ program
53
45
  .argument('<path>', 'Path to the file to validate')
54
46
  .option('--json', 'Output results as JSON (machine-readable, for hooks)')
55
47
  .action(async (filePath, options) => {
56
- const exitCode = await validateFile(filePath, { json: options.json });
48
+ const exitCode = await validateFileCommand(filePath, { json: options.json });
57
49
  process.exit(exitCode);
58
50
  });
59
51
  program
@@ -97,7 +89,7 @@ program
97
89
  .option('--new-frame <title>', 'Create a new frame on the board and sync into it')
98
90
  .option('--dry-run', 'Show what would change without touching Miro')
99
91
  .option('-v, --verbose', 'Detailed output')
100
- .action((spaceName, options) => miroSync(buildSpaceContext(spaceName), options));
92
+ .action((spaceName, options) => miroSyncCommand(buildSpaceContext(spaceName), options));
101
93
  program
102
94
  .command('template-sync')
103
95
  .description('Sync schema examples to templates')
@@ -0,0 +1,40 @@
1
+ import type { Config } from './config';
2
+ import { type SpaceGraph } from './space-graph';
3
+ import type { ReadSpaceResult, SpaceContext } from './types';
4
+ export interface AssembleSpaceGraphOptions {
5
+ /** Named view (resolved via space.views) or a raw filter DSL expression. */
6
+ filter?: string;
7
+ /** Pre-loaded read result to assemble from, avoiding a re-read of the space. */
8
+ readResult?: ReadSpaceResult;
9
+ }
10
+ /**
11
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes that
12
+ * fail schema validation, build the hierarchy graph, and apply an optional
13
+ * view/filter expression.
14
+ *
15
+ * Nodes that fail schema validation are silently excluded so the graph is
16
+ * well-formed enough to render and traverse. This is NOT a validation pass —
17
+ * schema-valid nodes may still have broken references, rule violations, or
18
+ * duplicates. Use validateSpace for a full validation report.
19
+ *
20
+ * Shared by the render pipeline and the public loadSpaceGraph API so their
21
+ * graph-assembly behaviour cannot drift.
22
+ */
23
+ export declare function assembleSpaceGraph(context: SpaceContext, options?: AssembleSpaceGraphOptions): Promise<SpaceGraph>;
24
+ export interface LoadSpaceGraphOptions extends AssembleSpaceGraphOptions {
25
+ /**
26
+ * Directory to anchor relative plugin paths against. Required when config is
27
+ * hand-assembled (has no source file); see {@link createSpaceContext}.
28
+ */
29
+ configDir?: string;
30
+ }
31
+ /**
32
+ * Load a named space and return its assembled, filtered SpaceGraph.
33
+ *
34
+ * Convenience entry point over createSpaceContext + assembleSpaceGraph: pass a
35
+ * space name, config, and optional filter. Pass options.readResult to reuse an
36
+ * already-loaded read (e.g. when also calling validateSpace) and skip re-reading.
37
+ * When you already hold a SpaceContext, call assembleSpaceGraph directly to avoid
38
+ * rebuilding (and recompiling the schema for) the context.
39
+ */
40
+ export declare function loadSpaceGraph(spaceName: string, config: Config, options?: LoadSpaceGraphOptions): Promise<SpaceGraph>;
@@ -0,0 +1,42 @@
1
+ import { filterNodes } from './filter/filter-nodes';
2
+ import { readSpace } from './read/read-space';
3
+ import { createSpaceContext } from './space-context';
4
+ import { buildSpaceGraph } from './space-graph';
5
+ /**
6
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes that
7
+ * fail schema validation, build the hierarchy graph, and apply an optional
8
+ * view/filter expression.
9
+ *
10
+ * Nodes that fail schema validation are silently excluded so the graph is
11
+ * well-formed enough to render and traverse. This is NOT a validation pass —
12
+ * schema-valid nodes may still have broken references, rule violations, or
13
+ * duplicates. Use validateSpace for a full validation report.
14
+ *
15
+ * Shared by the render pipeline and the public loadSpaceGraph API so their
16
+ * graph-assembly behaviour cannot drift.
17
+ */
18
+ export async function assembleSpaceGraph(context, options = {}) {
19
+ const { nodes: allNodes } = options.readResult ?? (await readSpace(context));
20
+ const { schemaValidator } = context;
21
+ const validNodes = allNodes.filter((node) => schemaValidator(node.schemaData));
22
+ const levels = context.schema.metadata.hierarchy?.levels ?? [];
23
+ let graph = buildSpaceGraph(validNodes, levels);
24
+ if (options.filter) {
25
+ const expression = context.space.views?.[options.filter]?.expression ?? options.filter;
26
+ graph = await filterNodes(expression, graph);
27
+ }
28
+ return graph;
29
+ }
30
+ /**
31
+ * Load a named space and return its assembled, filtered SpaceGraph.
32
+ *
33
+ * Convenience entry point over createSpaceContext + assembleSpaceGraph: pass a
34
+ * space name, config, and optional filter. Pass options.readResult to reuse an
35
+ * already-loaded read (e.g. when also calling validateSpace) and skip re-reading.
36
+ * When you already hold a SpaceContext, call assembleSpaceGraph directly to avoid
37
+ * rebuilding (and recompiling the schema for) the context.
38
+ */
39
+ export async function loadSpaceGraph(spaceName, config, options = {}) {
40
+ const context = createSpaceContext(spaceName, config, { configDir: options.configDir });
41
+ return assembleSpaceGraph(context, options);
42
+ }
@@ -1,3 +1,5 @@
1
1
  import { markdownPlugin } from './markdown';
2
+ import { mermaidPlugin } from './mermaid';
3
+ import { miroPlugin } from './miro';
2
4
  /** All built-in plugins, in default load order. */
3
- export const builtinPlugins = [markdownPlugin];
5
+ export const builtinPlugins = [markdownPlugin, mermaidPlugin, miroPlugin];
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
3
  import Ajv, {} from 'ajv';
4
4
  import { getConfigSourceFiles } from '../config';
5
5
  import { builtinPlugins } from '.';
6
- import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX } from './util';
6
+ import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, shortenPluginName, } from './util';
7
7
  /**
8
8
  * Walk a plugin's configSchema and resolve any string fields annotated with
9
9
  * format:'path' relative to configDir.
@@ -92,7 +92,7 @@ export async function loadPlugins(pluginMap, configDir) {
92
92
  }
93
93
  // Built-in plugins: always loaded, config taken from map if declared (with or without prefix)
94
94
  for (const builtin of builtinPlugins) {
95
- const rawConfig = pluginMap[builtin.name] ?? pluginMap[builtin.name.slice(PLUGIN_PREFIX.length)] ?? {};
95
+ const rawConfig = pluginMap[builtin.name] ?? pluginMap[shortenPluginName(builtin.name)] ?? {};
96
96
  const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir);
97
97
  const validate = ajv.compile(builtin.configSchema);
98
98
  if (!validate(pluginConfig)) {
@@ -0,0 +1,23 @@
1
+ import type { Node } from 'mdast';
2
+ import type { ContentLink, SchemaMetadata } from '../../types';
3
+ /**
4
+ * Recursively walk an mdast subtree and collect all links.
5
+ * Handles standard markdown link/image nodes and scans text nodes for wikilinks.
6
+ */
7
+ export declare function extractLinksFromAstNode(node: Node): ContentLink[];
8
+ /**
9
+ * Extract all links from a raw markdown body string.
10
+ * Parses the full document and collects links from every node in the tree.
11
+ */
12
+ export declare function extractLinksFromBody(body: string): ContentLink[];
13
+ /**
14
+ * Build the set of field names that serve as graph edges (hierarchy + relationship fields).
15
+ * Used to exclude edge fields from frontmatter link extraction.
16
+ */
17
+ export declare function getEdgeFieldNames(metadata: SchemaMetadata): Set<string>;
18
+ /**
19
+ * Extract links from frontmatter data fields, excluding known graph edge fields.
20
+ * Parses each string value as markdown (via mdast) to keep link extraction consistent
21
+ * with body content parsing — handles wikilinks, markdown links, images, and bare URLs.
22
+ */
23
+ export declare function extractLinksFromFrontmatter(data: Record<string, unknown>, edgeFields: Set<string>): ContentLink[];
@@ -0,0 +1,144 @@
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
+ /**
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
+ }
29
+ /**
30
+ * Extract wikilinks (and Obsidian embed wikilinks) from a plain text string.
31
+ * Matches [[target]], ![[target]], [[target#anchor]], [[target|alias]], etc.
32
+ */
33
+ function extractWikilinksFromText(text) {
34
+ const links = [];
35
+ for (const match of text.matchAll(/(!?)\[\[([^\]]+)\]\]/g)) {
36
+ const isEmbed = match[1] === '!';
37
+ const inner = match[2];
38
+ const { target, anchor, displayText } = parseWikilinkInner(inner);
39
+ if (!target)
40
+ continue;
41
+ links.push({
42
+ text: displayText ?? target,
43
+ target,
44
+ action: isEmbed ? 'embed' : 'link',
45
+ ...(anchor !== undefined ? { anchor } : {}),
46
+ linkSyntax: 'wikilink',
47
+ });
48
+ }
49
+ return links;
50
+ }
51
+ /**
52
+ * Recursively walk an mdast subtree and collect all links.
53
+ * Handles standard markdown link/image nodes and scans text nodes for wikilinks.
54
+ */
55
+ export function extractLinksFromAstNode(node) {
56
+ const links = [];
57
+ function walk(n) {
58
+ switch (n.type) {
59
+ case 'link': {
60
+ const linkNode = n;
61
+ links.push({
62
+ text: mdastToString(linkNode),
63
+ target: linkNode.url,
64
+ action: 'link',
65
+ linkSyntax: 'markdown',
66
+ });
67
+ // Don't recurse into children — they are display text, not link targets
68
+ break;
69
+ }
70
+ case 'image': {
71
+ const imgNode = n;
72
+ links.push({
73
+ text: imgNode.alt ?? '',
74
+ target: imgNode.url,
75
+ action: 'embed',
76
+ linkSyntax: 'markdown',
77
+ });
78
+ break;
79
+ }
80
+ case 'text': {
81
+ // Remark does not parse wikilinks natively; scan text nodes for [[...]] patterns
82
+ links.push(...extractWikilinksFromText(n.value));
83
+ break;
84
+ }
85
+ default: {
86
+ if ('children' in n) {
87
+ for (const child of n.children) {
88
+ walk(child);
89
+ }
90
+ }
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ walk(node);
96
+ return links;
97
+ }
98
+ /**
99
+ * Extract all links from a raw markdown body string.
100
+ * Parses the full document and collects links from every node in the tree.
101
+ */
102
+ export function extractLinksFromBody(body) {
103
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
104
+ return extractLinksFromAstNode(tree);
105
+ }
106
+ /**
107
+ * Build the set of field names that serve as graph edges (hierarchy + relationship fields).
108
+ * Used to exclude edge fields from frontmatter link extraction.
109
+ */
110
+ export function getEdgeFieldNames(metadata) {
111
+ const fields = new Set();
112
+ for (const level of metadata.hierarchy?.levels ?? []) {
113
+ fields.add(level.field);
114
+ if (level.selfRefField)
115
+ fields.add(level.selfRefField);
116
+ }
117
+ for (const rel of metadata.relationships ?? []) {
118
+ fields.add(rel.field);
119
+ }
120
+ return fields;
121
+ }
122
+ /**
123
+ * Extract links from frontmatter data fields, excluding known graph edge fields.
124
+ * Parses each string value as markdown (via mdast) to keep link extraction consistent
125
+ * with body content parsing — handles wikilinks, markdown links, images, and bare URLs.
126
+ */
127
+ export function extractLinksFromFrontmatter(data, edgeFields) {
128
+ const links = [];
129
+ for (const [key, value] of Object.entries(data)) {
130
+ if (edgeFields.has(key))
131
+ continue;
132
+ if (typeof value === 'string') {
133
+ links.push(...extractLinksFromBody(value));
134
+ }
135
+ else if (Array.isArray(value)) {
136
+ for (const item of value) {
137
+ if (typeof item === 'string') {
138
+ links.push(...extractLinksFromBody(item));
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return links;
144
+ }
@@ -1,48 +1,58 @@
1
+ import type { FromSchema } from 'json-schema-to-ts';
1
2
  import type { StructuredContextPlugin } from '../util';
2
- export type TypeInferenceConfig = {
3
- mode?: 'folder-name' | 'off';
4
- folderMap?: Record<string, string>;
5
- };
6
- export type MarkdownPluginConfig = {
7
- templateDir?: string;
8
- fieldMap?: Record<string, string>;
9
- templatePrefix?: string;
10
- typeInference?: TypeInferenceConfig;
3
+ declare const TYPE_INFERENCE_CONFIG_SCHEMA: {
4
+ readonly type: "object";
5
+ readonly properties: {
6
+ readonly mode: {
7
+ readonly type: "string";
8
+ readonly enum: readonly ["folder-name", "off"];
9
+ };
10
+ readonly folderMap: {
11
+ readonly type: "object";
12
+ readonly additionalProperties: {
13
+ readonly type: "string";
14
+ };
15
+ };
16
+ };
17
+ readonly additionalProperties: false;
11
18
  };
12
19
  export declare const MARKDOWN_CONFIG_SCHEMA: {
13
- type: string;
14
- properties: {
15
- templateDir: {
16
- type: string;
17
- format: string;
20
+ readonly type: "object";
21
+ readonly properties: {
22
+ readonly templateDir: {
23
+ readonly type: "string";
24
+ readonly format: "path";
18
25
  };
19
- fieldMap: {
20
- type: string;
21
- additionalProperties: {
22
- type: string;
26
+ readonly fieldMap: {
27
+ readonly type: "object";
28
+ readonly additionalProperties: {
29
+ readonly type: "string";
23
30
  };
24
31
  };
25
- templatePrefix: {
26
- type: string;
32
+ readonly templatePrefix: {
33
+ readonly type: "string";
27
34
  };
28
- typeInference: {
29
- type: string;
30
- properties: {
31
- mode: {
32
- type: string;
33
- enum: string[];
35
+ readonly typeInference: {
36
+ readonly type: "object";
37
+ readonly properties: {
38
+ readonly mode: {
39
+ readonly type: "string";
40
+ readonly enum: readonly ["folder-name", "off"];
34
41
  };
35
- folderMap: {
36
- type: string;
37
- additionalProperties: {
38
- type: string;
42
+ readonly folderMap: {
43
+ readonly type: "object";
44
+ readonly additionalProperties: {
45
+ readonly type: "string";
39
46
  };
40
47
  };
41
48
  };
42
- additionalProperties: boolean;
49
+ readonly additionalProperties: false;
43
50
  };
44
51
  };
45
- additionalProperties: boolean;
52
+ readonly additionalProperties: false;
46
53
  };
54
+ export type MarkdownPluginConfig = FromSchema<typeof MARKDOWN_CONFIG_SCHEMA>;
55
+ export type TypeInferenceConfig = FromSchema<typeof TYPE_INFERENCE_CONFIG_SCHEMA>;
47
56
  export declare function getMarkdownConfig(plugins?: Record<string, Record<string, unknown>>): MarkdownPluginConfig;
48
57
  export declare const markdownPlugin: StructuredContextPlugin;
58
+ export {};
@@ -2,22 +2,22 @@ import { statSync } from 'node:fs';
2
2
  import { PLUGIN_PREFIX } from '../util';
3
3
  import { readSpaceDirectory, readSpaceOnAPage } from './read-space';
4
4
  import { renderBullets } from './render-bullets';
5
- import { renderMermaid } from './render-mermaid';
6
5
  import { templateSync } from './template-sync';
6
+ const TYPE_INFERENCE_CONFIG_SCHEMA = {
7
+ type: 'object',
8
+ properties: {
9
+ mode: { type: 'string', enum: ['folder-name', 'off'] },
10
+ folderMap: { type: 'object', additionalProperties: { type: 'string' } },
11
+ },
12
+ additionalProperties: false,
13
+ };
7
14
  export const MARKDOWN_CONFIG_SCHEMA = {
8
15
  type: 'object',
9
16
  properties: {
10
17
  templateDir: { type: 'string', format: 'path' }, // format is hint to config loader to resolve relative directories
11
18
  fieldMap: { type: 'object', additionalProperties: { type: 'string' } },
12
19
  templatePrefix: { type: 'string' },
13
- typeInference: {
14
- type: 'object',
15
- properties: {
16
- mode: { type: 'string', enum: ['folder-name', 'off'] },
17
- folderMap: { type: 'object', additionalProperties: { type: 'string' } },
18
- },
19
- additionalProperties: false,
20
- },
20
+ typeInference: TYPE_INFERENCE_CONFIG_SCHEMA,
21
21
  },
22
22
  additionalProperties: false,
23
23
  };
@@ -36,15 +36,10 @@ export const markdownPlugin = {
36
36
  parse,
37
37
  templateSync,
38
38
  render: {
39
- formats: [
40
- { name: 'bullets', description: 'Indented bullet list' },
41
- { name: 'mermaid', description: 'Mermaid graph TD diagram' },
42
- ],
39
+ formats: [{ name: 'bullets', description: 'Indented bullet list' }],
43
40
  render(_context, graph, { format }) {
44
41
  if (format === 'bullets')
45
42
  return renderBullets(graph);
46
- if (format === 'mermaid')
47
- return renderMermaid(graph);
48
43
  throw new Error(`Unknown markdown render format: "${format}"`);
49
44
  },
50
45
  },
@@ -1,4 +1,4 @@
1
- import type { BaseNode, Relationship, SchemaMetadata } from '../../plugin-api';
1
+ import type { BaseNode, Relationship, SchemaMetadata } from '../../api';
2
2
  /** Type values that identify a space_on_a_page container (not themselves space nodes). */
3
3
  export declare const ON_A_PAGE_TYPES: string[];
4
4
  type StackEntry = {