chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>';
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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'] });
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
}
|
||||
};
|
||||
@@ -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'] });
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>';
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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>';
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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 (F1–F5): 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 (F1–F5): 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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/’/g, "'")
|
||||
.replace(/‘/g, "'")
|
||||
.replace(/”/g, '"')
|
||||
.replace(/“/g, '"')
|
||||
.replace(/ /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 (F1–F5): 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 (F1–F5): 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 ``.
|
||||
// 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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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 };
|
||||
@@ -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/');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 F1–F5 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. F1–F5 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. F1–F5 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)
|
||||
// L2–L6 — 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 L9–L11 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);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user