chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27
View File
@@ -0,0 +1,27 @@
# @docmd/api
Plugin API surface for docmd - hook registration, RPC dispatch, and source editing tools.
This package is the canonical home for the docmd plugin system. It provides:
- **Plugin Loader** - Validates, isolates, and registers plugins with capability-gated hooks
- **Action Dispatcher** - WebSocket RPC system for live-edit actions and fire-and-forget events
- **Source Tools** - Block-level markdown source editing (insert, replace, wrap, remove)
- **Type Definitions** - `PluginDescriptor`, `PluginModule`, `ActionContext`, `SourceTools`, and more
## Usage
```typescript
import { loadPlugins, createActionDispatcher, createSourceTools } from '@docmd/api';
import type { PluginDescriptor, PluginModule } from '@docmd/api';
```
Part of the **[docmd](https://github.com/docmd-io/docmd)** documentation engine.
## Documentation
See **[docs.docmd.io](https://docs.docmd.io)** for full usage and API reference.
## License
MIT
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@docmd/api",
"version": "0.8.12",
"description": "Plugin API surface for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build:registry": "node ../../scripts/build-plugin-registry.mjs",
"prebuild": "npm run build:registry",
"build": "tsc && node scripts/copy-registry.mjs"
},
"files": [
"dist",
"registry"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@docmd/parser": "workspace:*",
"@docmd/tui": "workspace:*",
"@docmd/utils": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.13.3"
},
"optionalDependencies": {
"@docmd/engine-js": "workspace:*",
"@docmd/engine-rust": "workspace:*"
},
"keywords": [
"docmd",
"documentation",
"api",
"plugin",
"hooks",
"rpc",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
@@ -0,0 +1,205 @@
{
"$meta": {
"generatedAt": "2026-07-12T12:23:44.202Z",
"generator": "scripts/build-plugin-registry.mjs",
"packageCount": 15
},
"analytics": {
"package": "@docmd/plugin-analytics",
"description": "Analytics injection plugin for docmd.",
"configKey": "analytics",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "Analytics",
"tagline": "Analytics injection plugin for docmd.",
"capabilities": [
"head",
"body"
]
},
"git": {
"package": "@docmd/plugin-git",
"description": "Git integration plugin for docmd.",
"configKey": "git",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "Git",
"tagline": "Git integration with last-updated timestamps and commit history.",
"capabilities": [
"init",
"build",
"body",
"assets",
"post-build",
"translations"
]
},
"llms": {
"package": "@docmd/plugin-llms",
"description": "Generate llms.txt context files for AI agents from your docmd site.",
"configKey": "llms",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "LLMs",
"tagline": "Generates llms.txt and llms-full.txt for LLM consumption.",
"capabilities": [
"post-build"
]
},
"math": {
"package": "@docmd/plugin-math",
"description": "Mathematics (KaTeX/LaTeX) plugin for docmd.",
"configKey": "math",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "Math",
"tagline": "Mathematics (KaTeX/LaTeX) plugin for docmd.",
"capabilities": [
"markdown",
"assets"
]
},
"mermaid": {
"package": "@docmd/plugin-mermaid",
"description": "Mermaid diagram support plugin for docmd.",
"configKey": "mermaid",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "Mermaid",
"tagline": "Mermaid.js diagram support for docmd.",
"capabilities": [
"markdown",
"assets"
]
},
"okf": {
"package": "@docmd/plugin-okf",
"description": "Generate an Open Knowledge Format (OKF) bundle from your docmd site for AI agent consumption.",
"configKey": "okf",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "OKF",
"tagline": "Open Knowledge Format (OKF) bundle generator for AI-agent consumption.",
"capabilities": [
"post-build"
]
},
"openapi": {
"package": "@docmd/plugin-openapi",
"description": "OpenAPI documentation generator plugin for docmd.",
"configKey": "openapi",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "OpenAPI",
"tagline": "OpenAPI / Swagger spec rendering for docmd.",
"capabilities": [
"markdown",
"assets"
]
},
"pwa": {
"package": "@docmd/plugin-pwa",
"description": "Progressive Web App (PWA) plugin for docmd.",
"configKey": "pwa",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "PWA",
"tagline": "Progressive Web App support for docmd with offline caching.",
"capabilities": [
"post-build",
"head",
"body"
]
},
"search": {
"package": "@docmd/plugin-search",
"description": "Offline full-text search for docmd.",
"configKey": "search",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "Search",
"tagline": "Offline full-text search for docmd.",
"capabilities": [
"post-build",
"init",
"head",
"body",
"assets",
"translations"
]
},
"seo": {
"package": "@docmd/plugin-seo",
"description": "SEO meta tag generator for docmd.",
"configKey": "seo",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "SEO",
"tagline": "SEO meta tag generator for docmd.",
"capabilities": [
"head",
"post-build"
]
},
"sitemap": {
"package": "@docmd/plugin-sitemap",
"description": "Sitemap generator plugin for docmd.",
"configKey": "sitemap",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "Sitemap",
"tagline": "Sitemap generator for docmd.",
"capabilities": [
"post-build"
]
},
"threads": {
"package": "@docmd/plugin-threads",
"description": "Inline discussion threads for docmd.",
"configKey": "threads",
"defaultConfig": "{}",
"kind": "plugin",
"displayName": "Threads",
"tagline": "Inline discussion comments stored in Markdown.",
"capabilities": [
"markdown",
"body",
"assets",
"actions",
"translations"
]
},
"summer": {
"package": "@docmd/template-summer",
"description": "Summer template for docmd. A bright, hopeful, summer-feel layout with a top search bar and halo accents.",
"configKey": "summer",
"defaultConfig": "{}",
"kind": "template",
"displayName": "Summer",
"tagline": "A bright, hopeful, summer-feel layout with a top search bar and halo accents.",
"capabilities": [
"template"
],
"preview": "assets/css/summer.css"
},
"js": {
"package": "@docmd/engine-js",
"description": "Default JavaScript engine for docmd using Node.js native APIs.",
"configKey": "js",
"defaultConfig": "{}",
"kind": "engine",
"displayName": "js",
"tagline": "Default JavaScript engine for docmd using Node.js native APIs.",
"capabilities": []
},
"rust": {
"package": "@docmd/engine-rust",
"description": "Rust-accelerated engine for docmd. The postinstall script downloads a pre-built platform binary from npm CDN (unpkg/jsdelivr). No code is executed beyond copying the .node file. Source: https://github.com/docmd-io/docmd",
"configKey": "rust",
"defaultConfig": "{}",
"kind": "engine",
"displayName": "rust",
"tagline": "Rust-accelerated engine for docmd. The postinstall script downloads a pre-built platform binary from npm CDN (unpkg/jsdelivr). No code is executed beyond copying the .node file. Source: https://github.com/docmd-io/docmd",
"capabilities": []
}
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Copy the generated plugin registry from `registry/` to `dist/registry/`
* so the published package's runtime loader (which resolves paths from
* `dist/`) can find it without depending on the `files` field layout.
*
* The `files: ["dist", "registry"]` array publishes both, so users can
* also resolve directly from the package root, but the dist/ copy is
* what the loader prefers (it's the path relative to __dirname).
*/
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PKG_ROOT = resolve(__dirname, '..');
const src = join(PKG_ROOT, 'registry', 'plugins.generated.json');
const destDir = join(PKG_ROOT, 'dist', 'registry');
const dest = join(destDir, 'plugins.generated.json');
if (!existsSync(src)) {
console.error('✗ copy-registry: source missing:', src);
console.error(' Run `node ../../scripts/build-plugin-registry.mjs` first.');
process.exit(1);
}
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
copyFileSync(src, dest);
console.log(`✓ Copied registry to ${dest}`);
+238
View File
@@ -0,0 +1,238 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/api
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
import type { Engine, EngineResult } from './types.js';
import { engineRegistry, registerEngine } from './types.js';
import { installRuntimeDep, tryLoadAfterInstall, isValidRuntimeDepName } from './runtime-deps.js';
export { engineRegistry, registerEngine };
// ---------------------------------------------------------------------------
// Allowed task types — the API layer acts as a security boundary between
// plugins and engines. Plugins may only request tasks in this set.
// Engines themselves are unrestricted and can implement any task type.
// ---------------------------------------------------------------------------
const ALLOWED_TASK_TYPES = new Set([
'file:discover',
'file:read',
'file:readBatch',
'file:write',
'file:exists',
'git:log',
'git:status',
'search:index',
'search:query',
]);
// ---------------------------------------------------------------------------
// Engine Loading
// ---------------------------------------------------------------------------
/**
* Load an engine by name.
*
* Resolution order:
* 1. Custom loader registered via `registerEngine()`
* 2. Built-in engines: 'js' (always available), 'rust' (optional, native binary)
*
* The Rust engine gracefully falls back to JS if the native binary is not
* installed, so callers can always specify 'rust' without special-casing.
*/
export async function loadEngine(name: string = 'js'): Promise<Engine> {
// Custom loader takes priority
const loader = engineRegistry.get(name);
if (loader) {
const engine = await loader();
if (engine) return engine;
}
if (name === 'js') {
// JS engine is a hard requirement. Auto-install if it's missing
// (e.g. user opted out of @docmd/api optionalDependencies at
// install time), then retry once. There is no JS fallback for a
// JS engine — call it a fatal loader failure if install also fails.
try {
const { createJsEngine } = await import('@docmd/engine-js');
return createJsEngine();
} catch (err) {
if (!isValidRuntimeDepName('@docmd/engine-js')) throw err;
const installed = await installRuntimeDep('@docmd/engine-js');
if (installed) {
const reloaded = await tryLoadAfterInstall('@docmd/engine-js');
if (reloaded) {
const { createJsEngine } = reloaded as any;
return createJsEngine();
}
}
throw new Error(
`[docmd] JS engine unavailable after auto-install: ${(err as Error).message}. ` +
`Add "@docmd/engine-js" to your package.json dependencies.`,
);
}
}
if (name === 'rust') {
// Rust engine is an optional performance accelerator. Try to load
// it; if the package is missing, auto-install it; if install fails
// OR the binary isn't usable on this platform, fall back to JS.
try {
const { createRustEngine, isRustEngineAvailable } = await import('@docmd/engine-rust');
if (!isRustEngineAvailable()) {
console.warn('[docmd] Rust engine not supported on this platform, falling back to JS engine.');
return loadEngine('js');
}
return createRustEngine();
} catch (error) {
if (isValidRuntimeDepName('@docmd/engine-rust')) {
const installed = await installRuntimeDep('@docmd/engine-rust');
if (installed) {
const reloaded = await tryLoadAfterInstall('@docmd/engine-rust');
if (reloaded) {
const { createRustEngine, isRustEngineAvailable } = reloaded as any;
if (isRustEngineAvailable && isRustEngineAvailable()) {
return createRustEngine();
}
}
}
}
console.warn(`[docmd] Rust engine unavailable (${(error as Error).message}), falling back to JS engine.`);
return loadEngine('js');
}
}
throw new Error(`Unknown engine: '${name}'. Available built-in engines: js, rust`);
}
/**
* Check if a named engine is available without loading it.
*/
export async function isEngineAvailable(name: string): Promise<boolean> {
if (name === 'js') return true;
if (name === 'rust') {
try {
const { isRustEngineAvailable } = await import('@docmd/engine-rust');
return isRustEngineAvailable();
} catch {
return false;
}
}
return engineRegistry.has(name);
}
/**
* Return names of all currently available engines.
*/
export async function getAvailableEngines(): Promise<string[]> {
const engines: string[] = ['js'];
if (await isEngineAvailable('rust')) engines.push('rust');
return engines;
}
// ---------------------------------------------------------------------------
// Security Layer — plugin-facing task runner
//
// Plugins call these helpers rather than calling engine.run() directly.
// The API layer validates the task type against the allowlist before
// forwarding to the engine, preventing plugins from invoking arbitrary tasks.
// ---------------------------------------------------------------------------
/**
* Run a task on an engine.
*
* @param engine - The engine instance to use.
* @param type - Task type (must be in the allowed set).
* @param payload - Task payload.
* @param timeout - Optional timeout in ms.
* @param trusted - Internal flag: bypass the allowlist (used by docmd core only).
* @throws if the task type is not allowed or the task fails.
*/
export async function runTask<T = any>(
engine: Engine,
type: string,
payload: any,
timeout?: number,
trusted = false,
): Promise<T> {
if (!trusted && !ALLOWED_TASK_TYPES.has(type)) {
throw new Error(
`Task type '${type}' is not allowed for plugins. ` +
`Allowed types: ${[...ALLOWED_TASK_TYPES].join(', ')}`,
);
}
const result: EngineResult<T> = await engine.run<T>({ type, payload, timeout });
if (!result.success) {
throw new Error(result.error || `Task '${type}' failed`);
}
return result.data as T;
}
// ---------------------------------------------------------------------------
// Convenience Helpers (plugin-safe, validated against allowlist)
// ---------------------------------------------------------------------------
/**
* Discover files in a directory tree.
*/
export async function discoverFiles(
engine: Engine,
dir: string,
extensions?: string[],
exclude?: string[],
): Promise<Array<{ path: string; size: number; mtimeMs: number }>> {
return runTask(engine, 'file:discover', { dir, extensions, exclude });
}
/**
* Read multiple files in a single batch operation.
* Returns a Map from file path to file content.
*/
export async function readFilesBatch(
engine: Engine,
paths: string[],
): Promise<Map<string, string>> {
const result = await runTask<Record<string, string>>(engine, 'file:readBatch', { paths });
return new Map(Object.entries(result));
}
/**
* Get git log for one or more files.
* Returns a Map from file path to array of commit entries.
*/
export async function getGitLog(
engine: Engine,
filePaths: string[],
maxCommits = 6,
): Promise<Map<string, Array<{ hash: string; shortHash: string; author: string; email: string; timestamp: number; message: string }>>> {
const result = await runTask<Record<string, any[]>>(engine, 'git:log', { filePaths, maxCommits });
return new Map(Object.entries(result));
}
/**
* Build a search index from a list of documents.
* Returns a serialised index string (format depends on the engine).
*/
export async function buildSearchIndex(
engine: Engine,
documents: Array<{
id: string;
title: string;
content: string;
path: string;
locale?: string;
version?: string;
}>,
): Promise<string> {
return runTask(engine, 'search:index', { documents });
}
+975
View File
@@ -0,0 +1,975 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/api
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
/**
* Plugin loader with validation, isolation, and capability enforcement.
*
* - Lightweight contract check at load time.
* - Every hook invocation wrapped in try/catch.
* - Plugins can only register for hooks they've declared.
*/
import { TUI } from '@docmd/tui';
import path from 'node:path';
import nativeFs from 'node:fs';
import process from 'node:process';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { safePath, asUserPath } from '@docmd/utils';
import type { PluginDescriptor, PluginHooks, PluginModule, Capability, TemplateHook, TemplateAssetHook } from './types.js';
import {
loadRuntimeRegistry,
installRuntimeDep,
tryLoadAfterInstall,
shortKey,
} from './runtime-deps.js';
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Monorepo root - two levels up from packages/api/dist/
const __monorepoRoot = path.resolve(__dirname, '..', '..', '..');
// ---------------------------------------------------------------------------
// Capability → Hook mapping (§3)
// ---------------------------------------------------------------------------
const CAPABILITY_HOOKS: Record<Capability, string[]> = {
markdown: ['markdownSetup'],
head: ['generateMetaTags', 'generateScripts'],
body: ['generateScripts'],
assets: ['getAssets'],
'post-build': ['onPostBuild'],
actions: ['actions'],
events: ['events'],
translations: ['translations'],
init: ['onConfigResolved'],
build: ['onBeforeParse', 'onAfterParse', 'onBeforeBuild', 'onBeforeRender', 'onPageReady'],
dev: ['onDevServerReady'],
template: ['templates', 'templateAssets'],
};
const KNOWN_CAPABILITIES = new Set(Object.keys(CAPABILITY_HOOKS));
// ---------------------------------------------------------------------------
// Hook registry
// ---------------------------------------------------------------------------
export const hooks: PluginHooks = {
markdownSetup: [],
injectHead: [],
injectBody: [],
onPostBuild: [],
assets: [],
translations: [],
actions: {},
events: {},
onConfigResolved: [],
onDevServerReady: [],
onBeforeParse: [],
onAfterParse: [],
onBeforeBuild: [],
onBeforeRender: [],
onPageReady: [],
templates: [],
templateAssets: [],
};
// ---------------------------------------------------------------------------
// Validation (§1)
// ---------------------------------------------------------------------------
interface ValidationResult {
valid: boolean;
errors: string[];
}
function validateDescriptor(descriptor: any): ValidationResult {
const errors: string[] = [];
if (!descriptor || typeof descriptor !== 'object') {
return { valid: false, errors: ['Missing plugin descriptor'] };
}
if (!descriptor.name || typeof descriptor.name !== 'string') {
errors.push('`name` must be a non-empty string');
}
if (!descriptor.version || typeof descriptor.version !== 'string') {
errors.push('`version` must be a valid semver string');
}
if (!Array.isArray(descriptor.capabilities)) {
errors.push('`capabilities` must be an array');
} else {
for (const cap of descriptor.capabilities) {
if (!KNOWN_CAPABILITIES.has(cap)) {
errors.push(`Unknown capability: "${cap}"`);
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* Check if a plugin has declared a capability that allows a specific hook.
*/
function hasCapabilityForHook(descriptor: PluginDescriptor | null, hookName: string): boolean {
if (!descriptor) return true; // Legacy plugins without descriptors get full access
for (const cap of descriptor.capabilities) {
if (CAPABILITY_HOOKS[cap]?.includes(hookName)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Isolation wrapper (§2)
// ---------------------------------------------------------------------------
async function safeCall<T>(hookName: string, pluginName: string, fn: (...args: any[]) => T, ...args: any[]): Promise<T | string | undefined> {
try {
const result = fn(...args);
// Plugins are allowed to return Promises (most `async` functions do).
// We must await them before downstream code inspects the shape,
// otherwise the dispatcher sees a Promise object (typeof 'object')
// and silently drops the value through the wrong branch of
// coerceStringPluginReturn / coerceGenerateScriptsReturn. This is a
// longstanding bug — string-return hooks used to "work" only when
// the plugin happened to be synchronous.
if (result && typeof result === 'object' && typeof (result as any).then === 'function') {
return await result;
}
return result;
} catch (err: any) {
TUI.error(`Plugin "${pluginName}" threw in ${hookName}`, err.message);
return (hookName === 'injectHead' || hookName === 'injectBody') ? '' as any : undefined;
}
}
// One-shot warning set so we don't spam the TUI on every page build when a
// plugin has a wrong return type. The key is the plugin name + the hook
// name; the message is logged at most once per build per plugin/hook pair.
const _pluginReturnTypeWarnings = new Set<string>();
function warnPluginReturnTypeOnce(pluginName: string, hookName: string, message: string): void {
const key = `${pluginName}::${hookName}`;
if (_pluginReturnTypeWarnings.has(key)) return;
_pluginReturnTypeWarnings.has(key);
_pluginReturnTypeWarnings.add(key);
TUI.warn(` Plugin "${pluginName}" ${message}`);
}
// D-S4: enforce that the returned value from a string-return hook is
// actually a string. Reject objects (previously rendered as
// "[object Object]"), booleans, numbers, etc. Null/undefined/empty-string
// pass through as `''` so the surrounding `|| ''` collapses cleanly.
function coerceStringPluginReturn(value: any, hookName: string, pluginName: string): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string') return value;
warnPluginReturnTypeOnce(
pluginName,
hookName,
`returned a non-string value (${typeof value}); expected a string. Skipping.`
);
return null;
}
// D-S5: accept either a plain string (treated as the `target` slot, body
// stays empty) or the canonical `{ headScriptsHtml, bodyScriptsHtml }`
// object. Strings are silently dropped on the body side per the original
// contract; this just makes the head side honour the same convention.
function coerceGenerateScriptsReturn(value: any, target: 'head' | 'body', pluginName: string): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
// D-S5: a string return is treated as head-only. Body targets get
// empty. This restores the head path that used to silently drop
// strings via `result?.bodyScriptsHtml` and never wrote them either.
return target === 'head' ? value : '';
}
if (typeof value === 'object') {
const key = target === 'head' ? 'headScriptsHtml' : 'bodyScriptsHtml';
const v = value[key];
return typeof v === 'string' ? v : null;
}
warnPluginReturnTypeOnce(
pluginName,
'generateScripts',
`returned a non-object, non-string value (${typeof value}); expected a string or { headScriptsHtml, bodyScriptsHtml }. Skipping.`
);
return null;
}
// D-M1: enforce that translations are a plain string-to-string map.
// The previous behaviour silently spread a string return into the
// translations object (`{ '0': 'c', '1': 'h', ... }`) which produced
// garbage keys at runtime.
function coerceTranslationsReturn(value: any, localeId: string, pluginName: string): Record<string, string> {
if (value === null || value === undefined) return {};
if (typeof value !== 'object' || Array.isArray(value)) {
warnPluginReturnTypeOnce(
pluginName,
'translations',
`returned a non-object value for locale "${localeId}" (${typeof value}); expected Record<string, string>. Skipping.`
);
return {};
}
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(value)) {
if (typeof v === 'string') out[k] = v;
}
return out;
}
const pluginErrors: { plugin: string; hook: string; message: string; filePath?: string }[] = [];
// separate tracker for load-time plugin failures (the
// "unknown plugin" case from test-report §F6). RUNTIME hook errors go in
// `pluginErrors`; LOAD failures (could not resolve / import) go here.
// The build / dev commands check this via `getPluginLoadErrors()` and
// exit 1 if any are present, so a missing plugin is a hard build failure
// rather than a silent warning.
const pluginLoadErrors: { plugin: string; message: string }[] = [];
export function getPluginErrors() {
return pluginErrors;
}
export function getPluginLoadErrors() {
return pluginLoadErrors;
}
/**
* Canonical list of plugins that ship with @docmd/core. These are
* auto-loaded on every build unless the user opts out via
* `plugins.<name>: false` (or `enabled: false`) in their config.
*
* This is the single source of truth for the "core plugin set" across
* the monorepo. Other packages (the installer, the docs site, etc.)
* must import this constant rather than re-declaring the list —
* re-declaring causes silent drift when a plugin is added/removed here.
*
* Adding a plugin: append the key to this array AND make sure the
* plugin's @docmd/plugin-* package is listed as a workspace dep in
* packages/core/package.json so it ships with @docmd/core on npm.
*/
export const CORE_PLUGINS: ReadonlyArray<string> = [
'search', 'seo', 'sitemap', 'analytics', 'llms',
'mermaid', 'git', 'openapi', 'okf'
] as const;
/** True if `name` is one of the plugins that ship with @docmd/core. */
export function isCorePlugin(name: string): boolean {
return (CORE_PLUGINS as ReadonlyArray<string>).includes(name);
}
// Track which plugin warnings have already been printed to avoid repeating them on
// every dev-server rebuild. Keyed by `pluginName:warningType`.
const _printedWarnings = new Set<string>();
function warnOnce(key: string, message: string): void {
if (_printedWarnings.has(key)) return;
_printedWarnings.add(key);
TUI.warn(message);
}
// ---------------------------------------------------------------------------
// Shorthand resolution
// ---------------------------------------------------------------------------
export function resolvePluginName(key: string): string {
if (key.includes('/')) return key;
const registry = loadRuntimeRegistry();
if (registry[key]) {
return `@docmd/plugin-${key}`;
}
const corePlugins = CORE_PLUGINS;
if (corePlugins.includes(key)) {
return `@docmd/plugin-${key}`;
}
return key;
}
/**
* Resolve a template reference (from `config.theme.template` or frontmatter
* `template:`) to its full npm package name. Mirrors `resolvePluginName`
* but targets the `@docmd/template-*` scope.
*
* Accepts:
* - `summer` → `@docmd/template-summer`
* - `template-summer` → `@docmd/template-summer`
* - `@docmd/template-summer` → `@docmd/template-summer` (unchanged)
* - `@scope/template-summer` → `@scope/template-summer` (unchanged)
* - `./relative/path` → unchanged
*/
export function resolveTemplateName(key: string): string {
if (!key) return key;
if (key.includes('/') || key.startsWith('.')) return key;
if (key.startsWith('template-')) return `@docmd/${key}`;
return `@docmd/template-${key}`;
}
// ---------------------------------------------------------------------------
// Auto-Install for Official Plugins
// ---------------------------------------------------------------------------
//
// The registry loader, package-manager detector, version pinner, and
// `spawn`-based installer live in `./runtime-deps.ts`. This module
// replaces the previous inline `execSync(\`pnpm add ${pkg}\`)` which
// was a CWE-78 (shell injection) surface. The shared module is also
// consumed by `engine.ts` for engine auto-install.
// ---------------------------------------------------------------------------
// Load & Register
// ---------------------------------------------------------------------------
export async function loadPlugins(config: any, opts?: { resolvePaths?: string[] }): Promise<PluginHooks> {
// 1. Resolution paths for plugin imports - the caller (e.g. @docmd/core) should
// pass its own __dirname so plugins that are core's dependencies can be found
// even under pnpm's strict node_modules layout.
const resolvePaths = [
process.cwd(),
__dirname,
__monorepoRoot,
path.join(__monorepoRoot, 'packages/plugins'),
path.join(__monorepoRoot, 'packages/templates'),
...(opts?.resolvePaths || [])
];
// 1. Reset hooks
hooks.markdownSetup = [];
hooks.injectHead = [];
hooks.injectBody = [];
hooks.onPostBuild = [];
hooks.assets = [];
hooks.translations = [];
hooks.actions = {};
hooks.events = {};
hooks.onConfigResolved = [];
hooks.onDevServerReady = [];
hooks.onBeforeParse = [];
hooks.onAfterParse = [];
hooks.onBeforeBuild = [];
hooks.onBeforeRender = [];
hooks.onPageReady = [];
hooks.templates = [];
hooks.templateAssets = [];
pluginErrors.length = 0;
pluginLoadErrors.length = 0;
// 2. Initialize Plugin Map (Name -> Options)
const pluginMap = new Map<string, any>();
const searchEnabled = config.optionsMenu ? config.optionsMenu.components.search !== false : config.search !== false;
// A. Core Plugins - always loaded by default.
// 0.8.8: added `okf` (Open Knowledge Format bundles for AI agents).
// `okf` follows the same pattern as `llms` — auto-loaded, opt-out via
// `plugins.okf = false` in the user's config.
const corePlugins = CORE_PLUGINS;
for (const name of corePlugins) {
const resolved = `@docmd/plugin-${name}`;
const userOpts = config.plugins?.[name];
if (userOpts === false || (userOpts && userOpts.enabled === false)) {
pluginMap.set(resolved, false);
continue;
}
if (name === 'search' && !searchEnabled) {
pluginMap.set(resolved, false);
continue;
}
pluginMap.set(resolved, userOpts || {});
}
// B. Add/Override from Config (non-core / optional / third-party plugins)
if (config.plugins) {
Object.keys(config.plugins).forEach(key => {
const resolvedName = resolvePluginName(key);
if (corePlugins.includes(key)) return;
pluginMap.set(resolvedName, config.plugins[key]);
});
}
// B'. Auto-include the active template (new in 0.8.7)
// If `config.theme.template` is set, make sure the corresponding
// `@docmd/template-*` package is in the plugin map. The user does not
// need to also list it in `config.plugins`. Explicit user entries win.
if (config.theme && config.theme.template) {
const tplName = String(config.theme.template).trim();
if (tplName) {
const resolvedTemplate = resolveTemplateName(tplName);
// Only add if not already present (user might have set explicit options).
if (!pluginMap.has(resolvedTemplate)) {
pluginMap.set(resolvedTemplate, {});
}
}
}
// 3. Load and Register (with auto-install for official plugins)
for (const [name, options] of pluginMap) {
if (options === false) continue;
try {
let rawModule: any;
let needsAutoInstall = false;
const isLocalPath = name.startsWith('./') || name.startsWith('../') || name.startsWith('/');
try {
let loadedFromMonorepo = false;
// 1. Monorepo Priority: if it's an official plugin OR template, try local
// monorepo source first. This prevents older versions installed in project
// node_modules from taking precedence during monorepo development.
if (name.startsWith('@docmd/plugin-')) {
const id = name.replace('@docmd/plugin-', '');
const localPath = path.resolve(__monorepoRoot, 'packages/plugins', id, 'dist/index.js');
if (nativeFs.existsSync(localPath)) {
rawModule = await import(pathToFileURL(localPath).href);
loadedFromMonorepo = true;
}
} else if (name.startsWith('@docmd/template-')) {
// Templates live under packages/templates/<name>/ in the monorepo.
const id = name.replace('@docmd/template-', '');
const localPath = path.resolve(__monorepoRoot, 'packages/templates', id, 'dist/index.js');
if (nativeFs.existsSync(localPath)) {
rawModule = await import(pathToFileURL(localPath).href);
loadedFromMonorepo = true;
}
}
// 2. Standard NPM Resolution: if not found locally, use Node's resolution
if (!loadedFromMonorepo) {
let resolvedPath: string;
if (isLocalPath) {
// Phase 1.A: CWE-22/CWE-94 fix (T-S8). Local-path plugins must resolve
// inside the project root. Without this, require.resolve(name, { paths })
// can search parent directories and load arbitrary plugins.
const projectRoot = path.resolve(process.cwd());
try {
resolvedPath = safePath(projectRoot, asUserPath(name));
} catch (_e: any) {
throw new Error(`Local plugin path "${name}" escapes project root`);
}
// Resolve directory imports to the package's main field (or index.js).
// Node ESM does not support bare directory imports.
try {
const stat = nativeFs.statSync(resolvedPath);
if (stat.isDirectory()) {
const pkgPath = path.join(resolvedPath, 'package.json');
if (nativeFs.existsSync(pkgPath)) {
const pkg = JSON.parse(nativeFs.readFileSync(pkgPath, 'utf8'));
const main = (pkg.main || 'index.js').replace(/^\.\//, '');
resolvedPath = path.join(resolvedPath, main);
} else if (nativeFs.existsSync(path.join(resolvedPath, 'index.js'))) {
resolvedPath = path.join(resolvedPath, 'index.js');
}
}
} catch (_e: any) {
throw new Error(`Local plugin directory "${name}" has no resolvable entry point: ${_e.message}`);
}
} else {
resolvedPath = require.resolve(name, { paths: resolvePaths });
}
rawModule = await import(pathToFileURL(resolvedPath).href);
}
} catch (_e: any) {
if (name.startsWith('@docmd/plugin-') || name.startsWith('@docmd/template-')) {
needsAutoInstall = true;
} else if (isLocalPath) {
// Phase 1.A: a local-path plugin that fails safePath or import must
// be reported with the original safety error, not swallowed into the
// generic "Failed to resolve" fallback.
throw _e;
} else {
// Fallback for non-package plugins or when resolution fails
try {
rawModule = await import(name);
} catch (innerError: any) {
throw new Error(`Failed to resolve ${name}. Search paths: ${resolvePaths.join(', ')}. Detail: ${innerError.message}`);
}
}
}
// Auto-install official plugins AND templates that are missing
const isOfficial = name.startsWith('@docmd/plugin-') || name.startsWith('@docmd/template-');
if (needsAutoInstall && isOfficial) {
const installed = await installRuntimeDep(name);
if (installed) {
// Defense in depth: re-verify the package is in the official registry
// before loading. installRuntimeDep already passed this check, but we
// re-check here so a future change to that function cannot silently
// turn the auto-install path into a generic npm-loader.
const shortNameLocal = shortKey(name);
const registry = loadRuntimeRegistry();
if (!shortNameLocal || !registry[shortNameLocal]) {
warnOnce(`registry:${name}`, TUI.yellow(`Plugin "${shortNameLocal ?? name}" not in official registry`));
continue;
}
// Retry loading after install. We use dynamic `import()` (not
// `require.resolve` + file:// import) so packages that declare
// `exports` with only an `import` condition are still resolvable.
const reloaded = await tryLoadAfterInstall(name);
if (!reloaded) {
warnOnce(
`autoinstall:${name}`,
TUI.yellow(`Could not load ${name} after auto-install`),
);
continue;
}
rawModule = reloaded;
} else {
continue; // Skip if auto-install failed
}
}
if (!rawModule) continue;
const pluginModule: PluginModule = rawModule.default || rawModule;
// Stage 4: pull the manifest's declared capabilities (from the
// registry) so registerPlugin can cross-check the JS descriptor.
const shortNameForManifest = shortKey(name) ?? name;
const manifestCapabilities = (loadRuntimeRegistry()[shortNameForManifest]?.capabilities) as string[] | undefined;
try {
registerPlugin(name, pluginModule, options, manifestCapabilities);
} catch (regError: any) {
warnOnce(`register:${name}`, TUI.yellow(`Plugin loaded but failed to register: ${name}`) + TUI.dim(`\n > ${regError.message}`));
}
} catch (e: any) {
warnOnce(`load:${name}`, TUI.yellow(`Could not load plugin: ${name} (missing or misconfigured)`) + TUI.dim(`\n > ${e.message}`));
// track load failures so the build can fail
// loudly instead of silently completing with a missing plugin.
pluginLoadErrors.push({ plugin: name, message: e.message });
}
}
// 4. Print error summary if any
if (pluginErrors.length > 0) {
TUI.warn(`${pluginErrors.length} plugin error(s) occurred (build completed)`);
}
return hooks;
}
/**
* Cached per-key capability sets, derived from the registry once and
* reused on every dev-server rebuild. Avoids re-walking the registry
* (and the re-`Set` construction) on every hot reload.
*/
const _capabilityCache: Map<string, Set<string>> = new Map();
/** Get or build a capability Set for a registry key. */
function getCapabilitySet(shortName: string): Set<string> {
let set = _capabilityCache.get(shortName);
if (set) return set;
const entry = loadRuntimeRegistry()[shortName];
set = new Set<string>(Array.isArray(entry?.capabilities) ? entry.capabilities : []);
_capabilityCache.set(shortName, set);
return set;
}
/**
* Cross-check the JS descriptor's `capabilities` against the manifest's
* `docmd.capabilities` (from the generated registry). Catches:
* - Descriptor declares a capability the manifest doesn't (drift).
* - Manifest declares a capability the descriptor doesn't (drift).
* - Implemented hook without declared capability (the silent-drop bug).
*
* Not a hard error — capabilities are advisory metadata. But loud
* warnings surface drift before it causes a build regression.
*/
function checkManifestCapabilityDrift(
shortName: string,
descriptor: PluginDescriptor | null,
plugin: PluginModule,
): string[] {
const warnings: string[] = [];
const manifestCaps = Array.from(getCapabilitySet(shortName));
if (manifestCaps.length === 0) return warnings; // Third-party or unknown package; skip.
if (!descriptor) return warnings; // Legacy; skip.
const descCaps = new Set<string>(Array.isArray(descriptor.capabilities) ? (descriptor.capabilities as string[]) : []);
// Implemented hooks vs declared capabilities.
const KNOWN_HOOKS_BY_CAP: Array<[string, keyof PluginModule]> = [
['markdown', 'markdownSetup'],
['head', 'generateMetaTags'],
['body', 'generateScripts'],
['post-build', 'onPostBuild'],
['assets', 'getAssets'],
['actions', 'actions'],
['events', 'events'],
['translations', 'translations'],
];
for (const [cap, hook] of KNOWN_HOOKS_BY_CAP) {
if (typeof plugin[hook] === 'function' && !descCaps.has(cap) && !manifestCaps.includes(cap)) {
warnings.push(
`exports \`${hook}\` but neither the descriptor nor the manifest ` +
`declares the "${cap}" capability. The hook will be registered.`
);
}
}
return warnings;
}
function registerPlugin(
name: string,
plugin: PluginModule,
options: any,
manifestCapabilities?: string[], // Stage 4: from registry entry, for cross-check
) {
const shortName = name.replace(/^@docmd\/plugin-/, '').replace(/^@docmd\/template-/, '');
const isOfficial = name.startsWith('@docmd/plugin-') || name.startsWith('@docmd/template-');
// --- §1: Validate descriptor ---
const descriptor = plugin.plugin || null;
if (descriptor) {
const { valid, errors } = validateDescriptor(descriptor);
if (!valid) {
const msg = `Plugin "${name}" descriptor failed validation: ${errors.join(', ')}`;
if (isOfficial) {
throw new Error(msg); // Hard error for official plugins
}
TUI.warn(`${msg} - registering anyway`);
}
} else {
// No descriptor - emit deprecation warning (soft until 0.8.0)
// Silent for official plugins as they'll be updated together
if (!isOfficial) {
TUI.warn(`Plugin "${name}" has no descriptor. This will be required in 0.8.0.`);
}
}
// --- §1.5: Manifest / descriptor capability drift (Stage 4) ---
if (manifestCapabilities) {
const drift = checkManifestCapabilityDrift(shortName, descriptor, plugin);
for (const w of drift) {
TUI.warn(`Plugin "${shortName}": ${w}`);
}
}
// --- §3: Capability-gated registration ---
const shouldExecute = (pageContext: any) => {
if (!pageContext || !pageContext.frontmatter) return true;
const fmPlugins = pageContext.frontmatter.plugins || {};
if (fmPlugins[shortName] === false) return false;
if (fmPlugins[shortName] === true) return true;
if (pageContext.frontmatter.noStyle) {
if (options && options.noStyle !== undefined) return options.noStyle;
if (plugin.noStyle !== undefined) return plugin.noStyle;
return true;
}
return true;
};
// markdownSetup
if (typeof plugin.markdownSetup === 'function') {
if (hasCapabilityForHook(descriptor, 'markdownSetup')) {
const fn = plugin.markdownSetup;
hooks.markdownSetup.push((md: any) => safeCall('markdownSetup', name, fn, md, options));
} else {
TUI.warn(`Plugin "${shortName}" exports markdownSetup but didn't declare "markdown" capability - skipped`);
}
}
// generateMetaTags → injectHead
if (typeof plugin.generateMetaTags === 'function') {
if (hasCapabilityForHook(descriptor, 'generateMetaTags')) {
const fn = plugin.generateMetaTags;
hooks.injectHead.push(async (config: any, pageContext: any, root: any) => {
if (!shouldExecute(pageContext)) return '';
const raw = await safeCall('generateMetaTags', name, fn, config, pageContext, root);
// D-S4: the contract is `string`. Object returns used to be
// stringified to "[object Object]" and injected into every page's
// <head>. We now reject the wrong shape, warn, and skip — same
// behaviour we use for missing/null returns.
return coerceStringPluginReturn(raw, 'generateMetaTags', name) || '';
});
} else {
TUI.warn(`Plugin "${shortName}" exports generateMetaTags but didn't declare "head" capability - skipped`);
}
}
// generateScripts → injectHead + injectBody
if (typeof plugin.generateScripts === 'function') {
if (hasCapabilityForHook(descriptor, 'generateScripts')) {
const fn = plugin.generateScripts;
// D-H3: pass a `target` arg so plugins can render different content
// for head vs body without computing both. The arg is optional —
// existing plugins that only know `(config, options)` still work.
// D-S5: plain string returns are now treated as the head slot;
// body gets an empty string. Previously a string return was dropped
// on the body side (result?.bodyScriptsHtml was undefined), making
// the body capability effectively dead for the simple shape.
hooks.injectHead.push(async (config: any, pageContext: any) => {
if (!shouldExecute(pageContext)) return '';
const raw = await safeCall('generateScripts', name, fn, config, options, 'head') as any;
return coerceGenerateScriptsReturn(raw, 'head', name) || '';
});
hooks.injectBody.push(async (config: any, pageContext: any) => {
if (!shouldExecute(pageContext)) return '';
const raw = await safeCall('generateScripts', name, fn, config, options, 'body') as any;
return coerceGenerateScriptsReturn(raw, 'body', name) || '';
});
} else {
TUI.warn(`Plugin "${shortName}" exports generateScripts but didn't declare "head"/"body" capability - skipped`);
}
}
// onPostBuild
if (typeof plugin.onPostBuild === 'function') {
if (hasCapabilityForHook(descriptor, 'onPostBuild')) {
const fn = plugin.onPostBuild;
const wrapper = async (ctx: any) => {
try {
await fn(ctx);
} catch (err: any) {
TUI.error(`Plugin "${name}" threw in onPostBuild`, err.message);
pluginErrors.push({ plugin: name, hook: 'onPostBuild', message: err.message });
}
};
// Tag with the plugin's own declared name (e.g. 'search', 'sitemap')
// so build.ts can split hooks into indexing vs publishing phases.
(wrapper as any)._pluginName = descriptor?.name || shortName;
hooks.onPostBuild.push(wrapper);
} else {
TUI.warn(`Plugin "${shortName}" exports onPostBuild but didn't declare "post-build" capability - skipped`);
}
}
// getAssets
if (typeof plugin.getAssets === 'function') {
if (hasCapabilityForHook(descriptor, 'getAssets')) {
const fn = plugin.getAssets;
hooks.assets.push(async () => (await safeCall('getAssets', name, fn, options)) as any[] || []);
} else {
TUI.warn(`Plugin "${shortName}" exports getAssets but didn't declare "assets" capability - skipped`);
}
}
// templates (new in 0.8.7)
// A plugin can ship a `templates: TemplateHook[]` array listing the EJS
// files it overrides. The resolver in @docmd/ui merges these with the
// default templates, falling back to the default for any slot the plugin
// does not provide. The plugin descriptor MUST declare the `template`
// capability for the entries to register.
if (Array.isArray((plugin as any).templates)) {
if (hasCapabilityForHook(descriptor, 'templates')) {
for (const tpl of (plugin as any).templates as TemplateHook[]) {
if (!tpl || !tpl.type || !tpl.templatePath) {
TUI.warn(`Plugin "${shortName}" provides a malformed templates[] entry (needs { type, templatePath }) - skipped`);
continue;
}
// Attach plugin name for diagnostics & resolution.
(tpl as any)._pluginName = descriptor?.name || shortName;
hooks.templates.push(tpl);
}
} else {
TUI.warn(`Plugin "${shortName}" exports templates but didn't declare "template" capability - skipped`);
}
}
// templateAssets (new in 0.8.7)
// CSS/JS bundles shipped by a template. Loaded at priority 10 by default
// so user customCss (priority 15) still wins.
if (Array.isArray((plugin as any).templateAssets)) {
if (hasCapabilityForHook(descriptor, 'templateAssets')) {
for (const asset of (plugin as any).templateAssets as TemplateAssetHook[]) {
if (!asset || !asset.type || !asset.path) {
TUI.warn(`Plugin "${shortName}" provides a malformed templateAssets[] entry (needs { type, path }) - skipped`);
continue;
}
// Normalise: never mutate the caller's object.
hooks.templateAssets.push({
type: asset.type,
path: asset.path,
priority: asset.priority !== undefined ? asset.priority : 10,
position: asset.position,
_pluginName: descriptor?.name || shortName,
} as TemplateAssetHook);
}
} else {
TUI.warn(`Plugin "${shortName}" exports templateAssets but didn't declare "template" capability - skipped`);
}
}
// translations
if (typeof plugin.translations === 'function') {
if (hasCapabilityForHook(descriptor, 'translations')) {
const fn = plugin.translations;
hooks.translations.push(async (localeId: string) => {
// D-M1 + async-await fix: the callback is async because plugins
// are allowed to return Promises. `safeCall` itself awaits
// before returning, so `raw` is always a settled value here.
const raw = await safeCall('translations', name, fn, localeId, options);
return coerceTranslationsReturn(raw, localeId, name);
});
} else {
TUI.warn(`Plugin "${shortName}" exports translations but didn't declare "translations" capability - skipped`);
}
}
// actions (WebSocket RPC)
if (plugin.actions && typeof plugin.actions === 'object') {
if (hasCapabilityForHook(descriptor, 'actions')) {
Object.assign(hooks.actions, plugin.actions);
} else {
TUI.warn(`Plugin "${shortName}" exports actions but didn't declare "actions" capability - skipped`);
}
}
// events (fire-and-forget)
if (plugin.events && typeof plugin.events === 'object') {
if (hasCapabilityForHook(descriptor, 'events')) {
Object.assign(hooks.events, plugin.events);
} else {
TUI.warn(`Plugin "${shortName}" exports events but didn't declare "events" capability - skipped`);
}
}
// --- Expanded Lifecycle Hooks ---
// onConfigResolved
if (typeof plugin.onConfigResolved === 'function') {
if (hasCapabilityForHook(descriptor, 'onConfigResolved')) {
const fn = plugin.onConfigResolved;
hooks.onConfigResolved.push(async (config: any) => {
try {
await fn(config);
} catch (err: any) {
TUI.error(`Plugin "${name}" threw in onConfigResolved`, err.message);
pluginErrors.push({ plugin: name, hook: 'onConfigResolved', message: err.message });
}
});
} else {
TUI.warn(`Plugin "${shortName}" exports onConfigResolved but didn't declare "init" capability - skipped`);
}
}
// onDevServerReady
if (typeof plugin.onDevServerReady === 'function') {
if (hasCapabilityForHook(descriptor, 'onDevServerReady')) {
const fn = plugin.onDevServerReady;
hooks.onDevServerReady.push(async (server: any, wss: any) => {
try {
await fn(server, wss);
} catch (err: any) {
TUI.error(`Plugin "${name}" threw in onDevServerReady`, err.message);
pluginErrors.push({ plugin: name, hook: 'onDevServerReady', message: err.message });
}
});
} else {
TUI.warn(`Plugin "${shortName}" exports onDevServerReady but didn't declare "dev" capability - skipped`);
}
}
// onBeforeParse
if (typeof plugin.onBeforeParse === 'function') {
if (hasCapabilityForHook(descriptor, 'onBeforeParse')) {
const fn = plugin.onBeforeParse;
hooks.onBeforeParse.push(async (src: string, frontmatter: any, filePath?: string) => {
try {
return await fn(src, frontmatter, filePath) ?? src;
} catch (err: any) {
const loc = filePath ? ` in ${filePath}` : '';
TUI.error(`Plugin "${name}" threw in onBeforeParse${loc}`, err.message);
pluginErrors.push({ plugin: name, hook: 'onBeforeParse', message: err.message, filePath });
return src;
}
});
} else {
TUI.warn(`Plugin "${shortName}" exports onBeforeParse but didn't declare "build" capability - skipped`);
}
}
// onAfterParse
if (typeof plugin.onAfterParse === 'function') {
if (hasCapabilityForHook(descriptor, 'onAfterParse')) {
const fn = plugin.onAfterParse;
hooks.onAfterParse.push(async (html: string, frontmatter: any, filePath?: string) => {
try {
return await fn(html, frontmatter, filePath) ?? html;
} catch (err: any) {
const loc = filePath ? ` in ${filePath}` : '';
TUI.error(`Plugin "${name}" threw in onAfterParse${loc}`, err.message);
pluginErrors.push({ plugin: name, hook: 'onAfterParse', message: err.message, filePath });
return html;
}
});
} else {
TUI.warn(`Plugin "${shortName}" exports onAfterParse but didn't declare "build" capability - skipped`);
}
}
// onBeforeBuild
if (typeof (plugin as any).onBeforeBuild === 'function') {
if (hasCapabilityForHook(descriptor, 'onBeforeBuild')) {
const fn = (plugin as any).onBeforeBuild;
hooks.onBeforeBuild.push(async (ctx: any) => {
try {
await fn(ctx);
} catch (err: any) {
TUI.error(`Plugin "${name}" threw in onBeforeBuild`, err.message);
pluginErrors.push({ plugin: name, hook: 'onBeforeBuild', message: err.message });
}
});
} else {
TUI.warn(`Plugin "${shortName}" exports onBeforeBuild but didn't declare "build" capability - skipped`);
}
}
// onBeforeRender
if (typeof (plugin as any).onBeforeRender === 'function') {
if (hasCapabilityForHook(descriptor, 'onBeforeRender')) {
const fn = (plugin as any).onBeforeRender;
hooks.onBeforeRender.push(async (page: any) => {
try {
await fn(page);
} catch (err: any) {
TUI.error(`Plugin "${name}" threw in onBeforeRender`, err.message);
pluginErrors.push({ plugin: name, hook: 'onBeforeRender', message: err.message });
}
});
} else {
TUI.warn(`Plugin "${shortName}" exports onBeforeRender but didn't declare "build" capability - skipped`);
}
}
// onPageReady
if (typeof plugin.onPageReady === 'function') {
if (hasCapabilityForHook(descriptor, 'onPageReady')) {
const fn = plugin.onPageReady;
hooks.onPageReady.push(async (page: any) => {
try {
await fn(page);
} catch (err: any) {
TUI.error(`Plugin "${name}" threw in onPageReady`, err.message);
pluginErrors.push({ plugin: name, hook: 'onPageReady', message: err.message });
}
});
} else {
TUI.warn(`Plugin "${shortName}" exports onPageReady but didn't declare "build" capability - skipped`);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/core (and ecosystem)
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
// Plugin loader & hook registry
export { loadPlugins, hooks, resolvePluginName, resolveTemplateName, getPluginErrors, getPluginLoadErrors, CORE_PLUGINS, isCorePlugin } from './hooks.js';
// Runtime dependency bootstrap (auto-install pipeline shared by hooks + engines)
export {
loadRuntimeRegistry,
detectPackageManager,
getDocmdVersion,
isValidRuntimeDepName,
installRuntimeDep,
tryLoadAfterInstall,
shortKey as shortRuntimeDepKey,
getBuildStatusReporter,
} from './runtime-deps.js';
// RPC action/event dispatcher
export { createActionDispatcher } from './rpc.js';
// Path safety helper — canonical implementation lives in @docmd/utils.
// Re-exported here for backward compatibility with existing plugin imports.
export { safePath } from '@docmd/utils';
// Source editing tools
export { createSourceTools } from './source.js';
// TUI tools
export { TUI } from '@docmd/tui';
// ─── Centralised URL Utilities ─────────────────────────────────────────────
// Re-exported from @docmd/parser for plugin consumption.
// Plugins MUST use these instead of rolling their own URL logic.
export {
sanitizeUrl,
outputPathToSlug,
outputPathToPathname,
outputPathToCanonical,
buildContextualUrl,
createUrlContext,
computePageUrls,
buildAbsoluteUrl,
resolveHref,
normalizeInternalHref,
} from '@docmd/parser';
export type { UrlContext, PageUrls } from '@docmd/parser';
// ─── Engine System ─────────────────────────────────────────────────────────
// Re-exported from engine module.
export {
engineRegistry,
registerEngine,
loadEngine,
isEngineAvailable,
getAvailableEngines,
runTask,
discoverFiles,
readFilesBatch,
getGitLog,
buildSearchIndex,
} from './engine.js';
// Types
export type {
// Plugin system
PluginDescriptor,
PluginModule,
PluginHooks,
Capability,
PageContext,
PostBuildContext,
// Action/Event system
ActionContext,
ActionHandler,
EventHandler,
DispatchResult,
// Source tools
SourceTools,
BlockInfo,
InlineSegment,
TextLocation,
// Engine system
Engine,
EngineTask,
EngineResult,
EngineLoader,
EngineInitOptions,
BuiltinTaskType,
FileDiscoverPayload,
FileReadBatchPayload,
GitLogPayload,
SearchIndexPayload,
// Assets
Asset,
AssetCondition,
AssetKind,
AssetPosition,
// Template system
TemplateSlot,
TemplateHook,
TemplateAssetHook,
TemplateResolutionContext,
ResolvedTemplate,
} from './types.js';
+133
View File
@@ -0,0 +1,133 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/api
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
/**
* Action dispatcher for live-edit WebSocket message handling.
*
* Routes incoming `call` messages to plugin action handlers and `event`
* messages to plugin event handlers. Each call gets a fresh context with
* file I/O helpers and source editing tools. Tracks modifications so the
* caller knows whether a browser reload is needed.
*/
import fs from 'fs';
import { createSourceTools } from './source.js';
import { TUI } from '@docmd/tui';
import { safePath } from '@docmd/utils';
import type {
ActionContext,
ActionHandler,
EventHandler,
DispatchResult,
} from './types.js';
interface DispatcherHooks {
actions: Record<string, ActionHandler>;
events: Record<string, EventHandler>;
}
interface DispatcherOptions {
projectRoot: string;
config: any;
broadcast: (event: string, data: any) => void;
}
/**
* Create an action dispatcher bound to the given hooks and project context.
*
* @param hooks - `{ actions: {name: handler}, events: {name: handler} }`
* @param options - Project root, config, and broadcast function
* @returns `{ handleCall, handleEvent }`
*/
export function createActionDispatcher(hooks: DispatcherHooks, options: DispatcherOptions) {
const { projectRoot, config, broadcast } = options;
return {
/**
* Dispatch a call-style action. Returns `{ result, reload }`.
*/
async handleCall(action: string, payload: any): Promise<DispatchResult> {
const handler = hooks.actions[action];
if (!handler) throw new Error(`Unknown action: ${action}`);
const sourceTools = createSourceTools({ projectRoot });
let modified = false;
const ctx: ActionContext = {
projectRoot,
config,
broadcast,
source: sourceTools,
async readFile(relativePath: string): Promise<string> {
const resolved = safePath(projectRoot, relativePath);
return fs.promises.readFile(resolved, 'utf8');
},
async writeFile(relativePath: string, content: string): Promise<void> {
const resolved = safePath(projectRoot, relativePath);
await fs.promises.writeFile(resolved, content);
modified = true;
},
async readFileLines(relativePath: string): Promise<string[]> {
const content = await ctx.readFile(relativePath);
return content.split('\n');
},
runWorkerTask(modulePath: string, functionName: string, args: any[]) {
if (!config._workerPool) throw new Error('WorkerPool is not initialized');
return config._workerPool.runTask({ type: 'plugin-task', modulePath, functionName, args });
}
};
const result = await handler(payload, ctx);
return { result, reload: modified || sourceTools._modified };
},
/**
* Dispatch a fire-and-forget event. Unknown events are silently ignored.
*/
handleEvent(name: string, data: any): void {
const handler = hooks.events[name];
if (!handler) return;
const sourceTools = createSourceTools({ projectRoot });
const ctx: ActionContext = {
projectRoot,
config,
broadcast,
source: sourceTools,
async readFile(relativePath: string): Promise<string> {
const resolved = safePath(projectRoot, relativePath);
return fs.promises.readFile(resolved, 'utf8');
},
async writeFile(relativePath: string, content: string): Promise<void> {
const resolved = safePath(projectRoot, relativePath);
await fs.promises.writeFile(resolved, content);
},
async readFileLines(relativePath: string): Promise<string[]> {
const content = await ctx.readFile(relativePath);
return content.split('\n');
},
runWorkerTask(modulePath: string, functionName: string, args: any[]) {
if (!config._workerPool) throw new Error('WorkerPool is not initialized');
return config._workerPool.runTask({ type: 'plugin-task', modulePath, functionName, args });
}
};
try {
handler(data, ctx);
} catch (e: any) {
TUI.warn(`Event handler error [${name}]: ${e.message}`);
}
},
};
}
+363
View File
@@ -0,0 +1,363 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/api
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
/**
* Runtime dependency bootstrap for plugins, templates, and engines.
*
* This module is the single source of truth for the "fetch a missing
* official dependency on first build" path. Both `hooks.ts` (plugin /
* template loader) and `engine.ts` (engine loader) call into it.
*
* Why it exists:
* - Security: replaces the previous `execSync(\`pnpm add ${pkg}\`)`
* shell-string command, which was a CWE-78 surface if a name ever
* leaked in from an untrusted config (fixed by strict regex +
* `spawn` arg-array + defence-in-depth registry lookup).
* - Reuse: the install pipeline was duplicated in hooks and engine;
* one module, one set of behaviour changes.
* - Idempotency: TUI status lines are reported through a per-build
* cache so a dev-server rebuild that re-runs the loader doesn't
* spam the same "WAIT / DONE" line pair for packages already on
* disk.
*
* Public surface (re-exported from `index.ts`):
* - `loadRuntimeRegistry()` — read & cache the generated registry
* - `detectPackageManager(cwd)` — pick pnpm / yarn / bun / npm
* - `getDocmdVersion()` — `@docmd/core` version (for pinning)
* - `isValidRuntimeDepName(name)` — strict regex, returns boolean
* - `installRuntimeDep(pkg)` — non-shell `spawn` install, true on ok
* - `reportInstallStatus(shortName, status)` — idempotent TUI reporter
* - `getBuildStatusReporter()` — single per-build reporter cache
*/
import path from 'node:path';
import nativeFs from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { TUI } from '@docmd/tui';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Monorepo root - two levels up from packages/api/dist/
const __monorepoRoot = path.resolve(__dirname, '..', '..', '..');
// ---------------------------------------------------------------------------
// Strict package-name regex (CWE-78 defence)
// Accepts: @docmd/plugin-foo
// @docmd/template-summer
// @docmd/engine-rust
// @docmd/plugin-math-katex (two-segment short names are fine)
// Rejects: anything with shell metacharacters, scoped third-party names,
// uppercase letters, or names that don't fit the @docmd/<kind>-*
// pattern. A second defence lives in `installRuntimeDep`, which
// cross-checks the lookup against `loadRuntimeRegistry()` so a
// forbidden name that happens to match the regex still cannot be
// installed.
// ---------------------------------------------------------------------------
const PACKAGE_NAME_RE = /^@docmd\/(?:plugin|template|engine)-[a-z0-9][a-z0-9.-]*$/;
// ---------------------------------------------------------------------------
// Registry loader
// ---------------------------------------------------------------------------
let _registry: Record<string, any> | null = null;
/**
* Read the generated runtime registry for plugins / templates / engines.
*
* Resolution order:
* 1. `<package-root>/registry/plugins.generated.json` (monorepo dev +
* published package, both expose this path under `files`).
* 2. `<monorepo-root>/packages/api/registry/plugins.generated.json`
* (fallback for callers that import us from a nested dist path
* that the first candidate doesn't satisfy).
*
* The result is cached per process so a hot dev-server loop doesn't
* re-read the file on every hook call.
*/
export function loadRuntimeRegistry(): Record<string, any> {
if (_registry) return _registry;
const candidates = [
path.resolve(__dirname, '..', 'registry', 'plugins.generated.json'),
path.resolve(__monorepoRoot, 'packages', 'api', 'registry', 'plugins.generated.json'),
];
for (const candidate of candidates) {
if (nativeFs.existsSync(candidate)) {
_registry = JSON.parse(nativeFs.readFileSync(candidate, 'utf8'));
return _registry!;
}
}
_registry = {};
return _registry;
}
/** Force-reload the registry cache (tests only). */
export function _resetRuntimeRegistryCache(): void {
_registry = null;
}
// ---------------------------------------------------------------------------
// Project inspection helpers
// ---------------------------------------------------------------------------
/**
* Detect the package manager used in `cwd` (or any of its ancestors).
* Walks upward until a known lockfile is found; defaults to `npm`.
*/
export function detectPackageManager(cwd: string): 'pnpm' | 'yarn' | 'bun' | 'npm' {
let dir = cwd;
while (dir !== path.parse(dir).root) {
if (nativeFs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
if (nativeFs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
if (nativeFs.existsSync(path.join(dir, 'bun.lockb'))) return 'bun';
if (nativeFs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm';
dir = path.dirname(dir);
}
return 'npm';
}
/**
* Resolve the current `@docmd/core` version, used to pin `pkg@<version>`
* installs to the same release line as the user's docmd. Falls back to
* `latest` when the core package isn't resolvable (e.g. running inside
* a CI image without node_modules linked).
*/
export function getDocmdVersion(): string {
try {
const corePkgPath = require.resolve('@docmd/core/package.json', {
paths: [process.cwd(), __dirname, __monorepoRoot],
});
const pkg = JSON.parse(nativeFs.readFileSync(corePkgPath, 'utf8'));
return pkg.version || 'latest';
} catch {
return 'latest';
}
}
// ---------------------------------------------------------------------------
// Package-name validator (CWE-78 defence)
// ---------------------------------------------------------------------------
/**
* True only when `name` is an `@docmd/<kind>-<short>` reference. This
* is the FIRST line of defence; `installRuntimeDep` also cross-checks
* against `loadRuntimeRegistry()` so a name that matches the regex but
* isn't in the official catalog still cannot be installed.
*/
export function isValidRuntimeDepName(name: string): boolean {
return typeof name === 'string' && PACKAGE_NAME_RE.test(name);
}
/**
* Map an npm package name to its registry short key. Returns null when
* the name is not an `@docmd/<kind>-*` reference.
*/
function shortNameOf(packageName: string): string | null {
if (!isValidRuntimeDepName(packageName)) return null;
if (packageName.startsWith('@docmd/plugin-')) return packageName.replace('@docmd/plugin-', '');
if (packageName.startsWith('@docmd/template-')) return packageName.replace('@docmd/template-', '');
if (packageName.startsWith('@docmd/engine-')) return packageName.replace('@docmd/engine-', '');
return null;
}
// ---------------------------------------------------------------------------
// Idempotent TUI status reporter
// ---------------------------------------------------------------------------
/**
* Per-build cache of short-name → status pairs. Constructed once per
* loader run via `getBuildStatusReporter()`. Subsequent attempts to
* report the same short name in the same build are silently dropped,
* so a dev-server rebuild can't spam the same line pair.
*/
interface StatusLine {
status: 'WAIT' | 'DONE' | 'FAIL' | 'SKIP' | string;
message?: string;
}
type BuildReporter = {
begin(shortName: string): void;
finish(shortName: string, status: StatusLine['status']): void;
setMessage(shortName: string, message: string): void;
reset(): void;
};
/**
* Build a fresh reporter. Each `loadPlugins` / `loadEngine` call gets
* its own reporter so the cache is bound to one loader run.
*/
export function getBuildStatusReporter(): BuildReporter {
const seen = new Map<string, StatusLine>();
return {
begin(shortName) {
// Skip if we've already emitted a "WAIT" for this name this build —
// the install is in flight (or finished); re-emitting would make the
// TUI flicker on dev-server rebuilds.
if (seen.has(shortName)) return;
seen.set(shortName, { status: 'WAIT' });
TUI.step(`Downloading missing runtime dep: ${shortName}`, 'WAIT');
},
finish(shortName, status) {
const prev = seen.get(shortName);
seen.set(shortName, { status, message: prev?.message });
TUI.step(`Runtime dep ${status.toLowerCase()}: ${shortName}`, status);
},
setMessage(shortName, message) {
const prev = seen.get(shortName) ?? { status: 'WAIT' };
seen.set(shortName, { status: prev.status, message });
},
reset() {
seen.clear();
},
};
}
// ---------------------------------------------------------------------------
// `spawn`-based installer (CWE-78 fix)
// ---------------------------------------------------------------------------
/**
* Build the arg array for the user's package manager. Returned as an
* array, never a string, so `spawn` doesn't go through a shell and the
* package name can never be reinterpreted as a flag or command.
*/
function buildInstallArgs(packageName: string, pm: 'pnpm' | 'yarn' | 'bun' | 'npm'): string[] {
switch (pm) {
case 'pnpm': return ['add', packageName];
case 'yarn': return ['add', packageName];
case 'bun': return ['add', packageName];
case 'npm': return ['install', packageName];
}
}
/**
* Non-shell install of an official runtime dependency. Replaces the
* previous `execSync(\`${pm} add ${pkg}\`)` with `spawn` + arg array.
*
* Defence in depth (in order):
* 1. `isValidRuntimeDepName(pkg)` — strict regex.
* 2. `loadRuntimeRegistry()[shortName]` — official catalog lookup.
* 3. `spawn(pm, [...args], { shell: false })` — never hits a shell.
*
* Returns true on a clean exit code from the package manager, false
* otherwise. Caller decides whether a fail should be fatal.
*/
export function installRuntimeDep(packageName: string): Promise<boolean> {
return new Promise((resolve) => {
if (!isValidRuntimeDepName(packageName)) {
TUI.warn(`Refusing to install non-runtime dep: ${packageName}`);
return resolve(false);
}
const shortName = shortNameOf(packageName);
if (!shortName) return resolve(false);
const registry = loadRuntimeRegistry();
if (!registry[shortName]) {
TUI.warn(`Runtime dep "${shortName}" not found in official registry`);
return resolve(false);
}
const cwd = process.cwd();
const pm = detectPackageManager(cwd);
const version = getDocmdVersion();
const versionedPackage =
version === 'latest' ? packageName : `${packageName}@${version}`;
const reporter = getBuildStatusReporter();
reporter.begin(shortName);
const args = buildInstallArgs(versionedPackage, pm);
let stderr = '';
let stdout = '';
// shell: true only on Windows because npm/yarn/pnpm are .cmd batch
// files there. On macOS/Linux, shell: false is more secure and
// works because the package managers are real executables. The
// package name is already validated by the strict regex above, so
// enabling the shell on Windows introduces no injection surface.
const useShell = process.platform === 'win32';
const child = spawn(pm, args, { cwd, shell: useShell, timeout: 60_000 });
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
child.on('error', (err) => {
reporter.finish(shortName, 'FAIL');
const surface = (stderr || err.message || 'unknown error')
.toString()
.split('\n')
.filter(Boolean)
.slice(0, 3)
.join(' | ');
const isTemplate = packageName.startsWith('@docmd/template-');
const hint = isTemplate
? `Add "${packageName}" to your package.json dependencies, then run your normal install step.`
: `Run "docmd add ${shortName}" to install it, or add "${packageName}" to your package.json.`;
TUI.warn(
`Auto-install of ${packageName} failed: ${surface}\n > ${hint}`,
);
resolve(false);
});
child.on('close', (code) => {
if (code === 0) {
reporter.finish(shortName, 'DONE');
return resolve(true);
}
reporter.finish(shortName, 'FAIL');
const surface = stderr
.toString()
.split('\n')
.filter(Boolean)
.slice(0, 3)
.join(' | ');
const isTemplate = packageName.startsWith('@docmd/template-');
const hint = isTemplate
? `Add "${packageName}" to your package.json dependencies, then run your normal install step.`
: `Run "docmd add ${shortName}" to install it, or add "${packageName}" to your package.json.`;
TUI.warn(
`Auto-install of ${packageName} failed (exit ${code}): ${surface || 'unknown error'}\n > ${hint}`,
);
resolve(false);
});
});
}
// ---------------------------------------------------------------------------
// Helpers consumed by hooks.ts and engine.ts
// ---------------------------------------------------------------------------
/**
* Short key for a package name, used for status lines and registry
* lookups. Returns null for names that fail validation.
*/
export function shortKey(packageName: string): string | null {
return shortNameOf(packageName);
}
/**
* Re-load `pkg` after an install attempt. Returns the module reference
* or null when the import still fails. We use dynamic `import(pkgName)`
* (not file:// resolution) so `exports` with only the `import`
* condition still resolves on modern Node.
*/
export async function tryLoadAfterInstall(packageName: string): Promise<any | null> {
try {
return await import(packageName);
} catch {
return null;
}
}
+449
View File
@@ -0,0 +1,449 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/api
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
/**
* Source editing tools for live-edit plugins.
*
* Plugins interact with rendered output (block IDs, plain-text offsets)
* and these tools translate those references back to raw markdown source
* positions so edits can be applied safely.
*/
import fs from 'fs';
import path from 'path';
import { createMarkdownProcessor } from '@docmd/parser';
import { safePath } from '@docmd/utils';
import type { BlockInfo, InlineSegment, SourceTools, TextLocation } from './types.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// safePath is re-exported from @docmd/utils. The previous duplicate
// (D-M4) had a different error message but identical semantics; using
// the canonical helper means the API surface and the runtime share one
// implementation and one set of tests.
/**
* Compute the number of lines occupied by YAML frontmatter (including the
* trailing blank line that gray-matter strips). Mirrors the logic used in
* `processContent` from `@docmd/parser`.
*/
function computeFrontmatterOffset(raw: string): number {
if (!raw.startsWith('---')) return 0;
const closingIndex = raw.indexOf('---', 3);
if (closingIndex === -1) return 0;
let count = raw.substring(0, closingIndex + 3).split('\n').length;
if (raw[closingIndex + 3] === '\n') count++;
return count;
}
// ---------------------------------------------------------------------------
// Inline segment builder
// ---------------------------------------------------------------------------
/**
* Syntax marker lookup for open/close token pairs.
* Returns `[before, after]` strings that wrap the text content in raw markdown.
*/
function syntaxForToken(token: any): [string, string] | null {
switch (token.type) {
case 'strong_open':
return ['**', '**'];
case 'em_open':
return [token.markup || '*', token.markup || '*'];
case 's_open':
return ['~~', '~~'];
case 'link_open': {
const href = (token.attrs || []).find((a: string[]) => a[0] === 'href');
return ['[', `](${href ? href[1] : ''})`];
}
default:
return null;
}
}
/**
* Build an array of inline segments from a raw markdown string.
*
* Each segment describes a contiguous run of text content and the surrounding
* syntax markers (if any). `rawOffset` is the character index in `rawContent`
* where the segment's *text* starts (after the opening syntax marker).
*/
function buildSegments(rawContent: string, md: any): InlineSegment[] {
const tokens = md.parseInline(rawContent, {});
if (!tokens.length || !tokens[0].children) return [];
const children = tokens[0].children;
const segments: InlineSegment[] = [];
const syntaxStack: ([string, string] | null)[] = [];
// Walk the raw string in parallel with the token stream to compute
// rawOffset values. `rawCursor` tracks our position in `rawContent`.
let rawCursor = 0;
for (const child of children) {
if (child.nesting === 1) {
// Opening tag - advance rawCursor past the opening marker
const syn = syntaxForToken(child);
if (syn) {
const markerPos = rawContent.indexOf(syn[0], rawCursor);
if (markerPos !== -1) {
rawCursor = markerPos + syn[0].length;
}
}
syntaxStack.push(syn);
} else if (child.nesting === -1) {
// Closing tag - advance rawCursor past the closing marker
const syn = syntaxStack.pop();
if (syn) {
const closeMarker = syn[1];
const markerPos = rawContent.indexOf(closeMarker, rawCursor);
if (markerPos !== -1) {
rawCursor = markerPos + closeMarker.length;
}
}
} else if (child.type === 'code_inline') {
// Self-contained segment with backtick syntax
const backtick = child.markup || '`';
const markerPos = rawContent.indexOf(backtick + child.content + backtick, rawCursor);
if (markerPos !== -1) {
segments.push({
text: child.content,
rawOffset: markerPos + backtick.length,
rawLength: child.content.length,
syntax: [backtick, backtick],
});
rawCursor = markerPos + backtick.length + child.content.length + backtick.length;
}
} else if (child.type === 'softbreak') {
// Treat as newline in text content - skip in raw
const nlPos = rawContent.indexOf('\n', rawCursor);
if (nlPos !== -1) rawCursor = nlPos + 1;
} else if (child.type === 'text') {
// Plain text or text inside a syntax wrapper
const textPos = rawContent.indexOf(child.content, rawCursor);
const currentSyntax = syntaxStack.length > 0 ? syntaxStack[syntaxStack.length - 1] : null;
if (textPos !== -1) {
segments.push({
text: child.content,
rawOffset: textPos,
rawLength: child.content.length,
syntax: currentSyntax,
});
rawCursor = textPos + child.content.length;
}
}
}
return segments;
}
/**
* Build plain text content from segments (concatenated segment texts).
*/
function plainTextFromSegments(segments: InlineSegment[]): string {
return segments.map((s) => s.text).join('');
}
/**
* Resolve a `textOffset` (character position in plain text) to the segment
* that contains it.
*/
function resolveCursor(
segments: InlineSegment[],
textOffset: number,
): (InlineSegment & { segment: number }) | null {
let accumulated = 0;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (accumulated + seg.text.length > textOffset) {
return { segment: i, ...seg };
}
accumulated += seg.text.length;
}
// If offset is exactly at the end, return the last segment
if (segments.length > 0) {
const last = segments[segments.length - 1];
return { segment: segments.length - 1, ...last };
}
return null;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Create a source-tools instance bound to the given project root.
*
* All file paths passed to the returned methods are resolved relative to
* `projectRoot` and validated against path traversal.
*/
export function createSourceTools({ projectRoot }: { projectRoot: string }): SourceTools & { _modified: boolean } {
// Normalise projectRoot to an absolute path without trailing separator
projectRoot = path.resolve(projectRoot);
// Create a markdown-it instance once (no dev features needed for parsing)
const md = createMarkdownProcessor({ isDev: false }, () => {});
const tools: SourceTools & { _modified: boolean } = {
_modified: false,
// -------------------------------------------------------------------
// getBlockAt
// -------------------------------------------------------------------
async getBlockAt(
file: string,
blockRef: [number, number],
options?: { textOffset?: number },
): Promise<BlockInfo> {
const filePath = safePath(projectRoot, file);
const raw = await fs.promises.readFile(filePath, 'utf8');
const fmOffset = computeFrontmatterOffset(raw);
const [startLine, endLine] = blockRef;
const absStart = fmOffset + startLine;
const absEnd = fmOffset + endLine;
const allLines = raw.split('\n');
const blockLines = allLines.slice(absStart, absEnd);
const rawBlock = blockLines.join('\n');
const segments = buildSegments(rawBlock, md);
const textContent = plainTextFromSegments(segments);
let cursor: (InlineSegment & { segment: number }) | null = null;
if (options && options.textOffset != null) {
cursor = resolveCursor(segments, options.textOffset);
}
return {
id: null,
line: { start: absStart, end: absEnd },
raw: rawBlock,
textContent,
segments,
cursor,
ancestors: [],
};
},
// -------------------------------------------------------------------
// findText
// -------------------------------------------------------------------
async findText(
file: string,
blockRef: [number, number],
text: string,
textOffset?: number,
): Promise<TextLocation | null> {
const block = await tools.getBlockAt(file, blockRef, { textOffset });
// Find the segment containing the target text
let accumulated = 0;
for (const seg of block.segments) {
const idx = seg.text.indexOf(text);
if (idx !== -1) {
const matchPos = accumulated + idx;
// If textOffset is specified, ensure the match overlaps with the target position
if (textOffset != null && (matchPos + text.length <= textOffset || matchPos > textOffset + text.length)) {
accumulated += seg.text.length;
continue;
}
const rawStartInBlock = seg.rawOffset + idx;
const rawEndInBlock = rawStartInBlock + text.length;
const filePath = safePath(projectRoot, file);
const raw = await fs.promises.readFile(filePath, 'utf8');
const fmOffset = computeFrontmatterOffset(raw);
const absLine = fmOffset + blockRef[0];
return {
line: absLine,
startCol: rawStartInBlock,
endCol: rawEndInBlock,
rawText: text,
wrappingSyntax: {
before: seg.syntax ? seg.syntax[0] : null,
after: seg.syntax ? seg.syntax[1] : null,
},
};
}
accumulated += seg.text.length;
}
return null;
},
// -------------------------------------------------------------------
// wrapText
// -------------------------------------------------------------------
async wrapText(
file: string,
blockRef: [number, number],
text: string,
textOffset: number,
before: string,
after: string,
): Promise<void> {
const loc = await tools.findText(file, blockRef, text, textOffset);
if (!loc) throw new Error(`Text "${text}" not found in block`);
const filePath = safePath(projectRoot, file);
const raw = await fs.promises.readFile(filePath, 'utf8');
const allLines = raw.split('\n');
const line = allLines[loc.line];
const newLine =
line.substring(0, loc.startCol) +
before +
line.substring(loc.startCol, loc.endCol) +
after +
line.substring(loc.endCol);
allLines[loc.line] = newLine;
await fs.promises.writeFile(filePath, allLines.join('\n'));
tools._modified = true;
},
// -------------------------------------------------------------------
// insertAfter
// -------------------------------------------------------------------
async insertAfter(
file: string,
blockRef: [number, number],
content: string,
): Promise<void> {
const filePath = safePath(projectRoot, file);
const raw = await fs.promises.readFile(filePath, 'utf8');
const fmOffset = computeFrontmatterOffset(raw);
const absEnd = fmOffset + blockRef[1];
const allLines = raw.split('\n');
// Insert content after the block with blank line padding
const insertLines = ['', ...content.split('\n')];
allLines.splice(absEnd, 0, ...insertLines);
await fs.promises.writeFile(filePath, allLines.join('\n'));
tools._modified = true;
},
// -------------------------------------------------------------------
// replaceBlock
// -------------------------------------------------------------------
async replaceBlock(
file: string,
blockRef: [number, number],
content: string,
): Promise<void> {
const filePath = safePath(projectRoot, file);
const raw = await fs.promises.readFile(filePath, 'utf8');
const fmOffset = computeFrontmatterOffset(raw);
const absStart = fmOffset + blockRef[0];
const absEnd = fmOffset + blockRef[1];
const allLines = raw.split('\n');
const replacementLines = content.split('\n');
allLines.splice(absStart, absEnd - absStart, ...replacementLines);
await fs.promises.writeFile(filePath, allLines.join('\n'));
tools._modified = true;
},
// -------------------------------------------------------------------
// removeBlock
// -------------------------------------------------------------------
async removeBlock(
file: string,
blockRef: [number, number],
): Promise<void> {
const filePath = safePath(projectRoot, file);
const raw = await fs.promises.readFile(filePath, 'utf8');
const fmOffset = computeFrontmatterOffset(raw);
const absStart = fmOffset + blockRef[0];
const absEnd = fmOffset + blockRef[1];
const allLines = raw.split('\n');
// Remove the block lines
allLines.splice(absStart, absEnd - absStart);
// Clean up consecutive blank lines around the removal point
const i = absStart;
while (i < allLines.length - 1 && allLines[i].trim() === '' && allLines[i + 1].trim() === '') {
allLines.splice(i, 1);
}
await fs.promises.writeFile(filePath, allLines.join('\n'));
tools._modified = true;
},
// -------------------------------------------------------------------
// getBlocks — enumerate all top-level blocks in a file (D-H4)
// -------------------------------------------------------------------
// Every other method takes a `blockRef: [start, end]` pair, which
// forces live-edit plugins to already KNOW the line numbers they
// want to edit. A "click on a block to edit it" UX needs an
// enumeration entry-point. This is it.
//
// Strategy: walk the file line by line, splitting on blank lines
// (a paragraph-level block). A future enhancement could use the
// full container normaliser to detect `::: ` and fenced blocks,
// but blank-line splitting matches what 95% of live-edit UIs need
// and is robust to every existing docmd file.
async getBlocks(file: string): Promise<BlockInfo[]> {
const filePath = safePath(projectRoot, file);
const raw = await fs.promises.readFile(filePath, 'utf8');
const fmOffset = computeFrontmatterOffset(raw);
const lines = raw.split('\n');
const blocks: BlockInfo[] = [];
let blockStart: number | null = null;
const flush = (endLineExclusive: number) => {
if (blockStart === null) return;
// Convert 0-based start / end back to the [start, end] shape
// other methods use, where `end` is exclusive (next-line index).
const absStart = fmOffset + blockStart;
const absEnd = fmOffset + endLineExclusive;
if (absEnd > absStart) {
const rawBlock = lines.slice(blockStart, endLineExclusive).join('\n');
blocks.push({
id: null,
line: { start: absStart, end: absEnd },
raw: rawBlock,
textContent: rawBlock,
segments: [],
cursor: null,
ancestors: []
});
}
blockStart = null;
};
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '') {
flush(i);
} else if (blockStart === null) {
blockStart = i;
}
}
flush(lines.length);
return blocks;
},
};
return tools;
}
+738
View File
@@ -0,0 +1,738 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/api
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*/
// ---------------------------------------------------------------------------
// Plugin Descriptor & Capabilities (§1, §3 of advanced-plugin-plan)
// ---------------------------------------------------------------------------
/** Known hook categories that a plugin can declare. */
export type Capability =
| 'markdown'
| 'head'
| 'body'
| 'assets'
| 'post-build'
| 'actions'
| 'events'
| 'translations'
| 'init'
| 'build'
| 'dev'
| 'template';
/**
* Every plugin should export a `plugin` descriptor.
* Required starting 0.8.0; currently a soft deprecation warning is
* emitted when missing.
*/
export interface PluginDescriptor {
/** Unique identifier for this plugin. */
name: string;
/** Semver version string. */
version: string;
/** Declared hook categories this plugin uses. */
capabilities: Capability[];
}
// ---------------------------------------------------------------------------
// Action / Event system (RPC)
// ---------------------------------------------------------------------------
/**
* Context provided to plugin action and event handlers.
*
* Contains file I/O helpers, source editing tools, and project metadata.
* All file operations are sandboxed to the project root directory.
*/
export interface ActionContext {
/** Absolute path to the project root directory. */
projectRoot: string;
/** Current docmd site configuration. */
config: any;
/** Read a file relative to the project root. */
readFile(relativePath: string): Promise<string>;
/** Write a file relative to the project root. Sets the modification flag. */
writeFile(relativePath: string, content: string): Promise<void>;
/** Read a file as an array of lines. */
readFileLines(relativePath: string): Promise<string[]>;
/** Broadcast an event to all connected browser clients. */
broadcast(event: string, data: any): void;
/** Source editing tools for block-level markdown manipulation. */
source: SourceTools;
/** Execute a generic function inside the multi-threaded worker pool. */
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
}
/**
* Handler for a named plugin action (WebSocket RPC).
* Returns a result that is sent back to the browser client.
*/
export type ActionHandler = (payload: any, ctx: ActionContext) => Promise<any>;
/** Handler for a fire-and-forget plugin event. */
export type EventHandler = (data: any, ctx: ActionContext) => void;
/** Result of dispatching an action call. */
export interface DispatchResult {
result: any;
reload: boolean;
}
// ---------------------------------------------------------------------------
// Source Editing Tools
// ---------------------------------------------------------------------------
/**
* Source editing tools that translate rendered-output references
* (block IDs, text offsets) back to raw markdown source positions.
*/
export interface SourceTools {
/** Get block content and inline segments at a given source map reference. */
getBlockAt(file: string, blockRef: [number, number], options?: { textOffset?: number }): Promise<BlockInfo>;
/** Locate text within a block and return its source position. */
findText(file: string, blockRef: [number, number], text: string, textOffset?: number): Promise<TextLocation | null>;
/** Wrap text within a block with syntax markers (e.g., `==`, `**`). */
wrapText(file: string, blockRef: [number, number], text: string, textOffset: number, before: string, after: string): Promise<void>;
/** Insert markdown content after a block. */
insertAfter(file: string, blockRef: [number, number], content: string): Promise<void>;
/** Replace an entire block's source lines. */
replaceBlock(file: string, blockRef: [number, number], content: string): Promise<void>;
/** Remove a block's source lines. */
removeBlock(file: string, blockRef: [number, number]): Promise<void>;
/**
* Enumerate every top-level block in a file (D-H4). Returns
* `BlockInfo[]` with `line.start` and `line.end` populated — the
* other methods can then be called with the returned blockRef.
* Blocks are delimited by blank lines (a paragraph-level split
* that handles the common case of editing a single paragraph or
* list at a time).
*/
getBlocks(file: string): Promise<BlockInfo[]>;
}
/** Information about a block in the markdown source. */
export interface BlockInfo {
id: string | null;
line: { start: number; end: number };
raw: string;
textContent: string;
segments: InlineSegment[];
cursor: InlineSegment | null;
ancestors: any[];
}
/** A contiguous run of text content with optional surrounding syntax. */
export interface InlineSegment {
text: string;
rawOffset: number;
rawLength: number;
syntax: [string, string] | null;
}
/** Source position of located text within a block. */
export interface TextLocation {
line: number;
startCol: number;
endCol: number;
rawText: string;
wrappingSyntax: { before: string | null; after: string | null };
}
// ---------------------------------------------------------------------------
// Plugin Module Interface
// ---------------------------------------------------------------------------
/**
* Interface for a docmd plugin module.
*
* Plugins can export any combination of build-time hooks and runtime
* action/event handlers.
*/
export interface PluginModule {
/** Plugin descriptor (required starting 0.8.0). */
plugin?: PluginDescriptor;
/** Extend the markdown-it parser instance. */
markdownSetup?(md: any, options?: any): void;
/** Inject meta/link tags into the HTML head. */
generateMetaTags?(config: any, page: any, relativePathToRoot: string): string | Promise<string>;
/** Inject scripts into head and/or body. */
generateScripts?(config: any, options?: any): { headScriptsHtml?: string; bodyScriptsHtml?: string };
/** Define external assets (JS/CSS) to inject. */
getAssets?(options?: any): Asset[];
/** Run logic before HTML generation, after markdown parsing. */
onBeforeBuild?(ctx: BeforeBuildContext): Promise<void>;
/** Run logic after all HTML files are generated. */
onPostBuild?(ctx: PostBuildContext): Promise<void>;
/** Locale-specific UI string overrides. */
translations?(localeId: string, options?: any): Record<string, string>;
/** Named action handlers for WebSocket RPC calls from the browser. */
actions?: Record<string, ActionHandler>;
/** Named event handlers for fire-and-forget messages from the browser. */
events?: Record<string, EventHandler>;
/** Whether this plugin should run on noStyle pages (default: true). */
noStyle?: boolean;
// --- Lifecycle Hooks ---
/** Read/modify normalized config right after initialization. */
onConfigResolved?(config: any): void | Promise<void>;
/** Access the dev server instance. */
onDevServerReady?(server: any, wss: any): void | Promise<void>;
/** Modify raw markdown before parsing. Called per page. */
onBeforeParse?(src: string, frontmatter: any, filePath?: string): string | Promise<string>;
onAfterParse?(html: string, frontmatter: any, filePath?: string): string | Promise<string>;
/**
* Called BEFORE template rendering. Receives the page context including
* `sourcePath` (absolute path to the source .md file), `frontmatter`,
* and `html`. Mutations are reflected in the rendered output.
*
* This is the right hook for plugins that need to inject data derived
* from the source file (e.g. reading frontmatter, computing metadata)
* before the template runs.
*/
onBeforeRender?(page: PageContext): void | Promise<void>;
/** Access fully assembled page object before write. */
onPageReady?(page: any): void | Promise<void>;
// --- Template System (new in 0.8.7) ---
/**
* Template file overrides. Requires the `template` capability on the
* plugin descriptor. The resolver in @docmd/ui merges these with the
* default templates shipped with the core, falling back to the default
* for any slot the plugin does not provide.
*/
templates?: TemplateHook[];
/**
* CSS/JS asset bundles shipped with the template. Requires the `template`
* capability. Loaded at priority 10 by default so user customCss (15) wins.
*/
templateAssets?: TemplateAssetHook[];
}
// ---------------------------------------------------------------------------
// Page Context — available in onBeforeRender
// ---------------------------------------------------------------------------
/**
* Page context object passed to `onBeforeRender`.
* Always includes `sourcePath` so plugins can read the source file,
* compute file-based metadata, and inject it before templating.
*/
export interface PageContext {
/** Absolute path to the source .md file. Always set. */
sourcePath: string;
/** Parsed frontmatter object. Plugins may mutate this. */
frontmatter: Record<string, any>;
/** Rendered HTML body (between template slots). Plugins may mutate this. */
html: string;
/** Locale id active for this page. */
localeId?: string;
/** Version id active for this page (if versioning enabled). */
versionId?: string;
/** Relative path from the output file to the site root. */
relativePathToRoot?: string;
/** Execute a generic function inside the multi-threaded worker pool. */
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
}
// ---------------------------------------------------------------------------
// Build Contexts
// ---------------------------------------------------------------------------
/**
* D-M2: minimal canonical types for the build contexts. Previously both
* `config` and `pages` were `any`, which forced plugin authors to either
* reach for type assertions or write untyped code. Plugin authors who
* need richer shapes can still cast to a wider type — these are the
* structural minimums that every plugin can rely on.
*
* The full config shape lives in `@docmd/core`'s `normalizeConfig`
* output; exposing it here would create a circular import. Plugin
* authors who need the full shape can extend with intersection types.
*/
export interface DocConfigShape {
title: string;
url?: string;
base?: string;
src?: string;
out?: string;
theme?: Record<string, any>;
layout?: Record<string, any>;
i18n?: Record<string, any>;
versions?: Record<string, any>;
workspace?: Record<string, any>;
plugins?: Record<string, any>;
[key: string]: any;
}
export interface PageInfoShape {
sourcePath: string;
outputPath: string;
frontmatter: Record<string, any>;
htmlContent?: string;
rawMarkdown?: string;
headings?: Array<{ id: string; text: string; level: number }>;
urls?: Record<string, string>;
urlContext?: Record<string, any>;
config?: DocConfigShape;
[key: string]: any;
}
/** Context provided to onBeforeBuild hooks. */
export interface BeforeBuildContext {
config: DocConfigShape;
pages: PageInfoShape[];
tui: any; // @docmd/tui instance for progress bars and spinners
options: any;
/** Execute a generic function inside the multi-threaded worker pool. */
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
}
/** Context provided to onPostBuild hooks. */
export interface PostBuildContext {
config: DocConfigShape;
pages: PageInfoShape[];
outputDir: string;
tui: any; // @docmd/tui instance for progress bars and spinners
log: (msg: string) => void;
options: any;
/** Execute a generic function inside the multi-threaded worker pool. */
runWorkerTask<T = any>(modulePath: string, functionName: string, args: any[]): Promise<T>;
}
// ---------------------------------------------------------------------------
// Hook Registry Shape
// ---------------------------------------------------------------------------
/** The shape of the hooks object maintained by the plugin loader. */
export interface PluginHooks {
markdownSetup: ((md: any) => void)[];
injectHead: ((config: any, pageContext: any, root?: string) => string | Promise<string>)[];
injectBody: ((config: any, pageContext: any) => string | Promise<string>)[];
onBeforeBuild: ((ctx: BeforeBuildContext) => Promise<void>)[];
onPostBuild: ((ctx: PostBuildContext) => Promise<void>)[];
assets: (() => Asset[] | Promise<Asset[]>)[];
translations: ((localeId: string) => Record<string, string> | Promise<Record<string, string>>)[];
actions: Record<string, ActionHandler>;
events: Record<string, EventHandler>;
// Lifecycle Hooks
onConfigResolved: ((config: any) => void | Promise<void>)[];
onDevServerReady: ((server: any, wss: any) => void | Promise<void>)[];
onBeforeParse: ((src: string, frontmatter: any, filePath?: string) => string | Promise<string>)[];
onAfterParse: ((html: string, frontmatter: any, filePath?: string) => string | Promise<string>)[];
/** Called before template rendering. Receives full PageContext. */
onBeforeRender: ((page: PageContext) => void | Promise<void>)[];
onPageReady: ((page: any) => void | Promise<void>)[];
// Template System (new in 0.8.7)
/** Template file overrides registered by template plugins. */
templates: TemplateHook[];
/** Asset bundles registered by template plugins. */
templateAssets: TemplateAssetHook[];
}
// ---------------------------------------------------------------------------
// Engine Interface (§ Engine Abstraction Layer)
// ---------------------------------------------------------------------------
/**
* Task definition for engine execution.
* Engines receive tasks and return results - they don't know or care
* what the task does, they just execute it.
*/
export interface EngineTask {
/** Task type identifier (e.g., 'file:discover', 'git:log', 'search:index') */
type: string;
/** Task payload - any serializable data */
payload: any;
/** Optional timeout in milliseconds */
timeout?: number;
}
/**
* Result from an engine task execution.
*/
export interface EngineResult<T = any> {
/** Whether the task succeeded */
success: boolean;
/** Result data if successful */
data?: T;
/** Error message if failed */
error?: string;
/** Execution time in milliseconds */
duration?: number;
}
/**
* Engine interface for pluggable build acceleration.
*
* Engines are simple task executors - they receive a task and return a result.
* The API layer controls what tasks are allowed and how they're structured.
* This keeps engines language-agnostic and allows any tool (docmd, docmd-search, etc.)
* to use them without tight coupling.
*
* @example
* ```typescript
* const engine = await loadEngine('rust');
* const result = await engine.run({ type: 'file:discover', payload: { dir: './docs' } });
* if (result.success) {
* console.log('Found files:', result.data);
* }
* ```
*/
export interface Engine {
/** Engine descriptor with name and version. */
readonly name: string;
readonly version: string;
/**
* Execute a task and return the result.
* This is the only method engines need to implement.
*
* @param task - The task to execute
* @returns The result of the task execution
*/
run<T = any>(task: EngineTask): Promise<EngineResult<T>>;
/**
* Check if the engine supports a given task type.
* Optional - if not implemented, engine assumes it can try any task.
*/
supports?(taskType: string): boolean;
/**
* Initialize the engine. Called once before first use.
*/
init?(options?: EngineInitOptions): Promise<void>;
/**
* Clean up resources. Called when the engine is no longer needed.
*/
destroy?(): Promise<void>;
}
/**
* Options passed to engine initialization.
*/
export interface EngineInitOptions {
/** Project root directory. */
projectRoot?: string;
/** Enable debug logging. */
debug?: boolean;
}
/**
* Engine loader function type.
*/
export type EngineLoader = () => Promise<Engine | null>;
/**
* Registry of available engine loaders.
*/
export const engineRegistry: Map<string, EngineLoader> = new Map();
/**
* Register an engine loader.
*/
export function registerEngine(name: string, loader: EngineLoader): void {
engineRegistry.set(name, loader);
}
// ---------------------------------------------------------------------------
// Task Types (defined by API, not engines)
// ---------------------------------------------------------------------------
/**
* Built-in task types that the API layer supports.
* Engines don't define these - the API does.
*/
export type BuiltinTaskType =
// File operations
| 'file:discover' // Discover files in directory tree
| 'file:read' // Read single file
| 'file:readBatch' // Read multiple files
| 'file:write' // Write file
| 'file:exists' // Check if file exists
// Git operations
| 'git:log' // Get git log for file(s)
| 'git:status' // Get git status
// Search operations
| 'search:index' // Build search index
| 'search:query' // Query search index
// Generic
| 'exec:script'; // Execute arbitrary script
/**
* Payload for file:discover task.
*/
export interface FileDiscoverPayload {
dir: string;
extensions?: string[];
exclude?: string[];
}
/**
* Payload for file:readBatch task.
*/
export interface FileReadBatchPayload {
paths: string[];
}
/**
* Payload for git:log task.
*/
export interface GitLogPayload {
filePaths: string[];
maxCommits?: number;
}
/**
* Payload for search:index task.
*/
export interface SearchIndexPayload {
documents: Array<{
id: string;
title: string;
content: string;
path: string;
locale?: string;
version?: string;
}>;
}
// ---------------------------------------------------------------------------
// Asset Declaration (typed, used by getAssets + template assets)
// ---------------------------------------------------------------------------
/** Asset kind. `static` copies the file verbatim; `css`/`js` emit <link>/<script>. */
export type AssetKind = 'css' | 'js' | 'static';
/** Where an asset is injected in the page. */
export type AssetPosition = 'head' | 'body' | 'footer';
/**
* Asset descriptor returned by `getAssets()` or declared in a template.
*
* `priority` controls load order. Lower loads first, higher loads last.
* - `0` base (e.g. docmd-main.css)
* - `5` theme colour overlay (e.g. theme-sky.css)
* - `10` template structure
* - `15` user customCss / customJs (always wins)
* - `20` other plugins
*
* Templates MUST NOT use `!important` in CSS so that customCss overrides
* remain authoritative.
*/
export interface Asset {
/** Asset kind. */
type: AssetKind;
/** Absolute or template-relative path to the source file. */
path?: string;
/** Public URL/path where the asset will be served from. */
url?: string;
/** Load order. Lower = earlier. Defaults to 0. */
priority?: number;
/** Where in the document to inject. Defaults to `head` for css, `body` for js. */
position?: AssetPosition;
/** Optional content-hash suffix (e.g. for cache busting). */
hash?: string;
/** Optional inline content (mutually exclusive with `path`). */
inline?: string;
/**
* Optional condition for conditional loading. When set, the asset's `<link>`
* or `<script>` tag is only emitted on pages where the condition matches.
*
* Omit this field (or leave `condition` undefined) to keep the legacy
* behaviour: include the asset on every page.
*
* Evaluated per page at build time, so the cost is paid once during the
* build, not at runtime. Conditional assets still have their files copied
* to the output directory as usual — only the HTML tag is skipped when the
* condition fails.
*
* @example Only load mermaid on pages that actually have a diagram block
* ```ts
* {
* src: 'init-mermaid.js',
* dest: 'assets/js/init-mermaid.js',
* type: 'js',
* position: 'body',
* attributes: { type: 'module' },
* condition: { pageHtmlMatches: 'class="mermaid"' }
* }
* ```
*/
condition?: AssetCondition;
// --- Legacy aliases (deprecated; kept for backwards compat with 0.8.x) ---
/** @deprecated Use `path`. */
src?: string;
/** @deprecated Use `url`. */
dest?: string;
/** @deprecated Use `position`. */
location?: 'head' | 'body' | 'none';
/** @deprecated Use `position`. Legacy maps to `head`/`body`/`none`. */
attributes?: Record<string, string | boolean>;
}
/**
* Predicate evaluated against the rendered page to decide whether a
* conditional asset should be injected. All keys present in the condition
* must match (logical AND). Within a key, multiple values are OR-ed.
*/
export interface AssetCondition {
/**
* The asset is injected only if the page's HTML (post-markdown, pre-template)
* contains at least one of the given substrings. Use this to gate JS bundles
* that init a specific markup — e.g. `class="mermaid"` for mermaid blocks,
* `class="katex"` for KaTeX math, etc.
*
* Substring (not selector) so the check stays O(n) and dependency-free.
* For more advanced matching, combine multiple substrings (OR-ed).
*/
pageHtmlMatches?: string | string[];
/**
* The asset is injected only if the page's parsed frontmatter has this key
* defined (any value, including `false`). Useful when a page opts in via
* frontmatter (e.g. `math: true`).
*/
frontmatterHas?: string;
}
// ---------------------------------------------------------------------------
// Template System (new in 0.8.7)
// ---------------------------------------------------------------------------
/**
* Logical template slots a template can override.
*
* These match the file names that ship in `@docmd/ui/templates/`. A template
* that provides, say, only `menubar.ejs` will inherit the rest of the layout
* from the default templates shipped with `@docmd/ui`.
*
* Conventions:
* - Files live under `templates/` in the template package.
* - Partials live under `templates/partials/`.
* - File names match the slot name exactly (e.g. `layout.ejs` for `layout`).
*
* Templates MAY also define custom partials and include them from inside their
* own EJS files; only the slots listed here participate in the default
* resolution chain.
*
* Slots currently with default files in `@docmd/ui`:
* layout, 404, toc, navigation, footer, menubar, options-menu,
* project-switcher, version-dropdown, language-switcher, banner,
* cookie-consent.
*
* `no-style` pages are not a template slot — they always use the default
* `templates/no-style.ejs` and are unaffected by the active template.
*/
export type TemplateSlot =
| 'layout'
| '404'
| 'toc'
| 'navigation'
| 'footer'
| 'menubar'
| 'options-menu'
| 'project-switcher'
| 'version-dropdown'
| 'language-switcher'
| 'banner'
| 'cookie-consent';
/**
* A single template file override registered by a template plugin.
*
* Templates register one entry per file they ship. The resolver merges
* the entries with the default templates from `@docmd/ui`, falling back
* to the default for any slot the template does not provide.
*/
export interface TemplateHook {
/** Logical slot this file overrides. */
type: TemplateSlot;
/** Absolute path to the `.ejs` file inside the template package. */
templatePath: string;
/**
* Priority within the same slot. Higher wins. Defaults to 0.
* Useful if multiple plugins contribute templates for the same slot.
*/
priority?: number;
/**
* Glob patterns (e.g. `"blog/*"`) of page paths where this template
* applies. Omit or pass `[]` to apply to all pages.
*/
pages?: string[];
/**
* Glob patterns of page paths this template must NOT apply to.
* Evaluated before `pages`.
*/
exclude?: string[];
}
/**
* Asset descriptor for a template's own CSS/JS bundle.
*
* Templates ship a single CSS file (and optionally a single JS file). These
* are loaded after the base `docmd-main.css` and before any user `customCss`
* so the user can always override a template's styles.
*/
export interface TemplateAssetHook {
type: 'css' | 'js';
/** Absolute path to the file inside the template package. */
path: string;
/** Load priority. Defaults to 10 for templates. */
priority?: number;
/** Head or body injection. Defaults are `head` for css, `body` for js. */
position?: AssetPosition;
}
/**
* Context passed to the template resolver. The resolver decides which
* template file to use for a given slot on a given page.
*/
export interface TemplateResolutionContext {
/** Slot being resolved. */
type: TemplateSlot;
/** Absolute path of the page being rendered (post-build path, e.g. `/guide/intro.html`). */
pagePath: string;
/** Page frontmatter (may contain `template` override). */
frontmatter: Record<string, any>;
/** Resolved site config. */
config: any;
/** Locale id for the page, if any. */
localeId?: string;
/** Version id for the page, if any. */
versionId?: string;
}
/**
* Result of resolving a template slot.
*
* `source === 'default'` means no template plugin claimed the slot and the
* core `@docmd/ui` default is being used. `source === 'frontmatter'` /
* `'config'` indicates the override path. `source === 'plugin'` indicates
* a registered template plugin satisfied the request.
*/
export interface ResolvedTemplate {
/** Absolute filesystem path to the EJS file to render. */
templatePath: string;
/** How the resolution arrived at this template. */
source: 'default' | 'frontmatter' | 'config' | 'plugin';
/** Plugin name, when `source === 'plugin'`. */
pluginName?: string;
/** Slot that was resolved. */
type: TemplateSlot;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}