chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user