From d35dec2eccf3cb18c33637c16fc22862c391b3ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 01:50:50 +0000 Subject: [PATCH 1/4] feat(render): resourceDirs callback Render hooks get context.callbacks.resourceDirs(kind): each config root's resource folder of that kind, then each plugin's, tagged with the plugin's short name. A plugin's resource folder follows the convention schemas already use (package.json sctx., else /), now in pluginResourceDir. spacePluginNames is extracted from resolveSpacePluginSchemas so both share one list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- docs/api.md | 2 +- docs/config.md | 2 +- src/plugins/loader.ts | 59 ++++++-- src/plugins/util.ts | 126 +++++++----------- src/render/render.ts | 3 +- .../.sctx/map-views/local.json5 | 1 + .../.sctx/plugins/sctx-probe.ts | 4 + .../.sctx/plugins/sctx-views/index.ts | 6 + .../plugins/sctx-views/map-views/views.json5 | 1 + tests/render/render-plugins.test.ts | 5 + 10 files changed, 116 insertions(+), 93 deletions(-) create mode 100644 tests/fixtures/render-plugins/.sctx/map-views/local.json5 create mode 100644 tests/fixtures/render-plugins/.sctx/plugins/sctx-views/index.ts create mode 100644 tests/fixtures/render-plugins/.sctx/plugins/sctx-views/map-views/views.json5 diff --git a/docs/api.md b/docs/api.md index 818fd7c..02d8970 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,5 +142,5 @@ const myRenderHook: RenderHook = { }; ``` -A render hook can validate the space and export the rendered graph through `context.callbacks.validate()` and `exportGraph()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. +A render hook can validate the space, export the rendered graph and find resource folders (such as `map-views`) through `context.callbacks.validate()`, `exportGraph()` and `resourceDirs()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. diff --git a/docs/config.md b/docs/config.md index 1cbe546..283a48e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -98,7 +98,7 @@ A plugin comes from one of three places, and where it comes from decides when it | In a plugins folder (`.sctx/plugins/sctx-*`) | Spaces that name it in `plugins` | Every space | | Installed package (`sctx-*`) | Spaces that name it in `plugins` | Spaces that name it in `plugins` | -Plugins can also contribute schemas and partials via a `/schemas/` directory (or `"sctx": { "schemas": "..." }` in `package.json`). If a space specifies a plugin-prefixed schema (e.g. `"sctx-wardley-mapping/wardley_map.json"`), schemas are resolved statically without executing plugin code, and the plugin's code hooks are loaded when parsing or rendering. +Plugins can also contribute schemas and partials via a `/schemas/` directory (or `"sctx": { "schemas": "..." }` in `package.json`). Other resources follow the same pattern. For example, map view definitions go in `/map-views/`, or the folder named by `"sctx": { "map-views": "..." }`. If a space specifies a plugin-prefixed schema (e.g. `"sctx-wardley-mapping/wardley_map.json"`), schemas are resolved statically without executing plugin code, and the plugin's code hooks are loaded when parsing or rendering. ## Model providers and semantic rules diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d651c41..7dfe6eb 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -4,14 +4,18 @@ import type { AnySchemaObject } from 'ajv'; import Ajv2020 from 'ajv/dist/2020'; import { getConfigSourceFiles } from '../config'; import { configRoot, resourceDir } from '../config/layout'; +import type { SpaceContext } from '../types'; import { builtinPlugins } from '.'; import { isBuiltinPluginName } from './builtin-names'; import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, + pluginResourceDir, + resolvePluginDir, type StructuredContextPlugin, shortenPluginName, + spacePluginNames, } from './util'; export type LoadedPlugin = { @@ -75,6 +79,18 @@ async function spacePlugins( return [...named, ...builtinPlugins]; } +/** The roots of the loaded config files (the main config and the configs it includes), after `configDir` if given. */ +function configRoots(configDir?: string): string[] { + const loaded = Array.from(getConfigSourceFiles()).map((f) => configRoot(f)); + return [...new Set(configDir ? [configDir, ...loaded] : loaded)]; +} + +/** The names of the plugins in a config root's plugins folder. */ +function pluginsFolderEntries(configDir: string): string[] { + const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); + return pluginsDir ? readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)) : []; +} + /** * Discover all available plugins: the built-ins, then any found in the plugins folder of * each loaded config file's root (the main config and the configs it includes). @@ -88,18 +104,13 @@ export async function discoverPlugins(space?: { const plugins = space ? await spacePlugins(space.pluginMap, space.configDir) : [...builtinPlugins]; const seenNames = new Set(plugins.map((p) => p.name)); - const configDirs = Array.from(getConfigSourceFiles()).map((f) => configRoot(f)); - for (const configDir of new Set(space ? [space.configDir, ...configDirs] : configDirs)) { - const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); - if (pluginsDir) { - const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)); - for (const entry of entries) { - if (seenNames.has(entry)) continue; - const plugin = await importPlugin(entry, configDir); - if (seenNames.has(plugin.name)) continue; - plugins.push(plugin); - seenNames.add(plugin.name); - } + for (const configDir of configRoots(space?.configDir)) { + for (const entry of pluginsFolderEntries(configDir)) { + if (seenNames.has(entry)) continue; + const plugin = await importPlugin(entry, configDir); + if (seenNames.has(plugin.name)) continue; + plugins.push(plugin); + seenNames.add(plugin.name); } } return plugins; @@ -135,3 +146,27 @@ export async function loadPlugins( pluginConfig: validatePluginConfig(plugin, pluginMap, configDir), })); } + +/** Every folder of one kind of resource available to a space: config roots' first, then plugins' (named by the space or in a plugins folder). */ +export function resourceDirs(kind: string, context: SpaceContext): Array<{ dir: string; plugin?: string }> { + const roots = configRoots(context.configDir); + const pluginDirs = new Map(); + const addPlugin = (name: string, fromDir: string) => { + const short = shortenPluginName(name); + const dir = !pluginDirs.has(short) && resolvePluginDir(name, fromDir); + if (dir) pluginDirs.set(short, dir); + }; + for (const root of roots) for (const entry of pluginsFolderEntries(root)) addPlugin(entry, root); + for (const name of spacePluginNames(context.space, context.config)) addPlugin(name, context.configDir); + + return [ + ...roots.flatMap((root) => { + const dir = resourceDir(root, kind); + return dir ? [{ dir }] : []; + }), + ...[...pluginDirs].flatMap(([plugin, pluginDir]) => { + const dir = pluginResourceDir(pluginDir, kind); + return dir ? [{ dir, plugin }] : []; + }), + ]; +} diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 510a09e..5740771 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import type { AnySchemaObject } from 'ajv'; import type { Config, SpaceConfig } from '../config'; -import { resourceDir } from '../config/layout'; +import { resourceDir, SCHEMAS_RESOURCE } from '../config/layout'; import { PLUGIN_PREFIX as _PLUGIN_PREFIX } from '../constants'; import type { PluginSchemaSource } from '../schema/schema'; import type { SpaceGraph } from '../space-graph'; @@ -38,6 +38,8 @@ export type PluginContext = SpaceContext & { ) => Promise; /** Render only: the graph being rendered, in the space graph format. */ exportGraph?: () => SpaceGraphData; + /** Render only: every folder of one kind of resource (such as `map-views`), with the plugin providing it if any. */ + resourceDirs?: (kind: string) => Array<{ dir: string; plugin?: string }>; }; }; @@ -168,40 +170,28 @@ export function resolvePluginDir(pluginName: string, configDir: string): string return undefined; } +/** A plugin's folder for one kind of resource: `"sctx": { "": "…" }` in its package.json, else `/`. */ +export function pluginResourceDir(pluginDir: string, kind: string): string | undefined { + const isDir = (path: string) => existsSync(path) && statSync(path).isDirectory(); + const pkgJsonPath = join(pluginDir, 'package.json'); + if (existsSync(pkgJsonPath)) { + try { + const declared = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).sctx?.[kind]; + if (typeof declared === 'string' && isDir(resolve(pluginDir, declared))) return resolve(pluginDir, declared); + } catch {} + } + const conventionDir = join(pluginDir, kind); + return isDir(conventionDir) ? conventionDir : undefined; +} + /** - * Synchronously locate the schemas directory for a plugin. - * Convention: - * 1. package.json "sctx": { "schemas": "..." } - * 2. Convention: /schemas - * 3. Fallback for test fixtures: /schemas + * Synchronously locate the schemas directory for a plugin: its schemas resource folder + * (see {@link pluginResourceDir}), else the plugins folder's `schemas/` (for test fixtures). */ export function resolvePluginSchemasDir(pluginName: string, configDir: string): string | undefined { const dir = resolvePluginDir(pluginName, configDir); - if (dir) { - // 1. package.json sctx.schemas - const pkgJsonPath = join(dir, 'package.json'); - if (existsSync(pkgJsonPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); - if (pkg.sctx?.schemas && typeof pkg.sctx.schemas === 'string') { - const customSchemas = resolve(dir, pkg.sctx.schemas); - if (existsSync(customSchemas) && statSync(customSchemas).isDirectory()) { - return customSchemas; - } - } - } catch {} - } - - // 2. Convention: /schemas - const conventionDir = join(dir, 'schemas'); - if (existsSync(conventionDir)) { - try { - if (statSync(conventionDir).isDirectory()) { - return conventionDir; - } - } catch {} - } - } + const schemasDir = dir && pluginResourceDir(dir, SCHEMAS_RESOURCE); + if (schemasDir) return schemasDir; // 3. Check the plugins folder's schemas as fallback (e.g. test fixtures) const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); @@ -218,58 +208,38 @@ export function resolvePluginSchemasDir(pluginName: string, configDir: string): } /** - * Synchronously collect all plugin schema sources available to a space. + * The plugins a space names, in order: the plugin prefixing its schema (e.g. `sctx-wardley-mapping/wardley_map.json`), + * then those configured on the space, then those configured globally. Built-ins are left out. */ -export function resolveSpacePluginSchemas( - space: SpaceConfig | undefined, - config: Config, - configDir: string, -): PluginSchemaSource[] { - const pluginSources: PluginSchemaSource[] = []; - const seenPlugins = new Set(); - - const maybeAddPlugin = (name: string) => { - // Built-in plugins ship no schemas. Resolving one as a package can query the npm registry or find an unrelated - // package of the same short name (such as `markdown`). - if (isBuiltinPluginName(name)) return; - const shortName = shortenPluginName(name); - if (seenPlugins.has(shortName)) return; - seenPlugins.add(shortName); - - const schemasDir = resolvePluginSchemasDir(name, configDir); - if (schemasDir) { - pluginSources.push({ - pluginName: shortName, - schemasDir, - }); - } - }; - - // 1. Plugin referenced by space.schema prefix if any (e.g. sctx-wardley-mapping/wardley_map.json) +export function spacePluginNames(space: SpaceConfig | undefined, config: Config): string[] { + const names: string[] = []; const rawSchema = space?.schema ?? config.schema; if (rawSchema && !isAbsolute(rawSchema) && !rawSchema.startsWith('.')) { const normalized = rawSchema.replace(/\\/g, '/'); const slashIdx = normalized.indexOf('/'); - if (slashIdx !== -1) { - const prefix = normalized.slice(0, slashIdx); - maybeAddPlugin(prefix); - } - } - - // 2. Plugins configured on space - if (space?.plugins) { - for (const name of Object.keys(space.plugins)) { - maybeAddPlugin(name); - } - } - - // 3. Plugins configured globally - const globalPlugins = (config as { plugins?: Record }).plugins; - if (globalPlugins) { - for (const name of Object.keys(globalPlugins)) { - maybeAddPlugin(name); - } + if (slashIdx !== -1) names.push(normalized.slice(0, slashIdx)); } + names.push(...Object.keys(space?.plugins ?? {})); + names.push(...Object.keys((config as { plugins?: Record }).plugins ?? {})); + // Built-in plugins ship no resources. Resolving one as a package can query the npm registry or find an unrelated + // package of the same short name (such as `markdown`). + const seen = new Set(); + return names.filter((name) => { + const short = shortenPluginName(name); + if (isBuiltinPluginName(name) || seen.has(short)) return false; + seen.add(short); + return true; + }); +} - return pluginSources; +/** Synchronously collect all plugin schema sources available to a space. */ +export function resolveSpacePluginSchemas( + space: SpaceConfig | undefined, + config: Config, + configDir: string, +): PluginSchemaSource[] { + return spacePluginNames(space, config).flatMap((name) => { + const schemasDir = resolvePluginSchemasDir(name, configDir); + return schemasDir ? [{ pluginName: shortenPluginName(name), schemasDir }] : []; + }); } diff --git a/src/render/render.ts b/src/render/render.ts index c53fedb..696524f 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,6 +1,6 @@ import { updateSpaceField } from '../config'; import { assembleSpaceGraph } from '../load-space-graph'; -import { validatePluginConfig } from '../plugins/loader'; +import { resourceDirs, validatePluginConfig } from '../plugins/loader'; import { type PluginContext, type RenderResult, shortenPluginName } from '../plugins/util'; import { readSpace } from '../read/read-space'; import { exportSpaceGraph } from '../space-graph-data'; @@ -44,6 +44,7 @@ export async function executeRender( hierarchy: graph.hierarchy, hierarchies: context.schema.metadata.hierarchies, }), + resourceDirs: (kind) => resourceDirs(kind, context), }, }; return plugin.render!.render(pluginContext, graph, { format: entry.format.name, data: options.data }); diff --git a/tests/fixtures/render-plugins/.sctx/map-views/local.json5 b/tests/fixtures/render-plugins/.sctx/map-views/local.json5 new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/render-plugins/.sctx/map-views/local.json5 @@ -0,0 +1 @@ +{} diff --git a/tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts b/tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts index e6de94e..c0fab56 100644 --- a/tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts +++ b/tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts @@ -1,3 +1,4 @@ +import { basename, dirname } from 'node:path'; import type { StructuredContextPlugin } from '../../../../../src/plugins/util'; /** Renders what the render callbacks return, for tests. */ @@ -12,6 +13,9 @@ const probe: StructuredContextPlugin = { return JSON.stringify({ issues: issues.map(({ severity, nodeId }) => ({ severity, nodeId })), nodes: graph?.nodes.map((n) => n.title).sort(), + mapViews: context.callbacks + ?.resourceDirs?.('map-views') + .map(({ dir, plugin }) => ({ dir: basename(dirname(dir)), plugin })), }); }, }, diff --git a/tests/fixtures/render-plugins/.sctx/plugins/sctx-views/index.ts b/tests/fixtures/render-plugins/.sctx/plugins/sctx-views/index.ts new file mode 100644 index 0000000..6d8083e --- /dev/null +++ b/tests/fixtures/render-plugins/.sctx/plugins/sctx-views/index.ts @@ -0,0 +1,6 @@ +import type { StructuredContextPlugin } from '../../../../../../src/plugins/util'; + +/** A plugin that only ships resources (a map-views folder). */ +const views: StructuredContextPlugin = { name: 'sctx-views', configSchema: { type: 'object' } }; + +export default views; diff --git a/tests/fixtures/render-plugins/.sctx/plugins/sctx-views/map-views/views.json5 b/tests/fixtures/render-plugins/.sctx/plugins/sctx-views/map-views/views.json5 new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/fixtures/render-plugins/.sctx/plugins/sctx-views/map-views/views.json5 @@ -0,0 +1 @@ +{} diff --git a/tests/render/render-plugins.test.ts b/tests/render/render-plugins.test.ts index aa0883a..dd5b28c 100644 --- a/tests/render/render-plugins.test.ts +++ b/tests/render/render-plugins.test.ts @@ -14,6 +14,11 @@ describe('render with discovered plugins', () => { expect(output.issues).toContainEqual({ severity: 'error', nodeId: 'Missing status.md' }); }); + it('lists resource folders from config roots and plugins', async () => { + const output = JSON.parse((await executeRender('probe.callbacks', context, {})) as string); + expect(output.mapViews).toEqual([{ dir: '.sctx' }, { dir: 'sctx-views', plugin: 'views' }]); + }); + it('exports the filtered graph', async () => { const output = JSON.parse( (await executeRender('probe.callbacks', context, { filter: "WHERE status = 'exploring'" })) as string, From 76fe6b8347f3b805a1f9cf66cf9ce018ea3d74df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 03:11:16 +0000 Subject: [PATCH 2/4] feat(plugins): resourceDirs for every plugin hook Parse and template sync get callbacks.resourceDirs too; validate and exportGraph stay render-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- docs/api.md | 4 +++- src/commands/template-sync.ts | 7 +++++-- src/plugins/util.ts | 2 +- src/read/read-space.ts | 8 ++++++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/api.md b/docs/api.md index 02d8970..a246b06 100644 --- a/docs/api.md +++ b/docs/api.md @@ -122,6 +122,8 @@ folder holding one kind of resource for that root (such as `plugins` or `map-vie there is none. It follows the [resources folder](config.md#resources-folder) layout, so a tool that adds its own kind of resource finds it the same way. +A plugin hook finds these folders for its space, including other plugins' folders, with `context.callbacks.resourceDirs(kind)`. + ## Plugin render hooks Plugins declare render formats via `render.formats` and implement `render(context, graph, options)`. The hook may return a single string or multi-file output: @@ -142,5 +144,5 @@ const myRenderHook: RenderHook = { }; ``` -A render hook can validate the space, export the rendered graph and find resource folders (such as `map-views`) through `context.callbacks.validate()`, `exportGraph()` and `resourceDirs()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. +A render hook can validate the space and export the rendered graph through `context.callbacks.validate()` and `exportGraph()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. diff --git a/src/commands/template-sync.ts b/src/commands/template-sync.ts index 0527791..9c242da 100644 --- a/src/commands/template-sync.ts +++ b/src/commands/template-sync.ts @@ -1,4 +1,4 @@ -import { loadPlugins } from '../plugins/loader'; +import { loadPlugins, resourceDirs } from '../plugins/loader'; import type { TemplateSyncOptions } from '../plugins/util'; import type { SpaceContext } from '../types'; @@ -8,7 +8,10 @@ export async function templateSync(context: SpaceContext, options: TemplateSyncO for (const { plugin, pluginConfig } of loaded) { if (!plugin.templateSync) continue; - const result = await plugin.templateSync({ ...context, pluginConfig }, options); + const result = await plugin.templateSync( + { ...context, pluginConfig, callbacks: { resourceDirs: (kind) => resourceDirs(kind, context) } }, + options, + ); if (result !== null) return; } diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 5740771..32f3b66 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -38,7 +38,7 @@ export type PluginContext = SpaceContext & { ) => Promise; /** Render only: the graph being rendered, in the space graph format. */ exportGraph?: () => SpaceGraphData; - /** Render only: every folder of one kind of resource (such as `map-views`), with the plugin providing it if any. */ + /** Every folder of one kind of resource (such as `map-views`), with the plugin providing it if any. */ resourceDirs?: (kind: string) => Array<{ dir: string; plugin?: string }>; }; }; diff --git a/src/read/read-space.ts b/src/read/read-space.ts index 2196759..a3301bb 100644 --- a/src/read/read-space.ts +++ b/src/read/read-space.ts @@ -1,4 +1,4 @@ -import { loadPlugins } from '../plugins/loader'; +import { loadPlugins, resourceDirs } from '../plugins/loader'; import type { ReadSpaceResult, SpaceContext } from '../types'; import { resolveGraphEdges } from './resolve-graph-edges'; @@ -8,7 +8,11 @@ export async function readSpace(context: SpaceContext): Promise for (const { plugin, pluginConfig } of loaded) { if (!plugin.parse) continue; - const result = await plugin.parse({ ...context, pluginConfig }); + const result = await plugin.parse({ + ...context, + pluginConfig, + callbacks: { resourceDirs: (kind) => resourceDirs(kind, context) }, + }); if (result !== null) { const { nodes, unresolvedRefs, unresolvedContentLinks } = resolveGraphEdges( result.nodes, From f6f8d17b108b6df50d4c640af39fec61c0c1bb0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 03:20:08 +0000 Subject: [PATCH 3/4] refactor(plugins): callbacks stay in render; drop where-used comments Revert resourceDirs for parse and template sync. Remove the "Render only" notes from the callback types, since where callbacks are passed is decided by their callers. doc-review gains a check for comments that state a fact another module decides. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- .claude/skills/doc-review/SKILL.md | 2 ++ docs/api.md | 4 +--- src/commands/template-sync.ts | 7 ++----- src/plugins/util.ts | 4 ++-- src/read/read-space.ts | 8 ++------ 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/.claude/skills/doc-review/SKILL.md b/.claude/skills/doc-review/SKILL.md index 447d00b..9757e3d 100644 --- a/.claude/skills/doc-review/SKILL.md +++ b/.claude/skills/doc-review/SKILL.md @@ -40,6 +40,8 @@ Skills and references (`plugin/skills/**`), also: Code comments and messages: 11. Flag comments that restate code or explain history, and library errors that mention CLI flags. +12. Flag comments that state a fact another module decides, such as where a type is used ("render only") or a + rule enforced elsewhere. Name the owner, or leave it out. ## Output diff --git a/docs/api.md b/docs/api.md index a246b06..4d2bd3a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -122,8 +122,6 @@ folder holding one kind of resource for that root (such as `plugins` or `map-vie there is none. It follows the [resources folder](config.md#resources-folder) layout, so a tool that adds its own kind of resource finds it the same way. -A plugin hook finds these folders for its space, including other plugins' folders, with `context.callbacks.resourceDirs(kind)`. - ## Plugin render hooks Plugins declare render formats via `render.formats` and implement `render(context, graph, options)`. The hook may return a single string or multi-file output: @@ -144,5 +142,5 @@ const myRenderHook: RenderHook = { }; ``` -A render hook can validate the space and export the rendered graph through `context.callbacks.validate()` and `exportGraph()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. +A render hook can validate the space, export the rendered graph and find resource folders (including other plugins') through `context.callbacks.validate()`, `exportGraph()` and `resourceDirs()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. diff --git a/src/commands/template-sync.ts b/src/commands/template-sync.ts index 9c242da..0527791 100644 --- a/src/commands/template-sync.ts +++ b/src/commands/template-sync.ts @@ -1,4 +1,4 @@ -import { loadPlugins, resourceDirs } from '../plugins/loader'; +import { loadPlugins } from '../plugins/loader'; import type { TemplateSyncOptions } from '../plugins/util'; import type { SpaceContext } from '../types'; @@ -8,10 +8,7 @@ export async function templateSync(context: SpaceContext, options: TemplateSyncO for (const { plugin, pluginConfig } of loaded) { if (!plugin.templateSync) continue; - const result = await plugin.templateSync( - { ...context, pluginConfig, callbacks: { resourceDirs: (kind) => resourceDirs(kind, context) } }, - options, - ); + const result = await plugin.templateSync({ ...context, pluginConfig }, options); if (result !== null) return; } diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 32f3b66..2d60cf7 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -32,11 +32,11 @@ export type PluginContext = SpaceContext & { callbacks?: { /** Persist plugin config updates to the config file. */ persistConfig?: (updates: Record) => void; - /** Render only: validate the whole space and return every issue found, of every severity. */ + /** Validate the whole space and return every issue found, of every severity. */ validate?: ( options?: Pick, ) => Promise; - /** Render only: the graph being rendered, in the space graph format. */ + /** The graph being rendered, in the space graph format. */ exportGraph?: () => SpaceGraphData; /** Every folder of one kind of resource (such as `map-views`), with the plugin providing it if any. */ resourceDirs?: (kind: string) => Array<{ dir: string; plugin?: string }>; diff --git a/src/read/read-space.ts b/src/read/read-space.ts index a3301bb..2196759 100644 --- a/src/read/read-space.ts +++ b/src/read/read-space.ts @@ -1,4 +1,4 @@ -import { loadPlugins, resourceDirs } from '../plugins/loader'; +import { loadPlugins } from '../plugins/loader'; import type { ReadSpaceResult, SpaceContext } from '../types'; import { resolveGraphEdges } from './resolve-graph-edges'; @@ -8,11 +8,7 @@ export async function readSpace(context: SpaceContext): Promise for (const { plugin, pluginConfig } of loaded) { if (!plugin.parse) continue; - const result = await plugin.parse({ - ...context, - pluginConfig, - callbacks: { resourceDirs: (kind) => resourceDirs(kind, context) }, - }); + const result = await plugin.parse({ ...context, pluginConfig }); if (result !== null) { const { nodes, unresolvedRefs, unresolvedContentLinks } = resolveGraphEdges( result.nodes, From e51c30fc35c6ef287452aa6c513007237900923f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Nov 2026 03:49:28 +0000 Subject: [PATCH 4/4] refactor(plugins): rework loader and plugin resources; trim plugin docs Loader reads top-down: loadPlugins, discoverPlugins, validatePluginConfig, then private helpers. resourceDirs moves to plugins/util beside the other plugin-location helpers and uses the space's config root only. pluginResourceDir and spacePluginNames are private. No blind catches: a malformed plugin package.json throws. Docs: api.md drops what code comments and types already say; config.md keeps only config; concepts.md Plugin says what a plugin is and links to config. doc-review check 12 has no exception. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019XphpeFwEYm8K11SoT9ewz --- .claude/skills/doc-review/SKILL.md | 2 +- docs/api.md | 66 +++-------- docs/concepts.md | 13 +- docs/config.md | 10 +- src/plugins/loader.ts | 183 ++++++++++------------------- src/plugins/util.ts | 147 ++++++++++------------- src/render/render.ts | 6 +- 7 files changed, 145 insertions(+), 282 deletions(-) diff --git a/.claude/skills/doc-review/SKILL.md b/.claude/skills/doc-review/SKILL.md index 9757e3d..79647a2 100644 --- a/.claude/skills/doc-review/SKILL.md +++ b/.claude/skills/doc-review/SKILL.md @@ -41,7 +41,7 @@ Code comments and messages: 11. Flag comments that restate code or explain history, and library errors that mention CLI flags. 12. Flag comments that state a fact another module decides, such as where a type is used ("render only") or a - rule enforced elsewhere. Name the owner, or leave it out. + rule enforced elsewhere. Leave it out. ## Output diff --git a/docs/api.md b/docs/api.md index 4d2bd3a..d6133c3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,12 +15,12 @@ see [architecture.md](architecture.md#information-flow) for that flow. | `loadConfig()` | Load the resolved config (spaces, schemas) from the standard config locations. | | `createSpaceContext(spaceName, config, options?)` | Build a `SpaceContext` for a named space (resolves bundled, local, or plugin schemas). | | `readSpace(context)` | Read a space into a flat `ReadSpaceResult` (`nodes`, `parseIssues`, `diagnostics`, `source`). | -| `loadSpaceGraph(spaceName, config, options?)` | Read, assemble, and optionally filter a space into a navigable `SpaceGraph`. `options.hierarchy` names the hierarchy that structures the graph (default: the main hierarchy). | -| `assembleSpaceGraph(context, options?)` | Assemble (and optionally filter) a graph from a context you already hold — reuses the context instead of rebuilding it. | +| `loadSpaceGraph(spaceName, config, options?)` | Read, assemble, and optionally filter a space into a navigable `SpaceGraph`. | +| `assembleSpaceGraph(context, options?)` | Assemble (and optionally filter) a graph from a context you already hold. | | `buildSpaceGraph(nodes, hierarchy?)` | Build a navigable `SpaceGraph` directly from an array of `SpaceNode`s and a `Hierarchy` definition. | | `exportSpaceGraph(nodes, metadata)` | Turn nodes and the schema's hierarchies into plain JSON for another process. See [Space graph format](#space-graph-format). | -| `spaceGraphFrom(data, options?)` | Build a `SpaceGraph` from that JSON. `options.hierarchy` names the hierarchy that structures it (default: the main hierarchy). | -| `validateSpace(context, options?)` | Run full validation (schema, references, rules, duplicates) over a space. Semantic rules run only when `semantic` is passed; it takes the same values as `sctx validate --semantic`, with the provider coming from config and the environment. | +| `spaceGraphFrom(data, options?)` | Build a `SpaceGraph` from that JSON. | +| `validateSpace(context, options?)` | Run full validation (schema, references, rules, duplicates) over a space. | | `validateFile(filePath, config)` | Validate a single file within its space. | | `listSchemas(options?)` | List the schemas available to a config: bundled, plugin-contributed and configured. | | `formatWikilink(target, alias?)` | Format a target into an Obsidian wikilink (`[[target]]` or `[[target|alias]]`). | @@ -28,10 +28,7 @@ see [architecture.md](architecture.md#information-flow) for that flow. | `wikilinkToTarget(link)` | Extract the plain target lookup key from a wikilink string. | | `buildTargetIndex(nodes)` | Build a fast lookup index mapping link target strings to nodes (marking ambiguous targets as `null`). | -`ValidationResult.issues` lists every [validation issue](concepts.md#validation-results-and-nodes) the run found, and `validateFile` returns one node's issues. Only `error` issues are failures. Read each issue's `severity` rather than deriving it from `kind`. `VALIDATION_ISSUE_KINDS` lists the kinds at runtime. - -A `SpaceGraph` classifies nodes against one hierarchy, and indexes child nodes and typed edges in both -directions. +Read each [validation issue](concepts.md#validation-results-and-nodes)'s `severity` rather than deriving it from `kind`. ## Space graph format @@ -75,30 +72,19 @@ import { const config = loadConfig(); -// Common path: read, assemble, and filter in one call. -// `filter` resolves a named view from the space config, falling back to a raw filter expression. const graph = await loadSpaceGraph('my-space', config, { filter: 'my-view' }); -// Read once, then assemble a graph and validate from the same read and context. -// Reusing the context avoids re-reading the space *and* recompiling the schema. +// Read once; assemble and validate from the same read. const context = createSpaceContext('my-space', config); const readResult = await readSpace(context); const sharedGraph = await assembleSpaceGraph(context, { readResult }); const validation = await validateSpace(context, { readResult }); ``` -## Validation vs. graph assembly - -`loadSpaceGraph` keeps every node whose type the schema defines, including nodes with field errors -such as an invalid enum value. Nodes of an unknown type are left out. The graph is *not* a validation -pass: use `validateSpace` (or `validateFile`) for field errors, broken references, rule violations and -duplicates. - ## Hand-assembled config -`config` does not have to come from `loadConfig()` — you can construct a `Config` in memory and pass -it to `createSpaceContext`, `loadSpaceGraph`, or `validateFile`, the same way the CLI does. Provide -each space's `path` and a resolvable `schema` (absolute paths are simplest): +A `Config` can be built in memory. Pass `configDir` with it, since relative paths in plugin config resolve from the +[config root](config.md#config-file-location) and an in-memory config has none: ```ts import { loadSpaceGraph, type Config } from 'structured-context/api'; @@ -109,38 +95,14 @@ const config: Config = { const graph = await loadSpaceGraph('my-space', config, { configDir: process.cwd() }); ``` -Relative paths in a space's plugin config are resolved against the [config root](config.md#config-file-location) -of the file that defined the space. A hand-assembled `Config` has no such file, so you must pass an explicit -`configDir` to anchor them (`createSpaceContext`, `loadSpaceGraph`, and `validateFile` all accept it). -Omitting it throws rather than silently guessing the working directory — prefer absolute paths so the -anchor never matters. - ## Resources -`resolveConfigPath()` returns the config's `root` along with its path. `resourceDir(root, name)` returns the -folder holding one kind of resource for that root (such as `plugins` or `map-views`), or `undefined` when -there is none. It follows the [resources folder](config.md#resources-folder) layout, so a tool that adds its -own kind of resource finds it the same way. - -## Plugin render hooks +`resourceDir(root, name)` finds one kind of resource (such as `plugins` or `map-views`) for a config root, by +the [resources folder](config.md#resources-folder) layout. A tool that adds its own kind of resource uses it too. -Plugins declare render formats via `render.formats` and implement `render(context, graph, options)`. The hook may return a single string or multi-file output: +A plugin ships its own resources in `//`, or in the folder its `package.json` names under +`"sctx": { "": "…" }`. Plugin schemas use the `schemas` kind. -```ts -import type { RenderHook, RenderResult, RenderMultiFileOutput } from 'structured-context/api'; - -const myRenderHook: RenderHook = { - formats: [{ name: 'my-format', description: 'Outputs multiple files' }], - render(context, graph, options) { - return { - files: [ - { path: 'section-1.md', content: '...' }, - { path: 'subfolder/section-2.md', content: '...' }, - ], - }; - }, -}; -``` - -A render hook can validate the space, export the rendered graph and find resource folders (including other plugins') through `context.callbacks.validate()`, `exportGraph()` and `resourceDirs()`. Pass `semantic` to `validate` only when the render run asks for it, never from plugin config. +## Plugin render hooks +A plugin adds formats to `sctx render` with a `render` hook (`RenderHook`). diff --git a/docs/concepts.md b/docs/concepts.md index 652733b..75d3176 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -472,16 +472,7 @@ Status is required on all node types at _validation_ time. Note however that cur ## Plugin -A **plugin** is a module that extends the tool's capabilities. - -Plugins that support parsing produce raw `SpaceNode[]` results given a suitable configuration. Graph edge resolution (`resolveGraphEdges`) is called by the core afterwards. - -The `plugins` field in config is a **map** from plugin name to plugin config object. All plugin names must start with `sctx-`, but the prefix is optional in config and normalised on load. Built-in plugins take precedence and all are loaded by default. External plugin names specified in config are then resolved in order: - -1. The config root's plugins folder: `.sctx/plugins/{sctx-name}` -2. npm: a package matching `sctx-*` - -Each plugin declares a `configSchema` JSON Schema; the loader validates the config block against it before invoking the plugin. Config fields annotated with `format: 'path'` are resolved relative to the config root by the loader. +A **plugin** is a module that reads spaces from a source (parse), syncs templates, or renders a space in a format. The `plugins` config names the plugins a space uses and holds each one's config. See [Plugins](config.md#plugins). ### Config root @@ -489,7 +480,7 @@ The **config root** is the folder a config file's relative paths resolve from: t ### Dispatch contract -Plugins are called in order (external plugins first, then built-ins). For each operation: +For parse and template sync, plugins are called in order: those named in config first, then the built-ins. - A plugin that does not implement the hook is skipped. - A plugin that implements the hook returns `T | null`: non-null means "I handled it"; null means "not me, try the next plugin." - The first non-null result wins. If no plugin handles the operation, the command throws an error. diff --git a/docs/config.md b/docs/config.md index 283a48e..5bd3893 100644 --- a/docs/config.md +++ b/docs/config.md @@ -69,7 +69,7 @@ A config must define at least one of `spaces` or `includeSpacesFrom`. When using ## Plugins -Use `plugins` to load parse plugins that read spaces from non-markdown sources. The built-in markdown plugin is always available without any declaration. Plugins are tried in order; the first to return a result wins. The `plugins` field is a map of plugin name to plugin config, and can be declared at the top level (applies to all spaces) or per-space (overrides the top level): +`plugins` maps a plugin name to its config. Declare it at the top level for every space, or on a space to override the top level: ```json { @@ -83,14 +83,14 @@ Use `plugins` to load parse plugins that read spaces from non-markdown sources. } ], "plugins": { - "sctx-confluence": { "baseUrl": "https://example.atlassian.net" } + "sctx-example": { "baseUrl": "https://example.com" } } } ``` -All plugin names must start with `sctx-` (the prefix is optional in config and normalised on load). The special name `markdown` refers to the built-in markdown plugin. A plugin named in `plugins` is resolved in order: the root's plugins folder (`.sctx/plugins/{name}`, see [Resources folder](#resources-folder)), then npm. Each plugin must export a `configSchema` JSON Schema; config is validated against it on load. Fields annotated `format: 'path'` in a plugin's `configSchema` are resolved relative to the config root. +Plugin names start with `sctx-`, which config may leave out. `markdown` is the built-in markdown plugin. A named plugin is found in the root's plugins folder (`.sctx/plugins/`), else as an installed package. Its config is checked against the plugin's schema, and relative paths in it resolve from the config root. -A plugin comes from one of three places, and where it comes from decides when it is used: +Where a plugin comes from decides when it is used: | Plugin | Parse and template sync | Render formats | |---|---|---| @@ -98,8 +98,6 @@ A plugin comes from one of three places, and where it comes from decides when it | In a plugins folder (`.sctx/plugins/sctx-*`) | Spaces that name it in `plugins` | Every space | | Installed package (`sctx-*`) | Spaces that name it in `plugins` | Spaces that name it in `plugins` | -Plugins can also contribute schemas and partials via a `/schemas/` directory (or `"sctx": { "schemas": "..." }` in `package.json`). Other resources follow the same pattern. For example, map view definitions go in `/map-views/`, or the folder named by `"sctx": { "map-views": "..." }`. If a space specifies a plugin-prefixed schema (e.g. `"sctx-wardley-mapping/wardley_map.json"`), schemas are resolved statically without executing plugin code, and the plugin's code hooks are loaded when parsing or rendering. - ## Model providers and semantic rules structured-context evaluates [semantic rules](semantic-rules.md) with a **model provider**: a named definition that says which wire API to speak, which model to use and where. A provider definition combines an `api`, explicit values or environment-variable names, and the key source. diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 7dfe6eb..9a34355 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -4,131 +4,63 @@ import type { AnySchemaObject } from 'ajv'; import Ajv2020 from 'ajv/dist/2020'; import { getConfigSourceFiles } from '../config'; import { configRoot, resourceDir } from '../config/layout'; -import type { SpaceContext } from '../types'; import { builtinPlugins } from '.'; import { isBuiltinPluginName } from './builtin-names'; import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, - pluginResourceDir, - resolvePluginDir, type StructuredContextPlugin, shortenPluginName, - spacePluginNames, } from './util'; +type PluginMap = Record>; + export type LoadedPlugin = { plugin: StructuredContextPlugin; pluginConfig: Record; }; -/** - * Walk a plugin's configSchema and resolve any string fields annotated with - * format:'path' relative to configDir. - */ -function resolveConfigPaths( - schema: AnySchemaObject, - config: Record, - configDir: string, -): Record { - const props = schema.properties as Record | undefined; - if (!props) return config; - const result = { ...config }; - for (const [key, propSchema] of Object.entries(props)) { - if (propSchema.format === 'path' && typeof result[key] === 'string') { - const value = result[key] as string; - if (!isAbsolute(value)) { - result[key] = resolve(configDir, value); - } - } - } - return result; -} - -/** Import a plugin by its `sctx-` name: from the config root's plugins folder if it is there, else as a package. */ -async function importPlugin(name: string, configDir: string): Promise { - const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); - const localPath = pluginsDir && resolve(join(pluginsDir, name)); - const local = localPath && (existsSync(localPath) || existsSync(`${localPath}.ts`)); - const module = local ? await import(localPath) : await import(name); - const plugin = (module as { default?: StructuredContextPlugin }).default ?? (module as StructuredContextPlugin); - if (!plugin || typeof plugin.name !== 'string') { - throw new Error(`Plugin "${name}" must export a StructuredContextPlugin as its default export`); - } - return plugin; -} - -/** - * The plugins a space uses: the ones its plugin map names, in map order, then the built-ins. - * A named plugin resolves from the config root's plugins folder, then npm. - */ -async function spacePlugins( - pluginMap: Record>, - configDir: string, -): Promise { - const named: StructuredContextPlugin[] = []; - for (const rawName of Object.keys(pluginMap)) { - const name = normalizePluginName(rawName); - if (isBuiltinPluginName(name)) continue; - if (!name.startsWith(PLUGIN_PREFIX)) { - throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`); - } - named.push(await importPlugin(name, configDir)); - } - return [...named, ...builtinPlugins]; -} - -/** The roots of the loaded config files (the main config and the configs it includes), after `configDir` if given. */ -function configRoots(configDir?: string): string[] { - const loaded = Array.from(getConfigSourceFiles()).map((f) => configRoot(f)); - return [...new Set(configDir ? [configDir, ...loaded] : loaded)]; -} - -/** The names of the plugins in a config root's plugins folder. */ -function pluginsFolderEntries(configDir: string): string[] { - const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); - return pluginsDir ? readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX)) : []; +/** The plugins a space uses, each with its validated config, in the order parse and templateSync try them. */ +export async function loadPlugins(pluginMap: PluginMap, configDir: string): Promise { + return [...(await namedPlugins(pluginMap, configDir)), ...builtinPlugins].map((plugin) => ({ + plugin, + pluginConfig: validatePluginConfig(plugin, pluginMap, configDir), + })); } -/** - * Discover all available plugins: the built-ins, then any found in the plugins folder of - * each loaded config file's root (the main config and the configs it includes). - * Given a space, starts from the plugins it uses instead of the built-ins, and also searches - * its config root's plugins folder. - */ +/** Every available plugin: the built-ins, the plugins a space names, and those in config roots' plugins folders. */ export async function discoverPlugins(space?: { - pluginMap: Record>; + pluginMap: PluginMap; configDir: string; }): Promise { - const plugins = space ? await spacePlugins(space.pluginMap, space.configDir) : [...builtinPlugins]; - const seenNames = new Set(plugins.map((p) => p.name)); + const plugins = [...builtinPlugins, ...(space ? await namedPlugins(space.pluginMap, space.configDir) : [])]; + const names = new Set(plugins.map((p) => p.name)); + const roots = new Set([...(space ? [space.configDir] : []), ...[...getConfigSourceFiles()].map(configRoot)]); - for (const configDir of configRoots(space?.configDir)) { - for (const entry of pluginsFolderEntries(configDir)) { - if (seenNames.has(entry)) continue; - const plugin = await importPlugin(entry, configDir); - if (seenNames.has(plugin.name)) continue; + for (const root of roots) { + const pluginsDir = resourceDir(root, CONFIG_PLUGINS_DIR); + for (const entry of pluginsDir ? readdirSync(pluginsDir) : []) { + if (!entry.startsWith(PLUGIN_PREFIX) || names.has(entry)) continue; + const plugin = await importPlugin(entry, root); + if (names.has(plugin.name)) continue; plugins.push(plugin); - seenNames.add(plugin.name); + names.add(plugin.name); } } return plugins; } -/** - * A plugin's config block from the space's plugin map ({} if absent), validated against its - * configSchema. Fields annotated with format:'path' are resolved relative to configDir first. - */ +/** A plugin's config block from a plugin map ({} if absent), validated against its configSchema. */ export function validatePluginConfig( plugin: StructuredContextPlugin, - pluginMap: Record>, + pluginMap: PluginMap, configDir: string, ): Record { const rawConfig = pluginMap[plugin.name] ?? pluginMap[shortenPluginName(plugin.name)] ?? {}; + const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir); const ajv = new Ajv2020(); ajv.addFormat('path', (value: string) => value.length > 0 && !value.includes('\0')); - const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir); const validate = ajv.compile(plugin.configSchema); if (!validate(pluginConfig)) { throw new Error(`Invalid config for plugin "${plugin.name}": ${JSON.stringify(validate.errors)}`); @@ -136,37 +68,46 @@ export function validatePluginConfig( return pluginConfig; } -/** The plugins a space uses, each with its validated config. Parse and templateSync try them in this order. */ -export async function loadPlugins( - pluginMap: Record>, - configDir: string, -): Promise { - return (await spacePlugins(pluginMap, configDir)).map((plugin) => ({ - plugin, - pluginConfig: validatePluginConfig(plugin, pluginMap, configDir), - })); +/** The non-built-in plugins a plugin map names, in map order. */ +async function namedPlugins(pluginMap: PluginMap, configDir: string): Promise { + const plugins: StructuredContextPlugin[] = []; + for (const rawName of Object.keys(pluginMap)) { + const name = normalizePluginName(rawName); + if (isBuiltinPluginName(name)) continue; + if (!name.startsWith(PLUGIN_PREFIX)) { + throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`); + } + plugins.push(await importPlugin(name, configDir)); + } + return plugins; } -/** Every folder of one kind of resource available to a space: config roots' first, then plugins' (named by the space or in a plugins folder). */ -export function resourceDirs(kind: string, context: SpaceContext): Array<{ dir: string; plugin?: string }> { - const roots = configRoots(context.configDir); - const pluginDirs = new Map(); - const addPlugin = (name: string, fromDir: string) => { - const short = shortenPluginName(name); - const dir = !pluginDirs.has(short) && resolvePluginDir(name, fromDir); - if (dir) pluginDirs.set(short, dir); - }; - for (const root of roots) for (const entry of pluginsFolderEntries(root)) addPlugin(entry, root); - for (const name of spacePluginNames(context.space, context.config)) addPlugin(name, context.configDir); +/** Import a plugin by name from the config root's plugins folder, else as a package. */ +async function importPlugin(name: string, configDir: string): Promise { + const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); + const localPath = pluginsDir && resolve(join(pluginsDir, name)); + const local = localPath && (existsSync(localPath) || existsSync(`${localPath}.ts`)); + const module = local ? await import(localPath) : await import(name); + const plugin = (module as { default?: StructuredContextPlugin }).default ?? (module as StructuredContextPlugin); + if (!plugin || typeof plugin.name !== 'string') { + throw new Error(`Plugin "${name}" must export a StructuredContextPlugin as its default export`); + } + return plugin; +} - return [ - ...roots.flatMap((root) => { - const dir = resourceDir(root, kind); - return dir ? [{ dir }] : []; - }), - ...[...pluginDirs].flatMap(([plugin, pluginDir]) => { - const dir = pluginResourceDir(pluginDir, kind); - return dir ? [{ dir, plugin }] : []; - }), - ]; +function resolveConfigPaths( + schema: AnySchemaObject, + config: Record, + configDir: string, +): Record { + const props = schema.properties as Record | undefined; + if (!props) return config; + const result = { ...config }; + for (const [key, propSchema] of Object.entries(props)) { + const value = result[key]; + if (propSchema.format === 'path' && typeof value === 'string' && !isAbsolute(value)) { + result[key] = resolve(configDir, value); + } + } + return result; } diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 2d60cf7..ea1cc60 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import type { AnySchemaObject } from 'ajv'; @@ -127,109 +127,58 @@ export type StructuredContextPlugin = { render?: RenderHook; }; -/** - * Synchronously locate the root directory of an external plugin. - * Resolution order: - * 1. The config root's plugins folder: /{name} or /sctx-{name} - * 2. Node module: resolve from configDir using standard module resolution - */ +function isDirectory(path: string): boolean { + return existsSync(path) && statSync(path).isDirectory(); +} + +/** A plugin's root directory: in the config root's plugins folder, else by module resolution from configDir. */ export function resolvePluginDir(pluginName: string, configDir: string): string | undefined { - const normalized = normalizePluginName(pluginName); - const short = shortenPluginName(pluginName); + const candidates = [normalizePluginName(pluginName), shortenPluginName(pluginName)]; const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); - for (const candidate of pluginsDir ? [normalized, short] : []) { - const localDir = resolve(pluginsDir!, candidate); - if (existsSync(localDir)) { - try { - if (statSync(localDir).isDirectory()) { - return localDir; - } - } catch {} - } - } + const local = pluginsDir && candidates.map((c) => resolve(pluginsDir, c)).find(isDirectory); + if (local) return local; - // Node module resolution from configDir - for (const candidate of [normalized, short]) { + const req = createRequire(join(configDir, 'package.json')); + for (const candidate of candidates) { try { - const req = createRequire(join(configDir, 'package.json')); - const pkgPath = req.resolve(`${candidate}/package.json`); - return dirname(pkgPath); + return dirname(req.resolve(`${candidate}/package.json`)); } catch { - const nmCandidate = resolve(configDir, 'node_modules', candidate); - if (existsSync(nmCandidate)) { - try { - if (statSync(nmCandidate).isDirectory()) { - return nmCandidate; - } - } catch {} - } + // Not resolvable as a package; a package without a package.json export may still sit in node_modules. + const installed = resolve(configDir, 'node_modules', candidate); + if (isDirectory(installed)) return installed; } } - return undefined; } -/** A plugin's folder for one kind of resource: `"sctx": { "": "…" }` in its package.json, else `/`. */ -export function pluginResourceDir(pluginDir: string, kind: string): string | undefined { - const isDir = (path: string) => existsSync(path) && statSync(path).isDirectory(); +/** A plugin's folder for one kind of resource: the folder its package.json names under `sctx`, else `/`. */ +function pluginResourceDir(pluginDir: string, kind: string): string | undefined { const pkgJsonPath = join(pluginDir, 'package.json'); - if (existsSync(pkgJsonPath)) { - try { - const declared = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).sctx?.[kind]; - if (typeof declared === 'string' && isDir(resolve(pluginDir, declared))) return resolve(pluginDir, declared); - } catch {} - } - const conventionDir = join(pluginDir, kind); - return isDir(conventionDir) ? conventionDir : undefined; + const named = existsSync(pkgJsonPath) ? JSON.parse(readFileSync(pkgJsonPath, 'utf8')).sctx?.[kind] : undefined; + const dir = typeof named === 'string' ? resolve(pluginDir, named) : join(pluginDir, kind); + return isDirectory(dir) ? dir : undefined; } -/** - * Synchronously locate the schemas directory for a plugin: its schemas resource folder - * (see {@link pluginResourceDir}), else the plugins folder's `schemas/` (for test fixtures). - */ +/** A plugin's schemas folder, else the plugins folder's `schemas/` (used by test fixtures). */ export function resolvePluginSchemasDir(pluginName: string, configDir: string): string | undefined { const dir = resolvePluginDir(pluginName, configDir); - const schemasDir = dir && pluginResourceDir(dir, SCHEMAS_RESOURCE); - if (schemasDir) return schemasDir; - - // 3. Check the plugins folder's schemas as fallback (e.g. test fixtures) + const own = dir && pluginResourceDir(dir, SCHEMAS_RESOURCE); + if (own) return own; const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); - const fallbackSchemas = pluginsDir && resolve(pluginsDir, 'schemas'); - if (fallbackSchemas && existsSync(fallbackSchemas)) { - try { - if (statSync(fallbackSchemas).isDirectory()) { - return fallbackSchemas; - } - } catch {} - } - - return undefined; + const shared = pluginsDir && resolve(pluginsDir, SCHEMAS_RESOURCE); + return shared && isDirectory(shared) ? shared : undefined; } -/** - * The plugins a space names, in order: the plugin prefixing its schema (e.g. `sctx-wardley-mapping/wardley_map.json`), - * then those configured on the space, then those configured globally. Built-ins are left out. - */ -export function spacePluginNames(space: SpaceConfig | undefined, config: Config): string[] { - const names: string[] = []; - const rawSchema = space?.schema ?? config.schema; - if (rawSchema && !isAbsolute(rawSchema) && !rawSchema.startsWith('.')) { - const normalized = rawSchema.replace(/\\/g, '/'); - const slashIdx = normalized.indexOf('/'); - if (slashIdx !== -1) names.push(normalized.slice(0, slashIdx)); - } - names.push(...Object.keys(space?.plugins ?? {})); - names.push(...Object.keys((config as { plugins?: Record }).plugins ?? {})); - // Built-in plugins ship no resources. Resolving one as a package can query the npm registry or find an unrelated - // package of the same short name (such as `markdown`). - const seen = new Set(); - return names.filter((name) => { - const short = shortenPluginName(name); - if (isBuiltinPluginName(name) || seen.has(short)) return false; - seen.add(short); - return true; - }); +/** The non-built-in plugins a space names, by schema prefix or in `plugins`. */ +function spacePluginNames(space: SpaceConfig | undefined, config: Config): string[] { + const schema = (space?.schema ?? config.schema)?.replace(/\\/g, '/'); + const schemaPlugin = + schema && !isAbsolute(schema) && !schema.startsWith('.') && schema.includes('/') ? [schema.split('/')[0]!] : []; + const topLevel = (config as { plugins?: Record }).plugins ?? {}; + const names = [...schemaPlugin, ...Object.keys(space?.plugins ?? {}), ...Object.keys(topLevel)]; + // Resolving a built-in as a package can query the npm registry or find an unrelated package such as `markdown`. + return [...new Set(names.map(shortenPluginName))].filter((name) => !isBuiltinPluginName(name)); } /** Synchronously collect all plugin schema sources available to a space. */ @@ -238,8 +187,30 @@ export function resolveSpacePluginSchemas( config: Config, configDir: string, ): PluginSchemaSource[] { - return spacePluginNames(space, config).flatMap((name) => { - const schemasDir = resolvePluginSchemasDir(name, configDir); - return schemasDir ? [{ pluginName: shortenPluginName(name), schemasDir }] : []; + return spacePluginNames(space, config).flatMap((pluginName) => { + const schemasDir = resolvePluginSchemasDir(pluginName, configDir); + return schemasDir ? [{ pluginName, schemasDir }] : []; }); } + +/** Every folder of one kind of resource for a space: its config root's first, then its plugins'. */ +export function resourceDirs(context: SpaceContext, kind: string): Array<{ dir: string; plugin?: string }> { + const { configDir } = context; + const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR); + const folderPlugins = pluginsDir + ? readdirSync(pluginsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory() && e.name.startsWith(PLUGIN_PREFIX)) + .map((e) => shortenPluginName(e.name)) + : []; + const plugins = [...new Set([...folderPlugins, ...spacePluginNames(context.space, context.config)])]; + + const own = resourceDir(configDir, kind); + return [ + ...(own ? [{ dir: own }] : []), + ...plugins.flatMap((plugin) => { + const pluginDir = resolvePluginDir(plugin, configDir); + const dir = pluginDir && pluginResourceDir(pluginDir, kind); + return dir ? [{ dir, plugin }] : []; + }), + ]; +} diff --git a/src/render/render.ts b/src/render/render.ts index 696524f..dedc7aa 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,7 +1,7 @@ import { updateSpaceField } from '../config'; import { assembleSpaceGraph } from '../load-space-graph'; -import { resourceDirs, validatePluginConfig } from '../plugins/loader'; -import { type PluginContext, type RenderResult, shortenPluginName } from '../plugins/util'; +import { validatePluginConfig } from '../plugins/loader'; +import { type PluginContext, type RenderResult, resourceDirs, shortenPluginName } from '../plugins/util'; import { readSpace } from '../read/read-space'; import { exportSpaceGraph } from '../space-graph-data'; import type { SpaceContext } from '../types'; @@ -44,7 +44,7 @@ export async function executeRender( hierarchy: graph.hierarchy, hierarchies: context.schema.metadata.hierarchies, }), - resourceDirs: (kind) => resourceDirs(kind, context), + resourceDirs: (kind) => resourceDirs(context, kind), }, }; return plugin.render!.render(pluginContext, graph, { format: entry.format.name, data: options.data });