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 package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/monorepo",
"version": "0.8.10",
"version": "0.8.11",
"private": true,
"description": "The minimalist, zero-config documentation generator monorepo.",
"scripts": {
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.10",
"version": "0.8.11",
"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.10",
"version": "0.8.11",
"description": "Plugin API surface for docmd.",
"type": "module",
"main": "dist/index.js",
Expand Down
3 changes: 2 additions & 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-09T07:41:19.155Z",
"generatedAt": "2026-07-11T05:18:59.145Z",
"generator": "scripts/build-plugin-registry.mjs",
"packageCount": 15
},
Expand Down Expand Up @@ -121,6 +121,7 @@
"tagline": "Offline full-text search for docmd.",
"capabilities": [
"post-build",
"init",
"head",
"body",
"assets",
Expand Down
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.10",
"version": "0.8.11",
"description": "Build production-ready documentation from Markdown in seconds. No React, no bloat, just content.",
"type": "module",
"browser": false,
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,16 @@ export async function buildSite(configPath: string, opts: any = {}) {
// check, `docmd build` exits 0 even when a plugin is missing — the
// site still builds but the user has no way to know a plugin was
// dropped unless they read the warning text.
// N-12: list each failed plugin by name and reason in the TUI, so
// the operator doesn't have to dig through the error message string.
const loadErrors = getPluginLoadErrors();
if (loadErrors.length > 0) {
if (!options.isDev && !options.quiet) {
TUI.error(`${loadErrors.length} plugin(s) could not be loaded`, '');
for (const e of loadErrors) {
console.error(` ${TUI.red('•')} ${e.plugin} — ${e.message}`);
}
}
const lines = loadErrors.map((e) => `${e.plugin}: ${e.message}`);
throw new Error(
`Build failed: ${loadErrors.length} plugin(s) could not be loaded:\n` +
Expand Down Expand Up @@ -383,6 +391,18 @@ export async function buildSite(configPath: string, opts: any = {}) {
const { getPluginErrors } = await import('@docmd/api');
const errors = getPluginErrors();
if (errors.length > 0) {
// N-12: surface every plugin error in one place. Previously the
// user saw "Build complete" and only later discovered failures when
// a generated page was missing or a downstream tool choked on a
// bad artifact. The summary lists every error with its plugin +
// hook, so the operator can grep for any of them.
if (!options.isDev && !options.quiet) {
TUI.error('Plugin errors during build', '');
for (const err of errors) {
const filePart = err.filePath ? ` (${err.filePath})` : '';
console.error(` ${TUI.red('•')} ${err.plugin} :: ${err.hook}${filePart} — ${err.message}`);
}
}
throw new Error(`Build failed: ${errors.length} plugin error(s) occurred during execution.`);
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const defaultConfigContent = `{
"pageNavigation": true,
"navigation": [
{ "title": "Quick Start", "path": "/", "icon": "zap" },
{ "title": "Agent Skills", "path": "/skills/", "icon": "brain-circuit" }
{ "title": "Agent Skills", "path": "/skills/", "icon": "brain-circuit" },
{
"title": "Quick Guide",
"icon": "book-open",
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/commands/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,71 @@ export async function migrateProject(options: { docusaurus?: boolean; mkdocs?: b
return root.length > 0 ? root : null;
};

/**
* T-Z14 / T-Z17: walk a freshly-copied docs directory and translate
* Docusaurus-specific frontmatter keys to their docmd equivalents.
*
* `id:` → removed (docmd derives route ids from filenames)
* `sidebar_label:`→ `nav_title:` (the docmd-supported nav override)
*
* The translation is best-effort: a file that fails to parse keeps
* its original content. Returns the count of files touched.
*/
const translateDocusaurusFrontmatter = async (docsDir: string): Promise<number> => {
const targets: string[] = [];
const walk = async (dir: string): Promise<void> => {
const entries = await nativeFs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
await walk(full);
} else if (entry.isFile() && /\.(md|mdx|markdown)$/i.test(entry.name)) {
targets.push(full);
}
}
};
await walk(docsDir);

let count = 0;
for (const file of targets) {
const raw = await nativeFs.promises.readFile(file, 'utf8');
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) continue;
// Line-by-line parse: we only care about top-level `key: value`
// lines. Docusaurus lets these be unquoted, single-quoted, or
// double-quoted. Nested objects (e.g. `sidebar:` with sub-keys)
// are preserved as-is.
const lines = m[1].split('\n');
let changed = false;
const newLines = lines.map((line) => {
const stripped = line.replace(/\r$/, '');
const idMatch = stripped.match(/^\s*id\s*:\s*(.*?)\s*$/);
if (idMatch) { changed = true; return null; }
const slMatch = stripped.match(/^(\s*)sidebar_label\s*:\s*(.*?)\s*$/);
if (slMatch) {
changed = true;
const indent = slMatch[1];
let val = slMatch[2];
// Strip wrapping quotes if present so the rewritten value
// matches the original document's quote style as closely as
// possible. docmd accepts both forms.
const qm = val.match(/^['"](.*)['"]$/);
if (qm) val = qm[1];
return `${indent}nav_title: ${val}`;
}
return stripped;
}).filter((l) => l !== null) as string[];

if (!changed) continue;
const newFm = newLines.join('\n');
const newContent = raw.replace(/^---\r?\n[\s\S]*?\r?\n---/, `---\n${newFm}\n---`);
await nativeFs.promises.writeFile(file, newContent);
count++;
}
return count;
};

if (options.docusaurus) {
TUI.section('Docusaurus Migration');
const configPath = path.resolve(CWD, 'docusaurus.config.js');
Expand Down Expand Up @@ -190,6 +255,23 @@ export async function migrateProject(options: { docusaurus?: boolean; mkdocs?: b
TUI.step('Created new docs directory', 'DONE');
}

// T-Z14 / T-Z17: translate Docusaurus-specific frontmatter keys.
// docmd derives route ids from filenames, so Docusaurus `id:` is
// dropped. `sidebar_label:` is preserved as `nav_title:` (a
// docmd-supported override of the auto-generated nav title).
// The translation is best-effort: files that fail to parse keep
// their original frontmatter, and a TUI line reports the count.
let fmTranslated = 0;
try {
const translated = await translateDocusaurusFrontmatter(newDocsDir);
fmTranslated = translated;
} catch (err: any) {
TUI.warn(`Frontmatter translation skipped: ${err.message}`);
}
if (fmTranslated > 0) {
TUI.step(`Translated Docusaurus frontmatter in ${fmTranslated} file(s)`, 'DONE');
}

await nativeFs.promises.writeFile(path.resolve(CWD, 'docmd.config.js'), serializeConfig(docmdConfig));
TUI.step('Generated docmd.config.js', 'DONE');

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/engine/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,12 @@ export async function renderPages({ config, srcDir, fallbackSrcDir, outputDir, h
// #167: offline-mode + relative-path context for the markdown post-processor.
isOfflineMode: options.offline === true,
relativePathToRoot: relativePathToRootForMarkdown,
// M-5: tell the post-processor which locale is the default so it
// can strip the default-locale prefix from absolute hrefs
// (e.g. `/en/foo` from a `fr/` page → `/foo`, since the default
// locale lives at root, not under its own prefix).
defaultLocale: config._defaultLocale || null,
allLocales: (config._allLocales || []).map((l: any) => l.id),
config: { base: config.base || '/' }
},
hooks
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.10",
"version": "0.8.11",
"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/deployer/src/providers/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function generateDocker(ctx: DeployContext): Promise<{ dockerfile:
# ---------------------------------------------------

# Stage 1: Build the docmd site
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app

# Copy dependency manifests first for optimal layer caching
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.10",
"version": "0.8.11",
"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.10',
version: '0.8.11',

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.10"
version = "0.8.11"
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.10",
"version": "0.8.11",
"description": "Pre-built Rust binaries for the docmd engine. All platforms bundled.",
"scripts": {
"build": "node build.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/engines/rust/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/engine-rust",
"version": "0.8.10",
"version": "0.8.11",
"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",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/engines/rust/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function createRustEngine(): Engine {

return {
name: 'rust',
version: '0.8.10',
version: '0.8.11',

supports(_taskType: string): boolean {
return true;
Expand Down
2 changes: 1 addition & 1 deletion packages/legacy/doc.md/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "doc.md",
"version": "0.8.10",
"version": "0.8.11",
"description": "Discontinued. Legacy wrapper for docmd. Please use @docmd/core.",
"bin": {
"docmd": "bin/docmd.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/legacy/mgks/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mgks/docmd",
"version": "0.8.10",
"version": "0.8.11",
"description": "Discontinued. Legacy wrapper for docmd. Please use @docmd/core.",
"bin": {
"docmd": "bin/docmd.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/live/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/live",
"version": "0.8.10",
"version": "0.8.11",
"description": "Browser-based editor engine for docmd.",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/parser/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/parser",
"version": "0.8.10",
"version": "0.8.11",
"description": "Pure Markdown parsing engine for docmd.",
"type": "module",
"main": "dist/index.js",
Expand Down
49 changes: 38 additions & 11 deletions packages/parser/src/markdown-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,17 +462,17 @@ async function processContentAsync(rawString: string, mdInstance: any, config: a
}

let htmlContent = mdInstance.render(markdownContent, env);

// #167 (permanent fix): in offline mode, rewrite every internal href in
// the rendered markdown so the output works in file://, HTTP servers, and
// custom domains identically. The button container (`:::`) already goes
// through `fixHtmlLinks` via the template helper, but the markdown-rendered
// HTML never did — that's why `<a href="/destination/">` survived into the
// offline HTML and broke `file://` navigation. The post-process pass uses
// the same `fixHtmlLinks` logic so the two paths stay aligned. Non-offline
// builds are unchanged (clean URLs preserved).
if (env && env.isOfflineMode === true) {
htmlContent = rewriteInternalHrefsForOffline(htmlContent, env.relativePathToRoot || './', env.config?.base || '/');
if (env) {
htmlContent = stripDefaultLocalePrefix(htmlContent, env.defaultLocale, env.allLocales, env.relativePathToRoot || './');
if (env.isOfflineMode === true) {
htmlContent = rewriteInternalHrefsForOffline(htmlContent, env.relativePathToRoot || './', env.config?.base || '/');
} else {
// M-5 also affects online builds: `fr/index.html` linking to
// `/en/` is a 404 on HTTP servers too, not just file://. Run the
// same fixHtmlLinks pass (without the offline-specific `index.html`
// suffix) so cross-locale paths stay clean and correct.
htmlContent = fixHtmlLinks(htmlContent, env.relativePathToRoot || './', env.config?.base || '/');
}
}

if (hooks && hooks.onAfterParse) {
Expand All @@ -495,6 +495,33 @@ async function processContentAsync(rawString: string, mdInstance: any, config: a
return { frontmatter, htmlContent, headings, searchData };
}

/**
* M-5: strip the default-locale prefix from absolute hrefs in the rendered
* HTML. The default locale lives at root, not under its own prefix, so a
* link like `/en/foo` from a non-default-locale page is a 404 unless the
* prefix is dropped. We only run this when the source page is in a
* non-default locale (which is when the default-locale prefix could
* actually appear in author-written links).
*
* The function only touches absolute paths under root, only when the
* first segment matches the default locale id, and never touches
* external URLs / anchors / mailto.
*/
function stripDefaultLocalePrefix(html: string, defaultLocale: string | null, allLocales: string[] | undefined, _relativePathToRoot: string): string {
if (!defaultLocale || !allLocales || allLocales.length < 2) return html;
// Escape regex special chars in the locale id (e.g. "en-us" with hyphen).
const escaped = defaultLocale.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match `<a href="/xx/...">` and `<a href='/xx/...'>` where xx is the
// default locale. We do NOT touch `src=` (img) because images are
// typically under /assets/ and never locale-prefixed. We do NOT touch
// bare `/xx` at end of attribute (rare author pattern) — that case is
// ambiguous and the user can write `/` instead.
const re = new RegExp(`(<a\\s+[^>]*?\\bhref\\s*=\\s*)(["'])(\\/${escaped}\\/)([^"'#]*)\\2`, 'gi');
return html.replace(re, (_full, prefix, quote, _stripped, rest) => {
return `${prefix}${quote}/${rest}${quote}`;
});
}

/**
* Walk every `<a href="...">` and `<img src="...">` in a piece of rendered
* HTML and rewrite internal links for offline mode. External URLs (http,
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/analytics/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/plugin-analytics",
"version": "0.8.10",
"version": "0.8.11",
"description": "Analytics injection plugin for docmd.",
"type": "module",
"main": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/analytics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { scriptLiteral } from '@docmd/utils';

export const plugin: PluginDescriptor = {
name: 'analytics',
version: '0.8.10',
version: '0.8.11',
capabilities: ['head', 'body']
};

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/git/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docmd/plugin-git",
"version": "0.8.10",
"version": "0.8.11",
"description": "Git integration plugin for docmd.",
"type": "module",
"main": "dist/index.js",
Expand Down
Loading
Loading