Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +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.

10 changes: 9 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,15 @@ Use `plugins` to load parse plugins that read spaces from non-markdown sources.
}
```

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. External plugins are 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.
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.

A plugin comes from one of three places, and where it comes from decides when it is used:

| Plugin | Parse and template sync | Render formats |
|---|---|---|
| Built-in | Every space | Every space |
| 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 `<pluginRoot>/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.

Expand Down
14 changes: 2 additions & 12 deletions src/commands/render.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { discoverPlugins, type LoadedPlugin, loadPlugins } from '../plugins/loader';
import { isRenderMultiFileOutput } from '../plugins/util';
import { buildFormatRegistry } from '../render/registry';
import { renderFormats } from '../render/registry';
import { executeRender } from '../render/render';
import type { SpaceContext } from '../types';

Expand Down Expand Up @@ -89,16 +88,7 @@ export async function render(
}

export async function renderList(context?: SpaceContext): Promise<void> {
let loaded: LoadedPlugin[];
if (context) {
const pluginMap: Record<string, Record<string, unknown>> = context.space?.plugins ?? {};
loaded = await loadPlugins(pluginMap, context.configDir);
} else {
const discovered = await discoverPlugins();
loaded = discovered.map((plugin) => ({ plugin, pluginConfig: {} }));
}

const registry = buildFormatRegistry(loaded);
const registry = await renderFormats(context);

if (registry.length === 0) {
console.log('No render formats available.');
Expand Down
113 changes: 59 additions & 54 deletions src/plugins/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,8 @@ function resolveConfigPaths(
return result;
}

/**
* Resolve an external plugin by canonical name.
* Resolution order: the config root's plugins folder → npm (import(name)).
*/
async function resolveExternalPlugin(name: string, configDir: string): Promise<StructuredContextPlugin> {
/** 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<StructuredContextPlugin> {
const pluginsDir = resourceDir(configDir, CONFIG_PLUGINS_DIR);
const localPath = pluginsDir && resolve(join(pluginsDir, name));
const local = localPath && (existsSync(localPath) || existsSync(`${localPath}.ts`));
Expand All @@ -59,74 +56,82 @@ async function resolveExternalPlugin(name: string, configDir: string): Promise<S
}

/**
* Discover all available plugins: built-ins first, then any found in the plugins folder of
* each loaded config file's root. Does not load npm plugins
* (those are only declared in space configs).
* 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.
*/
export async function discoverPlugins(): Promise<StructuredContextPlugin[]> {
const configDirs = [...new Set(Array.from(getConfigSourceFiles()).map((f) => configRoot(f)))];
async function spacePlugins(
pluginMap: Record<string, Record<string, unknown>>,
configDir: string,
): Promise<StructuredContextPlugin[]> {
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];
}

const plugins: StructuredContextPlugin[] = [...builtinPlugins];
const seenNames = new Set(builtinPlugins.map((p) => p.name));
/**
* 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.
*/
export async function discoverPlugins(space?: {
pluginMap: Record<string, Record<string, unknown>>;
configDir: string;
}): Promise<StructuredContextPlugin[]> {
const plugins = space ? await spacePlugins(space.pluginMap, space.configDir) : [...builtinPlugins];
const seenNames = new Set(plugins.map((p) => p.name));

for (const configDir of configDirs) {
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)) {
plugins.push(await resolveExternalPlugin(entry, configDir));
seenNames.add(entry);
}
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;
}

/**
* Load plugins for a space.
*
* Built-in plugins are always included (with config from the map if declared, else {}).
* External plugins are loaded from the map and prepended in declaration order.
* Resolution order for external plugins: config-adjacent → npm.
* Fields annotated with format:'path' in a plugin's configSchema are resolved
* relative to configDir.
* 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.
*/
export async function loadPlugins(
export function validatePluginConfig(
plugin: StructuredContextPlugin,
pluginMap: Record<string, Record<string, unknown>>,
configDir: string,
): Promise<LoadedPlugin[]> {
): Record<string, unknown> {
const rawConfig = pluginMap[plugin.name] ?? pluginMap[shortenPluginName(plugin.name)] ?? {};
const ajv = new Ajv2020();
ajv.addFormat('path', (value: string) => value.length > 0 && !value.includes('\0'));
const loaded: LoadedPlugin[] = [];

// External plugins: entries in the map that are not built-in names
for (const [rawName, rawConfig] of Object.entries(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}")`);
}
const plugin = await resolveExternalPlugin(name, configDir);
const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir);
const validate = ajv.compile(plugin.configSchema);
if (!validate(pluginConfig)) {
throw new Error(`Invalid config for plugin "${name}": ${JSON.stringify(validate.errors)}`);
}
loaded.push({ plugin, pluginConfig });
}

// Built-in plugins: always loaded, config taken from map if declared (with or without prefix)
for (const builtin of builtinPlugins) {
const rawConfig = pluginMap[builtin.name] ?? pluginMap[shortenPluginName(builtin.name)] ?? {};
const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir);
const validate = ajv.compile(builtin.configSchema);
if (!validate(pluginConfig)) {
throw new Error(`Invalid config for plugin "${builtin.name}": ${JSON.stringify(validate.errors)}`);
}
loaded.push({ plugin: builtin, pluginConfig });
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)}`);
}
return pluginConfig;
}

return loaded;
/** The plugins a space uses, each with its validated config. Parse and templateSync try them in this order. */
export async function loadPlugins(
pluginMap: Record<string, Record<string, unknown>>,
configDir: string,
): Promise<LoadedPlugin[]> {
return (await spacePlugins(pluginMap, configDir)).map((plugin) => ({
plugin,
pluginConfig: validatePluginConfig(plugin, pluginMap, configDir),
}));
}
10 changes: 9 additions & 1 deletion src/plugins/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { resourceDir } from '../config/layout';
import { PLUGIN_PREFIX as _PLUGIN_PREFIX } from '../constants';
import type { PluginSchemaSource } from '../schema/schema';
import type { SpaceGraph } from '../space-graph';
import type { BaseNode, ParseIssue, SpaceContext } from '../types';
import type { SpaceGraphData } from '../space-graph-data';
import type { BaseNode, ParseIssue, SpaceContext, ValidationIssue } from '../types';
import type { ValidateSpaceOptions } from '../validate';
import { isBuiltinPluginName } from './builtin-names';

export const PLUGIN_PREFIX = _PLUGIN_PREFIX;
Expand All @@ -30,6 +32,12 @@ export type PluginContext = SpaceContext & {
callbacks?: {
/** Persist plugin config updates to the config file. */
persistConfig?: (updates: Record<string, string>) => void;
/** Render only: validate the whole space and return every issue found, of every severity. */
validate?: (
options?: Pick<ValidateSpaceOptions, 'includeGated' | 'ruleFilter' | 'semantic' | 'depth'>,
) => Promise<ValidationIssue[]>;
/** Render only: the graph being rendered, in the space graph format. */
exportGraph?: () => SpaceGraphData;
};
};

Expand Down
31 changes: 17 additions & 14 deletions src/render/registry.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import type { LoadedPlugin } from '../plugins/loader';
import type { RenderFormat } from '../plugins/util';
import { discoverPlugins } from '../plugins/loader';
import type { RenderFormat, StructuredContextPlugin } from '../plugins/util';
import { PLUGIN_PREFIX } from '../plugins/util';
import type { SpaceContext } from '../types';

export type ResolvedFormat = {
qualifiedName: string;
format: RenderFormat;
plugin: LoadedPlugin;
plugin: StructuredContextPlugin;
};

/**
* Build a registry of all render formats from loaded plugins.
* Build a registry of all render formats from the given plugins.
* Formats are namespaced as `{shortPluginName}.{formatName}` where
* shortPluginName strips the `sctx-` prefix.
*/
export function buildFormatRegistry(loaded: LoadedPlugin[]): ResolvedFormat[] {
export function buildFormatRegistry(plugins: StructuredContextPlugin[]): ResolvedFormat[] {
const registry: ResolvedFormat[] = [];
for (const lp of loaded) {
if (!lp.plugin.render) continue;
const shortName = lp.plugin.name.replace(PLUGIN_PREFIX, '');
for (const format of lp.plugin.render.formats) {
registry.push({
qualifiedName: `${shortName}.${format.name}`,
format,
plugin: lp,
});
for (const plugin of plugins) {
if (!plugin.render) continue;
const shortName = plugin.name.replace(PLUGIN_PREFIX, '');
for (const format of plugin.render.formats) {
registry.push({ qualifiedName: `${shortName}.${format.name}`, format, plugin });
}
}
return registry;
}

/** The render formats of every discovered plugin, including those the space declares. */
export async function renderFormats(context?: SpaceContext): Promise<ResolvedFormat[]> {
const space = context && { pluginMap: context.space?.plugins ?? {}, configDir: context.configDir };
return buildFormatRegistry(await discoverPlugins(space));
}
37 changes: 22 additions & 15 deletions src/render/render.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { updateSpaceField } from '../config';
import { assembleSpaceGraph } from '../load-space-graph';
import { loadPlugins } from '../plugins/loader';
import { type RenderResult, shortenPluginName } from '../plugins/util';
import { 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';
import type { SpaceContext } from '../types';
import { buildFormatRegistry } from './registry';
import { validateSpace } from '../validate';
import { renderFormats } from './registry';

export async function executeRender(
formatName: string,
context: SpaceContext,
options: { filter?: string; hierarchy?: string; data?: Record<string, unknown> },
): Promise<RenderResult> {
const pluginMap: Record<string, Record<string, unknown>> = context.space?.plugins ?? {};
const loaded = await loadPlugins(pluginMap, context.configDir);
const registry = buildFormatRegistry(loaded);
const registry = await renderFormats(context);

const entry = registry.find((r) => r.qualifiedName === formatName);
if (!entry) {
Expand All @@ -21,23 +22,29 @@ export async function executeRender(
`Unknown render format: "${formatName}".${available ? ` Available: ${available}` : ' No formats registered.'}`,
);
}
const { plugin } = entry;
const pluginConfig = validatePluginConfig(plugin, context.space?.plugins ?? {}, context.configDir);

const graph = await assembleSpaceGraph(context, { filter: options.filter, hierarchy: options.hierarchy });
const readResult = await readSpace(context);
const graph = await assembleSpaceGraph(context, { filter: options.filter, hierarchy: options.hierarchy, readResult });

const shortName = shortenPluginName(entry.plugin.plugin.name);
const pluginContext = {
const shortName = shortenPluginName(plugin.name);
const pluginContext: PluginContext = {
...context,
pluginConfig: entry.plugin.pluginConfig,
pluginConfig,
callbacks: {
persistConfig: (updates: Record<string, string>) => {
persistConfig: (updates) => {
for (const [field, value] of Object.entries(updates)) {
updateSpaceField(context.space.name, field, value, shortName);
}
},
validate: async (validateOptions) => (await validateSpace(context, { ...validateOptions, readResult })).issues,
exportGraph: () =>
exportSpaceGraph(graph.nodes.values(), {
hierarchy: graph.hierarchy,
hierarchies: context.schema.metadata.hierarchies,
}),
},
};
return entry.plugin.plugin.render!.render(pluginContext, graph, {
format: entry.format.name,
data: options.data,
});
return plugin.render!.render(pluginContext, graph, { format: entry.format.name, data: options.data });
}
13 changes: 13 additions & 0 deletions tests/fixtures/render-plugins/.sctx/plugins/sctx-needs-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { StructuredContextPlugin } from '../../../../../src/plugins/util';

/** A render plugin whose config requires a field no space declares. */
const needsConfig: StructuredContextPlugin = {
name: 'sctx-needs-config',
configSchema: { type: 'object', required: ['token'] },
render: {
formats: [{ name: 'out', description: 'Never renders' }],
render: () => '',
},
};

export default needsConfig;
20 changes: 20 additions & 0 deletions tests/fixtures/render-plugins/.sctx/plugins/sctx-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { StructuredContextPlugin } from '../../../../../src/plugins/util';

/** Renders what the render callbacks return, for tests. */
const probe: StructuredContextPlugin = {
name: 'sctx-probe',
configSchema: { type: 'object' },
render: {
formats: [{ name: 'callbacks', description: 'Callback results as JSON' }],
async render(context) {
const issues = (await context.callbacks?.validate?.()) ?? [];
const graph = context.callbacks?.exportGraph?.();
return JSON.stringify({
issues: issues.map(({ severity, nodeId }) => ({ severity, nodeId })),
nodes: graph?.nodes.map((n) => n.title).sort(),
});
},
},
};

export default probe;
4 changes: 4 additions & 0 deletions tests/fixtures/render-plugins/space/Grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
type: goal
status: exploring
---
3 changes: 3 additions & 0 deletions tests/fixtures/render-plugins/space/Missing status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
type: goal
---
Loading
Loading