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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
# @docmd/parser
The isomorphic Markdown engine that powers docmd - parses Markdown to HTML with support for callouts, tabs, steps, and changelogs, and runs identically in Node.js and the browser.
Part of the **[docmd](https://github.com/docmd-io/docmd)** documentation engine.
## Documentation
See **[docs.docmd.io](https://docs.docmd.io)** for full usage and API reference.
## License
MIT
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@docmd/parser",
"version": "0.8.12",
"description": "Pure Markdown parsing engine for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "node --test 'test/**/*.test.js'"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@docmd/tui": "workspace:*",
"@docmd/utils": "workspace:*",
"embed-lite": "^0.1.4",
"lite-hl": "^0.1.2",
"lite-matter": "^0.1.1",
"lite-template": "^0.1.2",
"lucide-static": "^0.577.0",
"markdown-it": "^14.3.0",
"markdown-it-abbr": "^1.0.4",
"markdown-it-attrs": "^4.5.0",
"markdown-it-deflist": "^2.1.0",
"markdown-it-emoji": "^2.0.2",
"markdown-it-footnote": "^3.0.3",
"markdown-it-task-lists": "^2.1.1"
},
"keywords": [
"docmd",
"parser",
"utils",
"tools",
"containers",
"markdown-it",
"markdown",
"markdown-processor",
"icon-renderer",
"renderer",
"validator",
"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"
}
+43
View File
@@ -0,0 +1,43 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
export default {
name: 'basics',
setup(md) {
// 1. Custom Image Renderer
const defaultImageRenderer = md.renderer.rules.image || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.image = function (tokens, idx, options, env, self) {
const renderedImage = defaultImageRenderer(tokens, idx, options, env, self);
const nextToken = tokens[idx + 1];
// Look ahead for attributes syntax { .class } immediately after image
if (nextToken && nextToken.type === 'attrs_block') {
// markdown-it-attrs usually handles this, but if we need specific logic for
// aligning images that don't use standard attributes, we do it here.
}
return renderedImage;
};
// 2. Table Wrapper (Horizontal Scroll)
md.renderer.rules.table_open = (tokens, idx, options, env, self) => {
return '<div class="table-wrapper">' + self.renderToken(tokens, idx, options);
};
md.renderer.rules.table_close = (tokens, idx, options, env, self) => {
return self.renderToken(tokens, idx, options) + '</div>';
};
}
};
+106
View File
@@ -0,0 +1,106 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import { renderIcon } from '../utils/icon-renderer.js';
import { resolveHref } from '../utils/normalize-href.js';
function buttonRule(state, startLine, endLine, silent) {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineContent = state.src.slice(start, max).trim();
// Regex matches: ::: button "Text" Link [color] or :::button (spaceless)
const match = lineContent.match(/^:::\s*button\s+(?:["'](.*?)["']|(\S+))\s+(.*)$/);
if (!match) return false;
if (silent) return true;
// Extract Data
// Group 1 is quoted text, Group 2 is single-word text
let text = match[1] || match[2] || 'Button';
// Replace underscores only if it was a single word (legacy support)
if (match[2]) text = text.replace(/_/g, ' ');
const rest = match[3].trim();
// Parse Link and Options
// We look for the first string as the link, rest as options
const parts = rest.split(/\s+/);
const rawLink = parts[0];
let color = '';
let icon = '';
// Look for options in remaining parts
for (let i = 1; i < parts.length; i++) {
if (parts[i].startsWith('color:')) {
color = parts[i].replace('color:', '');
} else if (parts[i].startsWith('icon:')) {
icon = parts[i].replace('icon:', '');
}
}
// Resolve link through the unified normaliser
const result = resolveHref(rawLink);
let href = result.href;
// Clean URL depth adjustment for non-index pages
if (!result.isRaw && !result.isExternal && !href.startsWith('#')) {
let hashPart = '';
let pathPart = href;
const hashIdx = href.indexOf('#');
if (hashIdx >= 0) {
hashPart = href.substring(hashIdx);
pathPart = href.substring(0, hashIdx);
}
const isProtocol = pathPart.match(/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i);
if (!isProtocol && !pathPart.startsWith('/') && state.env && state.env.isIndex === false) {
if (pathPart.startsWith('./')) {
pathPart = '../' + pathPart.substring(2);
} else if (pathPart !== '') {
pathPart = '../' + pathPart;
}
}
href = pathPart + hashPart;
}
// Generate Token
const token = state.push('html_inline', '', 0);
let styleAttr = '';
if (color) {
// Basic validation to prevent CSS injection if needed, or allow flexibility
styleAttr = ` style="background-color: ${color}; border-color: ${color}; color: #fff;"`;
}
const targetAttr = result.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
let iconHtml = '';
if (icon) {
iconHtml = renderIcon(icon, { class: 'button-icon' });
}
token.content = `<a href="${href}" class="docmd-button"${styleAttr}${targetAttr}>${iconHtml}${state.md.renderInline(text)}</a>`;
state.line = startLine + 1;
return true;
}
export default {
name: 'buttons',
setup(md) {
md.block.ruler.before('paragraph', 'docmd_button', buttonRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
}
};
+163
View File
@@ -0,0 +1,163 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
function smartDedent(str) {
const lines = str.split('\n');
let minIndent = Infinity;
lines.forEach(line => {
if (line.trim().length === 0) return;
const match = line.match(/^ */);
if (match[0].length < minIndent) minIndent = match[0].length;
});
if (minIndent === Infinity) return str;
return lines.map(line => line.trim().length ? line.substring(minIndent) : '').join('\n');
}
function parseQuotedTitle(info) {
if (!info) return '';
const match = info.match(/"([^"]*)"/);
return match ? match[1] : info.trim();
}
function changelogRule(state, startLine, endLine, silent) {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineContent = state.src.slice(start, max).trim();
// Support both '::: changelog' and ':::changelog' (spaceless)
if (lineContent !== '::: changelog' && lineContent !== ':::changelog') return false;
if (silent) return true;
let nextLine = startLine;
let found = false;
let depth = 1;
let fenceMarker = null;
while (nextLine < endLine) {
nextLine++;
if (nextLine >= endLine) break;
const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
const nextMax = state.eMarks[nextLine];
const nextContent = state.src.slice(nextStart, nextMax).trim();
if (!fenceMarker) {
const match = nextContent.match(/^(`{3,}|~{3,})/);
if (match) fenceMarker = match[1];
} else if (nextContent.startsWith(fenceMarker)) {
fenceMarker = null;
}
if (!fenceMarker) {
if (nextContent.match(/^:::\s*[a-zA-Z]/) && !nextContent.match(/^:::\s*button/)) {
depth++;
} else if (nextContent.match(/^:::\s*$/)) {
depth--;
if (depth === 0) {
found = true;
break;
}
}
}
}
if (!found) return false;
// Extract content block
let content = '';
for (let i = startLine + 1; i < nextLine; i++) {
const lineStart = state.bMarks[i];
const lineEnd = state.eMarks[i];
content += state.src.slice(lineStart, lineEnd) + '\n';
}
// Parse "== Date" entries
const lines = content.split('\n');
const entries = [];
let currentEntry = null;
let currentContentLines = [];
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
const trimmedLine = rawLine.trim();
const markerMatch = trimmedLine.match(/^==\s+(.+)$/);
if (markerMatch) {
if (currentEntry) {
currentEntry.content = smartDedent(currentContentLines.join('\n'));
entries.push(currentEntry);
}
currentEntry = { meta: parseQuotedTitle(markerMatch[1]), content: '' };
currentContentLines = [];
} else if (currentEntry) {
currentContentLines.push(rawLine);
}
}
if (currentEntry) {
currentEntry.content = smartDedent(currentContentLines.join('\n'));
entries.push(currentEntry);
}
state.push('changelog_open', 'div', 1);
// Slugify a changelog entry's meta string into a URL-safe id. Mirrors
// the Unicode-aware rules used by the parser-side heading slug
// generator (see markdown-processor.ts) so anchors for non-ASCII
// dates work the same as anchors for non-ASCII headings.
const slugifyMeta = (text: string): string =>
text.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/--+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '') || 'entry';
// Lucide `link-2` icon — same SVG used by .heading-anchor and
// .step-permalink so all three permalink affordances are visually
// identical.
const PERMALINK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 0h2a5 5 0 1 1 0 10h-2m-7-5h8"/></svg>';
entries.forEach((entry, idx) => {
// We render HTML blocks directly for the timeline structure
const slug = `changelog-${slugifyMeta(entry.meta || String(idx))}`;
const permalink = `<a href="#${slug}" class="changelog-permalink" aria-label="Permalink to this entry">${PERMALINK_SVG}</a>`;
const entryOpen = state.push('html_block', '', 0);
entryOpen.content = `<div class="changelog-entry">
<div class="changelog-meta"><span class="changelog-date" id="${slug}">${state.md.renderInline(entry.meta)}</span>${permalink}</div>
<div class="changelog-body">`;
// Recurse render the markdown inside the entry
entryOpen.content += state.md.render(entry.content, state.env);
const entryClose = state.push('html_block', '', 0);
entryClose.content = `</div></div>`;
});
state.push('changelog_close', 'div', -1);
state.line = nextLine + 1;
return true;
}
export default {
name: 'changelog',
setup(md) {
// Register Rule
md.block.ruler.before('fence', 'changelog_timeline', changelogRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
// Register Container Renderer
md.renderer.rules.changelog_open = () => '<div class="docmd-container changelog-timeline">';
md.renderer.rules.changelog_close = () => '</div>';
}
};
@@ -0,0 +1,270 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import { renderIcon } from '../utils/icon-renderer.js';
import { parseTitleAndIcon } from '../utils/container-helper.js';
import { normaliseContainers } from '../utils/container-normaliser.js';
function smartDedent(str) {
const lines = str.split('\n');
while (lines.length && lines[0].trim() === '') lines.shift();
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
let minIndent = Infinity;
for (const line of lines) {
if (!line.trim()) continue;
const indent = line.match(/^ */)[0].length;
minIndent = Math.min(minIndent, indent);
}
if (!isFinite(minIndent) || minIndent === 0) return lines.join('\n');
return lines.map(line =>
line.startsWith(' '.repeat(minIndent)) ? line.slice(minIndent) : line
).join('\n');
}
/**
* Creates a depth-tracking container block rule for markdown-it.
* Handles arbitrary nesting of `:::` containers by tracking open/close depth.
*
* @example
* ```ts
* import { createDepthTrackingContainer } from '@docmd/parser';
*
* createDepthTrackingContainer(md, 'note',
* (tokens, idx) => `<div class="note">\n`,
* () => '</div>\n'
* );
* ```
*
* @param md - The markdown-it instance
* @param name - Container name (matched after `:::`)
* @param renderOpen - Renderer for the opening token
* @param renderClose - Renderer for the closing token
*/
export function createDepthTrackingContainer(md: any, name: string, renderOpen: any, renderClose: any) {
md.block.ruler.before('fence', `custom_${name}`, (state, startLine, endLine, silent) => {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineContent = state.src.slice(start, max).trim();
// Match opening tag e.g., `::: callout info Title` or `:::callout info Title` (spaceless)
const regex = new RegExp(`^:::\\s*${name}(?:\\s+(.*))?$`);
const match = lineContent.match(regex);
if (!match) return false;
if (silent) return true;
let nextLine = startLine;
let found = false;
let depth = 1;
let fenceMarker = null;
while (nextLine < endLine) {
nextLine++;
if (nextLine >= endLine) break;
const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
const nextMax = state.eMarks[nextLine];
const nextContent = state.src.slice(nextStart, nextMax).trim();
if (!fenceMarker) {
const match = nextContent.match(/^(`{3,}|~{3,})/);
if (match) fenceMarker = match[1];
} else if (nextContent.startsWith(fenceMarker)) {
fenceMarker = null;
}
if (!fenceMarker) {
if (nextContent.match(/^:::\s*[a-zA-Z]/) && !nextContent.match(/^:::\s*(button|embed|tag)\b/)) {
depth++;
} else if (nextContent.match(/^:::\s*$/)) {
depth--;
if (depth === 0) {
found = true;
break;
}
}
}
}
if (!found) return false;
const info = match[1] || '';
// Extract raw content lines and dedent to reset indentation,
// then re-render recursively so nested containers parse correctly.
let rawContent = '';
for (let i = startLine + 1; i < nextLine; i++) {
const lineStart = state.bMarks[i];
const lineEnd = state.eMarks[i];
rawContent += state.src.slice(lineStart, lineEnd) + '\n';
}
// Phase 2 (F1): re-run the container normaliser on the extracted body
// BEFORE smartDedent. The top-level normaliser balances the OUTER
// container's open/close depth, but inside a recursive render of the
// extracted body the depth-tracker is ambiguous again (e.g. nested
// grids with one close per inner card). Normalising here inserts the
// missing closes inside the body so the recursive grid/card rule sees
// balanced input.
const normalisedBody = normaliseContainers(rawContent, {
sourcePath: `<${name}-body@L${startLine + 1}>`
});
const innerContent = smartDedent(normalisedBody.source);
const openToken = state.push(`custom_${name}_open`, 'div', 1);
openToken.info = info;
if (innerContent) {
// Flag the environment so the inner heading plugin skips generating permalinks & IDs
const oldIsInsideContainer = state.env.isInsideContainer;
state.env.isInsideContainer = true;
const renderedContent = state.md.render(innerContent, state.env);
state.env.isInsideContainer = oldIsInsideContainer;
const htmlToken = state.push('html_block', '', 0);
htmlToken.content = renderedContent;
}
state.push(`custom_${name}_close`, 'div', -1);
state.line = nextLine + 1;
return true;
}, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
// Register Renderers
md.renderer.rules[`custom_${name}_open`] = renderOpen;
md.renderer.rules[`custom_${name}_close`] = renderClose;
}
export default {
name: 'common-containers',
setup(md: any) {
// 1. Callout
createDepthTrackingContainer(md, 'callout', (tokens, idx) => {
const info = tokens[idx].info.trim();
const parts = info.split(' ');
const type = parts[0] || 'info';
// Support ::: callout type "Title" or ::: callout type Title
let title = '';
let icon = '';
const remaining = parts.slice(1).join(' ').trim();
if (remaining) {
const parsed = parseTitleAndIcon(remaining);
title = parsed.title;
icon = parsed.icon;
}
const renderedTitle = title ? md.renderInline(title) : '';
const iconHtml = icon ? renderIcon(icon, { class: 'callout-icon-heading' }) : '';
const safeType = md.utils.escapeHtml(type);
return `<div class="docmd-container callout callout-${safeType}">${renderedTitle || iconHtml ? `<div class="callout-title">${iconHtml}${renderedTitle}</div>` : ''}<div class="callout-content">\n`;
}, () => '</div></div>\n');
// 2. Card
createDepthTrackingContainer(md, 'card', (tokens, idx) => {
const { title, icon } = parseTitleAndIcon(tokens[idx].info);
const renderedTitle = title ? md.renderInline(title) : '';
const iconHtml = icon ? renderIcon(icon, { class: 'card-icon-heading' }) : '';
// No trailing newline after <div class="card-content">: any inline
// markdown immediately after the ::: card opener (e.g. a callout
// block or a heading) would otherwise get a literal newline inserted
// before its first tag, which broke card layouts where the inner
// block was meant to sit flush against the card edge.
return `<div class="docmd-container card">${renderedTitle || iconHtml ? `<div class="card-title">${iconHtml}${renderedTitle}</div>` : ''}<div class="card-content">`;
}, () => '</div></div>');
// 3. Collapsible
createDepthTrackingContainer(md, 'collapsible', (tokens, idx) => {
const info = tokens[idx].info.trim();
const isOpen = info.startsWith('open ') || info === 'open';
const rawInfo = isOpen ? info.replace('open', '').trim() : info;
const { title, icon } = parseTitleAndIcon(rawInfo);
const displayTitle = title || 'Click to expand';
const renderedTitle = md.renderInline(displayTitle);
const iconHtml = icon ? renderIcon(icon, { class: 'collapsible-icon-heading' }) : '';
const safeOpen = isOpen ? ' open' : '';
return `<details class="docmd-container collapsible"${safeOpen}>
<summary class="collapsible-summary">
<span class="collapsible-title">${iconHtml}${renderedTitle}</span>
<span class="collapsible-arrow"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg></span>
</summary>
<div class="collapsible-content">\n`;
}, () => '</div></details>\n');
// 4. Grids
createDepthTrackingContainer(md, 'grids', () => {
return `<div class="docmd-container grids">\n`;
}, () => '</div>\n');
// 5. Grid Item
createDepthTrackingContainer(md, 'grid', () => {
return `<div class="docmd-container grid-item">\n`;
}, () => '</div>\n');
// ─────────────────────────────────────────────────────────────────────────
// Container Aliases (VitePress/Docusaurus compatibility)
// These allow users migrating from other documentation engines to use
// familiar syntax without modification.
// ─────────────────────────────────────────────────────────────────────────
// Callout type aliases - map directly to callout variants
const calloutAliases = [
// VitePress aliases
{ name: 'tip', type: 'tip' },
{ name: 'warning', type: 'warning' },
{ name: 'danger', type: 'danger' },
{ name: 'info', type: 'info' },
// Docusaurus aliases
{ name: 'note', type: 'info' },
{ name: 'caution', type: 'warning' },
];
for (const alias of calloutAliases) {
createDepthTrackingContainer(md, alias.name, (tokens, idx) => {
const info = tokens[idx].info.trim();
const { title, icon } = parseTitleAndIcon(info);
const renderedTitle = title ? md.renderInline(title) : '';
const iconHtml = icon ? renderIcon(icon, { class: 'callout-icon-heading' }) : '';
const safeType = md.utils.escapeHtml(alias.type);
return `<div class="docmd-container callout callout-${safeType}">${renderedTitle || iconHtml ? `<div class="callout-title">${iconHtml}${renderedTitle}</div>` : ''}<div class="callout-content">\n`;
}, () => '</div></div>\n');
}
// VitePress details alias -> collapsible
createDepthTrackingContainer(md, 'details', (tokens, idx) => {
const info = tokens[idx].info.trim();
const { title, icon } = parseTitleAndIcon(info);
const displayTitle = title || 'Details';
const renderedTitle = md.renderInline(displayTitle);
const iconHtml = icon ? renderIcon(icon, { class: 'collapsible-icon-heading' }) : '';
return `<details class="docmd-container collapsible">
<summary class="collapsible-summary">
<span class="collapsible-title">${iconHtml}${renderedTitle}</span>
<span class="collapsible-arrow"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg></span>
</summary>
<div class="collapsible-content">\n`;
}, () => '</div></details>\n');
}
};
+57
View File
@@ -0,0 +1,57 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import { embed } from 'embed-lite';
function embedRule(state: any, startLine: number, endLine: number, silent: boolean) {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineContent = state.src.slice(start, max).trim();
// Support both '::: embed' and ':::embed' (spaceless)
const match = lineContent.match(/^:::\s*embed\s+(?:\[|")?((https?:\/\/)[^\]"\s]+)(?:\]|")?$/i);
if (!match) return false;
if (silent) return true;
const urlStr = match[1];
let html = '';
try {
const embedResult = embed(urlStr, { className: 'docmd-embed' });
if (embedResult && embedResult.html) {
// We received a valid parsed native iframe/blockquote component
html = embedResult.html;
} else {
const url = new URL(urlStr);
const hostname = url.hostname.replace('www.', '');
html = `<div class="docmd-embed-fallback"><a href="${urlStr}" class="docmd-button docmd-button-external" target="_blank" rel="noopener noreferrer">Open ${hostname} link</a></div>`;
}
} catch {
// Hard fallback if string is entirely invalid URL
html = `<div class="docmd-embed-fallback"><a href="${urlStr}" class="docmd-button docmd-button-external" target="_blank" rel="noopener noreferrer">Open link</a></div>`;
}
const token = state.push('html_inline', '', 0);
token.content = html;
state.line = startLine + 1;
return true;
}
export default {
name: 'embed',
setup(md: any) {
md.block.ruler.before('paragraph', 'docmd_embed', embedRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
}
};
+218
View File
@@ -0,0 +1,218 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
function smartDedent(str) {
const lines = str.split('\n');
while (lines.length && lines[0].trim() === '') lines.shift();
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
let minIndent = Infinity;
for (const line of lines) {
if (!line.trim()) continue;
const indent = line.match(/^ */)[0].length;
minIndent = Math.min(minIndent, indent);
}
if (!isFinite(minIndent) || minIndent === 0) return lines.join('\n');
return lines.map(line =>
line.startsWith(' '.repeat(minIndent)) ? line.slice(minIndent) : line
).join('\n');
}
function heroRule(state, startLine, endLine, silent) {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineContent = state.src.slice(start, max).trim();
// Support both '::: hero' and ':::hero' (spaceless)
const regex = /^:::\s*hero(?:\s+(.*))?$/;
const match = lineContent.match(regex);
if (!match) return false;
if (silent) return true;
const flagsStr = match[1] || '';
const flags = {
split: flagsStr.includes('layout:split'),
slider: flagsStr.includes('layout:slider'),
glow: flagsStr.includes('glow:true')
};
let nextLine = startLine;
let found = false;
let depth = 1;
let fenceMarker = null;
while (nextLine < endLine) {
nextLine++;
if (nextLine >= endLine) break;
const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
const nextMax = state.eMarks[nextLine];
const nextContent = state.src.slice(nextStart, nextMax).trim();
if (!fenceMarker) {
const match = nextContent.match(/^(`{3,}|~{3,})/);
if (match) fenceMarker = match[1];
} else if (nextContent.startsWith(fenceMarker)) {
fenceMarker = null;
}
if (!fenceMarker) {
if (nextContent.match(/^:::\s*[a-zA-Z]/) && !nextContent.match(/^:::\s*(button|embed|tag)\b/)) {
depth++;
} else if (nextContent.match(/^:::\s*$/)) {
depth--;
if (depth === 0) {
found = true;
break;
}
}
}
}
if (!found) return false;
// Extract content
let content = '';
for (let i = startLine + 1; i < nextLine; i++) {
const lineStart = state.bMarks[i];
const lineEnd = state.eMarks[i];
content += state.src.slice(lineStart, lineEnd) + '\n';
}
const lines = content.split('\n');
// Handle Sections based on separators (== side or == slide)
if (flags.slider) {
const slides = [];
let currentSlide = null;
let currentLines = [];
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
const trimmed = rawLine.trim();
if (trimmed.startsWith('== slide')) {
if (currentSlide !== null) {
slides.push(smartDedent(currentLines.join('\n')));
}
currentSlide = true;
currentLines = [];
} else {
currentLines.push(rawLine);
}
}
if (currentSlide !== null) slides.push(smartDedent(currentLines.join('\n')));
const slideCount = slides.length;
// Build Tokens
const openToken = state.push('hero_open', 'div', 1);
openToken.attrs = [['class', `docmd-hero hero-slider ${flags.glow ? 'hero-glow' : ''}`.trim()]];
const trackToken = state.push('hero_slider_track_open', 'div', 1);
trackToken.attrs = [['class', 'hero-slider-track']];
slides.forEach(slideContent => {
state.push('hero_slide_open', 'div', 1);
const rendered = state.md.render(slideContent, { ...state.env, isInsideContainer: true });
const html = state.push('html_block', '', 0);
html.content = rendered;
state.push('hero_slide_close', 'div', -1);
});
state.push('hero_slider_track_close', 'div', -1);
// Inject controls (prev/next + dots)
if (slideCount > 1) {
const prevSvg = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>`;
const nextSvg = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`;
const dots = slides.map((_, i) => `<button class="hero-slider-dot${i === 0 ? ' active' : ''}" data-slide="${i}" aria-label="Go to slide ${i + 1}"></button>`).join('');
const controls = state.push('html_block', '', 0);
controls.content = `<div class="hero-slider-controls">
<button class="hero-slider-btn hero-slider-prev" aria-label="Previous slide">${prevSvg}</button>
<div class="hero-slider-dots">${dots}</div>
<button class="hero-slider-btn hero-slider-next" aria-label="Next slide">${nextSvg}</button>
</div>\n`;
}
state.push('hero_close', 'div', -1);
} else if (flags.split) {
const mainContentLines = [];
const sideContentLines = [];
let foundSeparator = false;
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
if (rawLine.trim().startsWith('== side')) {
foundSeparator = true;
continue;
}
if (foundSeparator) sideContentLines.push(rawLine);
else mainContentLines.push(rawLine);
}
const openToken = state.push('hero_open', 'div', 1);
openToken.attrs = [['class', `docmd-hero hero-split ${flags.glow ? 'hero-glow' : ''}`.trim()]];
// Main Section
state.push('hero_content_open', 'div', 1);
const renderedMain = state.md.render(smartDedent(mainContentLines.join('\n')), { ...state.env, isInsideContainer: true });
const htmlMain = state.push('html_block', '', 0);
htmlMain.content = renderedMain;
state.push('hero_content_close', 'div', -1);
// Side Section
state.push('hero_side_open', 'div', 1);
const renderedSide = state.md.render(smartDedent(sideContentLines.join('\n')), { ...state.env, isInsideContainer: true });
const htmlSide = state.push('html_block', '', 0);
htmlSide.content = renderedSide;
state.push('hero_side_close', 'div', -1);
state.push('hero_close', 'div', -1);
} else {
// Normal Banner Layout
const openToken = state.push('hero_open', 'div', 1);
openToken.attrs = [['class', `docmd-hero hero-banner ${flags.glow ? 'hero-glow' : ''}`.trim()]];
state.push('hero_content_open', 'div', 1);
const rendered = state.md.render(smartDedent(content), { ...state.env, isInsideContainer: true });
const htmlAt = state.push('html_block', '', 0);
htmlAt.content = rendered;
state.push('hero_content_close', 'div', -1);
state.push('hero_close', 'div', -1);
}
state.line = nextLine + 1;
return true;
}
export default {
name: 'hero',
setup(md) {
md.block.ruler.before('fence', 'docmd_hero', heroRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
md.renderer.rules.hero_open = (tokens, idx) => `<div class="${tokens[idx].attrs[0][1]}">`;
md.renderer.rules.hero_close = () => '</div>\n';
md.renderer.rules.hero_content_open = () => '<div class="hero-content">\n';
md.renderer.rules.hero_content_close = () => '</div>\n';
md.renderer.rules.hero_side_open = () => '<div class="hero-side">\n';
md.renderer.rules.hero_side_close = () => '</div>\n';
md.renderer.rules.hero_slider_track_open = () => '<div class="hero-slider-track">\n';
md.renderer.rules.hero_slider_track_close = () => '</div>\n';
md.renderer.rules.hero_slide_open = () => '<div class="hero-slide">\n';
md.renderer.rules.hero_slide_close = () => '</div>\n';
}
};
+35
View File
@@ -0,0 +1,35 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import common from './common-containers.js';
export { createDepthTrackingContainer } from './common-containers.js';
import tabs from './tabs.js';
import steps from './steps.js';
import changelog from './changelog.js';
import buttons from './buttons.js';
import basics from './basics.js';
import embed from './embed.js';
import hero from './hero.js';
import tags from './tags.js';
const FEATURES = [basics, buttons, tags, embed, common, tabs, steps, changelog, hero];
function registerFeatures(md) {
FEATURES.forEach(feature => {
if (feature.setup) feature.setup(md);
});
}
export { registerFeatures };
+128
View File
@@ -0,0 +1,128 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
function stepsRule(state, startLine, endLine, silent) {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineContent = state.src.slice(start, max).trim();
// Support both '::: steps' and ':::steps' (spaceless)
if (lineContent !== '::: steps' && lineContent !== ':::steps') return false;
if (silent) return true;
let nextLine = startLine;
let found = false;
let depth = 1;
let fenceMarker = null;
while (nextLine < endLine) {
nextLine++;
if (nextLine >= endLine) break;
const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
const nextMax = state.eMarks[nextLine];
const nextContent = state.src.slice(nextStart, nextMax).trim();
if (!fenceMarker) {
const match = nextContent.match(/^(`{3,}|~{3,})/);
if (match) fenceMarker = match[1];
} else if (nextContent.startsWith(fenceMarker)) {
fenceMarker = null;
}
if (!fenceMarker) {
if (nextContent.match(/^:::\s*[a-zA-Z]/) && !nextContent.match(/^:::\s*button/)) {
depth++;
} else if (nextContent.match(/^:::\s*$/)) {
depth--;
if (depth === 0) {
found = true;
break;
}
}
}
}
if (!found) return false;
const openToken = state.push('steps_open', 'div', 1);
openToken.info = '';
const oldParentType = state.parentType;
const oldLineMax = state.lineMax;
state.parentType = 'container';
state.lineMax = nextLine;
state.md.block.tokenize(state, startLine + 1, nextLine);
state.push('steps_close', 'div', -1);
state.parentType = oldParentType;
state.lineMax = oldLineMax;
state.line = nextLine + 1;
return true;
}
export default {
name: 'steps',
setup(md) {
md.block.ruler.before('fence', 'steps_container', stepsRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
// Module-level counter so each <li> gets a globally unique `id`
// (step-1, step-2, …) across the whole document. This matters for
// permalinks: clicking `#step-1` on a page with two step containers
// would otherwise scroll to the first match in both.
let stepIndex = 0;
// Custom List Renderer for Steps
md.renderer.rules.steps_open = () => '<div class="docmd-container steps steps-reset">';
md.renderer.rules.steps_close = () => '</div>';
// Hook into list rendering to add classes when inside steps
md.renderer.rules.ordered_list_open = function (tokens, idx, options, env, self) {
let isInSteps = false;
// Check tokens backward to see if we are inside a steps container
for (let i = idx - 1; i >= 0; i--) {
if (tokens[i].type === 'steps_open') { isInSteps = true; break; }
if (tokens[i].type === 'steps_close') break;
}
if (isInSteps) {
const start = tokens[idx].attrGet('start');
return start ? `<ol class="steps-list" start="${start}">` : '<ol class="steps-list">';
}
return self.renderToken(tokens, idx, options);
};
// Lucide `link-2` icon — same as the one used by .heading-anchor so the
// visual language stays consistent across the site.
const STEP_PERMALINK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 0h2a5 5 0 1 1 0 10h-2m-7-5h8"/></svg>';
md.renderer.rules.list_item_open = function (tokens, idx, _options, _env, _self) {
let isInSteps = false;
for (let i = idx - 1; i >= 0; i--) {
if (tokens[i].type === 'steps_open') { isInSteps = true; break; }
if (tokens[i].type === 'steps_close') break;
}
if (isInSteps) {
stepIndex++;
// Render a permalink anchor that's hidden by default and revealed
// on hover. Uses `scroll-margin-top` via .step-item so jumping
// to the anchor lands just below the sticky header.
const id = `step-${stepIndex}`;
const permalink = `<a href="#${id}" class="step-permalink" aria-label="Permalink to this step">${STEP_PERMALINK_SVG}</a>`;
return `<li class="step-item" id="${id}">${permalink}`;
}
return '<li>';
};
}
};
+192
View File
@@ -0,0 +1,192 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import { renderIcon } from '../utils/icon-renderer.js';
import { parseTitleAndIcon } from '../utils/container-helper.js';
function smartDedent(str) {
const lines = str.split('\n');
// Ignore first and last blank lines (common in container blocks)
while (lines.length && lines[0].trim() === '') lines.shift();
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
let minIndent = Infinity;
// Find minimum indentation of non-empty lines
for (const line of lines) {
if (!line.trim()) continue;
const indent = line.match(/^ */)[0].length;
minIndent = Math.min(minIndent, indent);
}
// If no indentation to strip, return joined lines
if (!isFinite(minIndent) || minIndent === 0) return lines.join('\n');
// Strip exactly minIndent from each line
return lines.map(line =>
line.startsWith(' '.repeat(minIndent))
? line.slice(minIndent)
: line
).join('\n');
}
// The Parsing Rule
function tabsRule(state: any, startLine: number, endLine: number, silent: boolean) {
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineContent = state.src.slice(start, max).trim();
// Support both '::: tabs' and ':::tabs' (spaceless)
if (lineContent !== '::: tabs' && lineContent !== ':::tabs') return false;
if (silent) return true;
let nextLine = startLine;
let found = false;
let depth = 1;
let fenceMarker = null;
while (nextLine < endLine) {
nextLine++;
if (nextLine >= endLine) break;
const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
const nextMax = state.eMarks[nextLine];
const nextContent = state.src.slice(nextStart, nextMax).trim();
if (!fenceMarker) {
const match = nextContent.match(/^(`{3,}|~{3,})/);
if (match) fenceMarker = match[1];
} else if (nextContent.startsWith(fenceMarker)) {
fenceMarker = null;
}
if (!fenceMarker) {
// Increment depth for block-level container openers only.
// Inline self-closing containers (tag, button, embed) must NOT affect depth
// because they have no matching closing `:::` - treating them as openers
// would make the tabs parser wait for an extra closing `:::` that never comes.
const INLINE_CONTAINERS = /^:::\s*(tag|button|embed)\b/;
if (nextContent.match(/^:::\s*[a-zA-Z]/) && !INLINE_CONTAINERS.test(nextContent)) {
depth++;
} else if (nextContent.match(/^:::\s*$/)) {
depth--;
if (depth === 0) {
found = true;
break;
}
}
}
}
if (!found) return false;
// Extract content
let content = '';
for (let i = startLine + 1; i < nextLine; i++) {
const lineStart = state.bMarks[i];
const lineEnd = state.eMarks[i];
content += state.src.slice(lineStart, lineEnd) + '\n';
}
// Parse "== tab" lines
const lines = content.split('\n');
const tabs = [];
let currentTab = null;
let currentContentLines = [];
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
const trimmedLine = rawLine.trim();
const tabMatch = trimmedLine.match(/^==\s*tab\s+(.*)/);
if (tabMatch) {
if (currentTab) {
currentTab.content = smartDedent(currentContentLines.join('\n'));
tabs.push(currentTab);
}
const { title, icon } = parseTitleAndIcon(tabMatch[1]);
currentTab = { title, icon, content: '' };
currentContentLines = [];
} else if (currentTab) {
currentContentLines.push(rawLine);
}
}
if (currentTab) {
currentTab.content = smartDedent(currentContentLines.join('\n'));
tabs.push(currentTab);
}
// Generate Tokens
const openToken = state.push('tabs_open', 'div', 1);
openToken.attrs = [['class', 'docmd-tabs']];
state.push('tabs_nav_open', 'div', 1);
tabs.forEach((tab, index) => {
const navItemToken = state.push('tabs_nav_item', 'div', 0);
navItemToken.attrs = [['class', `docmd-tabs-nav-item ${index === 0 ? 'active' : ''}`]];
if (tab.icon) {
navItemToken.attrs.push(['data-icon', state.md.utils.escapeHtml(tab.icon)]);
}
navItemToken.content = tab.title;
});
state.push('tabs_nav_close', 'div', -1);
state.push('tabs_content_open', 'div', 1);
tabs.forEach((tab, index) => {
const paneToken = state.push('tab_pane_open', 'div', 1);
paneToken.attrs = [['class', `docmd-tab-pane ${index === 0 ? 'active' : ''}`]];
if (tab.content) {
// Recurse parsing inside tabs and flag as inside container
const oldIsInsideContainer = state.env.isInsideContainer;
state.env.isInsideContainer = true;
const renderedContent = state.md.render(tab.content, state.env);
state.env.isInsideContainer = oldIsInsideContainer;
const htmlToken = state.push('html_block', '', 0);
htmlToken.content = renderedContent;
}
state.push('tab_pane_close', 'div', -1);
});
state.push('tabs_content_close', 'div', -1);
state.push('tabs_close', 'div', -1);
state.line = nextLine + 1;
return true;
}
export default {
name: 'tabs',
setup(md: any) {
md.block.ruler.before('fence', 'enhanced_tabs', tabsRule, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
// Register Renderers
md.renderer.rules.tabs_nav_open = () => '<div class="docmd-tabs-nav">';
md.renderer.rules.tabs_nav_close = () => '</div>';
md.renderer.rules.tabs_nav_item = (tokens, idx) => {
const iconAttr = tokens[idx].attrs.find(a => a[0] === 'data-icon');
const icon = iconAttr ? iconAttr[1] : null;
const iconHtml = icon ? renderIcon(icon, { class: 'tab-icon' }) : '';
const className = tokens[idx].attrs.find(a => a[0] === 'class')[1];
return `<div class="${className}">${iconHtml}<span>${md.renderInline(tokens[idx].content)}</span></div>`;
};
md.renderer.rules.tabs_content_open = () => '<div class="docmd-tabs-content">';
md.renderer.rules.tabs_content_close = () => '</div>';
md.renderer.rules.tab_pane_open = (tokens, idx) => `<div class="${tokens[idx].attrs[0][1]}">`;
md.renderer.rules.tab_pane_close = () => '</div>';
md.renderer.rules.tabs_close = () => '</div>';
}
};
+109
View File
@@ -0,0 +1,109 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import { renderIcon } from '../utils/icon-renderer.js';
import { resolveHref } from '../utils/normalize-href.js';
/**
* Strips a matched pair of surrounding quotes from a value if present.
* The tag options parser captures both quoted ("..." or '...') and
* unquoted forms in a single regex; this normalises them to a plain
* string before passing to downstream helpers like resolveHref.
*/
function unquote(value: string): string {
if (value.length >= 2) {
const first = value.charAt(0);
const last = value.charAt(value.length - 1);
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return value.slice(1, -1);
}
}
return value;
}
function tagInlineRule(state, silent) {
const start = state.pos;
const max = state.posMax;
if (state.src.charCodeAt(start) !== 0x3A /* : */) return false;
if (state.src.slice(start, start + 3) !== ':::') return false;
// We are at `:::`. Let's see if it's `::: tag` or `:::tag` (spaceless).
//
// Option values accept three forms so URLs and other values that
// contain reserved characters can be wrapped in quotes for clarity
// (matching the rule used by other docmd containers):
// - icon:check-circle (unquoted)
// - color:#ef4444 (unquoted)
// - url:"../../release.md" (double-quoted, recommended for URLs)
// - url:'../../release.md' (single-quoted)
//
// `url:` is the canonical name; `link:` is kept as an alias for
// backward compatibility with existing pages.
const match = state.src.slice(start, max).match(
/^:::\s*tag\s+(?:["']([^"']+)["']|(\S+))((?:\s+(?:icon|color|link|url):(?:"[^"]*"|'[^']*'|\S+))*)/
);
if (!match) return false;
if (silent) return true;
const text = match[1] || match[2] || 'Tag';
const optionsStr = match[3] || '';
let icon = '';
let color = '';
let link = '';
const parts = optionsStr.trim().split(/\s+/);
for (const part of parts) {
if (!part) continue;
if (part.startsWith('icon:')) icon = unquote(part.substring(5));
else if (part.startsWith('color:')) color = unquote(part.substring(6));
else if (part.startsWith('link:')) link = unquote(part.substring(5));
else if (part.startsWith('url:')) link = unquote(part.substring(4));
}
state.pos += match[0].length;
const token = state.push('html_inline', '', 0);
let styleAttr = '';
if (color) {
styleAttr = ` style="--tag-color: ${color}; background-color: color-mix(in srgb, ${color} 15%, transparent); color: ${color}; border-color: color-mix(in srgb, ${color} 30%, transparent);"`;
}
let iconHtml = '';
if (icon) {
iconHtml = renderIcon(icon, { class: 'tag-icon', style: 'width:12px;height:12px;margin-right:4px;' });
}
let tagHtml = `<span class="docmd-tag"${styleAttr}>${iconHtml}${state.md.renderInline(text)}</span>`;
if (link) {
const result = resolveHref(link);
const targetAttr = result.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
tagHtml = `<a href="${result.href}" class="docmd-tag-link" style="text-decoration:none;"${targetAttr}>${tagHtml}</a>`;
}
token.content = tagHtml;
return true;
}
export default {
name: 'tags',
setup(md) {
md.inline.ruler.before('text', 'docmd_tag_inline', tagInlineRule);
}
};
+84
View File
@@ -0,0 +1,84 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import tpl from 'lite-template';
import { renderIcon } from './utils/icon-renderer.js';
/**
* Renders an EJS template string with provided data.
*
* Injects docmd-specific context helpers (renderIcon, fixLink).
* Utilizes lite-template natively, while passing a preprocessor hook
* to automatically strip YAML frontmatter out of any recursive file includes.
*/
async function renderTemplateAsync(templateString, data, options: any = {}) {
// Inject core helpers into every template
const fullData: any = {
...data,
renderIcon,
// Helper to fix links relative to root
fixLink: (url) => fixHtmlLinks(url, data.relativePathToRoot, data.isOfflineMode, data.config?.base)
};
try {
const finalOptions = {
...options,
async: true,
preprocessor: (content) => {
// Strip frontmatter from included files - frontmatter is a docmd concern,
// not an EJS/template concern. The top-level page's frontmatter is handled
// by processContent/lite-matter, but recursive includes should not re-render it.
const fmRegex = /^(?:---[\r\n]+)([\s\S]*?)(?:[\r\n]+---(?:[\r\n]+|$))/;
const fmMatch = content.match(fmRegex);
if (fmMatch) {
return content.slice(fmMatch[0].length);
}
return content;
}
};
return await tpl.render(templateString, fullData, finalOptions);
} catch (e) {
throw new Error(`Template Render Error: ${e.message}`);
}
}
function fixHtmlLinks(url, root = './', isOffline = false, base = '/') {
if (!url || url.startsWith('http') || url.startsWith('#') || url.startsWith('mailto:')) return url;
let final = url;
// Strip base if present
if (base !== '/' && url.startsWith(base)) {
final = '/' + url.substring(base.length);
}
// Make relative
if (final.startsWith('/')) {
final = root + final.substring(1);
}
// Offline adjustments
if (isOffline) {
if (!final.includes('.') && !final.endsWith('/')) final += '/index.html';
else if (final.endsWith('/')) final += 'index.html';
} else {
// Clean URLs
if (final.endsWith('/index.html')) final = final.substring(0, final.length - 10);
}
return final;
}
export { renderTemplateAsync, fixHtmlLinks };
+62
View File
@@ -0,0 +1,62 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import { createMarkdownProcessor, processContent, processContentAsync, flushNormaliserWarnings, setNormaliserVerbose } from './markdown-processor.js';
import { renderTemplateAsync } from './html-renderer.js';
import { renderIcon } from './utils/icon-renderer.js';
import { validateConfig } from './utils/validator.js';
export {
// Logic
createMarkdownProcessor,
processContent,
processContentAsync,
renderTemplateAsync,
validateConfig,
// Utils
renderIcon,
flushNormaliserWarnings,
setNormaliserVerbose
};
export { createDepthTrackingContainer } from './features/index.js';
export {
normaliseContainers,
classifyLine,
indentOf,
SELF_CLOSING_CONTAINER_NAMES
} from './utils/container-normaliser.js';
export type {
NormaliserWarning,
NormaliserWarningSeverity,
NormaliserResult,
NormaliserOptions
} from './utils/container-normaliser.js';
export { findPageNeighbors, findBreadcrumbs } from './utils/navigation-helper.js';
export { normalizeInternalHref, normalizeNavPaths, normalizeMenubarPaths, resolveHref } from './utils/normalize-href.js';
// Centralised URL Utilities - the single source of truth for all URL transformations.
// Plugins, templates, and engine components MUST use these instead of rolling their own.
export {
sanitizeUrl,
outputPathToSlug,
outputPathToPathname,
outputPathToCanonical,
buildContextualUrl,
createUrlContext,
computePageUrls,
buildAbsoluteUrl,
} from './utils/url-utils.js';
export type { UrlContext, PageUrls } from './utils/url-utils.js';
+567
View File
@@ -0,0 +1,567 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import MarkdownIt from 'markdown-it';
import matter from 'lite-matter';
import { highlight } from 'lite-hl';
import { resolveHref } from './utils/normalize-href.js';
import { attrEsc } from '@docmd/utils';
import {
normaliseContainers,
type NormaliserWarning
} from './utils/container-normaliser.js';
import { fixHtmlLinks } from './html-renderer.js';
// URL schemes that can execute code or exfiltrate data when clicked.
// Phase 1.B (T-S4 fix, defense in depth): markdown-it 14 already rejects these
// at the parser layer, but we re-check inside the link_open override so any
// future refactor of the parser pipeline cannot accidentally re-introduce them.
const DANGEROUS_URL_SCHEMES = new Set([
'javascript:',
'data:text/html',
'vbscript:',
'file:'
]);
function isDangerousHref(href: string): boolean {
const lower = href.trim().toLowerCase();
for (const prefix of DANGEROUS_URL_SCHEMES) {
if (lower.startsWith(prefix)) return true;
}
return false;
}
/**
* Phase 2 (F1F5): accumulated warnings, summarised once at the end of
* the build via `flushNormaliserWarnings()`. Per-warning logging to
* stderr is opt-in via `setNormaliserVerbose(true)` so the dev-server
* console stays clean (one summary line per build instead of one line
* per warning) while CI / verbose mode still gets the full detail.
*/
const normaliserWarnings: NormaliserWarning[] = [];
let normaliserVerbose = false;
function emitNormaliserWarning(w: NormaliserWarning): void {
normaliserWarnings.push(w);
if (normaliserVerbose) {
console.warn(`[normaliser] ${w.severity.toUpperCase()} ${w.path}:${w.line}${w.message}`);
}
}
export function setNormaliserVerbose(verbose: boolean): void {
normaliserVerbose = verbose;
}
export function flushNormaliserWarnings(): void {
if (normaliserWarnings.length === 0) return;
const errors = normaliserWarnings.filter(w => w.severity === 'error').length;
const warnings = normaliserWarnings.length - errors;
const fileCount = new Set(normaliserWarnings.map(w => w.path)).size;
const severity = errors > 0 ? 'ERROR' : 'WARN';
const tag = errors > 0 ? '\x1b[31m' : '\x1b[33m';
const reset = '\x1b[0m';
console.warn(
`${tag}[normaliser] ${severity}${reset} ` +
`${normaliserWarnings.length} issue${normaliserWarnings.length === 1 ? '' : 's'} ` +
`(${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}) ` +
`across ${fileCount} file${fileCount === 1 ? '' : 's'}. ` +
`Run with --verbose to list each one.`
);
normaliserWarnings.length = 0;
}
/**
* Phase 2 (F1F5): run the container normaliser on the markdown body before
* it is handed to markdown-it (and before any user plugin's `onBeforeParse`
* hook so they always see balanced input).
*
* The function is allocation-only on the input — no module-level state — so
* output is deterministic across worker threads.
*/
function applyContainerNormaliser(markdownContent: string, env: { filePath?: string }): string {
const norm = normaliseContainers(markdownContent, {
sourcePath: env && env.filePath ? env.filePath : '<source>',
onWarning: emitNormaliserWarning
});
return norm.source;
}
// Standard Plugins
import attrs from 'markdown-it-attrs';
import footnote from 'markdown-it-footnote';
import taskLists from 'markdown-it-task-lists';
import abbr from 'markdown-it-abbr';
import deflist from 'markdown-it-deflist';
import emoji from 'markdown-it-emoji';
// The Feature Registry
import { registerFeatures } from './features/index.js';
// Custom Heading ID & Anchor Logic
const headingIdPlugin = (md, options: any = {}) => {
const uiStrings = options.uiStrings || {};
md.core.ruler.push('heading_anchors', function (state) {
let containerDepth = 0;
const lastHeadingIds = new Array(7).fill(null);
const usedIds = new Map();
for (let i = 0; i < state.tokens.length; i++) {
const token = state.tokens[i];
// Track block-level inline containers (like steps)
if (token.type === 'steps_open' || (token.type.startsWith('custom_') && token.type.endsWith('_open'))) {
containerDepth++;
}
if (token.type === 'steps_close' || (token.type.startsWith('custom_') && token.type.endsWith('_close'))) {
containerDepth--;
}
const inContainer = state.env.isInsideContainer || containerDepth > 0;
if (token.type === 'heading_open') {
const level = parseInt(token.tag.slice(1), 10);
const inlineToken = state.tokens[i + 1];
// 1. Generate ID if not present and NOT in a container
let id = token.attrGet('id');
if (!id && inlineToken && inlineToken.content && !inContainer) {
const slug = inlineToken.content
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/--+/g, '-')
.replace(/^-+/, '')
.replace(/-+$/, '');
if (slug) {
// Find parent ID from previous levels
let parentId = null;
for (let j = level - 1; j >= 1; j--) {
if (lastHeadingIds[j]) {
parentId = lastHeadingIds[j];
break;
}
}
id = parentId ? `${parentId}-${slug}` : slug;
// Handle hard collisions (same heading sequence twice)
if (usedIds.has(id)) {
const count = usedIds.get(id);
usedIds.set(id, count + 1);
id = `${id}-${count}`;
} else {
usedIds.set(id, 1);
}
token.attrSet('id', id);
lastHeadingIds[level] = id;
// Clear deeper levels to prevent wrong parent nesting on backtrack
for (let j = level + 1; j <= 6; j++) lastHeadingIds[j] = null;
}
}
// If we are in a container, strip existing IDs so they don't break the TOC parsing
if (inContainer) {
if (token.attrs) {
token.attrs = token.attrs.filter(a => a[0] !== 'id');
}
id = null;
}
// 2. Inject Hover Anchor as an HTML Token (for H1, H2, H3, H4)
if (id && level >= 1 && level <= 4 && !inContainer) {
const existingClass = token.attrGet('class') || '';
token.attrSet('class', `${existingClass} docmd-heading`.trim());
if (inlineToken && inlineToken.children) {
const anchorToken = new state.Token('html_inline', '', 0);
const anchorLabel = uiStrings.permalinkToSection || 'Permalink to this section';
anchorToken.content = `<a href="#${id}" class="heading-anchor" aria-label="${anchorLabel}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link2-icon lucide-link-2"><path d="M9 17H7A5 5 0 0 1 7 7h2m6 0h2a5 5 0 1 1 0 10h-2m-7-5h8"/></svg></a>`;
// Insert the anchor at the beginning of the heading text
inlineToken.children.unshift(anchorToken);
}
}
}
}
});
};
// Main Factory Function to Create a Markdown Processor
function createMarkdownProcessor(config: any = {}, pluginsCallback: any) {
// HTML policy from config (Phase 0.D, default 'escape').
// 'allow' -> markdown-it html: true (raw HTML passes through, UNSAFE)
// 'escape' -> markdown-it html: false (HTML escaped and shown as text)
// 'strip' -> html: false + disable html_block/html_inline rules (HTML removed)
const htmlPolicy = (config && config.security && config.security.html) || 'escape';
const mdOptions: any = {
html: htmlPolicy === 'allow',
linkify: true,
typographer: true,
breaks: config.markdown?.breaks ?? true,
};
// Syntax Highlighting (title extraction is handled separately in the fence renderer)
const highlightFn = (str, lang) => {
if (lang === 'mermaid') {
return `<pre class="mermaid">${new MarkdownIt().utils.escapeHtml(str)}</pre>`;
}
const highlighted = highlight(str, { language: lang, mimicHljs: true }).value;
// Tag the code with language-xxx so consumers (e.g. the summer
// template's codeblock title bar) can pick up the language.
const langAttr = lang ? ` class="language-${lang}"` : '';
return `<pre class="hljs"><code${langAttr}>${highlighted}</code></pre>`;
};
mdOptions.highlight = config.theme?.codeHighlight !== false ? highlightFn : (str: any, lang: any) => {
if (lang === 'mermaid') return `<pre class="mermaid">${new MarkdownIt().utils.escapeHtml(str)}</pre>`;
const langAttr = lang ? ` class="language-${lang}"` : '';
return `<pre><code${langAttr}>${new MarkdownIt().utils.escapeHtml(str)}</code></pre>`;
};
const md = new MarkdownIt(mdOptions);
// 'strip' policy: drop html_block and html_inline rules entirely so HTML
// tokens are never emitted. Distinct from 'escape' which keeps tokens and
// HTML-escapes their content.
if (htmlPolicy === 'strip') {
md.disable(['html_block', 'html_inline']);
}
// Core Plugins
md.use(attrs, { leftDelimiter: '{', rightDelimiter: '}' });
md.use(footnote);
md.use(taskLists);
md.use(abbr);
md.use(deflist);
md.use(emoji);
md.use(headingIdPlugin, { uiStrings: config._uiStrings || {} });
// Register Built-in Features
registerFeatures(md);
// External Plugins Hook
if (typeof pluginsCallback === 'function') {
pluginsCallback(md);
}
// Custom Fence Renderer: Extracts title from token.info (e.g., ```js "filename.js")
// markdown-it only passes the first word as `lang` to highlight(), so the title
// in quotes never reaches the highlight function. We intercept it here instead.
const defaultFence = md.renderer.rules.fence.bind(md.renderer.rules);
md.renderer.rules.fence = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const info = (token.info || '').trim();
// Match: language "title" or language 'title'
const titleMatch = info.match(/^[a-zA-Z0-9+#*-]*\s+["']([^"']+)["']/);
// Get the default rendered output (which includes the highlighted code)
const rendered = defaultFence(tokens, idx, options, env, self);
if (titleMatch) {
const title = titleMatch[1];
return `<div class="docmd-code-block-wrapper"><div class="docmd-code-block-header"><span class="docmd-code-block-title">${title}</span></div>${rendered}</div>`;
}
return rendered;
};
const defaultLinkOpen = md.renderer.rules.link_open || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const hrefIndex = token.attrIndex('href');
if (hrefIndex >= 0) {
const href = token.attrs[hrefIndex][1];
// Phase 1.B (T-S4 fix): drop dangerous schemes. The link is rendered as
// a safe hash-anchor instead so the surrounding text still appears as a
// clickable (but inert) element.
if (isDangerousHref(href)) {
token.attrs[hrefIndex][1] = '#';
return self.renderToken(tokens, idx, options);
}
const isHashOnly = href.startsWith('#');
const isAsset = href.match(/(^|\/)assets\//);
if (!isHashOnly && !isAsset) {
const result = resolveHref(href);
if (!result.isRaw) {
let pathPart = result.href;
let hashPart = '';
const hi = pathPart.indexOf('#');
if (hi >= 0) {
hashPart = pathPart.substring(hi);
pathPart = pathPart.substring(0, hi);
}
// Depth adjustment for clean URLs (non-index pages are shifted into subfolders)
const isProtocol = pathPart.match(/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i);
if (!isProtocol && !pathPart.startsWith('/') && env && env.isIndex === false) {
if (pathPart.startsWith('./')) {
pathPart = '../' + pathPart.substring(2);
} else if (pathPart !== '') {
pathPart = '../' + pathPart;
}
}
token.attrs[hrefIndex][1] = pathPart + hashPart;
} else {
token.attrs[hrefIndex][1] = result.href;
}
// Apply external attributes
if (result.isExternal) {
token.attrSet('target', '_blank');
token.attrSet('rel', 'noopener noreferrer');
}
}
}
return defaultLinkOpen(tokens, idx, options, env, self);
};
return md;
}
function stripHtml(html) {
if (!html) return '';
return html.replace(/<[^>]*>?/gm, '');
}
function decodeHtmlEntities(str: string): string {
if (!str) return '';
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&rsquo;/g, "'")
.replace(/&lsquo;/g, "'")
.replace(/&rdquo;/g, '"')
.replace(/&ldquo;/g, '"')
.replace(/&nbsp;/g, ' ');
}
function extractHeadings(html) {
const headings = [];
// Require non-empty ID match to exclude stripped container headings: "([^"]+)"
const regex = /<h([1-6])[^>]*?id="([^"]+)"[^>]*?>([\s\S]*?)<\/h\1>/g;
let match;
while ((match = regex.exec(html)) !== null) {
const rawText = match[3].replace(/<\/?[^>]+(>|$)/g, '').trim();
headings.push({
level: parseInt(match[1], 10),
id: match[2],
text: decodeHtmlEntities(rawText)
});
}
return headings;
}
function processContent(rawString, mdInstance, config, env = {}) {
let frontmatter, markdownContent;
try {
const parsed = matter(rawString);
frontmatter = parsed.data;
markdownContent = parsed.content;
} catch (e) {
console.error('Error parsing frontmatter:', e.message);
return null;
}
if (!frontmatter.title && config.autoTitleFromH1 !== false) {
const h1Match = markdownContent.match(/^#\s+(.*)/m);
if (h1Match) frontmatter.title = h1Match[1].trim();
}
// Phase 2 (F1F5): rewrite unbalanced `:::` containers so the existing
// depth-tracking block rule in features/common-containers.ts always sees
// a matching close.
markdownContent = applyContainerNormaliser(markdownContent, env);
const htmlContent = mdInstance.render(markdownContent, env);
const headings = extractHeadings(htmlContent);
let searchData = null;
if (!frontmatter.noindex) {
searchData = {
title: frontmatter.title || 'Untitled',
content: stripHtml(htmlContent).slice(0, 5000),
headings: headings.map(h => ({ id: h.id, text: h.text }))
};
}
return { frontmatter, htmlContent, headings, searchData };
}
async function processContentAsync(rawString: string, mdInstance: any, config: any, env: any = {}, hooks: any = null) {
let frontmatter, markdownContent;
try {
const parsed = matter(rawString);
frontmatter = parsed.data;
markdownContent = parsed.content;
} catch (e) {
console.error('Error parsing frontmatter:', e.message);
return null;
}
// Phase 2 (F1F5): rewrite unbalanced `:::` containers BEFORE user plugins
// so they always see balanced input, and BEFORE markdown-it so the
// depth-tracking block rule can match every container it sees.
markdownContent = applyContainerNormaliser(markdownContent, env);
if (hooks && hooks.onBeforeParse) {
for (const fn of hooks.onBeforeParse) {
// D-H7: previously a throw from any plugin's onBeforeParse would
// either propagate up (abort the file) or, after the safeCall
// wrapper landed, leave `markdownContent` set to `undefined` and
// crash the next iteration. We now catch each plugin's throw
// locally, log it via TUI, and keep walking the chain so plugins
// B, C, ... still see input (even if A's contribution is lost).
try {
const next = await fn(markdownContent, frontmatter, env.filePath);
// Only adopt the plugin's output if it returned a string.
// Plugins that want to "skip" should return undefined (we keep
// the previous markdown) rather than returning null.
if (typeof next === 'string') {
markdownContent = next;
}
} catch (err: any) {
const msg = err && err.message ? err.message : String(err);
console.error(`[parser] onBeforeParse plugin threw: ${msg}`);
}
}
}
if (!frontmatter.title && config.autoTitleFromH1 !== false) {
const h1Match = markdownContent.match(/^#\s+(.*)/m);
if (h1Match) frontmatter.title = h1Match[1].trim();
}
let htmlContent = mdInstance.render(markdownContent, env);
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) {
for (const fn of hooks.onAfterParse) {
htmlContent = await fn(htmlContent, frontmatter, env.filePath);
}
}
const headings = extractHeadings(htmlContent);
let searchData = null;
if (!frontmatter.noindex) {
searchData = {
title: frontmatter.title || 'Untitled',
content: stripHtml(htmlContent).slice(0, 5000),
headings: headings.map((h: any) => ({ id: h.id, text: h.text }))
};
}
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,
* https, mailto, tel, javascript, etc.), hash-only anchors, and asset paths
* are passed through unchanged.
*
* The function is intentionally permissive on the regex: it tolerates
* attribute ordering, single or double quotes, and surrounding whitespace.
* The result is intentionally not run through a full HTML parser because
* the document we're rewriting is the markdown-rendered fragment, not a
* full HTML page, and we don't want to introduce a new dependency.
*/
function rewriteInternalHrefsForOffline(html: string, relativePathToRoot: string, base: string): string {
// `<a href="...">` rewriting — covers markdown link `[text](url)`.
html = html.replace(
/<a\s+([^>]*?)\bhref\s*=\s*("([^"]*)"|'([^']*)')([^>]*)>/gi,
(full, _pre, quoted, dq, sq, _post) => {
const href = dq !== undefined ? dq : sq;
const fixed = fixHtmlLinks(href, relativePathToRoot, true, base);
if (fixed === href) return full;
const originalQuote = quoted.charAt(0);
return full.replace(quoted, originalQuote + fixed + originalQuote);
}
);
// `<img src="...">` rewriting — covers markdown image `![alt](src)`.
// The button template goes through `fixLink`, but markdown-it emits
// `<img>` directly without any template helper. Without this pass the
// offline HTML keeps `<img src="/assets/img.png">` which `file://`
// cannot resolve (it's an absolute path with no host).
html = html.replace(
/<img\s+([^>]*?)\bsrc\s*=\s*("([^"]*)"|'([^']*)')([^>]*)>/gi,
(full, _pre, quoted, dq, sq, _post) => {
const src = dq !== undefined ? dq : sq;
const fixed = fixHtmlLinks(src, relativePathToRoot, true, base);
if (fixed === src) return full;
const originalQuote = quoted.charAt(0);
return full.replace(quoted, originalQuote + fixed + originalQuote);
}
);
return html;
}
export { createMarkdownProcessor, processContent, processContentAsync };
@@ -0,0 +1,35 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
/**
* Extracts a quoted title (e.g., "My Title") and an optional icon (e.g., icon:rocket) from a string.
* This is the standard parser for docmd containers (callouts, cards, tabs, etc.)
*
* @param {string} info - The raw info string to parse
* @returns {{ title: string, icon: string }}
*/
export function parseTitleAndIcon(info) {
if (!info) return { title: '', icon: '' };
let icon = '';
const iconMatch = info.match(/icon:([a-zA-Z0-9-]+)/);
if (iconMatch) {
icon = iconMatch[1];
info = info.replace(iconMatch[0], '');
}
const titleMatch = info.match(/"([^"]*)"/);
const title = titleMatch ? titleMatch[1] : info.trim();
return { title, icon };
}
@@ -0,0 +1,314 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/parser
* @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.
* --------------------------------------------------------------------
*/
/**
* Container normaliser
* ====================
*
* Single-pass linear scan that rewrites `:::` container markdown so that
* the existing depth-tracking block rules in `features/common-containers.ts`
* always see balanced open/close pairs.
*
* The classic bugs this addresses:
*
* F1 — depth tracker is indentation-blind.
* `::: grids` + N×` ::: grid` + N×`:::` (one per card)
* leaves depth > 0 and the block rule fails to match, so the
* whole grids block is dumped as raw `<p>::: grids<br>...</p>`.
* F2 — `::: tag` is self-closing but the next orphan `:::` still
* decrements depth of the wrong container.
* F3 — `::: callout ... ::: card ... :::` silently re-roots.
* F4 — bare `:::` lines leak into the page as `<p>:::</p>` paragraphs.
* F5 — 5+ levels of nesting survive when opens and closes are balanced,
* but unbalanced user input collapses inner levels.
*
* The algorithm is the same one documented in
* `battle-test-reports/robust-parser-shim/index.js` (146 lines,
* dependency-free). This file is the in-tree port — no plugins, no
* configuration, always-on.
*
* Output is deterministic: the function is a pure function of its input.
* Two worker threads given the same source produce byte-identical output.
*
* ─── DETERMINISM AUDIT ─────────────────────────────────────────────────
* Phase 2 (worker-shared-state fix). This module deliberately does NOT
* use any of the following non-deterministic primitives. Adding any of
* them is a regression and must be flagged in code review.
*
* ✗ `Date.now()` / `new Date()` — wall-clock time
* ✗ `Math.random()` / `crypto.randomUUID` — entropy source
* ✗ module-level `let` / `var` — mutable shared state
* ✗ `console.log` from inside `normaliseContainers` (use the
* `onWarning` callback instead — `console.log` does not affect
* output but `DOCMD_ROBUST_DEBUG=1` enables it for ad-hoc tracing)
* ✗ reading from `process.env` — env may differ per worker
* (use `options` instead)
*
* The only module-level binding is `SELF_CLOSING_CONTAINER_NAMES`, a
* frozen `ReadonlySet<string>` that is constructed once at module load
* and never mutated. Safe to share across workers.
*
* The empirical guarantee lives in three places:
* 1. `packages/parser/test/container-normaliser.test.js` — replay
* determinism, 100-way concurrency, and cross-worker
* `node:worker_threads` determinism.
* 2. `packages/core/src/engine/worker-parser.ts` boot-time self-test
* (`verifyDeterminismAtBoot`).
* 3. The manual end-to-end check at the bottom of this file's docstring.
* ──────────────────────────────────────────────────────────────────────
*/
/**
* Container names that produce a single line (no body, no close).
* These are matched by name in the open line and the line is passed
* through unchanged; any stray `:::` that follows them is a user mistake
* (F2) and is removed.
*/
export const SELF_CLOSING_CONTAINER_NAMES: ReadonlySet<string> = new Set([
'button',
'tag',
'embed'
]);
/**
* Severity levels for normaliser warnings. Mirrors the three messages the
* shim emits so downstream consumers can route by severity if they want.
*/
export type NormaliserWarningSeverity = 'warning' | 'info' | 'error';
export interface NormaliserWarning {
/** 1-indexed line number in the original source. */
line: number;
severity: NormaliserWarningSeverity;
/** Path of the source file (or `<source>` when synthetic). */
path: string;
message: string;
}
export interface NormaliserResult {
/** Rewritten source with implicit closes added and stray closes removed. */
source: string;
warnings: NormaliserWarning[];
}
export interface NormaliserOptions {
/** Path used in warning messages. Defaults to `<source>`. */
sourcePath?: string;
/** When true, print debug lines to stdout. Defaults to false. */
debug?: boolean;
/** Optional sink for warnings — useful for tests and structured logging. */
onWarning?: (warning: NormaliserWarning) => void;
}
interface ClassifiedLine {
kind: 'open' | 'close' | 'other';
name?: string;
}
interface OpenFrame {
name: string;
/** 1-indexed line number where this container was opened. */
line: number;
/** Indent (in spaces) of the line that opened the container. */
indent: number;
}
/**
* Count the leading spaces of a line. Tabs are not interpreted — markdown
* container indentation is conventionally spaces.
*/
export function indentOf(line: string): number {
const m = line.match(/^ */);
return m ? m[0].length : 0;
}
/**
* Classify a single source line as `open`, `close`, or `other`.
*
* open — `::: <name>...` where `<name>` starts with a letter.
* Self-closing names (`button`, `tag`, `embed`) are still
* classified as `open` — the algorithm distinguishes them via
* the SELF_CLOSING_CONTAINER_NAMES set, not here.
* close — bare `:::` with optional surrounding whitespace.
* other — anything else, passed through verbatim.
*/
export function classifyLine(line: string): ClassifiedLine {
if (/^\s*:::\s*[a-zA-Z]/.test(line)) {
const m = line.match(/^\s*:::\s*([a-zA-Z][\w-]*)/);
return { kind: 'open', name: m ? m[1] : undefined };
}
if (/^\s*:::\s*$/.test(line)) {
return { kind: 'close' };
}
return { kind: 'other' };
}
/**
* Rewrite a markdown source so that every `:::` block has a matching close.
*
* The function never throws; instead it returns the rewritten source plus
* an array of warnings. Callers may surface warnings through `console.warn`,
* a structured logger, or both via `options.onWarning`.
*
* The algorithm is allocation-conscious (single array of output lines, single
* stack of open frames) but readability is prioritised over micro-optimisation.
*/
export function normaliseContainers(
source: string,
options: NormaliserOptions | string = {}
): NormaliserResult {
// Allow the legacy 2-arg call signature `normaliseContainers(src, path)` so
// any in-flight plugin code keeps working.
const opts: NormaliserOptions = typeof options === 'string'
? { sourcePath: options }
: options;
const sourcePath = opts.sourcePath || '<source>';
const debug = opts.debug === true;
const onWarning = typeof opts.onWarning === 'function' ? opts.onWarning : null;
const lines = source.split('\n');
const out: string[] = [];
const stack: OpenFrame[] = [];
const warnings: NormaliserWarning[] = [];
// Fenced code block tracking — see the in-loop comment below.
let inFence = false;
let fenceMarker: string | null = null;
const recordWarning = (w: NormaliserWarning): void => {
warnings.push(w);
if (onWarning) onWarning(w);
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cls = classifyLine(line);
const indent = indentOf(line);
// Fenced code block tracking. The normaliser must not interpret
// `:::` lines that appear inside a ``` fence as container opens or
// closes — they are literal text in a code listing. Without this
// check, any docs page that shows a `::: card ... :::` example
// inside a markdown code fence triggers a spurious "Unclosed
// <card>" error (the fence-opened line is classified as an open,
// pushed on the stack, and never matched because the matching :::
// is also inside the fence and would have been the close).
//
// We track the fence by its opening marker (``` or ~~~) and close
// on the same marker at the start of a later line. Tildes are
// included because CommonMark allows ~~~ as an alternative fence.
if (inFence) {
// Pass through verbatim; only check for the matching close marker.
if (/^\s*(```+|~~~+)/.test(line) && line.trimStart().startsWith(fenceMarker!)) {
inFence = false;
fenceMarker = null;
}
out.push(line);
continue;
}
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
inFence = true;
fenceMarker = fenceMatch[1][0]; // '`' or '~'
out.push(line);
continue;
}
if (cls.kind === 'open') {
// The shim's classification only tells us the line LOOKS like an open;
// the SELF_CLOSING set is the source of truth for whether the body
// exists. Without this distinction `::: tag` would corrupt depth (F2).
if (cls.name && SELF_CLOSING_CONTAINER_NAMES.has(cls.name)) {
out.push(line);
if (debug) {
console.log(`[normaliser] ${sourcePath}:${i + 1} self-close <${cls.name}>`);
}
continue;
}
stack.push({ name: cls.name || '', line: i + 1, indent });
out.push(line);
continue;
}
if (cls.kind === 'close') {
// Walk the stack from innermost outward and find the first open whose
// indent is <= this close's indent. That is the container this `:::`
// logically closes — anything above it was closed implicitly by the
// same user gesture.
let matchIdx = -1;
for (let j = stack.length - 1; j >= 0; j--) {
if (stack[j].indent <= indent) {
matchIdx = j;
break;
}
}
if (matchIdx === -1) {
recordWarning({
line: i + 1,
severity: 'warning',
path: sourcePath,
message: 'Stray `:::` removed. Common cause: `::: tag ... :::` (tag is self-closing).'
});
continue;
}
const closed = stack.splice(matchIdx);
const outerIndent = closed[0].indent;
// Emit one `:::` per closed entry, all at the outer indent. The
// upstream parser's `smartDedent` collapses higher indents to the
// outer indent, so N closes at the outer indent correctly pop N
// entries from its depth counter.
for (let k = 0; k < closed.length; k++) {
out.push(' '.repeat(outerIndent) + ':::');
}
if (closed.length > 1) {
recordWarning({
line: i + 1,
severity: 'info',
path: sourcePath,
message:
`Closed ${closed.length} containers implicitly (` +
closed.map((c) => `<${c.name}>`).join(' > ') +
`). Added ${closed.length - 1} explicit \`:::\` closes.`
});
}
continue;
}
out.push(line);
}
// Auto-close anything still on the stack at EOF. Without this the upstream
// block rule would loop to endLine without finding a close and the whole
// container would be dropped (F1, F3).
for (let i = stack.length - 1; i >= 0; i--) {
const frame = stack[i];
recordWarning({
line: frame.line,
severity: 'error',
path: sourcePath,
message: `Unclosed \`<${frame.name}>\` from line ${frame.line} — auto-closed at EOF.`
});
out.push(' '.repeat(frame.indent) + ':::');
}
return { source: out.join('\n'), warnings };
}
export default normaliseContainers;
@@ -0,0 +1,60 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import * as lucideStatic from 'lucide-static';
// Convert kebab-case to PascalCase (e.g., arrow-right -> ArrowRight)
function kebabToPascal(str: string): string {
return str.split('-').map((p: string) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
}
const exceptions: any = {
'arrow-up-right-square': 'ExternalLink',
'file-cog': 'Settings',
'cloud-upload': 'UploadCloud'
};
function escapeHtml(str: any): string {
const s = typeof str === 'string' ? str : String(str || '');
return s.replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[m] as string);
}
function renderIcon(name: string, options: any = {}) {
if (!name) return '';
const key = exceptions[name] || kebabToPascal(name);
const svgData = (lucideStatic as any)[key];
if (!svgData) return ''; // Fail silently or warn via callback
const escape = escapeHtml;
// Inject attributes into the raw SVG string
const attrs = [
`class="lucide-icon icon-${escape(name)} ${escape(options.class || '')}"`,
`width="${escape(options.width || '1em')}"`,
`height="${escape(options.height || '1em')}"`,
`stroke="${escape(options.stroke || 'currentColor')}"`,
`stroke-width="${escape(options.strokeWidth || 2)}"`,
'fill="none"',
'stroke-linecap="round"',
'stroke-linejoin="round"'
].join(' ');
return svgData.replace('<svg', `<svg ${attrs}`);
}
export { renderIcon };
@@ -0,0 +1,95 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
// Normalizes paths to a "canonical" form for comparison.
function getCanonicalPath(p) {
if (!p) return '';
if (p.startsWith('http')) return p;
// 1. Remove ./ and leading /
let path = p.replace(/^\.?\//, '');
// 2. Remove file extension
path = path.replace(/(\.html|\.md)$/, '');
// 3. Remove index/README suffix
if (path.endsWith('index')) {
path = path.slice(0, -5);
} else if (path.endsWith('README')) {
path = path.slice(0, -6);
}
// 4. Remove trailing slash
if (path.endsWith('/')) {
path = path.slice(0, -1);
}
// 5. Ensure root is empty string or consistent slash
return path === '' ? '/' : '/' + path;
}
function findPageNeighbors(navItems, currentPagePath) {
const flatNavigation = [];
const currentCanonical = getCanonicalPath(currentPagePath);
function recurse(items) {
if (!items) return;
for (const item of items) {
if (item.path && !item.external) {
flatNavigation.push({
title: item.title,
path: item.path,
url: item.path, // We will fix this URL in build.js context if needed
canonical: getCanonicalPath(item.path)
});
}
if (item.children) recurse(item.children);
}
}
recurse(navItems);
const index = flatNavigation.findIndex(item => item.canonical === currentCanonical);
return {
prevPage: index > 0 ? flatNavigation[index - 1] : null,
nextPage: index < flatNavigation.length - 1 ? flatNavigation[index + 1] : null
};
}
function findBreadcrumbs(navItems, currentPagePath) {
const currentCanonical = getCanonicalPath(currentPagePath);
let breadcrumbs = [];
function recurse(items, currentTrail = []) {
if (!items) return false;
for (const item of items) {
const trail = [...currentTrail, { title: item.title, path: item.path }];
if (item.path && getCanonicalPath(item.path) === currentCanonical) {
breadcrumbs = trail;
return true;
}
if (item.children) {
if (recurse(item.children, trail)) return true;
}
}
return false;
}
recurse(navItems);
return breadcrumbs;
}
export { findPageNeighbors, findBreadcrumbs };
+195
View File
@@ -0,0 +1,195 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
/**
* Result of processing a href through the normaliser.
*/
export interface NormalizedHref {
/** The cleaned, SEO-safe href */
href: string;
/** Whether the link should open in a new tab */
isExternal: boolean;
/** Whether the link should skip normalisation (raw file reference) */
isRaw: boolean;
}
/**
* Centralised internal href normaliser.
*
* Converts any user-written link format into a clean, SEO-optimised URL
* that ends with a trailing slash (for directory-style pages) or is left
* untouched (for external, hash-only, or asset links).
*
* Supports special prefixes:
* - `external:` - forces the link to open in a new tab (strips prefix)
* - `raw:` - bypasses normalisation (strips prefix, keeps extension)
*
* Supported input formats (all produce the same output):
* - overview.md → overview/
* - overview → overview/
* - overview/ → overview/
* - ./overview.md → ./overview/
* - ../api/commands.md → ../api/commands/
* - localisation/index.md → localisation/
* - ./content/index.md → ./content/
* - ../index.md → ../
* - index.md → (empty string - root of current dir)
* - /absolute/path.md → /absolute/path/
* - #section → #section (unchanged)
* - https://example.com → https://example.com (unchanged, auto-external)
* - mailto:hi@docmd.io → mailto:hi@docmd.io (unchanged)
* - external:overview.md → overview/ (opens in new tab)
* - raw:docs/readme.md → docs/readme.md (no normalisation)
*
* @param href The raw href string from a markdown link, button, or nav config.
* @returns The normalised result with external/raw flags.
*/
export function resolveHref(href: string): NormalizedHref {
if (!href) return { href, isExternal: false, isRaw: false };
// 1. Handle `raw:` prefix - bypass all normalisation, keep extension
if (href.startsWith('raw:')) {
return { href: href.slice(4), isExternal: false, isRaw: true };
}
// 2. Handle `external:` prefix - normalise but flag as external
// Users must explicitly use external: prefix to open in new tab
let isExternal = false;
if (href.startsWith('external:')) {
href = href.slice(9);
isExternal = true;
}
// 3. Auto-detect external protocols (only for detection, not for new-tab behavior)
// This info can be used separately if needed, but we don't set isExternal here
// to give users control over their own docs links
// 4. Pass through: all protocols (mailto:, tel:, ftp:, etc.), hash-only, asset paths
if (
href.match(/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i) || // http:, https:, mailto:, tel:, ftp:, //
href.startsWith('#') ||
href.match(/(^|\/)assets\//)
) {
return { href, isExternal, isRaw: false };
}
// 5. Separate hash fragment
let hash = '';
const hashIndex = href.indexOf('#');
if (hashIndex >= 0) {
hash = href.substring(hashIndex);
href = href.substring(0, hashIndex);
}
// 6. Strip .md, .html, and .ejs extensions
if (href.endsWith('.md')) {
href = href.slice(0, -3);
} else if (href.endsWith('.html')) {
href = href.slice(0, -5);
} else if (href.endsWith('.ejs')) {
href = href.slice(0, -4);
}
// 7. Strip trailing /index or /README (the page is the folder root)
// Handles: dir/index, ./dir/index, ../dir/index, /dir/index
// And: dir/README, ./dir/README, ../dir/README, /dir/README
// Also handles bare "index" or "README" (root index)
const lowerHref = href.toLowerCase();
if (lowerHref === 'index' || lowerHref === './index' || lowerHref === 'readme' || lowerHref === './readme') {
href = (lowerHref === 'index' || lowerHref === 'readme') ? '' : './';
} else if (lowerHref.endsWith('/index')) {
href = href.slice(0, -5); // "dir/index" → "dir/"
} else if (lowerHref.endsWith('/readme')) {
href = href.slice(0, -6); // "dir/readme" → "dir/"
}
// 8. Ensure trailing slash for non-empty paths
// But NOT for empty string (which represents current directory root)
if (href !== '' && !href.endsWith('/')) {
href += '/';
}
// 9. Collapse any accidental double slashes (preserve leading // caught above)
href = href.replace(/([^:])\/{2,}/g, '$1/');
return { href: href + hash, isExternal, isRaw: false };
}
/**
* Simplified normaliser for backward compatibility.
* Returns only the normalised href string (no external/raw flags).
* Used by navigation normalisation where external detection is handled separately.
*/
export function normalizeInternalHref(href: string): string {
return resolveHref(href).href;
}
/**
* Recursively normalises all `path` values in a navigation tree.
* Used for nav config from `navigation.json`, `docmd.config.js`, and the auto-router.
*
* Supports the `external:` prefix as a shorthand for `external: true`:
* { "path": "external:https://github.com" } → { "path": "https://github.com", "external": true }
*
* The explicit `external: true` attribute is also supported and takes precedence.
*/
export function normalizeNavPaths(items: any[]): void {
if (!items) return;
for (const item of items) {
if (item.path && typeof item.path === 'string') {
const result = resolveHref(item.path);
item.path = result.href;
// If external: prefix was used, set the external flag (unless already explicitly set)
if (result.isExternal && item.external !== false) {
item.external = true;
}
}
if (item.children) {
normalizeNavPaths(item.children);
}
}
}
/**
* Recursively normalises all `url` values in a menubar tree.
* Applies the same trailing-slash enforcement and external detection as Markdown links.
*/
export function normalizeMenubarPaths(items: any[]): void {
if (!items) return;
for (const item of items) {
if (item.url && typeof item.url === 'string') {
const result = resolveHref(item.url);
item.url = result.href;
if (result.isExternal) item.external = true;
}
if (item.items) {
normalizeMenubarPaths(item.items);
}
}
}
/**
* Sanitize a URL by collapsing consecutive slashes (except after protocol).
* This is the last-resort safety net - if upstream logic is correct, this
* should be a no-op.
*
* @example
* sanitizeUrl('//docs//guide/') → '/docs/guide/'
* sanitizeUrl('https://a.com//b') → 'https://a.com/b'
*/
export function sanitizeUrl(url: string): string {
if (!url) return url;
// Collapse double+ slashes, but preserve protocol://
return url.replace(/([^:])\/\/+/g, '$1/');
}
+294
View File
@@ -0,0 +1,294 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/parser
* @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.
* --------------------------------------------------------------------
*/
/**
* Centralised URL Utilities
* =========================
*
* This module is the **single source of truth** for all URL transformations
* in the docmd ecosystem. Every plugin, template, and engine component
* MUST use these utilities instead of rolling their own URL logic.
*
* Architecture:
*
* User Input (markdown, config)
* │
* ▼
* resolveHref() ← normalize-href.ts (user-facing href → clean path)
* │
* ▼
* Build Engine ← generator.ts produces outputPath per page
* │
* ▼
* URL Utilities ← THIS FILE
* │
* ├── outputPathToSlug() → "guide/"
* ├── outputPathToCanonical() → "https://site.com/guide/"
* ├── buildContextualUrl() → "../de/guide/" (relative, context-aware)
* ├── sanitizeUrl() → collapse //, enforce trailing /
* └── createUrlContext() → factory for page-level context
*
* Plugins receive pre-computed URLs via the page object, OR can import
* these utilities directly for custom URL generation.
*/
import { sanitizeUrl } from './normalize-href.js';
// Re-export sanitizeUrl from normalize-href for convenience
export { sanitizeUrl };
// ─── Types ──────────────────────────────────────────────────────────
/**
* Immutable context object that captures all the environmental factors
* needed to resolve a URL for a specific page render.
*
* Created once per page in generator.ts and passed to all templates
* and plugin hooks.
*/
export interface UrlContext {
/** Relative path from current page back to site root, e.g. `../../` or `./` */
readonly relativePathToRoot: string;
/** Locale + version prefix for the current build pass, e.g. `de/v1/` or `` */
readonly outputPrefix: string;
/** Whether we're generating for offline/file:// browsing */
readonly offline: boolean;
/** The site base path from config, e.g. `/docs/` or `/` */
readonly base: string;
/** The full site URL from config, e.g. `https://docmd.io` (no trailing slash) */
readonly siteUrl: string;
}
/**
* Pre-computed URL data attached to every page object.
* Plugins can read these directly - zero computation needed.
*/
export interface PageUrls {
/** Clean directory-style slug, e.g. `guide/` or `/` for root */
readonly slug: string;
/** Full canonical URL, e.g. `https://docmd.io/guide/` (only if siteUrl is set) */
readonly canonical: string;
/** Relative path from site root, e.g. `/guide/` or `/` */
readonly pathname: string;
}
// ─── Core Utilities ─────────────────────────────────────────────────
/**
* Collapse consecutive slashes (except after protocol `:`), enforce
* consistent formatting. This is the **last-resort safety net** - if
* the upstream logic is correct, this should be a no-op.
*
* Note: This function is imported from normalize-href.ts to ensure
* single source of truth for URL sanitization logic.
*/
/**
* Convert a build-engine outputPath to a clean directory-style slug.
*
* This is the **single canonical conversion** from the internal file path
* representation to a URL path segment. Every consumer that previously
* did its own `outputPath.replace('/index.html', '/')` MUST use this.
*
* @param outputPath - e.g. `guide/index.html`, `index.html`, `de/v1/api/index.html`
* @returns Clean slug, e.g. `guide/`, `/`, `de/v1/api/`
*
* @example
* outputPathToSlug('guide/index.html') → 'guide/'
* outputPathToSlug('index.html') → '/'
* outputPathToSlug('de/v1/api/index.html') → 'de/v1/api/'
* outputPathToSlug('about.html') → 'about/'
*/
export function outputPathToSlug(outputPath: string): string {
if (!outputPath) return '/';
let slug = outputPath.replace(/\\/g, '/');
// Strip trailing index.html
if (slug === 'index.html') return '/';
if (slug.endsWith('/index.html')) {
slug = slug.slice(0, -10); // remove 'index.html', keep trailing '/'
} else if (slug.endsWith('.html')) {
slug = slug.slice(0, -5) + '/';
}
// Ensure trailing slash
if (slug !== '/' && !slug.endsWith('/')) {
slug += '/';
}
return slug;
}
/**
* Convert an outputPath to a root-relative pathname (always starts with `/`).
*
* @param outputPath - e.g. `guide/index.html`
* @returns e.g. `/guide/`
*/
export function outputPathToPathname(outputPath: string): string {
const slug = outputPathToSlug(outputPath);
return slug.startsWith('/') ? slug : '/' + slug;
}
/**
* Convert an outputPath to a full canonical URL.
*
* @param outputPath - e.g. `guide/index.html`
* @param siteUrl - e.g. `https://docmd.io` (no trailing slash)
* @returns e.g. `https://docmd.io/guide/`
*/
export function outputPathToCanonical(outputPath: string, siteUrl: string): string {
if (!siteUrl) return '';
const cleanSiteUrl = siteUrl.replace(/\/+$/, '');
const pathname = outputPathToPathname(outputPath);
return sanitizeUrl(cleanSiteUrl + pathname);
}
/**
* Build a context-aware relative URL from a clean href.
*
* This replaces ALL inline URL building in EJS templates and the
* `buildRelativeUrl` function in generator.ts. It is the single
* function that understands relativePathToRoot, outputPrefix,
* offline mode, and base path.
*
* @param href - A clean, normalised href (output of resolveHref), e.g. `guide/`, `#section`, `https://...`
* @param context - The UrlContext for the current page
* @returns A fully resolved relative URL safe for use in `<a href="...">`
*
* @example
* // Page at /de/v1/getting-started/index.html
* buildContextualUrl('guide/', ctx)
* // → '../../de/v1/guide/' (relative, with locale+version prefix)
*
* buildContextualUrl('#section', ctx)
* // → '#section' (hash-only, untouched)
*
* buildContextualUrl('https://github.com', ctx)
* // → 'https://github.com' (external, untouched)
*/
export function buildContextualUrl(href: string, context: UrlContext): string {
// Pass-through: empty, hash-only, external protocols, data URIs
if (!href || href === '#') return href || '#';
// D-S2: strip the `external:` prefix defensively. Plugin callers may
// invoke this function directly without going through `resolveHref`
// first; previously `external:https://...` was treated as a literal
// path segment and got mangled into `./external:https://...`.
if (href.startsWith('external:')) {
href = href.slice('external:'.length);
}
if (href.startsWith('http') || href.startsWith('//') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('data:')) {
return href;
}
// Hash-only anchors pass through unchanged. Without this guard, a
// value like `#section` falls through to the path-combining branch
// and gets prepended with the page's `relativePathToRoot`, producing
// `./#section` — which a browser still resolves to the same anchor,
// but breaks expectations for callers that compare the output to the
// input hash verbatim.
if (href.startsWith('#')) return href;
// Separate hash fragment
let hash = '';
const hashIdx = href.indexOf('#');
if (hashIdx >= 0) {
hash = href.substring(hashIdx);
href = href.substring(0, hashIdx);
}
// Strip leading ./ and / to get a clean relative path
const cleanPath = href.replace(/^(\.\/|\/)+/, '');
// Build the prefixed path: outputPrefix (locale/version) + clean path
const prefixStr = context.outputPrefix ? context.outputPrefix.replace(/\/$/, '') : '';
let combinedPath = prefixStr
? (cleanPath ? prefixStr + '/' + cleanPath : prefixStr + '/')
: cleanPath;
// Offline mode: append /index.html for file:// browsing
if (context.offline && combinedPath !== '' && !combinedPath.endsWith('.html') && !combinedPath.endsWith('/')) {
combinedPath = combinedPath + '/index.html';
} else if (context.offline && combinedPath !== '' && combinedPath.endsWith('/')) {
combinedPath = combinedPath + 'index.html';
}
// Build final relative URL
const result = context.relativePathToRoot + combinedPath + hash;
return sanitizeUrl(result);
}
/**
* Create a UrlContext for a specific page render.
*
* Called once per page in generator.ts. The resulting context is then
* passed to all templates and can be forwarded to plugin hooks.
*
* @param options - Configuration for this page's URL context
*/
export function createUrlContext(options: {
relativePathToRoot: string;
outputPrefix?: string;
offline?: boolean;
base?: string;
siteUrl?: string;
}): UrlContext {
return Object.freeze({
relativePathToRoot: options.relativePathToRoot || './',
outputPrefix: options.outputPrefix || '',
offline: options.offline || false,
base: options.base || '/',
siteUrl: (options.siteUrl || '').replace(/\/+$/, ''),
});
}
/**
* Compute pre-built URL data for a page.
*
* Called once per page in generator.ts. The resulting PageUrls object
* is attached to the page object and available to all post-build plugins.
*
* @param outputPath - The page's output path, e.g. `guide/index.html`
* @param siteUrl - The site URL from config, e.g. `https://docmd.io`
*/
export function computePageUrls(outputPath: string, siteUrl: string): PageUrls {
return Object.freeze({
slug: outputPathToSlug(outputPath),
canonical: outputPathToCanonical(outputPath, siteUrl),
pathname: outputPathToPathname(outputPath),
});
}
/**
* Build an absolute URL from config.base + optional locale + optional version + page path.
*
* Used by version-dropdown.ejs and language-switcher.ejs for absolute navigation.
* Replaces the inline JS computations in those templates.
*
* @param base - config.base, e.g. `/docs/` or `/`
* @param localePrefix - e.g. `de/` or `` for default locale
* @param versionPrefix - e.g. `v1/` or `` for current version
* @param pagePath - e.g. `guide/` or ``
* @returns Absolute path, e.g. `/docs/de/v1/guide/`
*/
export function buildAbsoluteUrl(
base: string,
localePrefix: string = '',
versionPrefix: string = '',
pagePath: string = ''
): string {
const normalizedBase = base.endsWith('/') ? base : base + '/';
const result = normalizedBase + localePrefix + versionPrefix + pagePath;
return sanitizeUrl(result);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
import { TUI } from '@docmd/tui';
// Known configuration keys for typo detection (V2 + V3)
const KNOWN_KEYS = [
// V3 Modern Labels
'title', 'url', 'src', 'out', 'base', 'layout',
'versions', 'redirects', 'notFound', 'projects',
// Engine selection (e.g. "rust" for the rust preview engine)
'engine', 'engines',
// V2 Legacy Labels
'siteTitle', 'siteUrl', 'srcDir', 'outputDir',
// Shared Features
'logo', 'sidebar', 'theme', 'customJs', 'autoTitleFromH1',
'copyCode', 'plugins', 'navigation', 'footer', 'sponsor', 'favicon',
'search', 'minify', 'editLink', 'pageNavigation', 'i18n',
// Workspace
'workspace'
];
// Common typos mapping
const TYPO_MAPPING = {
'site_title': 'title',
'sitetitle': 'title',
'baseUrl': 'url',
'source': 'src',
'outDir': 'out',
'customCSS': 'theme.customCss',
'customcss': 'theme.customCss',
'customJS': 'customJs',
'customjs': 'customJs',
'nav': 'navigation',
'menu': 'navigation'
};
function validateConfig(config) {
const errors = [];
const warnings = [];
// 1. Required Fields (Accept either title OR siteTitle)
// Skip for multi-project root configs - they only have projects[]
if (!config.title && !config.siteTitle && !Array.isArray(config.projects)) {
errors.push('Missing required property: "title" (or "siteTitle")');
}
// 2. Type Checking
if (config.navigation && !Array.isArray(config.navigation)) {
errors.push('"navigation" must be an Array');
}
if (config.customJs && !Array.isArray(config.customJs)) {
errors.push('"customJs" must be an Array of strings');
}
if (config.theme) {
if (config.theme.customCss && !Array.isArray(config.theme.customCss)) {
errors.push('"theme.customCss" must be an Array of strings');
}
}
if (config.versions && config.versions.all && !Array.isArray(config.versions.all)) {
errors.push('"versions.all" must be an Array');
}
// 3. Typos and Unknown Keys (Top Level)
// T-Z3: previously completely-unknown keys were silently ignored. We
// now warn about every unrecognised top-level key (typo suggestions
// where applicable) so misconfigurations don't ship silently. Nested
// plugin config (`plugins.<name>.*`) is intentionally NOT checked —
// each plugin owns its own schema and is validated by the plugin
// loader.
Object.keys(config).forEach(key => {
// Skip checking internal keys (starting with _)
if (key.startsWith('_')) return;
if (!KNOWN_KEYS.includes(key)) {
if (TYPO_MAPPING[key]) {
warnings.push(`Found unknown property "${key}". Did you mean "${TYPO_MAPPING[key]}"?`);
} else {
warnings.push(`Unknown property "${key}" in config — ignored. (Top-level keys only; plugin-specific options belong under "plugins.<name>".)`);
}
}
});
// 4. Theme specific typos
if (config.theme) {
if (config.theme.customCSS) {
warnings.push('Found "theme.customCSS". Did you mean "theme.customCss"?');
}
}
// Output results
if (warnings.length > 0) {
TUI.warn('Configuration Warnings:');
warnings.forEach(w => TUI.warn(w));
}
if (errors.length > 0) {
TUI.error('Configuration Errors');
errors.forEach(e => TUI.error(e));
throw new Error('Invalid configuration file.');
}
return { warnings, errors };
}
export { validateConfig };
@@ -0,0 +1,814 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* Phase 2 container normaliser — edge-case test fixture
*
* These tests cover the five reported F1F5 failure modes plus the
* 50+ edge cases that derive from the shim's own warning surface
* (battle-test-reports/robust-parser-shim/index.js) and the
* `normaliseContainers` algorithm.
*
* Test categories (in order):
* 1. classifyLine — open / close / other classification
* 2. indentOf — leading-space counting
* 3. normaliseContainers — core algorithm (no warnings)
* 4. normaliseContainers — warning surface
* 5. F1F5 reported failure modes
* 6. processContentAsync integration — end-to-end HTML
* 7. Determinism — identical output across threads / replays
*
* Run: `pnpm --filter @docmd/parser test`
* --------------------------------------------------------------------
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
normaliseContainers,
classifyLine,
indentOf,
SELF_CLOSING_CONTAINER_NAMES,
createMarkdownProcessor,
processContentAsync
} from '../dist/index.js';
// ─────────────────────────────────────────────────────────────────────
// 1. classifyLine
// ─────────────────────────────────────────────────────────────────────
test('classifyLine: `::: callout` is open with name `callout`', () => {
assert.deepEqual(classifyLine('::: callout'), { kind: 'open', name: 'callout' });
});
test('classifyLine: `::: callout info "Title"` is open, name is the first word', () => {
assert.deepEqual(
classifyLine('::: callout info "Title"'),
{ kind: 'open', name: 'callout' }
);
});
test('classifyLine: `:::card` (no space) is still open (zero-or-more whitespace)', () => {
assert.deepEqual(classifyLine(':::card'), { kind: 'open', name: 'card' });
});
test('classifyLine: `:::` is close', () => {
assert.deepEqual(classifyLine(':::'), { kind: 'close' });
});
test('classifyLine: `::: ` (trailing whitespace) is close', () => {
assert.deepEqual(classifyLine('::: '), { kind: 'close' });
});
test('classifyLine: ` ::: callout` (leading indent) is still open', () => {
assert.deepEqual(
classifyLine(' ::: callout'),
{ kind: 'open', name: 'callout' }
);
});
test('classifyLine: `:::: callout` (four colons) is NOT matched as open or close', () => {
// Three colons then a fourth colon is not whitespace, so `[a-zA-Z]` fails.
// The line passes through to the parser unchanged.
assert.equal(classifyLine(':::: callout').kind, 'other');
});
test('classifyLine: `::: 123abc` (leading digit) is NOT open', () => {
// Name must start with a letter.
assert.equal(classifyLine('::: 123abc').kind, 'other');
});
test('classifyLine: `:::-foo` (hyphen, not letter) is NOT open', () => {
assert.equal(classifyLine(':::-foo').kind, 'other');
});
test('classifyLine: `plain text` is other', () => {
assert.equal(classifyLine('plain text').kind, 'other');
});
test('classifyLine: empty string is other', () => {
assert.equal(classifyLine('').kind, 'other');
});
// ─────────────────────────────────────────────────────────────────────
// 2. indentOf
// ─────────────────────────────────────────────────────────────────────
test('indentOf: empty line is 0', () => {
assert.equal(indentOf(''), 0);
});
test('indentOf: line with no leading spaces is 0', () => {
assert.equal(indentOf('::: callout'), 0);
});
test('indentOf: 4 leading spaces is 4', () => {
assert.equal(indentOf(' ::: callout'), 4);
});
test('indentOf: 8 leading spaces is 8', () => {
assert.equal(indentOf(' ::: card'), 8);
});
// ─────────────────────────────────────────────────────────────────────
// 3. normaliseContainers — core algorithm (no warnings expected)
// ─────────────────────────────────────────────────────────────────────
test('normaliseContainers: empty input is unchanged', () => {
const r = normaliseContainers('');
assert.equal(r.source, '');
assert.deepEqual(r.warnings, []);
});
test('normaliseContainers: input with no `:::` is unchanged', () => {
const src = '# Title\n\nSome **bold** text.\n\n- list item\n';
const r = normaliseContainers(src);
assert.equal(r.source, src);
assert.deepEqual(r.warnings, []);
});
test('normaliseContainers: balanced single callout is unchanged', () => {
const src = '::: callout info "Title"\nbody\n:::\n';
const r = normaliseContainers(src);
assert.equal(r.source, src);
assert.deepEqual(r.warnings, []);
});
test('normaliseContainers: self-closing `::: button` passes through with no stack push', () => {
const src = '::: button "Click me"\n';
const r = normaliseContainers(src);
assert.equal(r.source, src);
assert.deepEqual(r.warnings, []);
});
test('normaliseContainers: SELF_CLOSING_CONTAINER_NAMES has exactly button/tag/embed', () => {
// The whitelist is the source of truth for the depth counter's behaviour.
// Adding or removing names here is a parser-semantics change.
assert.deepEqual(
[...SELF_CLOSING_CONTAINER_NAMES].sort(),
['button', 'embed', 'tag']
);
});
test('normaliseContainers: three self-closing tags then a stray `:::` removes the stray', () => {
// F2 — orphan `:::` after self-closing tags must be dropped.
const src = '::: tag "a"\n::: tag "b"\n::: tag "c"\n:::\n';
const r = normaliseContainers(src);
assert.equal(r.source, '::: tag "a"\n::: tag "b"\n::: tag "c"\n');
assert.equal(r.warnings.length, 1);
assert.equal(r.warnings[0].severity, 'warning');
assert.match(r.warnings[0].message, /Stray `:::`/);
});
test('normaliseContainers: nested callout/card with one implicit close emits 2 closes', () => {
// Inner `::: card` is at indent 4; user's `:::` is at indent 0 → the
// close at indent 0 matches the OUTER callout, and the inner card is
// closed implicitly. Algorithm emits 2 closes at indent 0 (one replaces
// the user's `:::`, one is added for the card).
const src = '::: callout\n ::: card "x"\n body\n:::\n';
const r = normaliseContainers(src);
const lines = r.source.split('\n');
const closeCount = lines.filter((l) => /^:::\s*$/.test(l)).length;
assert.equal(closeCount, 2, `expected 2 closes at indent 0, got ${closeCount}`);
assert.equal(r.warnings.length, 1);
assert.equal(r.warnings[0].severity, 'info');
});
test('normaliseContainers: indented open matched by greater-indent close', () => {
// Open at indent 4, close at indent 8 (deeper indent). The shim's
// matching rule is `open.indent <= close.indent` so 4 <= 8 matches.
// The original `:::` is replaced with one at the open's indent.
const src = ' ::: card "x"\nbody\n :::\n';
const r = normaliseContainers(src);
// The original close is rewritten at the open's indent (4 spaces).
assert.match(r.source, / {4}::: card "x"\n/);
assert.match(r.source, /\n {4}:::/);
// No warnings — this is a balanced container.
assert.deepEqual(r.warnings, []);
});
test('normaliseContainers: close LESS indented than open is stray, container auto-closes at EOF', () => {
// Open at indent 4, close at indent 0. The shim's matching rule is
// `open.indent <= close.indent` so 4 <= 0 fails → stray close. The
// card then auto-closes at EOF with an ERROR (at the card's indent).
const src = ' ::: card "x"\nbody\n:::\n';
const r = normaliseContainers(src);
// The stray close at indent 0 is removed; the card is auto-closed at
// EOF at the card's original indent (4 spaces).
assert.doesNotMatch(r.source, /\n:::\s*$/);
assert.match(r.source, /\n {4}:::\s*$/);
// Warnings: 1 stray (warning) + 1 unclosed (error).
const stray = r.warnings.find((w) => w.severity === 'warning');
const unclosed = r.warnings.find((w) => w.severity === 'error');
assert.ok(stray, 'expected a stray-close warning');
assert.ok(unclosed, 'expected an unclosed-at-EOF error');
assert.match(unclosed.message, /Unclosed `<card>`/);
});
test('normaliseContainers: over-indented close is normalised, not stray', () => {
// Open at indent 0, close at indent 4 → close's indent (4) >= open's
// indent (0), so they match. The over-indented close is rewritten to
// the open's indent. This is the normaliser's signature behaviour:
// it lets users get the indent wrong without breaking the page.
const src = '::: callout\nbody\n :::\n';
const r = normaliseContainers(src);
// The callout is closed cleanly with no warnings.
assert.equal(r.source, '::: callout\nbody\n:::\n');
assert.deepEqual(r.warnings, []);
});
test('normaliseContainers: unclosed at EOF is auto-closed with an error', () => {
// F4 / F3 class — file ends with an open container still on the stack.
const src = '::: callout\nbody\n';
const r = normaliseContainers(src);
// Source ends with the auto-close at indent 0 (callout's indent). The
// original body line ended with `\n`, so there are two newlines
// between `body` and the inserted `:::`.
assert.match(r.source, /\n:::\s*$/);
assert.equal(r.warnings.length, 1);
assert.equal(r.warnings[0].severity, 'error');
assert.match(r.warnings[0].message, /Unclosed `<callout>`/);
});
test('normaliseContainers: multiple unclosed at EOF each emit an error', () => {
const src = '::: callout\n::: card "x"\n';
const r = normaliseContainers(src);
// Two auto-closes are appended, one per open frame.
assert.match(r.source, /\n:::\n:::\s*$/);
const errors = r.warnings.filter((w) => w.severity === 'error');
assert.equal(errors.length, 2);
// Inner card is reported first (LIFO from the stack).
assert.match(errors[0].message, /<card>/);
assert.match(errors[1].message, /<callout>/);
});
// ─────────────────────────────────────────────────────────────────────
// 4. normaliseContainers — warning surface
// ─────────────────────────────────────────────────────────────────────
test('normaliseContainers: stray `:::` at top level produces a warning with the line number', () => {
const r = normaliseContainers(':::\n');
assert.equal(r.warnings.length, 1);
assert.equal(r.warnings[0].line, 1);
assert.equal(r.warnings[0].severity, 'warning');
});
test('normaliseContainers: sourcePath is reflected in warnings', () => {
const r = normaliseContainers(':::\n', { sourcePath: 'docs/page.md' });
assert.equal(r.warnings[0].path, 'docs/page.md');
});
test('normaliseContainers: sourcePath defaults to `<source>`', () => {
const r = normaliseContainers(':::\n');
assert.equal(r.warnings[0].path, '<source>');
});
test('normaliseContainers: onWarning callback fires alongside the returned array', () => {
const collected = [];
const r = normaliseContainers(':::\n', { onWarning: (w) => collected.push(w) });
assert.equal(collected.length, 1);
assert.equal(collected[0].line, 1);
assert.equal(r.warnings.length, 1);
assert.equal(collected[0], r.warnings[0]);
});
test('normaliseContainers: legacy 2-arg signature `normaliseContainers(src, path)` works', () => {
// Used by the legacy shim-style plugin descriptor.
const r = normaliseContainers(':::\n', 'legacy/path.md');
assert.equal(r.warnings[0].path, 'legacy/path.md');
});
test('normaliseContainers: implicit multi-close info warning lists all closed containers', () => {
// Inner `::: card` at indent 4 is closed by the user's `:::` at indent 0.
// The outer `::: callout` is ALSO closed by the same `:::` because its
// indent (0) matches the close's indent (0). One user gesture closes two
// containers — the algorithm emits an INFO warning naming both.
const src = '::: callout\n ::: card "x"\n body\n:::\n';
const r = normaliseContainers(src);
const info = r.warnings.find((w) => w.severity === 'info');
assert.ok(info, 'expected an info warning for implicit multi-close');
assert.match(info.message, /<callout>/);
assert.match(info.message, /<card>/);
});
// ─────────────────────────────────────────────────────────────────────
// 5. F1F5 reported failure modes
// ─────────────────────────────────────────────────────────────────────
test('F1: nested grids with one close per card → 3 outer closes at indent 0', () => {
// The user-reported "grids don't work" pattern. Cards are closed
// individually (one `:::` per card body) at indent 8; only the outer
// grids has its own `:::` at indent 0. The user's final `:::` closes
// the OUTER grids, and the inner grids (×2) are closed implicitly.
const src = [
'::: grids',
' ::: grid',
' ::: card "Fast" icon:zap',
' body',
' :::',
' ::: grid',
' ::: card "Slow"',
' body',
' :::',
':::'
].join('\n');
const r = normaliseContainers(src);
// Algorithm emits 3 closes at indent 0: 1 replaces the user's final
// `:::`, 2 are added to close the 2 inner grids.
const lines = r.source.split('\n');
const outerCloses = lines.filter((l) => /^:::\s*$/.test(l));
assert.equal(outerCloses.length, 3, `expected 3 outer closes, got ${outerCloses.length}`);
// Top-level normaliser produces an info warning naming all 3 containers
// that the user's one `:::` closed implicitly.
const info = r.warnings.find((w) => w.severity === 'info');
assert.ok(info, 'expected an info warning for implicit multi-close');
assert.match(info.message, /<grids>/);
assert.match(info.message, /<grid>/);
});
test('F2: three `::: tag` lines plus an orphan `:::` → orphan removed, no stack pushes', () => {
const src = '::: tag "v0.8" color:blue\n::: tag "Experimental"\n::: tag "Live"\n:::\n';
const r = normaliseContainers(src);
// All three tag lines preserved, orphan `:::` removed with a warning.
assert.match(r.source, /::: tag "v0.8"/);
assert.match(r.source, /::: tag "Experimental"/);
assert.match(r.source, /::: tag "Live"/);
assert.equal(r.source.split('\n').filter((l) => l === ':::').length, 0);
assert.equal(r.warnings.length, 1);
assert.equal(r.warnings[0].severity, 'warning');
});
test('F3: `::: callout ... ::: card ... :::` mismatched close → auto-close callout', () => {
const src = '::: callout info "x"\nbody\n::: card "wrong close"\noops\n:::\n';
const r = normaliseContainers(src);
// The callout opener stays; the card opener stays; the user's `:::` closes
// the card; the callout is then auto-closed at EOF with an error.
assert.match(r.source, /::: callout info "x"/);
assert.match(r.source, /::: card "wrong close"/);
const errors = r.warnings.filter((w) => w.severity === 'error');
assert.equal(errors.length, 1);
assert.match(errors[0].message, /Unclosed `<callout>`/);
});
test('F4: triple `:::` after a balanced callout → two are removed, one closes callout', () => {
const src = '::: callout info "x"\nbody\n:::\n:::\n:::\n';
const r = normaliseContainers(src);
// First `:::` closes callout (matching); second and third are stray.
const warnings = r.warnings.filter((w) => w.severity === 'warning');
assert.equal(warnings.length, 2);
// Source retains exactly one `:::` (the callout close).
const closes = r.source.split('\n').filter((l) => /^:::\s*$/.test(l));
assert.equal(closes.length, 1);
});
test('F5: 5-level nested callouts → already balanced, normalisation is a no-op', () => {
const src = [
'::: callout info "l1"',
'::: callout tip "l2"',
'::: callout warning "l3"',
'::: callout danger "l4"',
'::: callout success "l5"',
'deep',
':::',
':::',
':::',
':::',
':::'
].join('\n');
const r = normaliseContainers(src);
assert.equal(r.source, src);
assert.deepEqual(r.warnings, []);
});
// ─────────────────────────────────────────────────────────────────────
// 6. processContentAsync integration — end-to-end HTML
// ─────────────────────────────────────────────────────────────────────
// Build a fresh processor per test so module-level caches don't leak.
function freshProcessor() {
return createMarkdownProcessor({}, () => {});
}
test('integration: plain markdown renders unchanged', async () => {
const md = freshProcessor();
const r = await processContentAsync('# Hello\n\nBody text.\n', md, {}, {});
// headingIdPlugin adds id + class + permalink anchor; just check the
// h1 exists and contains the heading text.
assert.match(r.htmlContent, /<h1[^>]*>[\s\S]*?Hello[\s\S]*?<\/h1>/);
assert.match(r.htmlContent, /<p>Body text\.<\/p>/);
});
test('integration: balanced single callout renders as `.callout` div', async () => {
const md = freshProcessor();
const r = await processContentAsync(
'::: callout info "Title"\nbody\n:::\n',
md,
{},
{ filePath: 'test.md' }
);
assert.match(r.htmlContent, /docmd-container callout callout-info/);
assert.match(r.htmlContent, /callout-title/);
assert.match(r.htmlContent, /<p>body<\/p>/);
});
test('integration: F1 grids render correctly (no raw `<p>::: grids<br>` text)', async () => {
const md = freshProcessor();
const src = [
'::: grids',
' ::: grid',
' ::: card "Fast"',
' body1',
' :::',
' ::: grid',
' ::: card "Slow"',
' body2',
' :::',
':::'
].join('\n');
const r = await processContentAsync(src, md, {}, { filePath: 'f1.md' });
assert.match(r.htmlContent, /docmd-container grids/);
assert.match(r.htmlContent, /grid-item/);
assert.match(r.htmlContent, /card-title/);
// No leaked raw container text.
assert.doesNotMatch(r.htmlContent, /<p>::: grids<br/);
});
test('integration: F2 self-closing tag renders inline, orphan `:::` is removed', async () => {
const md = freshProcessor();
const r = await processContentAsync(
'::: tag "v0.8" color:blue\n::: tag "Experimental"\n::: tag "Live"\n:::\n',
md,
{},
{ filePath: 'f2.md' }
);
assert.match(r.htmlContent, /docmd-tag/);
// No leaked `<p>:::</p>` orphan.
assert.doesNotMatch(r.htmlContent, /<p>:::<\/p>/);
});
test('integration: F3 mismatched close renders callout + card correctly', async () => {
const md = freshProcessor();
const r = await processContentAsync(
'::: callout info "x"\nbody\n::: card "wrong close"\noops\n:::\n',
md,
{},
{ filePath: 'f3.md' }
);
assert.match(r.htmlContent, /callout-info/);
assert.match(r.htmlContent, /card-title/);
assert.match(r.htmlContent, /<p>oops<\/p>/);
});
test('integration: F4 triple close renders callout only, no leaked `<p>:::</p>`', async () => {
const md = freshProcessor();
const r = await processContentAsync(
'::: callout info "x"\nbody\n:::\n:::\n:::\n',
md,
{},
{ filePath: 'f4.md' }
);
assert.match(r.htmlContent, /callout-info/);
assert.match(r.htmlContent, /<p>body<\/p>/);
// No leaked `<p>:::</p>` orphan paragraphs.
assert.doesNotMatch(r.htmlContent, /<p>:::<\/p>/);
});
test('integration: F5 5-level nested callouts render five `<div class="callout callout-*">` levels', async () => {
const md = freshProcessor();
const src = [
'::: callout info "l1"',
'::: callout tip "l2"',
'::: callout warning "l3"',
'::: callout danger "l4"',
'::: callout success "l5"',
'deep',
':::',
':::',
':::',
':::',
':::'
].join('\n');
const r = await processContentAsync(src, md, {}, { filePath: 'f5.md' });
for (const cls of ['callout-info', 'callout-tip', 'callout-warning', 'callout-danger', 'callout-success']) {
assert.match(r.htmlContent, new RegExp(cls), `expected ${cls} in HTML`);
}
assert.match(r.htmlContent, /<p>deep<\/p>/);
});
// ─────────────────────────────────────────────────────────────────────
// 7. Determinism — identical output across replays / concurrent calls
// ─────────────────────────────────────────────────────────────────────
test('determinism: normaliseContainers returns byte-identical output on replay', () => {
const src = [
'::: grids',
' ::: grid',
' ::: card "x"',
' body',
' :::',
':::'
].join('\n');
const a = normaliseContainers(src);
const b = normaliseContainers(src);
assert.equal(a.source, b.source);
assert.deepEqual(a.warnings, b.warnings);
});
test('determinism: 100 concurrent parses of the same source produce identical HTML', async () => {
const md = freshProcessor();
const src = [
'::: grids',
' ::: grid',
' ::: card "Fast"',
' body1',
' :::',
' ::: grid',
' ::: card "Slow"',
' body2',
' :::',
':::'
].join('\n');
const N = 100;
const results = await Promise.all(
Array.from({ length: N }, () => processContentAsync(src, md, {}, { filePath: 'det.md' }))
);
const first = results[0].htmlContent;
for (let i = 1; i < N; i++) {
assert.equal(results[i].htmlContent, first, `divergence at index ${i}`);
}
});
test('determinism: parser output is independent of `Date.now` / randomness', async () => {
// No Date.now / Math.random in the parser pipeline. Two parses made at
// very different times must produce identical HTML.
const md = freshProcessor();
const src = '# Heading\n\n::: callout\nbody\n:::\n';
const a = await processContentAsync(src, md, {}, { filePath: 'a.md' });
await new Promise((r) => setTimeout(r, 10));
const b = await processContentAsync(src, md, {}, { filePath: 'a.md' });
assert.equal(a.htmlContent, b.htmlContent);
});
// Cross-thread determinism (Phase 2 commit 3). Run the same parse in a
// real worker_threads worker — a different module instance, a different
// microtask queue — and assert the HTML is byte-identical to the main
// thread's result.
test('determinism: parse in a worker_threads worker produces identical HTML', async () => {
const { Worker } = await import('node:worker_threads');
const md = freshProcessor();
const src = [
'::: grids',
' ::: grid',
' ::: card "Fast"',
' body1',
' :::',
' ::: grid',
' ::: card "Slow"',
' body2',
' :::',
':::'
].join('\n') + '\n:::\n'; // trailing stray exercises the warning path
// Main-thread parse.
const mainResult = await processContentAsync(src, md, {}, { filePath: 'det.md' });
// Worker-thread parse. Inline worker that imports the same dist build.
const workerCode = `
import { parentPort, workerData } from 'node:worker_threads';
import { createMarkdownProcessor, processContentAsync } from '${import.meta.dirname}/../dist/index.js';
(async () => {
const md = createMarkdownProcessor({}, () => {});
const r = await processContentAsync(workerData.src, md, {}, { filePath: workerData.filePath });
parentPort.postMessage(r.htmlContent);
})().catch((e) => { parentPort.postMessage({ __err: e.message }); });
`;
const workerResult = await new Promise((resolve, reject) => {
const w = new Worker(workerCode, { eval: true, workerData: { src, filePath: 'det.md' } });
w.once('message', (msg) => {
if (msg && typeof msg === 'object' && msg.__err) reject(new Error(msg.__err));
else resolve(msg);
});
w.once('error', reject);
});
assert.equal(workerResult, mainResult.htmlContent);
});
// ─────────────────────────────────────────────────────────────────────
// 8. Edge cases that round out the fixture to 50+ assertions
// ─────────────────────────────────────────────────────────────────────
test('edge case: empty `info` after `::: callout` is preserved through normalisation', () => {
// `::: callout` with no extra info → passes through, no warnings.
const src = '::: callout\nbody\n:::\n';
const r = normaliseContainers(src);
assert.equal(r.source, src);
assert.deepEqual(r.warnings, []);
});
test('edge case: `::: callout` followed immediately by another container (no body)', () => {
// Two opens in a row — the inner one is "body" of the outer. Both
// push to the stack; one close closes the outer.
const src = '::: callout\n::: card "x"\nbody\n:::\n';
const r = normaliseContainers(src);
// The card is closed by the user's `:::`, the callout is auto-closed
// at EOF with an ERROR.
assert.equal(r.warnings.filter((w) => w.severity === 'error').length, 1);
});
test('edge case: CRLF line endings preserve `\r` on pass-through lines (documented behaviour)', () => {
// The algorithm splits on `\n` so `\r` ends up at the end of each
// pass-through line and is kept verbatim. The close regex `\s*$`
// still matches because `\r` is `\s`. The output replaces close lines
// with a freshly-emitted `:::` (no `\r`), so the trailing `\r` on the
// user's close line is dropped — but everything else keeps its `\r`.
// This matches the legacy shim's behaviour.
const src = '::: callout\r\nbody\r\n:::\r\n';
const r = normaliseContainers(src);
// Pass-through lines retain `\r\n`; only the emitted `:::` is clean.
assert.equal(r.source, '::: callout\r\nbody\r\n:::\n');
assert.deepEqual(r.warnings, []);
});
test('edge case: multiple spaces between `:::` and the container name', () => {
const src = '::: callout info "Title"\nbody\n:::\n';
const r = normaliseContainers(src);
assert.equal(r.source, src);
assert.deepEqual(r.warnings, []);
});
test('edge case: deeply nested self-closing tags do not corrupt the stack', () => {
// Many self-closing tag lines; no closes, no opens, no pushes.
const lines = Array.from({ length: 50 }, (_, i) => `::: tag "v${i}"`).join('\n');
const r = normaliseContainers(lines + '\n');
assert.equal(r.source, lines + '\n');
assert.deepEqual(r.warnings, []);
});
test('edge case: pre-existing normaliser output is idempotent', () => {
// Running the normaliser twice must produce the same result as once
// (for already-balanced input).
const src = [
'::: grids',
' ::: grid',
' ::: card "Fast"',
' body1',
' :::',
' ::: grid',
' ::: card "Slow"',
' body2',
' :::',
':::'
].join('\n');
const once = normaliseContainers(src);
const twice = normaliseContainers(once.source);
assert.equal(twice.source, once.source);
});
test('edge case: warning `line` numbers are 1-indexed and stable across edits', () => {
// Insert a line at the top; the warning for the stray `:::` should
// shift from line 1 to line 2.
const r1 = normaliseContainers(':::\n');
assert.equal(r1.warnings[0].line, 1);
const r2 = normaliseContainers('# heading\n:::\n');
assert.equal(r2.warnings[0].line, 2);
});
test('edge case: `NormaliserWarning` shape is exactly { line, severity, path, message }', () => {
const r = normaliseContainers(':::\n', { sourcePath: 'p.md' });
const w = r.warnings[0];
assert.deepEqual(Object.keys(w).sort(), ['line', 'message', 'path', 'severity']);
assert.equal(typeof w.line, 'number');
assert.equal(typeof w.severity, 'string');
assert.equal(typeof w.path, 'string');
assert.equal(typeof w.message, 'string');
});
test('edge case: container aliases (`tip`, `warning`, `info`, `note`, `danger`, `caution`) are NOT self-closing', () => {
// The parser maps `tip`, `warning`, `danger`, `info`, `note`, `caution`
// to callout types. The normaliser must NOT treat them as self-closing
// even though the names match a generic pattern. Only the literal set
// { button, tag, embed } is self-closing.
for (const name of ['tip', 'warning', 'danger', 'info', 'note', 'caution']) {
assert.ok(
!SELF_CLOSING_CONTAINER_NAMES.has(name),
`${name} must NOT be self-closing (it's a callout alias)`
);
}
});
test('edge case: debug mode logs self-close lines to stdout', () => {
// Capture console.log to verify the debug path fires.
const originalLog = console.log;
const captured = [];
console.log = (...args) => captured.push(args.join(' '));
try {
normaliseContainers('::: tag "x"\n', { debug: true });
} finally {
console.log = originalLog;
}
assert.equal(captured.length, 1);
assert.match(captured[0], /self-close <tag>/);
});
test('edge case: warnings preserve insertion order across severity', () => {
// Three different issues in one source, each in source order:
// L1 — stray top-level `:::` (warning)
// L2L6 — callout + card with one close at indent 0 → implicit multi-close (info)
// L8 — `::: unclosed` never closed (error)
const src = [
':::', // 1: stray (warning)
'::: callout', // 2
' ::: card "x"', // 3
' body', // 4
':::', // 5: closes callout + card (info)
'', // 6
'::: unclosed' // 7: unclosed at EOF (error)
].join('\n') + '\n';
const r = normaliseContainers(src);
const sevs = r.warnings.map((w) => w.severity);
// All three severities should appear, in source order.
assert.deepEqual(sevs, ['warning', 'info', 'error']);
assert.equal(r.warnings[0].line, 1);
assert.equal(r.warnings[1].line, 5);
assert.equal(r.warnings[2].line, 7);
});
// ─────────────────────────────────────────────────────────────────────
// 8. Fenced code block tracking (F6 — normaliser must not interpret
// `:::` lines that appear inside a ``` or ~~~ fence)
// ─────────────────────────────────────────────────────────────────────
test('F6: ::: lines inside a ``` fence are not classified as opens/closes', () => {
// The docs site has pages that show `::: card ... :::` examples inside
// markdown code fences. Without fence tracking the normaliser treats the
// fenced `::: card` as a real open, pushes it on the stack, and reports
// a spurious "Unclosed <card>" error when EOF arrives.
const src = [
'# Buttons', // 1
'', // 2
'```markdown', // 3: fence open
'::: card "Setup"', // 4: literal text in fence
' ::: button "Begin" ../../x', // 5: literal text in fence
':::', // 6: literal text in fence
'```', // 7: fence close
'', // 8
'::: callout', // 9: real open
'body', // 10
':::' // 11: real close
].join('\n') + '\n';
const r = normaliseContainers(src);
// No warnings: the fenced ::: lines are not counted, the real callout
// at L9L11 is balanced.
assert.equal(r.warnings.length, 0);
// Output is unchanged — the fence contents pass through verbatim.
assert.equal(r.source, src);
});
test('F6: ~~~ fences are also tracked', () => {
// CommonMark allows ~~~ as an alternative fence marker.
const src = [
'~~~markdown', // 1: fence open
'::: card "x"', // 2: literal
':::', // 3: literal
'~~~', // 4: fence close
'::: callout', // 5: real open
':::' // 6: real close
].join('\n') + '\n';
const r = normaliseContainers(src);
assert.equal(r.warnings.length, 0);
assert.equal(r.source, src);
});
test('F6: a real ::: card outside a fence still reports the unclosed error', () => {
// Regression guard: fence tracking must not swallow real container errors
// that occur outside any fence.
const src = [
'```markdown', // 1: fence open
'::: card "fenced"', // 2: literal
'```', // 3: fence close
'::: card "real"', // 4: real open, never closed
'body' // 5
].join('\n') + '\n';
const r = normaliseContainers(src);
const errors = r.warnings.filter((w) => w.severity === 'error');
assert.equal(errors.length, 1);
assert.match(errors[0].message, /Unclosed.*card/);
assert.equal(errors[0].line, 4);
});
test('F6: mismatched fence markers (``` open vs ~~~ close) do not close the fence', () => {
// CommonMark says a fence closes only on the same marker that opened it.
const src = [
'```markdown', // 1: ``` fence open
'::: card "x"', // 2: literal
'~~~', // 3: NOT a close (different marker)
'::: card "y"', // 4: literal (still inside fence)
'```' // 5: real close
].join('\n') + '\n';
const r = normaliseContainers(src);
assert.equal(r.warnings.length, 0);
assert.equal(r.source, src);
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}