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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default [
},
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'preserve-caught-error': 'off',
'@typescript-eslint/ban-ts-comment': 'warn',
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/monorepo",
"version": "0.8.12",
"version": "0.8.13",
"private": true,
"description": "The minimalist, zero-config documentation generator monorepo.",
"scripts": {
Expand All @@ -18,6 +18,8 @@
"docmd-live": "node packages/live/bin/docmd-live.js --cwd packages/_playground",
"prep": "node tools/prep.js",
"verify": "node tools/verify.js",
"simulate": "echo 'Run from a consumer project: cd ../docs && npm run sim' && exit 1",
"simulate:local": "echo 'Run from a consumer project: cd ../docs && npm run sim' && exit 1",
"lint": "eslint .",
"bump": "node tools/bump.js",
"docker:build": "docker build -t docmd:latest -f docker/Dockerfile .",
Expand Down
2 changes: 1 addition & 1 deletion packages/_playground/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/playground",
"version": "0.8.12",
"version": "0.8.13",
"private": true,
"dependencies": {
"@docmd/core": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/api",
"version": "0.8.12",
"version": "0.8.13",
"description": "Plugin API surface for docmd.",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/api/registry/plugins.generated.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$meta": {
"generatedAt": "2026-07-12T12:23:44.202Z",
"generatedAt": "2026-07-13T20:57:53.220Z",
"generator": "scripts/build-plugin-registry.mjs",
"packageCount": 15
},
Expand Down
78 changes: 57 additions & 21 deletions packages/api/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
errors: string[];
}

function validateDescriptor(descriptor: any): ValidationResult {

Check warning on line 97 in packages/api/src/hooks.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
const errors: string[] = [];

if (!descriptor || typeof descriptor !== 'object') {
Expand Down Expand Up @@ -137,7 +137,7 @@
// Isolation wrapper (§2)
// ---------------------------------------------------------------------------

async function safeCall<T>(hookName: string, pluginName: string, fn: (...args: any[]) => T, ...args: any[]): Promise<T | string | undefined> {

Check warning on line 140 in packages/api/src/hooks.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type

Check warning on line 140 in packages/api/src/hooks.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
try {
const result = fn(...args);
// Plugins are allowed to return Promises (most `async` functions do).
Expand All @@ -147,11 +147,11 @@
// 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') {

Check warning on line 150 in packages/api/src/hooks.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
return await result;
}
return result;
} catch (err: any) {

Check warning on line 154 in packages/api/src/hooks.ts

View workflow job for this annotation

GitHub Actions / verify

Unexpected any. Specify a different type
TUI.error(`Plugin "${pluginName}" threw in ${hookName}`, err.message);
return (hookName === 'injectHead' || hookName === 'injectBody') ? '' as any : undefined;
}
Expand Down Expand Up @@ -327,27 +327,60 @@
// ---------------------------------------------------------------------------
//
// 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.
// `spawn`-based installer live in `./runtime-deps.ts`. We import them
// here and wrap `installRuntimeDep` with the project's warn-once
// deduper so a dev-server rebuild can't flood the TUI.

// ---------------------------------------------------------------------------
// 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 || [])
];
// The monorepo dev fallback (loading plugins/templates from
// packages/plugins or packages/templates in the monorepo source)
// should ONLY fire when the process is running from inside the
// monorepo itself. When an external project runs the monorepo's
// dist binary (e.g. docs/ running `node ../docmd/packages/core/
// dist/bin/docmd.js dev`), the monorepo root is resolvable but the
// external project's node_modules is the right place to look.
// Without this gate, a template like @docmd/template-summer is found
// in the monorepo source, loaded directly, and auto-install never
// fires — so the package never lands in the user's package.json or
// node_modules. (Search plugin already scopes itself to
// process.cwd()/node_modules; this applies the same principle to
// the generic plugin/template loader.)
const isMonorepoContext = process.cwd().startsWith(__monorepoRoot + path.sep) || process.cwd() === __monorepoRoot;

// 1. Resolution paths for plugin imports.
//
// When running INSIDE the monorepo (dev/test), we include __dirname
// and the monorepo package dirs so workspace packages resolve during
// development.
//
// When running OUTSIDE the monorepo (an external consumer project like
// docs/ running `dev:local`), we deliberately EXCLUDE __dirname. Why:
// __dirname inside the monorepo binary points to docmd/packages/api/dist/,
// and Node walks up from there to docmd/node_modules/ where EVERY workspace
// package is symlinked. That means template-summer, math, pwa — packages
// the consumer never installed — resolve silently from the monorepo. The
// consumer never sees the auto-install path fire, the package never lands
// in their package.json, and CI/production breaks. Restricting to
// process.cwd() means core plugins (deps of @docmd/core) resolve from the
// consumer's own node_modules, and optional plugins trigger auto-install.
const resolvePaths = isMonorepoContext
? [
process.cwd(),
__dirname,
__monorepoRoot,
path.join(__monorepoRoot, 'packages/plugins'),
path.join(__monorepoRoot, 'packages/templates'),
...(opts?.resolvePaths || []),
]
: [
process.cwd(),
path.join(process.cwd(), 'node_modules'),
...(opts?.resolvePaths || []),
];

// 1. Reset hooks
hooks.markdownSetup = [];
Expand Down Expand Up @@ -436,14 +469,17 @@
// 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-')) {
// Gated on isMonorepoContext so external projects running the monorepo
// binary (e.g. via `node ../docmd/.../docmd.js`) don't accidentally load
// from the monorepo source and bypass auto-install.
if (isMonorepoContext && 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-')) {
} else if (isMonorepoContext && 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');
Expand Down Expand Up @@ -521,10 +557,10 @@
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);
// Retry loading after install, scoped to the consumer's
// node_modules (not the monorepo's, which is what bare
// `import(name)` would walk into).
const reloaded = await tryLoadAfterInstall(name, process.cwd());
if (!reloaded) {
warnOnce(
`autoinstall:${name}`,
Expand Down
88 changes: 80 additions & 8 deletions packages/api/src/runtime-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
import path from 'node:path';
import nativeFs from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { TUI } from '@docmd/tui';
Expand Down Expand Up @@ -137,6 +137,11 @@ export function detectPackageManager(cwd: string): 'pnpm' | 'yarn' | 'bun' | 'np
* a CI image without node_modules linked).
*/
export function getDocmdVersion(): string {
// DOCMD_INSTALL_VERSION overrides everything. Lets users / CI pin auto-installs
// to a specific version (or 'latest') independent of the installed core.
if (process.env.DOCMD_INSTALL_VERSION) {
return process.env.DOCMD_INSTALL_VERSION.trim() || 'latest';
}
try {
const corePkgPath = require.resolve('@docmd/core/package.json', {
paths: [process.cwd(), __dirname, __monorepoRoot],
Expand Down Expand Up @@ -349,15 +354,82 @@ export function shortKey(packageName: string): string | null {
}

/**
* 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.
* Manually resolve a package entry point by walking up from `startDir`
* looking for `node_modules/<packageName>/package.json`. This bypasses
* Node's internal module resolution cache, which can fail to find a
* package that was just installed during the same process (the cache
* remembers the "not found" result from the initial failed resolve).
*
* Returns the absolute path to the entry JS file, or null if not found.
*/
function manualResolvePackageEntry(packageName: string, startDir: string): string | null {
const pkgSubPath = path.join('node_modules', packageName);
let dir = path.resolve(startDir);
// Walk up the directory tree looking for node_modules/<pkg>
while (dir !== path.dirname(dir)) {
const candidate = path.join(dir, pkgSubPath, 'package.json');
if (nativeFs.existsSync(candidate)) {
try {
const pkg = JSON.parse(nativeFs.readFileSync(candidate, 'utf8'));
// Resolve the entry point: exports['.'] > main > index.js
let entry: string | undefined;
if (pkg.exports && typeof pkg.exports === 'object' && pkg.exports['.']) {
const exp = pkg.exports['.'];
entry = typeof exp === 'string' ? exp : (exp.import || exp.require || exp.default);
}
if (!entry) entry = pkg.main || 'index.js';
const cleanEntry = (entry || 'index.js').replace(/^\.\//, '');
const entryPath = path.join(dir, pkgSubPath, cleanEntry);
if (nativeFs.existsSync(entryPath)) return entryPath;
// Try dist/index.js as a common fallback
const distEntry = path.join(dir, pkgSubPath, 'dist', 'index.js');
if (nativeFs.existsSync(distEntry)) return distEntry;
} catch {
// Malformed package.json — keep walking
}
}
dir = path.dirname(dir);
}
return null;
}

/**
* Re-load `pkg` after an install attempt. Tries `createRequire` first
* (honours exports conditions), then falls back to a manual node_modules
* walk-up that bypasses Node's internal resolution cache. The cache can
* stale-fail when a package was just `npm install`ed during the same
* process — the initial `require.resolve` failure is remembered even
* after the package appears on disk.
*
* Returns the module reference or null on failure.
*/
export async function tryLoadAfterInstall(packageName: string): Promise<any | null> {
export async function tryLoadAfterInstall(
packageName: string,
consumerCwd: string = process.cwd(),
): Promise<any | null> {
// Strategy 1: createRequire (honours exports field, but may stale-cache)
try {
return await import(packageName);
const consumerRequire = createRequire(consumerCwd + '/');
const entry = consumerRequire.resolve(packageName);
return await import(pathToFileURL(entry).href);
} catch {
return null;
// Fall through to manual resolution
}

// Strategy 2: manual node_modules walk-up (bypasses cache, always
// does a fresh filesystem check). This is the reliable path when
// the package was installed seconds ago in the same process.
const manualEntry = manualResolvePackageEntry(packageName, consumerCwd);
if (manualEntry) {
try {
return await import(pathToFileURL(manualEntry).href);
} catch (e: any) {
const detail = e?.code ? `${e.code}: ${e.message}` : (e?.message || String(e));
TUI.warn(`Post-install load of ${packageName} (manual resolve to ${manualEntry}) failed: ${detail}`);
return null;
}
}

TUI.warn(`Post-install load of ${packageName} from ${consumerCwd} failed: package not found in node_modules tree`);
return null;
}
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/core",
"version": "0.8.12",
"version": "0.8.13",
"description": "Build production-ready documentation from Markdown in seconds. No React, no bloat, just content.",
"type": "module",
"browser": false,
Expand Down
40 changes: 29 additions & 11 deletions packages/core/src/engine/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import nativeFs from 'fs';
const _require = createRequire(import.meta.url);
import * as parser from '@docmd/parser';
import { TUI } from '@docmd/tui';
import { findPageNeighbors, findBreadcrumbs, normalizeNavPaths, createUrlContext, buildContextualUrl, computePageUrls, buildAbsoluteUrl, sanitizeUrl } from '@docmd/parser';
import { findPageNeighbors, findBreadcrumbs, normalizeNavPaths, createUrlContext, buildContextualUrl, computePageUrls, buildAbsoluteUrl, sanitizeUrl, normaliseBaseTag } from '@docmd/parser';
import * as ui from '@docmd/ui';


Expand Down Expand Up @@ -540,6 +540,19 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h
else relativePathToRoot += '/';
relativePathToRoot = relativePathToRoot.replace(/\\/g, '/');

// Canonical absolute site root (subpath deployment). The generator
// owns this decision — templates never compute it themselves.
// root deploy → '/'
// locale/version subpath → '/de/' or '/v1/'
// explicit `config.base` → '/my-repo/' (3rd-party deploys)
let siteRootAbs = '/';
if (config._activePrefix && config._activePrefix !== '/') {
siteRootAbs = config._activePrefix;
} else if (config.base && config.base !== '/') {
siteRootAbs = config.base;
}
if (siteRootAbs !== '/' && !siteRootAbs.endsWith('/')) siteRootAbs += '/';

// Navigation Context
let navPath = '/' + page.outputPath.replace(/\\/g, '/').replace(/\/index\.html$/, '').replace(/^index\.html$/, '');
if (navPath === '/.') navPath = '/';
Expand Down Expand Up @@ -777,16 +790,7 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h
localePrefix: config._localeOutputPrefix || '',
currentPagePath: navPath,
outputPrefix,
siteRootAbs: (() => {
let root = '/';
if (config._activePrefix && config._activePrefix !== '/') {
root = config._activePrefix;
} else if (config.base && config.base !== '/') {
root = config.base;
}
if (root !== '/' && !root.endsWith('/')) root += '/';
return root;
})(),
siteRootAbs,
t,
buildAbsoluteUrl,
sanitizeUrl,
Expand Down Expand Up @@ -815,6 +819,20 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h

fullHtml = pageObj.html;

// ── Centralised <base href> enforcement (single source of truth) ──
// The generator is the ONLY authority for the <base> tag. Templates
// must not emit one themselves — anything they emit will be stripped
// here and replaced with the canonical one computed from
// (siteRootAbs, isOfflineMode). This way old templates, third-party
// templates, and the default template all produce identical <base>
// behaviour without any per-template logic to drift.
//
// Emit rules:
// isOfflineMode=true → no <base> (file://)
// siteRootAbs === '/' (root deploy) → no <base>
// siteRootAbs !== '/' (subpath deploy) → <base href="siteRootAbs">
fullHtml = normaliseBaseTag(fullHtml, !!options.offline, siteRootAbs);

// Queue the write
writeQueue.push({ finalPath, html: fullHtml });
(page as any).urls = pageUrls;
Expand Down
2 changes: 1 addition & 1 deletion packages/deployer/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/deployer",
"version": "0.8.12",
"version": "0.8.13",
"description": "Deployment configuration generator for docmd - Docker, Nginx, Caddy, GitHub Pages, Vercel, and Netlify.",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/engines/js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/engine-js",
"version": "0.8.12",
"version": "0.8.13",
"description": "Default JavaScript engine for docmd using Node.js native APIs.",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/engines/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ const handlers: Record<string, TaskHandler> = {
export function createJsEngine(): Engine {
return {
name: 'js',
version: '0.8.12',
version: '0.8.13',

supports(taskType: string): boolean {
return taskType in handlers;
Expand Down
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/engines/rust-binaries/native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/engines/rust-binaries/native/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "docmd-engine"
version = "0.8.12"
version = "0.8.13"
edition = "2021"
description = "Native Node.js addon for the docmd Rust engine"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion packages/engines/rust-binaries/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/engine-rust-binaries",
"version": "0.8.12",
"version": "0.8.13",
"description": "Pre-built Rust binaries for the docmd engine. All platforms bundled.",
"scripts": {
"build": "node build.js"
Expand Down
Loading
Loading