diff --git a/.claude/skills/doc-review/SKILL.md b/.claude/skills/doc-review/SKILL.md index 447d00b..79647a2 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. Leave it out. ## Output diff --git a/docs/api.md b/docs/api.md index 818fd7c..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 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. +## 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 1cbe546..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`). 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 d651c41..9a34355 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -14,110 +14,53 @@ import { shortenPluginName, } 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 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)]); - 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 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); + 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)}`); @@ -125,13 +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>, +/** 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; +} + +/** 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; +} + +function resolveConfigPaths( + schema: AnySchemaObject, + config: Record, configDir: string, -): Promise { - return (await spacePlugins(pluginMap, configDir)).map((plugin) => ({ - plugin, - pluginConfig: validatePluginConfig(plugin, pluginMap, configDir), - })); +): 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 510a09e..ea1cc60 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -1,9 +1,9 @@ -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'; 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'; @@ -32,12 +32,14 @@ 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 }>; }; }; @@ -125,151 +127,90 @@ 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; } -/** - * Synchronously locate the schemas directory for a plugin. - * Convention: - * 1. package.json "sctx": { "schemas": "..." } - * 2. Convention: /schemas - * 3. Fallback for test fixtures: /schemas - */ +/** 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'); + 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; +} + +/** 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); - 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 {} - } - } - - // 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 {} - } + const shared = pluginsDir && resolve(pluginsDir, SCHEMAS_RESOURCE); + return shared && isDirectory(shared) ? shared : undefined; +} - return undefined; +/** 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. - */ +/** Synchronously collect all plugin schema sources available to a space. */ 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) - 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); - } - } + return spacePluginNames(space, config).flatMap((pluginName) => { + const schemasDir = resolvePluginSchemasDir(pluginName, configDir); + return schemasDir ? [{ pluginName, schemasDir }] : []; + }); +} - return pluginSources; +/** 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 c53fedb..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 { validatePluginConfig } from '../plugins/loader'; -import { type PluginContext, type RenderResult, shortenPluginName } from '../plugins/util'; +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,6 +44,7 @@ export async function executeRender( hierarchy: graph.hierarchy, hierarchies: context.schema.metadata.hierarchies, }), + resourceDirs: (kind) => resourceDirs(context, kind), }, }; 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,