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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
# @docmd/plugin-analytics
Adds Google Analytics (GA4) to your docmd site - bundled with `@docmd/core`, zero installation required.
```js
// docmd.config.js
module.exports = {
plugins: {
analytics: {
googleV4: { measurementId: 'G-XXXXXXXXXX' }
}
}
};
```
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
+64
View File
@@ -0,0 +1,64 @@
{
"name": "@docmd/plugin-analytics",
"version": "0.8.12",
"description": "Analytics injection plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "analytics",
"kind": "plugin",
"displayName": "Analytics",
"tagline": "Analytics injection plugin for docmd.",
"capabilities": [
"head",
"body"
]
},
"scripts": {
"build": "tsc"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@docmd/api": "workspace:*"
},
"dependencies": {
"@docmd/utils": "workspace:*"
},
"devDependencies": {
"@docmd/api": "workspace:*"
},
"keywords": [
"docmd",
"plugin",
"analytics",
"tracking",
"ga4",
"google-analytics",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+130
View File
@@ -0,0 +1,130 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
/**
* @returns {Object} { headScriptsHtml, bodyScriptsHtml }
*/
import type { PluginDescriptor } from '@docmd/api';
import { scriptLiteral } from '@docmd/utils';
export const plugin: PluginDescriptor = {
name: 'analytics',
version: '0.8.12',
capabilities: ['head', 'body']
};
// Phase 1.C (S-4 fix): format-validate the IDs before interpolation.
// GA4 measurementId is `G-XXXXXXXX` (uppercase letters and digits after the prefix).
// UA trackingId is `UA-XXXXXXX-Y`.
const GA4_ID_RE = /^G-[A-Z0-9]{6,12}$/;
const UA_ID_RE = /^UA-\d{6,12}-\d{1,3}$/;
export function generateScripts(config: any) {
let headScriptsHtml = '';
let bodyScriptsHtml = '';
const analytics = config.plugins?.analytics || {};
// Google Analytics 4
if (analytics.googleV4?.measurementId) {
const id = analytics.googleV4.measurementId;
if (!GA4_ID_RE.test(id)) {
console.error(`[analytics] Invalid googleV4.measurementId: ${JSON.stringify(id)}. Expected format G-XXXXXXXX. Analytics script skipped.`);
} else {
const idLiteral = scriptLiteral(id);
headScriptsHtml += `
<!-- GA4 -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${idLiteral}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', ${idLiteral});
</script>\n`;
}
}
// Legacy UA
if (analytics.googleUA?.trackingId) {
const id = analytics.googleUA.trackingId;
if (!UA_ID_RE.test(id)) {
console.error(`[analytics] Invalid googleUA.trackingId: ${JSON.stringify(id)}. Expected format UA-XXXXXXX-Y. Analytics script skipped.`);
} else {
const idLiteral = scriptLiteral(id);
headScriptsHtml += `
<!-- UA (Legacy) -->
<script async src="https://www.google-analytics.com/analytics.js"></script>
<script>
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', ${idLiteral}, 'auto');
ga('send', 'pageview');
</script>\n`;
}
}
// Auto-event Tagging (V2)
if (analytics.autoEvents !== false) {
const trackSearch = analytics.trackSearch !== false;
bodyScriptsHtml += `
<script>
(function() {
var searchDebounceTimer;
document.addEventListener('click', function(e) {
var link = e.target.closest('a');
if (!link || !window.gtag) return;
var url = link.href;
var hostname = window.location.hostname;
var isExternal = link.hostname && link.hostname !== hostname;
var isDownload = link.hasAttribute('download') || url.match(/\\.(pdf|zip|tar|gz|exe|pkg)$/i);
if (isExternal) {
gtag('event', 'click_external', { 'url': url, 'link_text': link.innerText.trim() });
} else if (isDownload) {
gtag('event', 'download', { 'file_name': url.split('/').pop(), 'url': url });
}
// TOC tracking
if (link.classList.contains('toc-link')) {
gtag('event', 'toc_click', { 'target_id': link.getAttribute('href'), 'title': link.innerText.trim() });
}
// Permalink tracking
if (link.classList.contains('heading-anchor')) {
gtag('event', 'permalink_click', { 'url': url });
}
});
${trackSearch ? `
// Search Keyword Tracking
document.addEventListener('input', function(e) {
if (e.target.id === 'docmd-search-input' && window.gtag) {
var query = e.target.value.trim();
if (query.length < 3) return;
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(function() {
gtag('event', 'search', { 'search_term': query });
}, 1000);
}
});
` : ''}
})();
</script>\n`;
}
return { headScriptsHtml, bodyScriptsHtml };
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# @docmd/plugin-git
Adds Git-based metadata to your docmd site: last-updated timestamps, contributor history, and edit links derived directly from your repository. Core plugin, already included in the core.
```bash
docmd add git
```
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
+39
View File
@@ -0,0 +1,39 @@
/**
* --------------------------------------------------------------------
* 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 fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const srcClientDir = path.resolve(__dirname, 'src', 'client');
const distDir = path.resolve(__dirname, 'dist', 'client');
// Ensure dist/client directory exists
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
// Copy client files
const files = ['git-widget.js', 'git-widget.css'];
for (const file of files) {
const src = path.join(srcClientDir, file);
const dest = path.join(distDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dest);
console.log(` ${file} → dist/client/`);
}
}
console.log('✓ Client assets built');
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "Zuletzt aktualisiert",
"recentCommits": "Letzte Commits",
"viewCommitHistory": "Commit-Verlauf anzeigen",
"editOnGitHub": "Auf GitHub bearbeiten",
"editOnGitLab": "Auf GitLab bearbeiten",
"editOnBitbucket": "Auf Bitbucket bearbeiten",
"editThisPage": "Seite bearbeiten",
"justNow": "gerade eben",
"minutesAgo": "vor {n} Min.",
"hoursAgo": "vor {n} Std.",
"daysAgo": "vor {n} T."
}
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "Last updated",
"recentCommits": "Recent commits",
"viewCommitHistory": "View commit history",
"editOnGitHub": "Edit on GitHub",
"editOnGitLab": "Edit on GitLab",
"editOnBitbucket": "Edit on Bitbucket",
"editThisPage": "Edit this page",
"justNow": "just now",
"minutesAgo": "{n}m ago",
"hoursAgo": "{n}h ago",
"daysAgo": "{n}d ago"
}
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "마지막 업데이트",
"recentCommits": "최근 커밋",
"viewCommitHistory": "커밋 기록 보기",
"editOnGitHub": "GitHub에서 편집",
"editOnGitLab": "GitLab에서 편집",
"editOnBitbucket": "Bitbucket에서 편집",
"editThisPage": "이 페이지 편집",
"justNow": "방금 전",
"minutesAgo": "{n}분 전",
"hoursAgo": "{n}시간 전",
"daysAgo": "{n}일 전"
}
+13
View File
@@ -0,0 +1,13 @@
{
"lastUpdated": "最后更新",
"recentCommits": "最近提交",
"viewCommitHistory": "查看提交历史",
"editOnGitHub": "在 GitHub 上编辑",
"editOnGitLab": "在 GitLab 上编辑",
"editOnBitbucket": "在 Bitbucket 上编辑",
"editThisPage": "编辑此页",
"justNow": "刚刚",
"minutesAgo": "{n}分钟前",
"hoursAgo": "{n}小时前",
"daysAgo": "{n}天前"
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@docmd/plugin-git",
"version": "0.8.12",
"description": "Git integration plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"docmd": {
"key": "git",
"kind": "plugin",
"displayName": "Git",
"tagline": "Git integration with last-updated timestamps and commit history.",
"capabilities": [
"init",
"build",
"body",
"assets",
"post-build",
"translations"
]
},
"scripts": {
"build": "tsc && node build.js"
},
"files": [
"dist",
"i18n"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@docmd/api": "workspace:*"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"git",
"github",
"gitlab",
"last-updated",
"contributors",
"edit-link",
"markdown",
"documentation",
"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,169 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
/* ── Wrapper ─────────────────────────────────────────────────────── */
.git-last-updated {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--text-light);
font-size: 0.875rem;
position: relative;
cursor: default;
}
.git-last-updated.has-history { cursor: default; }
.git-last-updated.has-history:hover,
.git-last-updated.has-history:focus-within { color: var(--link-color); }
/* ── Icon / Label / Time ─────────────────────────────────────────── */
.git-icon, .git-label, .git-time { pointer-events: none; }
.git-icon { width: 1em; height: 1em; flex-shrink: 0; }
.git-label { opacity: 0.8; }
.git-time { font-weight: 500; }
/* ── Spacer ──────────────────────────────────────────────────────── */
.git-spacer { flex: 1; }
/* ── Tooltip ─────────────────────────────────────────────────────── */
.git-commit-tooltip {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
min-width: 280px;
max-width: 360px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 0.5rem);
box-shadow: var(--shadow-sm);
padding: 0.75rem;
z-index: 100;
opacity: 0;
visibility: hidden;
transform: translateY(4px);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0.15s;
pointer-events: none;
}
/* Bridge fills gap between tooltip and text - no visual appearance */
.git-commit-tooltip::before {
content: '';
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 14px;
background: transparent;
pointer-events: auto;
}
/* Show via CSS :hover */
.git-last-updated.has-history:hover .git-commit-tooltip,
.git-last-updated.has-history:focus-within .git-commit-tooltip,
.git-commit-tooltip.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0s linear 0s;
pointer-events: auto;
}
/* Arrow points downward */
.git-commit-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 1.5rem;
border: 10px solid transparent;
border-top-color: var(--border-color);
pointer-events: none;
}
/* ── Tooltip Header ───────────────────────────────────────────────── */
.git-tooltip-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: var(--text-light);
margin-bottom: 0.5rem;
/* padding-bottom: 0.5rem; */
/* border-bottom: 1px solid var(--border-color); */
}
/* ── Commit List ─────────────────────────────────────────────────── */
.git-commit-list { list-style: none; margin: 0; padding: 0; }
.git-commit-item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.5rem 0;
}
.git-commit-avatar {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: var(--accent-color, #3b82f6);
color: #fff;
font-size: 0.625rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.git-commit-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.git-commit-details { flex: 1; min-width: 0; }
.git-commit-message {
font-size: 0.8125rem;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.git-commit-meta {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.7rem;
color: var(--text-light);
margin-top: 0.125rem;
}
.git-commit-author { font-weight: 500; }
.git-commit-date {font-size: .8em;color: var(--sidebar-text);}
/* ── Dark mode ───────────────────────────────────────────────────── */
[data-theme="dark"] .git-commit-tooltip { box-shadow: var(--shadow-lg); }
/* ── Responsive ──────────────────────────────────────────────────── */
@media (max-width: 640px) {
.git-commit-tooltip {
min-width: 240px;
max-width: calc(100vw - 2rem);
left: -0.5rem;
}
.git-label { display: none; }
}
@@ -0,0 +1,97 @@
/**
* --------------------------------------------------------------------
* 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 () {
const i18n = window.__git_i18n || {};
/**
* Format a Unix ms timestamp into a human-readable relative or absolute string.
*/
function formatTime(ts) {
const config = window.__git_config || {};
const format = config.dateFormat || 'relative';
const dateObj = new Date(ts);
if (format === 'iso') {
return dateObj.toISOString();
}
if (format === 'locale-aware') {
return dateObj.toLocaleString(document.documentElement.lang || 'en');
}
const diff = Date.now() - ts;
const s = Math.floor(diff / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (d < 1) {
if (h >= 1) return (i18n.hoursAgo || '{n}h ago').replace('{n}', h);
if (m >= 1) return (i18n.minutesAgo || '{n}m ago').replace('{n}', m);
return i18n.justNow || 'just now';
}
if (d < 7) return (i18n.daysAgo || '{n}d ago').replace('{n}', d);
return dateObj.toLocaleDateString(
document.documentElement.lang || 'en',
{ year: 'numeric', month: 'short', day: 'numeric' }
);
}
/**
* Hydrate all git [data-timestamp] elements with formatted relative time.
* Scoped to .git-last-updated and .git-commit-list to avoid collisions.
*/
function hydrateTimes() {
document.querySelectorAll('.git-last-updated [data-timestamp], .git-commit-list [data-timestamp]').forEach(function (el) {
const ts = parseInt(el.getAttribute('data-timestamp'), 10);
if (!ts || isNaN(ts)) return;
el.textContent = formatTime(ts);
});
}
/**
* Wire up keyboard accessibility on .git-last-updated elements.
* Tooltip visibility is handled by CSS :hover/:focus-within.
* JS only adds tabindex and aria attributes for keyboard users.
*/
function wireTooltips() {
document.querySelectorAll('.git-last-updated').forEach(function (wrapper) {
const tooltip = wrapper.querySelector('.git-commit-tooltip');
if (!tooltip) return;
wrapper.classList.add('has-history');
wrapper.setAttribute('tabindex', '0');
wrapper.setAttribute('role', 'button');
wrapper.setAttribute('aria-label', i18n.viewCommitHistory || 'View commit history');
});
}
function init() {
hydrateTimes();
wireTooltips();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Re-run after SPA navigation — the SPA router swaps .page-footer-actions
// innerHTML on every page change, so we must re-hydrate timestamps and
// re-wire tooltip accessibility on the freshly injected elements.
document.addEventListener('docmd:page-mounted', init);
})();
+596
View File
@@ -0,0 +1,596 @@
/**
* --------------------------------------------------------------------
* 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 path from 'path';
import fs from 'fs';
import os from 'os';
import { execFile } from 'child_process';
import { promisify } from 'util';
import crypto from 'crypto';
const execFileAsync = promisify(execFile);
import { fileURLToPath } from 'url';
import type { PluginDescriptor, PageContext, Engine } from '@docmd/api';
// ---------------------------------------------------------------------------
// Engine Integration
// ---------------------------------------------------------------------------
let _engine: Engine | null = null;
let _engineLoadAttempted = false;
let _configuredEngine: string = 'rust'; // default: prefer Rust, respected from config
/**
* Try to load an engine based on config.engine key.
* Falls back to JS engine, then to execFile if neither is available.
*/
async function getEngine(): Promise<Engine | null> {
if (_engineLoadAttempted) return _engine;
_engineLoadAttempted = true;
try {
const { loadEngine } = await import('@docmd/api');
if (_configuredEngine === 'js') {
_engine = await loadEngine('js');
} else {
// Default: try Rust, fall back to JS
_engine = await loadEngine('rust').catch(() => loadEngine('js'));
}
return _engine;
} catch {
// No engine available — will use execFile fallback
return null;
}
}
/**
* Get git log for multiple files using the engine (batch operation).
* Returns a Map from file path to commits array.
*/
async function getGitLogViaEngine(
filePaths: string[],
maxCommits: number
): Promise<Map<string, any[]> | null> {
const engine = await getEngine();
if (!engine) return null;
try {
const result = await engine.run<Record<string, any[]>>({
type: 'git:log',
payload: { filePaths, maxCommits }
});
if (!result.success) return null;
return new Map(Object.entries(result.data || {}));
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Original Implementation (fallback when engine is not available)
// ---------------------------------------------------------------------------
const gitRootCache = new Map<string, string | null>();
const gitCache = new Map<string, GitFileInfo>();
// Persistent disk cache for git histories
let _diskCache: Record<string, { mtimeMs: number, info: GitFileInfo }> | null = null;
let _diskCachePath: string | null = null;
let _cacheDirty = false;
// Per-build deduplication: git indexing only needs to run once per full build.
// The in-memory gitCache stores results for all subsequent locale/version passes.
let _gitBuildId = '';
let _gitIndexingDone = false;
/**
* Resolve the cache directory anchored to the git root (not process.cwd()).
* This fixes multi-project builds where process.chdir() shifts per-project,
* which caused the cache to land in unpredictable subdirectories.
*/
let _configuredTmpDir: string | null = null;
/**
* Resolve the cache directory base path.
* If config.tmp is configured, stores temporary files inside that specified path.
* Otherwise, moves them to the device's actual OS temporary folder nested by project hash
* so multiple documentations on the same device remain perfectly isolated.
*/
function resolveCacheDir(): string {
if (_configuredTmpDir) {
return path.join(path.resolve(process.cwd(), _configuredTmpDir), 'cache');
}
// Walk up from cwd to find the project git root, ensuring stable path resolution
let dir = process.cwd();
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, '.git'))) {
break;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// Derive a consistent project identifier (remote origin URL -> directory inode -> absolute path)
// Ensures cache directory resolution remains stable across local folder moves/renames.
let identifier = dir;
try {
const gitConfigPath = path.join(dir, '.git', 'config');
let urlFound = false;
if (fs.existsSync(gitConfigPath)) {
const content = fs.readFileSync(gitConfigPath, 'utf8');
const match = content.match(/url\s*=\s*([^\r\n]+)/);
if (match && match[1]) {
identifier = match[1].trim();
urlFound = true;
}
}
if (!urlFound) {
const stat = fs.statSync(dir);
if (stat && stat.ino) {
identifier = `ino:${stat.ino}`;
}
}
} catch {
try {
const stat = fs.statSync(dir);
if (stat && stat.ino) identifier = `ino:${stat.ino}`;
} catch { /* keep absolute path fallback */ }
}
// Generate a unique hash for this stable project identifier
const hash = crypto.createHash('md5').update(identifier).digest('hex').slice(0, 12);
const baseSlug = path.basename(dir).replace(/[^a-zA-Z0-9-_]/g, '');
return path.join(os.tmpdir(), `docmd-${baseSlug}-${hash}`, 'cache');
}
function initDiskCache() {
if (_diskCache !== null) return;
const cacheDir = resolveCacheDir();
_diskCachePath = path.join(cacheDir, 'git-history.json');
try {
if (fs.existsSync(_diskCachePath)) {
_diskCache = JSON.parse(fs.readFileSync(_diskCachePath, 'utf8'));
} else {
_diskCache = {};
}
} catch {
_diskCache = {};
}
}
function saveDiskCache() {
if (!_cacheDirty || !_diskCachePath || !_diskCache) return;
try {
// Prune entries for files that no longer exist on disk
const keys = Object.keys(_diskCache);
for (const key of keys) {
try {
if (!fs.existsSync(key)) delete _diskCache[key];
} catch { /* ignore stat errors */ }
}
const cacheDir = path.dirname(_diskCachePath);
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(_diskCachePath, JSON.stringify(_diskCache), 'utf8');
_cacheDirty = false;
} catch { /* ignore */ }
}
async function resolveGitRoot(dir: string): Promise<string | null> {
if (gitRootCache.has(dir)) return gitRootCache.get(dir)!;
try {
const realDir = fs.realpathSync(dir);
const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
cwd: realDir
});
const result = stdout.trim();
gitRootCache.set(dir, result);
return result;
} catch {
gitRootCache.set(dir, null);
return null;
}
}
async function getGitFileInfo(filePath: string, maxCommits: number = 6): Promise<GitFileInfo | null> {
if (gitCache.has(filePath)) return gitCache.get(filePath)!;
initDiskCache();
let fileStat;
try {
fileStat = fs.statSync(filePath);
} catch {
return null;
}
// Check disk cache
if (_diskCache && _diskCache[filePath] && _diskCache[filePath].mtimeMs === fileStat.mtimeMs) {
const cachedInfo = _diskCache[filePath].info;
gitCache.set(filePath, cachedInfo);
return cachedInfo;
}
const fileDir = path.dirname(filePath);
const gitRoot = await resolveGitRoot(fileDir);
if (!gitRoot) return null;
const normalizedRoot = fs.realpathSync(gitRoot);
const normalizedFile = fs.realpathSync(filePath);
const relPath = path.relative(normalizedRoot, normalizedFile).replace(/\\/g, '/');
if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) return null;
try {
const { stdout } = await execFileAsync('git', [
'log',
'--follow',
'-n', maxCommits.toString(),
'--format=%H|%h|%an|%ae|%at|%s',
'--',
relPath
], {
cwd: normalizedRoot
});
const logOutput = stdout.trim();
if (!logOutput) return null;
const commits: GitCommit[] = logOutput.split('\n').filter(Boolean).map((line: string) => {
const [hash, shortHash, author, email, timestamp, ...messageParts] = line.split('|');
const ts = parseInt(timestamp, 10) * 1000;
const hashEmail = crypto.createHash('md5').update(email.trim().toLowerCase()).digest('hex');
return {
hash,
shortHash,
author,
email,
date: new Date(ts).toISOString(),
timestamp: ts,
message: messageParts.join('|'),
avatarUrl: `https://www.gravatar.com/avatar/${hashEmail}?d=mp&s=64`
};
});
if (commits.length === 0) return null;
const info: GitFileInfo = {
lastUpdated: commits[0]?.date || '',
lastUpdatedTimestamp: commits[0]?.timestamp || 0,
commits
};
gitCache.set(filePath, info);
if (_diskCache) {
_diskCache[filePath] = { mtimeMs: fileStat.mtimeMs, info };
_cacheDirty = true;
}
return info;
} catch {
return null;
}
}
export const plugin: PluginDescriptor = {
name: 'git',
version: '0.8.12',
capabilities: ['build', 'body', 'assets', 'translations', 'init', 'post-build']
};
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const i18nDir = path.resolve(__dirname, '..', 'i18n');
export interface GitCommit {
hash: string;
shortHash: string;
author: string;
email: string;
date: string;
timestamp: number;
message: string;
avatarUrl: string;
}
export interface GitFileInfo {
lastUpdated: string;
lastUpdatedTimestamp: number;
commits: GitCommit[];
}
/**
* Format a timestamp for display.
* Uses relative time for recent updates, absolute for older ones.
*/
function formatLastUpdated(timestamp: number, locale: string = 'en'): string {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
// For very recent updates, show relative time
if (days < 1) {
if (hours >= 1) {
return `${hours}h ago`;
}
if (minutes >= 1) {
return `${minutes}m ago`;
}
return 'just now';
}
if (days < 7) {
return `${days}d ago`;
}
// For older updates, show date
const date = new Date(timestamp);
return date.toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Load translation strings for a given locale.
*/
function loadPluginStrings(localeId: string): Record<string, string> {
try {
const localePath = path.join(i18nDir, `${localeId}.json`);
if (fs.existsSync(localePath)) {
return JSON.parse(fs.readFileSync(localePath, 'utf8'));
}
} catch { /* fallback below */ }
try {
const enPath = path.join(i18nDir, 'en.json');
if (fs.existsSync(enPath)) {
return JSON.parse(fs.readFileSync(enPath, 'utf8'));
}
} catch { /* silent */ }
return {};
}
/**
* Plugin translations hook.
*/
export function translations(localeId: string): Record<string, string> {
return loadPluginStrings(localeId || 'en');
}
let pluginOptions: any = {};
export function onConfigResolved(config: any): void {
gitCache.clear();
gitRootCache.clear();
_gitIndexingDone = false;
_engineLoadAttempted = false; // Reset engine state for fresh builds
_engine = null;
_diskCache = null; // Reset disk cache so it re-reads from the correct path
_diskCachePath = null;
_cacheDirty = false;
_configuredEngine = config.engine || 'rust'; // Respect the config.engine key
_configuredTmpDir = config.tmp || null; // Respect the config.tmp custom path
pluginOptions = config.plugins?.git || {};
}
/**
* onBeforeParse: stub for future use (per-page processing).
*/
export function onBeforeParse(_ctx: any): void {
// Logic removed (moved to onConfigResolved for better performance)
}
export async function onBeforeBuild(ctx: any): Promise<void> {
const { pages, tui, options } = ctx;
if (!pages || pages.length === 0) return;
// Deduplicate per build: only the FIRST locale/version pass runs git log.
// Subsequent passes pull from the in-memory gitCache, which is already warm.
const buildId = (options as any)?._buildId || '';
if (buildId !== _gitBuildId) {
_gitBuildId = buildId;
_gitIndexingDone = false;
}
const commitHistory = pluginOptions?.commitHistory !== false;
const maxCommits = commitHistory ? (pluginOptions?.maxCommits || 5) : 1;
const showTui = tui && !options?.quiet && !_gitIndexingDone;
if (_gitIndexingDone) {
// Still populate frontmatter from in-memory cache for this locale's pages
for (const page of pages) {
if (page?.sourcePath && page.frontmatter) {
const cached = gitCache.get(page.sourcePath);
if (cached) {
if (!commitHistory) cached.commits = [];
page.frontmatter._git = cached;
}
}
}
return;
}
_gitIndexingDone = true;
const total = pages.length;
if (showTui) tui.step(`Syncing git metadata`, 'WAIT');
// Collect all source paths that need git info.
// Pre-warm in-memory cache from disk first so the engine is only called
// for uncached or modified files (warm builds benefit from persistence).
const pathsToFetch: string[] = [];
const pagesByPath = new Map<string, any[]>();
initDiskCache();
for (const page of pages) {
const sourcePath = page?.sourcePath;
if (sourcePath && page.frontmatter && !gitCache.has(sourcePath)) {
let fileStat: any;
try { fileStat = fs.statSync(sourcePath); } catch { /* skip */ }
// Hit disk cache if file hasn't changed since last build
if (fileStat && _diskCache && _diskCache[sourcePath] && _diskCache[sourcePath].mtimeMs === fileStat.mtimeMs) {
gitCache.set(sourcePath, _diskCache[sourcePath].info);
if (page.frontmatter) page.frontmatter._git = _diskCache[sourcePath].info;
} else {
pathsToFetch.push(sourcePath);
if (!pagesByPath.has(sourcePath)) pagesByPath.set(sourcePath, []);
pagesByPath.get(sourcePath)!.push(page);
}
}
}
// Try to use engine for batch processing (much faster with Rust)
const engineResult = await getGitLogViaEngine(pathsToFetch, maxCommits);
if (engineResult && engineResult.size > 0) {
// Engine succeeded — process and persist results
for (const [filePath, commits] of engineResult) {
if (commits && commits.length > 0) {
let fileStat: any;
try { fileStat = fs.statSync(filePath); } catch { /* skip */ }
const info: GitFileInfo = {
lastUpdated: new Date(commits[0].timestamp).toISOString(),
lastUpdatedTimestamp: commits[0].timestamp,
commits: commits.map((c: any) => {
const hashEmail = crypto.createHash('md5').update((c.email || '').trim().toLowerCase()).digest('hex');
return {
hash: c.hash,
shortHash: c.shortHash,
author: c.author,
email: c.email,
date: new Date(c.timestamp).toISOString(),
timestamp: c.timestamp,
message: c.message,
avatarUrl: `https://www.gravatar.com/avatar/${hashEmail}?d=mp&s=64`
};
})
};
if (!commitHistory) info.commits = [];
gitCache.set(filePath, info);
// Persist to disk cache so subsequent builds are fast (warm start)
if (_diskCache && fileStat) {
_diskCache[filePath] = { mtimeMs: fileStat.mtimeMs, info };
_cacheDirty = true;
}
// Update all pages with this source path
const pagesForPath = pagesByPath.get(filePath) || [];
for (const page of pagesForPath) {
if (page.frontmatter) page.frontmatter._git = info;
}
}
}
if (showTui) tui.step(`Syncing git metadata`, 'DONE');
} else {
// Fallback to original execFile-based implementation
let processed = 0;
const CONCURRENCY = 10;
for (let i = 0; i < total; i += CONCURRENCY) {
const batch = pages.slice(i, i + CONCURRENCY);
await Promise.all(batch.map(async (page: any) => {
const sourcePath = page?.sourcePath;
if (sourcePath && page.frontmatter) {
const gitInfo = await getGitFileInfo(sourcePath, maxCommits);
if (gitInfo) {
if (!commitHistory) gitInfo.commits = [];
page.frontmatter._git = gitInfo;
}
}
}));
processed += batch.length;
if (showTui) tui.step(`Syncing git metadata ${processed}/${total}`, 'WAIT');
}
if (showTui) tui.step(`Syncing git metadata`, 'DONE');
}
// Also populate pages that were already in cache
for (const page of pages) {
if (page?.sourcePath && page.frontmatter && !page.frontmatter._git) {
const cached = gitCache.get(page.sourcePath);
if (cached) {
if (!commitHistory) cached.commits = [];
page.frontmatter._git = cached;
}
}
}
}
export function onPostBuild(): void {
saveDiskCache();
}
/**
* Generate scripts to inject git i18n strings for the client widget.
*/
export function generateScripts(config: any, options?: any): { headScriptsHtml: string; bodyScriptsHtml: string } {
const gitConfig = {
repo: options?.repo || config.editLink?.baseUrl || null,
branch: options?.branch || 'main',
editLink: options?.editLink !== false && !!(options?.repo || config.editLink?.baseUrl),
lastUpdated: options?.lastUpdated !== false,
commitHistory: options?.commitHistory !== false,
maxCommits: options?.maxCommits || 5,
dateFormat: options?.dateFormat || 'relative'
};
const localeId = config._activeLocale?.id || 'en';
const i18nStrings = JSON.stringify(loadPluginStrings(localeId));
return {
headScriptsHtml: '',
bodyScriptsHtml: `<script>window.__git_config=${JSON.stringify(gitConfig)};window.__git_i18n=${i18nStrings}</script>`
};
}
/**
* Provide client-side assets.
* Always returns assets - client-side JS handles graceful degradation.
*/
export function getAssets(_options?: any): any[] {
// Always include assets - client-side JS handles visibility based on git status
const distDir = path.resolve(__dirname, '..', 'dist', 'client');
return [
{
src: path.join(distDir, 'git-widget.js'),
dest: 'assets/js/docmd-git.js',
type: 'js',
location: 'body',
attributes: { type: 'module' }
},
{
src: path.join(distDir, 'git-widget.css'),
dest: 'assets/css/docmd-git.css',
type: 'css',
location: 'head'
}
];
}
export { getGitFileInfo, formatLastUpdated };
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+20
View File
@@ -0,0 +1,20 @@
# @docmd/plugin-installer
The engine behind `docmd add` and `docmd remove` - downloads plugins, resolves dependencies, and injects configuration into your `docmd.config.js` automatically.
Bundled with `@docmd/core`. You do not need to install or configure this package directly.
```bash
docmd add search
docmd remove search
```
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
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@docmd/plugin-installer",
"version": "0.8.12",
"description": "Installer utility to add and remove plugins for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc"
},
"files": [
"dist",
"registry"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@docmd/api": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"installer",
"cli",
"add",
"remove",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
@@ -0,0 +1,81 @@
{
"analytics": {
"package": "@docmd/plugin-analytics",
"description": "Analytics injection plugin for docmd",
"configKey": "analytics",
"defaultConfig": "{}"
},
"git": {
"package": "@docmd/plugin-git",
"description": "Git integration with last-updated timestamps and commit history",
"configKey": "git",
"defaultConfig": "{}"
},
"search": {
"package": "@docmd/plugin-search",
"description": "Local search implementation for docmd",
"configKey": "search",
"defaultConfig": "{}"
},
"seo": {
"package": "@docmd/plugin-seo",
"description": "SEO meta tag generator for docmd",
"configKey": "seo",
"defaultConfig": "{}"
},
"sitemap": {
"package": "@docmd/plugin-sitemap",
"description": "Sitemap generator for docmd",
"configKey": "sitemap",
"defaultConfig": "{}"
},
"mermaid": {
"package": "@docmd/plugin-mermaid",
"description": "Mermaid.js diagram support for docmd",
"configKey": "mermaid",
"defaultConfig": "{}"
},
"openapi": {
"package": "@docmd/plugin-openapi",
"description": "OpenAPI / Swagger spec rendering for docmd",
"configKey": "openapi",
"defaultConfig": "{}"
},
"llms": {
"package": "@docmd/plugin-llms",
"description": "LLM integration plugin for docmd",
"configKey": "llms",
"defaultConfig": "{}"
},
"okf": {
"package": "@docmd/plugin-okf",
"description": "Open Knowledge Format (OKF) bundle generator for AI-agent consumption",
"configKey": "okf",
"defaultConfig": "{}"
},
"pwa": {
"package": "@docmd/plugin-pwa",
"description": "Progressive Web App support for docmd",
"configKey": "pwa",
"defaultConfig": "{}"
},
"threads": {
"package": "@docmd/plugin-threads",
"description": "Inline discussion threads for docmd",
"configKey": "threads",
"defaultConfig": "{}"
},
"math": {
"package": "@docmd/plugin-math",
"description": "Math rendering support for docmd using KaTeX",
"configKey": "math",
"defaultConfig": "{}"
},
"summer": {
"package": "@docmd/template-summer",
"description": "Summer template — bright, hopeful, summer-feel layout with top search bar and halo accents",
"kind": "template",
"configKey": "summer",
"templateName": "summer"
}
}
@@ -0,0 +1,382 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/plugin-installer
* @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.
* --------------------------------------------------------------------
*/
/**
* Config editor
* =============
*
* Phase 3 PR 3.B (F7 + M-3). \`docmd add <plugin>\` and \`docmd remove
* <plugin>\` must mutate the project's config file in a way that
* works for every supported format:
*
* - \`docmd.config.json\` — JSON, parsed via \`JSON.parse\`.
* - \`docmd.config.js\` — CJS (\`module.exports = { ... }\`).
* - \`docmd.config.mjs\` — ESM (\`export default { ... }\` or
* \`export default defineConfig({...})\`).
* - \`docmd.config.cjs\` — CJS, same as \`.js\`.
* - \`docmd.config.ts\` — TS (\`export default defineConfig({...})\`).
*
* The previous implementation used a single regex
* (\`/module\\.exports\\s*=\\s*(?:defineConfig\\()?\\{...\}`) that
* matched only the CJS pattern. For \`export default\` (TS / MJS) the
* regex fell through silently, and the function returned \`false\`
* ("already configured") without actually editing the file. The user
* saw "Plugin successfully installed and activated" but the config
* was untouched (M-3). The same bug affected \`remove\` (F7).
*
* This module replaces the regex with a brace-balanced scanner that:
*
* 1. Strips comments first (line + block) so commented-out plugin
* entries are not treated as live config.
* 2. Detects the format by file extension.
* 3. For JSON: full parse + serialise (the only correct path).
* 4. For JS/TS/MJS/CJS: scans for the \`plugins:\` block using
* brace-balancing so multi-line nested objects are handled
* correctly. Inserts / removes the entry while preserving
* existing formatting (indent, trailing comma style, etc.).
*
* The scanner is intentionally dependency-free. The legacy regex
* approach is preserved as a fallback (and still used for format
* detection in \`detectConfigFormat\`); the new brace-balanced logic
* supersedes it for the add / remove paths.
*/
/**
* Strip line and block comments from a JS/TS config body. Used to
* avoid matching commented-out plugin entries.
*/
function stripComments(content: string): string {
return content
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
}
export type ConfigFormat = 'json' | 'js' | 'ts' | 'mjs' | 'cjs';
export function detectConfigFormat(configPath: string): ConfigFormat {
if (configPath.endsWith('.json')) return 'json';
if (configPath.endsWith('.ts')) return 'ts';
if (configPath.endsWith('.mjs')) return 'mjs';
if (configPath.endsWith('.cjs')) return 'cjs';
return 'js';
}
/**
* Escape a string for safe use inside a RegExp.
*/
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Find the \`plugins: { ... }\` block in a JS/TS/MJS/CJS config body.
* Uses brace-balancing to correctly handle nested objects.
*
* Returns the \`{ ... }\` extent (inclusive of braces) or null if no
* plugins block exists.
*/
function findPluginsBlock(content: string): { start: number; end: number } | null {
// Look for `plugins:` (possibly with whitespace, possibly quoted,
// possibly with comments) followed by `{`. The `g` flag is important
// because some configs have `theme.plugins` etc.
// Strip comments first to avoid false matches.
const stripped = stripComments(content);
const re = /\bplugins\s*:\s*\{/g;
let m: RegExpExecArray | null;
while ((m = re.exec(stripped)) !== null) {
const openIdx = m.index + m[0].length - 1; // index of `{`
let depth = 0;
for (let i = openIdx; i < stripped.length; i++) {
const ch = stripped[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return { start: openIdx, end: i };
} else if (ch === '"' || ch === "'" || ch === '`') {
// Skip string literals so braces inside strings do not
// affect the depth count.
const quote = ch;
i++;
while (i < stripped.length && stripped[i] !== quote) {
if (stripped[i] === '\\') i++; // skip escaped char
i++;
}
}
}
}
return null;
}
/**
* Detect the indentation of a block's content. Returns the leading
* whitespace of the first non-empty line inside the block.
*/
function detectIndent(inner: string): string {
const lines = inner.split('\n');
for (const line of lines) {
if (line.trim() === '') continue;
const m = line.match(/^[\t ]*/);
return m ? m[0] : ' ';
}
return ' ';
}
/**
* Add a plugin entry to the \`plugins: { ... }\` block. Returns the
* new content, or the original content if the entry is already
* present (in which case the caller should report "already
* configured").
*
* @param content The full config file body.
* @param configKey The plugin key (e.g. \`search\`, \`mermaid\`).
* @param valueText The value to assign, as a JS source string
* (e.g. \`{}\` or \`{ theme: 'dark' }\`).
*/
export function addPluginToPluginsBlock(
content: string,
configKey: string,
valueText: string
): { newContent: string; changed: boolean } {
// Comment-aware "already configured" check.
const stripped = stripComments(content);
const alreadyRegex = new RegExp(
`(?:^|[\\s,\\{])(['"\`]?)${escapeRe(configKey)}\\1\\s*:`,
'm'
);
if (alreadyRegex.test(stripped)) {
return { newContent: content, changed: false };
}
const block = findPluginsBlock(content);
if (block) {
const inner = content.slice(block.start + 1, block.end);
const indent = detectIndent(inner) || ' ';
const entryIndent = indent + ' ';
const newEntry = `${entryIndent}'${configKey}': ${valueText}`;
// Insert into the existing block. Honour trailing-comma style:
// { } -> { 'search': {} }
// { foo } -> { foo, 'search': {} }
// { foo, } -> { foo, 'search': {} }
const trimmed = inner.replace(/\s+$/, ''); // strip trailing whitespace
if (trimmed.trim() === '') {
const newInner = `\n${newEntry}\n${indent}`;
return {
newContent:
content.slice(0, block.start + 1) + newInner + content.slice(block.end),
changed: true
};
}
const endsWithComma = trimmed.trimEnd().endsWith(',');
const sep = endsWithComma ? '\n' : ',\n';
const newInner = trimmed + sep + newEntry;
return {
newContent:
content.slice(0, block.start + 1) + newInner + content.slice(block.end),
changed: true
};
}
// No `plugins:` block yet — find the top-level object and add one.
// The top-level object can be:
// - JSON: { ... }
// - CJS: module.exports = { ... }
// - CJS + defineConfig: module.exports = defineConfig({ ... });
// - ESM: export default { ... }
// - ESM + defineConfig: export default defineConfig({ ... });
// - TS + defineConfig: export default defineConfig<UserConfig>({ ... });
const added = addPluginsBlockToTopLevel(content, configKey, valueText);
return { newContent: added, changed: added !== content };
}
/**
* Find the top-level object literal in a JS/TS config and add a
* \`plugins: { 'key': value }\` block to it. Returns the original
* content unchanged if no top-level object can be found.
*/
function addPluginsBlockToTopLevel(
content: string,
configKey: string,
valueText: string
): string {
// Strip comments so the search isn't confused by commented-out
// export statements.
const stripped = stripComments(content);
// Look for the FIRST opening `{` after an `export default` or
// `module.exports =` keyword. We want the top-level object, not a
// nested one.
const exportMatch = stripped.match(
/(?:export\s+default\s+(?:defineConfig(?:\s*<[^>]*>)?\s*)?|module\.exports\s*=\s*(?:defineConfig\s*)?)\s*\{/
);
if (!exportMatch) return content;
const openIdx = (exportMatch.index ?? 0) + exportMatch[0].length - 1;
// Brace-balance to find the matching `}`.
let depth = 0;
let endIdx = -1;
for (let i = openIdx; i < stripped.length; i++) {
const ch = stripped[i];
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) {
endIdx = i;
break;
}
} else if (ch === '"' || ch === "'" || ch === '`') {
const quote = ch;
i++;
while (i < stripped.length && stripped[i] !== quote) {
if (stripped[i] === '\\') i++;
i++;
}
}
}
if (endIdx === -1) return content;
// Find the LAST non-whitespace, non-`}` character before `endIdx`
// to detect if a trailing comma is already present.
const inner = content.slice(openIdx + 1, endIdx);
const trimmedInner = inner.replace(/\s+$/, '');
const endsWithComma = trimmedInner.trimEnd().endsWith(',');
// Match the indentation of the first sibling key in the object.
const indent = detectIndent(inner) || ' ';
const entryIndent = indent + ' ';
// Honour the existing trailing-comma style:
// { foo } -> { foo, plugins: { ... } }
// { foo, } -> { foo, plugins: { ... } }
// { } -> { plugins: { ... } }
// The block is appended directly after the trimmed inner (which may
// already end in a newline); we do NOT add another newline here.
const sep = endsWithComma || trimmedInner.trim() === '' ? '' : ',\n';
const pluginsBlock =
sep +
`${indent}plugins: {\n` +
`${entryIndent}'${configKey}': ${valueText}\n` +
`${indent}}\n`;
const newInner = trimmedInner + pluginsBlock;
return (
content.slice(0, openIdx + 1) +
newInner +
content.slice(endIdx)
);
}
/**
* Remove a plugin entry from the \`plugins: { ... }\` block. Returns
* the new content plus a `changed` flag. If the entry is not
* present, `changed` is false and the original content is returned.
*/
export function removePluginFromPluginsBlock(
content: string,
configKey: string
): { newContent: string; changed: boolean } {
// Comment-aware "is present" check.
const stripped = stripComments(content);
const presentRegex = new RegExp(
`(?:^|[\\s,\\{])(['"\`]?)${escapeRe(configKey)}\\1\\s*:`,
'm'
);
if (!presentRegex.test(stripped)) {
return { newContent: content, changed: false };
}
const block = findPluginsBlock(content);
if (!block) return { newContent: content, changed: false };
const inner = content.slice(block.start + 1, block.end);
// Match the entry. Tolerate:
// 'key': { ... }
// "key": { ... }
// `key`: { ... }
// key: { ... } (unquoted)
// The value is balanced braces (no nesting for default configs).
// Allow trailing comma and surrounding whitespace.
//
// The replacement is empty (NOT '\n') so that an empty `plugins: {}`
// block does not become `plugins: {\n}`. The caller (the installer)
// checks `changed` and only reports success when an actual edit was
// made; the visual rendering of the block is preserved.
const re = new RegExp(
`\\n?\\s*(['"\`]?)${escapeRe(configKey)}\\1\\s*:\\s*\\{[^{}]*\\}[,]?\\s*\\n?`,
'g'
);
const newInner = inner.replace(re, '');
if (newInner === inner) {
return { newContent: content, changed: false };
}
return {
newContent:
content.slice(0, block.start + 1) +
newInner +
content.slice(block.end),
changed: true
};
}
// ─────────────────────────────────────────────────────────────────────
// JSON path — uses JSON.parse / JSON.stringify for full safety.
// ─────────────────────────────────────────────────────────────────────
export function addPluginToJsonConfig(
content: string,
configKey: string,
valueText: string
): { newContent: string; changed: boolean } {
let config: any;
try {
config = JSON.parse(content);
} catch (e: any) {
throw new Error(`Could not parse config as JSON: ${e.message}`);
}
config.plugins = config.plugins || {};
if (configKey in config.plugins) {
return { newContent: content, changed: false };
}
// Parse the value text. The installer passes JS-ish text like
// `{}` or `{ theme: 'dark' }`; we normalise it to valid JSON by
// quoting unquoted keys and replacing single quotes with double.
const normalised = valueText
.replace(/'/g, '"')
.replace(/([{,]\s*)([A-Za-z_][\w-]*)(\s*:)/g, '$1"$2"$3');
let value: any;
try {
value = JSON.parse(normalised);
} catch {
// Fall back to a plain object — the value will serialise as `{}`.
value = {};
}
config.plugins[configKey] = value;
return { newContent: JSON.stringify(config, null, 2) + '\n', changed: true };
}
export function removePluginFromJsonConfig(
content: string,
configKey: string
): { newContent: string; changed: boolean } {
let config: any;
try {
config = JSON.parse(content);
} catch (e: any) {
throw new Error(`Could not parse config as JSON: ${e.message}`);
}
if (!config.plugins || !(configKey in config.plugins)) {
return { newContent: content, changed: false };
}
delete config.plugins[configKey];
return { newContent: JSON.stringify(config, null, 2) + '\n', changed: true };
}
+559
View File
@@ -0,0 +1,559 @@
/**
* --------------------------------------------------------------------
* 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 fs from 'fs';
import path from 'path';
import { execSync, execFileSync } from 'child_process';
import { createRequire } from 'module';
import {
detectConfigFormat,
addPluginToJsonConfig,
addPluginToPluginsBlock,
removePluginFromJsonConfig,
removePluginFromPluginsBlock
} from './config-editor.js';
import { isCorePlugin } from '@docmd/api';
const require = createRequire(import.meta.url);
// Plugin registry resolution. As of 0.8.9 the canonical registry is
// generated into @docmd/api/registry/plugins.generated.json by
// scripts/build-plugin-registry.mjs (single source of truth across the
// docmd monorepo). The bundled registry/plugins.json in this package is
// retained as a fallback for users who install @docmd/plugin-installer
// without @docmd/api, but the generated file is preferred.
//
// The bundled registry/plugins.json is DEPRECATED and will be removed in
// 0.9.0. The auto-install workflow and `docmd add <plugin>` both fall
// through to the new generated registry transparently.
let pluginsRegistry: any = {};
try {
const generatedPath = require.resolve('@docmd/api/registry/plugins.generated.json', {
paths: [process.cwd(), require('path').dirname(require.resolve('@docmd/api/package.json', { paths: [process.cwd()] }))]
});
pluginsRegistry = require(generatedPath);
} catch {
// Fallback: bundled registry (DEPRECATED, removed in 0.9.0).
try {
pluginsRegistry = require('../registry/plugins.json');
} catch {
pluginsRegistry = {};
}
}
/**
*
* @param err The thrown error from execFileSync.
* @param cmdExe The package-manager binary that was being spawned.
* @param action Human verb describing what was happening, e.g.
* "install" or "remove". Used in the suggestion copy.
* @param opts.verbose If true, return the raw `err.message` so power
* users can still see the full Node error.
*/
function formatSpawnError(err: any, cmdExe: string, action: string, opts: { verbose?: boolean } = {}): string {
const isMissingBinary = err && err.code === 'ENOENT' &&
typeof err.syscall === 'string' && err.syscall.startsWith('spawn');
if (isMissingBinary) {
const binary = err.path || cmdExe;
// Special case: if the failing binary is a package manager but Node itself
// is reachable, the issue is likely that docmd was launched through npx
// and the spawned child process can't see the parent's PATH. Suggest
// installing the plugin via the host package manager directly.
const nodeReachable = (() => {
try { require('child_process').execFileSync(process.execPath, ['--version'], { stdio: 'pipe' }); return true; }
catch { return false; }
})();
const isPkgManager = /^(npm|pnpm|yarn|bun)(\.cmd)?$/i.test(path.basename(binary));
if (nodeReachable && isPkgManager) {
return [
`Could not spawn ${binary} — it was not found on PATH when running through npx.`,
``,
`This is a common issue when @docmd/core itself was launched via npx on`,
`Windows or in restricted shells. Install the plugin directly using your`,
`package manager, then run the build again:`,
``,
` npm install <package-name>`,
``,
`If you still see this after a direct install, run with --verbose for the`,
`full spawn error.`,
].join('\n');
}
return [
`The package manager '${binary}' was not found on your system PATH.`,
``,
`To fix it:`,
` 1. Install ${binary} (e.g. \`npm install -g ${binary}\`, or use a`,
` Node version manager like nvm / volta / fnm)`,
` 2. Verify it's reachable: \`${binary} --version\` should print a version`,
` 3. Retry: \`npx @docmd/core ${action} <name>\``,
``,
`If you just installed ${binary}, restart your terminal so the updated`,
`PATH takes effect.`,
].join('\n');
}
if (opts.verbose && err && err.message) return err.message;
return 'Run with --verbose for detailed logs.';
}
/**
* Resolves the absolute path to a package-manager binary, falling back
* to PATH lookup if not found next to Node.
*
* On Windows, `npm`/`pnpm`/`yarn` are `.cmd` shims that may not be on
* PATH when `@docmd/core` is itself launched through `npx`. Since all
* major package managers ship their CLI scripts in Node's install
* directory (e.g. `node_modules/npm/bin/npm-cli.js`), resolving from
* `process.execPath` works reliably across all platforms and invocation
* methods (including `npx`).
*/
function resolvePackageManagerBin(name: string): string {
const nodeDir = path.dirname(process.execPath);
// Candidate scripts to look for, in priority order.
const candidates: Record<string, string[]> = {
npm: ['npm-cli.js', `npm${process.platform === 'win32' ? '.cmd' : ''}`],
pnpm: ['pnpm.js', `pnpm${process.platform === 'win32' ? '.cmd' : ''}`],
yarn: ['yarn.js', `yarn${process.platform === 'win32' ? '.cmd' : ''}`],
bun: ['bun.js', `bun${process.platform === 'win32' ? '.cmd' : ''}`],
};
const files = candidates[name] || [name];
// 1. Look next to the running Node binary (npm ships here, pnpm global does too).
for (const file of files) {
const candidate = path.join(nodeDir, file);
if (fs.existsSync(candidate)) return candidate;
}
// 2. Look in Node's lib/node_modules (npm/pnpm install here on most setups).
const libModules = path.join(nodeDir, 'lib', 'node_modules');
for (const file of files) {
const candidate = path.join(libModules, name, file === `${name}.cmd` ? 'bin' : 'bin', file);
if (fs.existsSync(candidate)) return candidate;
}
// 3. Last resort: return the bare name and let PATH resolve it.
return files[files.length - 1];
}
/**
* Detects the package manager used in the current project by looking for lockfiles upwards.
* Defaults to 'npm' if no lockfile is found.
*/
function getPackageManager(cwd) {
let dir = cwd;
while (dir !== path.parse(dir).root) {
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
if (fs.existsSync(path.join(dir, 'bun.lockb'))) return 'bun';
if (fs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm';
dir = path.dirname(dir);
}
return 'npm';
}
/**
* Resolves the project's config file path. Phase 3 PR 3.B (M-3):
* checks all five supported formats in preference order (JSON > JS >
* MJS > CJS > TS) and falls back to JSON when no config exists.
*
* The previous implementation only looked at `.json` and `.js`, so a
* project with `docmd.config.ts` would silently scaffold a new
* `docmd.config.json` instead of editing the existing TS file.
*/
function resolveConfigPath(cwd) {
const candidates = [
'docmd.config.json',
'docmd.config.js',
'docmd.config.mjs',
'docmd.config.cjs',
'docmd.config.ts'
];
for (const name of candidates) {
const p = path.join(cwd, name);
if (fs.existsSync(p)) return p;
}
return path.join(cwd, 'docmd.config.json');
}
function detectConfigFormatLegacy(configPath) {
return configPath.endsWith('.json') ? 'json' : 'js';
}
/**
* Resolves plugin metadata from the registry, or builds a fallback object.
*/
function resolvePluginMeta(name) {
if (pluginsRegistry[name]) {
return pluginsRegistry[name];
}
throw new Error(`Plugin "${name}" not found in the official registry. For custom plugins, please install via your package manager and configure manually.`);
}
/**
* Reads config and safely injects the plugin to the `plugins` object.
*
* Phase 3 PR 3.B (F7 + M-3): replaced the legacy regex-based injector
* (which only matched `module.exports = { ... }` and silently no-op'd
* for `export default defineConfig({...})` configs) with the
* brace-balanced scanner in `./config-editor.js`. JSON configs are
* handled via `JSON.parse` for full safety; JS / TS / MJS / CJS
* configs use a scanner that finds the `plugins:` block by
* brace-balancing and adds the entry while preserving the existing
* indentation and trailing-comma style.
*/
function injectPluginToConfig(configPath, meta) {
const configKey = meta.configKey;
const valueText = meta.defaultConfig || '{}';
let content = '';
if (fs.existsSync(configPath)) {
content = fs.readFileSync(configPath, 'utf8');
} else {
// Scaffold a minimal config in the matching format.
const fmt = detectConfigFormat(configPath);
if (fmt === 'json') {
content = '{\n "plugins": {}\n}\n';
} else if (fmt === 'mjs' || fmt === 'ts') {
content = "export default {\n plugins: {}\n};\n";
} else {
content = "module.exports = {\n plugins: {}\n};\n";
}
}
const fmt = detectConfigFormat(configPath);
let result: { newContent: string; changed: boolean };
if (fmt === 'json') {
result = addPluginToJsonConfig(content, configKey, valueText);
} else {
result = addPluginToPluginsBlock(content, configKey, valueText);
}
if (!result.changed) {
return false; // Already present
}
fs.writeFileSync(configPath, result.newContent, 'utf8');
return true;
}
/**
* Removes the plugin from the config file. Phase 3 PR 3.B (F7) fix:
* the legacy regex only matched CJS-style configs; the brace-balanced
* scanner handles all five supported formats.
*/
function removePluginFromConfig(configPath, meta) {
if (!fs.existsSync(configPath)) return false;
const content = fs.readFileSync(configPath, 'utf8');
const configKey = meta.configKey;
const fmt = detectConfigFormat(configPath);
let result: { newContent: string; changed: boolean };
if (fmt === 'json') {
result = removePluginFromJsonConfig(content, configKey);
} else {
result = removePluginFromPluginsBlock(content, configKey);
}
if (!result.changed) {
return false; // Not present
}
fs.writeFileSync(configPath, result.newContent, 'utf8');
return true;
}
/**
* Sets `theme.template` in the config. Replaces any existing template value.
* Returns true if a change was made. Handles both JSON and JS config formats.
* Templates do NOT stack — re-running with a different template overwrites.
*/
function injectTemplateToConfig(configPath, meta) {
const format = detectConfigFormat(configPath);
const templateName = meta.templateName || meta.configKey;
let content = '';
if (fs.existsSync(configPath)) {
content = fs.readFileSync(configPath, 'utf8');
} else {
content = format === 'json'
? '{\n "theme": {}\n}\n'
: 'module.exports = {\n theme: {}\n};\n';
}
if (format === 'json') {
let config;
try { config = JSON.parse(content); }
catch (err) {
TUI.warn(`Could not parse ${configPath} as JSON. Skipping template injection.`);
return false;
}
config.theme = config.theme || {};
if (config.theme.template === templateName) return false;
config.theme.template = templateName;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
return true;
}
// JS config (regex-based, matching the existing plugin injector)
const escaped = templateName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`template\\s*:\\s*['"\`]${escaped}['"\`]`).test(content)) return false;
const themeRegex = /theme\s*:\s*\{([\s\S]*?)\}/;
const themeMatch = content.match(themeRegex);
if (themeMatch) {
const inner = themeMatch[1];
let newInner;
if (/template\s*:/.test(inner)) {
// Replace existing `template: "..."` value
newInner = inner.replace(/template\s*:\s*['"`][^'"`]*['"`]/, `template: "${templateName}"`);
} else {
const trimmed = inner.trim();
if (trimmed === '') {
newInner = `\n template: "${templateName}"\n `;
} else {
// Strip trailing whitespace + optional comma, then add comma + template
const stripped = inner.replace(/[\s,]+$/, '');
newInner = `${stripped},\n template: "${templateName}"\n `;
}
}
content = content.replace(themeMatch[0], `theme: {${newInner}}`);
} else {
// No `theme: { ... }` yet — create one in module.exports
const moduleExportsRegex = /module\.exports\s*=\s*(?:defineConfig\()?\{([\s\S]*?)\}(?:\))?;?/g;
let matchE, lastMatch;
while ((matchE = moduleExportsRegex.exec(content)) !== null) lastMatch = matchE;
if (lastMatch) {
const closingBraceIndex = lastMatch.index + lastMatch[0].lastIndexOf('}');
const prefixRaw = content.substring(0, closingBraceIndex);
const suffix = content.substring(closingBraceIndex);
// Strip trailing whitespace from prefix so the separator lands cleanly after the last value
const prefix = prefixRaw.replace(/\s+$/, '');
const lastChar = prefix.slice(-1);
const separator = (lastChar === ',' || lastChar === '{') ? '\n ' : ',\n ';
const insert = `${separator}theme: {\n template: "${templateName}"\n }\n`;
content = prefix + insert + suffix;
} else {
TUI.warn(`Could not automatically inject template into ${configPath}. Please set theme.template = "${templateName}" manually.`);
return false;
}
}
fs.writeFileSync(configPath, content, 'utf8');
return true;
}
/**
* Clears `theme.template` from the config (reverts to default).
* Returns true if a change was made.
*/
function removeTemplateFromConfig(configPath) {
if (!fs.existsSync(configPath)) return false;
const format = detectConfigFormat(configPath);
const content = fs.readFileSync(configPath, 'utf8');
if (format === 'json') {
let config;
try { config = JSON.parse(content); }
catch { return false; }
if (!config.theme || !('template' in config.theme)) return false;
delete config.theme.template;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
return true;
}
// JS config
if (!/template\s*:/.test(content)) return false;
const newContent = content.replace(/\s*template\s*:\s*['"`][^'"`]*['"`]\s*,?\s*/, '');
if (content === newContent) return false;
fs.writeFileSync(configPath, newContent, 'utf8');
return true;
}
import { TUI } from '@docmd/api';
async function installPlugin(pluginInput: string, opts: { verbose?: boolean } = {}) {
const cwd = process.cwd();
const pkgManager = getPackageManager(cwd);
let meta;
try {
meta = resolvePluginMeta(pluginInput);
} catch (err: any) {
TUI.error('Installation Aborted', err.message);
return;
}
// Core plugins (search, seo, sitemap, analytics, llms, mermaid, git,
// openapi, okf) ship with @docmd/core — they're already installed
// and auto-loaded. `docmd add` for a core plugin would be a no-op at
// best and a config corruption at worst (it would inject the entry
// even though the package is a workspace dep already, leading to a
// duplicate install on user systems). The list lives in @docmd/api
// as CORE_PLUGINS so it stays in sync with hooks.ts.
if (!meta.kind && isCorePlugin(meta.configKey)) {
TUI.error(
'Installation Aborted (Plugin Already Present)',
`Plugin "${meta.configKey}" is a core plugin that ships with @docmd/core. ` +
`You can customise plugin behaviour via "plugins.${meta.configKey}" in your config.`
);
process.exit(1);
}
const packageName = meta.package;
const isTemplate = meta.kind === 'template';
TUI.section(isTemplate ? 'Template Installation' : 'Plugin Installation');
TUI.step(`Installing ${packageName} via ${pkgManager}`, 'WAIT');
let cmdExe = '';
let cmdArgs: string[] = [];
if (pkgManager === 'npm') { cmdExe = resolvePackageManagerBin('npm'); cmdArgs = ['install', packageName]; }
else if (pkgManager === 'yarn') { cmdExe = resolvePackageManagerBin('yarn'); cmdArgs = ['add', packageName]; }
else if (pkgManager === 'pnpm') { cmdExe = resolvePackageManagerBin('pnpm'); cmdArgs = ['add', packageName]; }
else if (pkgManager === 'bun') { cmdExe = resolvePackageManagerBin('bun'); cmdArgs = ['add', packageName]; }
if (pkgManager === 'npm' && !fs.existsSync(path.join(cwd, 'package.json'))) {
cmdArgs.push('--no-save');
}
try {
const stdioMode = opts.verbose ? 'inherit' : 'pipe';
execFileSync(cmdExe, cmdArgs, { stdio: stdioMode, cwd });
TUI.step(packageName, 'DONE');
const configPath = resolveConfigPath(cwd);
TUI.divider('Configuration');
let injected;
if (isTemplate) {
TUI.step(`Setting theme.template to "${meta.configKey}"`, 'WAIT', TUI.blue);
injected = injectTemplateToConfig(configPath, meta);
} else {
TUI.step(`Activating ${meta.configKey}`, 'WAIT', TUI.blue);
injected = injectPluginToConfig(configPath, meta);
}
if (injected) {
TUI.step(isTemplate ? 'Template activated' : 'Activation completed', 'DONE', TUI.blue);
} else {
TUI.step(isTemplate ? 'Template already configured' : 'Plugin already configured', 'SKIP', TUI.blue);
}
TUI.footer();
// M-14: the final success message must reflect what actually happened.
// When the plugin was already configured, the config was untouched —
// calling that "successfully installed" is misleading. Branch on the
// `injected` flag from the config editor and emit one of three
// messages: new install, already configured, or core-plugin (which
// is handled above with a hard error so we never reach this line
// for that case).
if (injected) {
TUI.success(isTemplate ? 'Template successfully installed and activated.' : 'Plugin successfully installed and activated.');
} else {
TUI.info(isTemplate ? 'Template was already configured. Nothing changed.' : 'Plugin was already configured. Nothing changed.');
}
} catch (err: any) {
TUI.step(packageName, 'FAIL');
TUI.footer();
TUI.error(`Could not install ${packageName}`, formatSpawnError(err, cmdExe, 'add', opts));
}
}
async function removePlugin(pluginInput: string, opts: { verbose?: boolean } = {}) {
const cwd = process.cwd();
const pkgManager = getPackageManager(cwd);
let meta;
try {
meta = resolvePluginMeta(pluginInput);
} catch (err: any) {
TUI.error('Removal Aborted', err.message);
// Phase 3 PR 3.A (F6): exit 1 so CI pipelines can gate on the
// documented "Removal Aborted" failure path. Previously this
// `return` left the process at exit code 0, silently passing a
// failed removal.
process.exit(1);
}
// Same gate as installPlugin: core plugins can't be removed because
// they're a workspace dep of @docmd/core, not user-installed.
if (!meta.kind && isCorePlugin(meta.configKey)) {
TUI.error(
'Removal Aborted (Core Plugin)',
`Plugin "${meta.configKey}" is a core plugin that ships with @docmd/core. ` +
`It cannot be removed — opt out instead via "plugins.${meta.configKey}: false" in your config.`
);
process.exit(1);
}
const packageName = meta.package;
const isTemplate = meta.kind === 'template';
TUI.section(isTemplate ? 'Template Removal' : 'Plugin Removal');
TUI.step(`Uninstalling ${packageName} via ${pkgManager}`, 'WAIT');
let cmdExe = '';
let cmdArgs: string[] = [];
if (pkgManager === 'npm') { cmdExe = 'npm'; cmdArgs = ['uninstall', packageName]; }
else if (pkgManager === 'yarn') { cmdExe = 'yarn'; cmdArgs = ['remove', packageName]; }
else if (pkgManager === 'pnpm') { cmdExe = 'pnpm'; cmdArgs = ['remove', packageName]; }
else if (pkgManager === 'bun') { cmdExe = 'bun'; cmdArgs = ['remove', packageName]; }
try {
const stdioMode = opts.verbose ? 'inherit' : 'pipe';
execFileSync(cmdExe, cmdArgs, { stdio: stdioMode, cwd });
TUI.step(packageName, 'DONE');
const configPath = resolveConfigPath(cwd);
TUI.divider('Configuration');
let removed;
if (isTemplate) {
TUI.step('Clearing theme.template', 'WAIT', TUI.blue);
removed = removeTemplateFromConfig(configPath);
} else {
TUI.step(`Removing ${meta.configKey}`, 'WAIT', TUI.blue);
removed = removePluginFromConfig(configPath, meta);
}
if (removed) {
TUI.step('Cleanup completed', 'DONE', TUI.blue);
} else {
TUI.step('No config entry found', 'SKIP', TUI.blue);
}
TUI.footer();
TUI.success(isTemplate ? 'Template successfully uninstalled.' : 'Plugin successfully uninstalled.');
} catch (err: any) {
TUI.step(packageName, 'FAIL');
TUI.footer();
TUI.error(`Could not remove ${packageName}`, formatSpawnError(err, cmdExe, 'remove', opts));
}
}
export {
installPlugin,
removePlugin
};
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
# @docmd/plugin-llms
Generates `llms.txt` and `llms-full.txt` at build time so your documentation is immediately accessible to AI agents - ChatGPT, Claude, Cursor, and any tool that follows the [llmstxt.org](https://llmstxt.org/) standard.
Bundled with `@docmd/core`. Requires `siteUrl` in your config to produce valid absolute links.
```js
// docmd.config.js
module.exports = {
siteUrl: 'https://your-site.com',
plugins: {
llms: {}
}
};
```
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
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@docmd/plugin-llms",
"version": "0.8.12",
"description": "Generate llms.txt context files for AI agents from your docmd site.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "llms",
"kind": "plugin",
"displayName": "LLMs",
"tagline": "Generates llms.txt and llms-full.txt for LLM consumption.",
"capabilities": [
"post-build"
]
},
"scripts": {
"build": "tsc"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"llms",
"ai",
"context",
"agents",
"text-generation",
"documentation",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+198
View File
@@ -0,0 +1,198 @@
/**
* --------------------------------------------------------------------
* 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 path from 'path';
import fs from 'fs/promises';
import type { PluginDescriptor } from '@docmd/api';
import { outputPathToPathname, sanitizeUrl } from '@docmd/api';
export const plugin: PluginDescriptor = {
name: 'llms',
version: '0.8.12',
capabilities: ['post-build']
};
export async function onPostBuild({ config, pages, outputDir, log }: any) {
const siteUrl = (config.url || '').replace(/\/$/, '');
const _options = config.plugins?.llms || {};
// 0.8.8: opt-in multi-locale mode.
// - Default (`i18n: false`): write `llms.txt` / `llms-full.txt` /
// `llms.json` for the DEFAULT locale only. File names are
// unchanged from prior versions — this is the break-free
// default.
// - Opt-in (`plugins.llms.i18n: true`): also write a
// `llms.<locale>.txt` / `llms-full.<locale>.txt` /
// `llms.<locale>.json` for every non-default locale in
// `config.i18n.locales`. The default-locale files keep the
// unsuffixed names so existing consumers don't break.
// - Single-locale projects (no `config.i18n` block, or only
// one locale) emit a single unsuffixed set regardless of
// the i18n flag.
const i18n = _options.i18n === true;
const localeIds: string[] = (config.i18n?.locales || []).map((l: any) => l.id);
const defaultLocale = config.i18n?.default || localeIds[0] || '';
if (log) log('Generating LLMs context files' + (i18n && localeIds.length > 1 ? ' (multi-locale)' : ''));
// Make sure outputDir exists — guards the case where the plugin
// is invoked directly from a test (the docmd build pipeline
// already creates the dir, but direct callers may not).
await fs.mkdir(outputDir, { recursive: true });
/**
* Group pages by their detected locale. Pages without a locale
* prefix in `outputPath` are bucketed under the default locale.
* The `noindex` and `llms: false` opt-outs are honoured here.
*/
function pageLocale(p: any): string {
const parts = String(p.outputPath || '').split('/').filter(Boolean);
if (localeIds.length && parts.length && localeIds.includes(parts[0])) return parts[0];
return defaultLocale;
}
const grouped: Map<string, any[]> = new Map();
for (const p of pages) {
if (p.frontmatter.noindex) continue;
if (p.frontmatter.llms === false) continue;
const loc = pageLocale(p);
if (!grouped.has(loc)) grouped.set(loc, []);
grouped.get(loc)!.push(p);
}
// Sort each bucket by URL for stable output.
for (const arr of grouped.values()) {
arr.sort((a, b) => a.outputPath.localeCompare(b.outputPath));
}
// Determine which locales to write. Default: only the default
// locale. i18n: all configured locales (or just the default
// if no i18n block is configured).
const localesToWrite: string[] = [];
if (i18n && localeIds.length > 1) {
localesToWrite.push(...localeIds);
} else {
localesToWrite.push(defaultLocale);
}
for (const loc of localesToWrite) {
const bucket = grouped.get(loc) || [];
// T-Z10 / T-Z11: sanitise any user-controlled string before it lands
// in the markdown list / CSV cell. Two threats:
// - Markdown injection: a title containing `]`, `[`, `\n`, or
// backticks breaks out of the `[title](url)` form, or renders
// as raw HTML in some markdown processors.
// - CSV formula injection: a title starting with `=`, `+`, `-`,
// or `@` is interpreted as a formula when the file is opened
// in a spreadsheet (T-Z11).
// The escape below:
// - Prefixes a single-quote when the string starts with a
// spreadsheet-formula sigil (neutralises CSV formula execution
// in Excel / LibreOffice / Sheets).
// - Escapes backslashes, backticks, and square brackets so the
// string cannot break out of `[title](url)` or render as code.
// - Replaces newlines and carriage returns with spaces so a multi-
// line title doesn't break the markdown list.
// - Truncates the result to a sane length.
const safeForMarkdownAndCsv = (raw: string): string => {
if (typeof raw !== 'string') return '';
let s = raw.replace(/[\r\n]+/g, ' ').slice(0, 200);
s = s.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
s = s.replace(/\[/g, '\\[').replace(/\]/g, '\\]');
if (/^\s*[=+\-@]/.test(s)) s = `'${s}`;
return s;
};
// ── llms.txt — title + description + link list ─────────────
let content = `# ${safeForMarkdownAndCsv(config.title || 'Documentation')}\n\n`;
content += `> Generated by docmd\n\n`;
if (config.description) content += `${safeForMarkdownAndCsv(config.description)}\n\n`;
content += `## Documentation Files\n\n`;
for (const page of bucket) {
const pathname = outputPathToPathname(page.outputPath);
const fullUrl = sanitizeUrl(siteUrl + pathname);
const title = safeForMarkdownAndCsv(page.frontmatter.title || 'Untitled');
content += `- [${title}](${fullUrl})\n`;
if (page.frontmatter.description) {
content += ` ${safeForMarkdownAndCsv(page.frontmatter.description)}\n`;
}
}
// ── llms-full.txt — full markdown for each page ─────────────
let fullContent = `# ${safeForMarkdownAndCsv(config.title || 'Documentation')} - Full Context\n\n`;
fullContent += `> Generated by docmd\n\n`;
if (config.description) fullContent += `${safeForMarkdownAndCsv(config.description)}\n\n`;
fullContent += `---\n\n`;
for (const page of bucket) {
const pathname = outputPathToPathname(page.outputPath);
const fullUrl = sanitizeUrl(siteUrl + pathname);
const title = safeForMarkdownAndCsv(page.frontmatter.title || 'Untitled');
fullContent += `## [${title}](${fullUrl})\n\n`;
try {
if (page.sourcePath) {
// T-Z10: the body of a user markdown file is treated as
// trusted source content (the user wrote it themselves), so
// we don't escape it. Consumers should treat llms-full.txt as
// a "first-party" channel and not render it in an HTML
// context without sanitising the body on the consumer side.
const rawMd = await fs.readFile(page.sourcePath, 'utf8');
fullContent += `${rawMd}\n\n---\n\n`;
} else {
fullContent += `*(Raw content unavailable)*\n\n---\n\n`;
}
} catch {
if (log) log(`Skipping raw markdown: ${page.sourcePath}`, 'SKIP');
}
}
// ── llms.json — machine-readable manifest ─────────────────
// T-Z10/T-Z11: same sanitisation for JSON output. The JSON parser
// handles strings safely but we still apply the same neutralisation
// so a title that starts with `=cmd|"/c calc"!A1` doesn't execute
// when the file is opened in a spreadsheet, and the JSON stays
// self-consistent.
const llmsJson = {
title: safeForMarkdownAndCsv(config.title || 'Documentation'),
description: safeForMarkdownAndCsv(config.description || ''),
pages: bucket.map((page) => {
const pathname = outputPathToPathname(page.outputPath);
const fullUrl = sanitizeUrl(siteUrl + pathname);
return {
title: safeForMarkdownAndCsv(page.frontmatter.title || 'Untitled'),
url: fullUrl,
description: safeForMarkdownAndCsv(page.frontmatter.description || ''),
priority: page.frontmatter.priority || (pathname === '/' ? 'high' : 'medium')
};
})
};
// File name strategy:
// - Default locale (or only locale in a single-locale
// project) keeps the unsuffixed names so existing
// consumers don't break.
// - Non-default locales get a `.${locale}` suffix:
// `llms.ja.txt`, `llms-full.ja.txt`, `llms.ja.json`,
// `llms.fr.txt`, etc.
const isDefault = loc === defaultLocale;
const baseTxt = isDefault ? 'llms' : `llms.${loc}`;
const fullBase = isDefault ? 'llms-full' : `llms-full.${loc}`;
const jsonBase = isDefault ? 'llms' : `llms.${loc}`;
await fs.writeFile(path.join(outputDir, `${baseTxt}.txt`), content);
await fs.writeFile(path.join(outputDir, `${fullBase}.txt`), fullContent);
await fs.writeFile(
path.join(outputDir, `${jsonBase}.json`),
JSON.stringify(llmsJson, null, 2)
);
}
}
+170
View File
@@ -0,0 +1,170 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* LLMS plugin — i18n opt-in tests
*
* The default behaviour writes `llms.txt` / `llms-full.txt` / `llms.json`
* for the **default locale only**. Multi-locale output is opt-in via
* `plugins.llms.i18n: true`, which writes per-locale files
* (`llms.<locale>.txt`, etc.).
*
* Run with: `node --test tests/llms.test.js`
* --------------------------------------------------------------------
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { plugin, onPostBuild } from '../dist/index.js';
describe('plugin descriptor', () => {
it('has the expected name', () => {
assert.equal(plugin.name, 'llms');
});
it('declares post-build capability', () => {
assert.ok(Array.isArray(plugin.capabilities));
assert.ok(plugin.capabilities.includes('post-build'));
});
});
describe('onPostBuild — default behaviour (default locale only)', () => {
let tmpDir;
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'llms-test-'));
});
after(async () => {
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
});
it('writes llms.txt for the default locale only (i18n off by default)', async () => {
const out = path.join(tmpDir, 'default');
await onPostBuild({
config: {
title: 'Default test',
url: 'https://example.com',
i18n: {
default: 'en',
locales: [
{ id: 'en', label: 'English' },
{ id: 'hi', label: 'Hindi' }
]
}
// No `plugins.llms` entry — use defaults.
},
pages: [
{ outputPath: 'en/welcome.html', frontmatter: { title: 'EN Welcome', description: 'Hello' }, sourcePath: '', rawMarkdown: '' },
{ outputPath: 'hi/welcome.html', frontmatter: { title: 'HI Welcome', description: 'Namaste' }, sourcePath: '', rawMarkdown: '' }
],
outputDir: out,
log: () => {}
});
// Single files at the root.
const txt = await fs.readFile(path.join(out, 'llms.txt'), 'utf8');
assert.match(txt, /\[EN Welcome\]/);
assert.doesNotMatch(txt, /\[HI Welcome\]/);
});
it('writes per-locale files when `plugins.llms.i18n: true`', async () => {
const out = path.join(tmpDir, 'i18n');
await onPostBuild({
config: {
title: 'I18N test',
url: 'https://example.com',
i18n: {
default: 'en',
locales: [
{ id: 'en', label: 'English' },
{ id: 'hi', label: 'Hindi' }
]
},
plugins: { llms: { i18n: true } }
},
pages: [
{ outputPath: 'en/welcome.html', frontmatter: { title: 'EN Welcome' }, sourcePath: '', rawMarkdown: '' },
{ outputPath: 'hi/welcome.html', frontmatter: { title: 'HI Welcome' }, sourcePath: '', rawMarkdown: '' }
],
outputDir: out,
log: () => {}
});
// Default locale keeps the unsuffixed names (no breaking change
// for existing consumers).
const txt = await fs.readFile(path.join(out, 'llms.txt'), 'utf8');
assert.match(txt, /\[EN Welcome\]/);
assert.doesNotMatch(txt, /\[HI Welcome\]/);
// Non-default locale gets a `.<locale>` suffix.
const txtHi = await fs.readFile(path.join(out, 'llms.hi.txt'), 'utf8');
assert.match(txtHi, /\[HI Welcome\]/);
assert.doesNotMatch(txtHi, /\[EN Welcome\]/);
// Full and JSON files: default → unsuffixed; non-default → suffixed.
const json = JSON.parse(await fs.readFile(path.join(out, 'llms.json'), 'utf8'));
assert.equal(json.pages.length, 1);
assert.equal(json.pages[0].title, 'EN Welcome');
const jsonHi = JSON.parse(await fs.readFile(path.join(out, 'llms.hi.json'), 'utf8'));
assert.equal(jsonHi.pages.length, 1);
assert.equal(jsonHi.pages[0].title, 'HI Welcome');
// `llms-full.txt` and `llms-full.hi.txt` both exist.
await fs.access(path.join(out, 'llms-full.txt'));
await fs.access(path.join(out, 'llms-full.hi.txt'));
});
it('default-mode treats a site with no i18n as the default locale', async () => {
// No `i18n` block at all — every page is in the (implicit)
// default locale, so the bundle has all pages.
const out = path.join(tmpDir, 'no-i18n');
await fs.mkdir(out, { recursive: true });
await onPostBuild({
config: { title: 'No i18n', url: 'https://example.com' },
pages: [
{ outputPath: 'a.html', frontmatter: { title: 'A' }, sourcePath: '', rawMarkdown: '' },
{ outputPath: 'b.html', frontmatter: { title: 'B' }, sourcePath: '', rawMarkdown: '' }
],
outputDir: out,
log: () => {}
});
const txt = await fs.readFile(path.join(out, 'llms.txt'), 'utf8');
assert.match(txt, /\[A\]/);
assert.match(txt, /\[B\]/);
});
it('default-mode with i18n + only-one-locale writes the single unsuffixed set', async () => {
// Edge case: `i18n: false` (default) + only ONE locale configured.
// The bundle has the single set at root with no locale suffix.
const out = path.join(tmpDir, 'single-locale');
await onPostBuild({
config: {
title: 'Single',
url: 'https://example.com',
i18n: {
default: 'en',
locales: [{ id: 'en', label: 'English' }]
}
},
pages: [
{ outputPath: 'a.html', frontmatter: { title: 'A' }, sourcePath: '', rawMarkdown: '' }
],
outputDir: out,
log: () => {}
});
// Unsuffixed files (no `llms.en.txt` is needed because there's
// only one locale — the suffix would just add noise).
const txt = await fs.readFile(path.join(out, 'llms.txt'), 'utf8');
assert.match(txt, /\[A\]/);
await assert.rejects(
fs.access(path.join(out, 'llms.en.txt')),
/ENOENT/
).catch(() => { /* expected: no per-locale suffix for single-locale sites */ });
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25
View File
@@ -0,0 +1,25 @@
# @docmd/plugin-math
Adds LaTeX mathematics to your docmd site via KaTeX - inline `$...$` and block `$$...$$` syntax, rendered client-side with no server overhead. An optional plugin, installed separately.
```bash
docmd add math
```
```js
$E = mc^2$
$$
\sum_{i=1}^n i^2 = \frac{n(n+1)(2n+1)}{6}
$$
```
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
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@docmd/plugin-math",
"version": "0.8.12",
"description": "Mathematics (KaTeX/LaTeX) plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "math",
"kind": "plugin",
"displayName": "Math",
"tagline": "Mathematics (KaTeX/LaTeX) plugin for docmd.",
"capabilities": [
"markdown",
"assets"
]
},
"scripts": {
"build": "tsc"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"katex": "^0.17.0",
"markdown-it-texmath": "^1.0.0"
},
"devDependencies": {
"@docmd/api": "workspace:*"
},
"keywords": [
"docmd",
"plugin",
"math",
"latex",
"katex",
"markdown",
"documentation",
"minimalist",
"zero-config",
"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"
}
+50
View File
@@ -0,0 +1,50 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/plugin-math
* @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 texmath from 'markdown-it-texmath';
import katex from 'katex';
import type { PluginDescriptor } from '@docmd/api';
export const plugin: PluginDescriptor = {
name: 'math',
version: '0.8.12',
capabilities: ['markdown', 'assets']
};
export function markdownSetup(md: any) {
// Suppress KaTeX's "quirks mode" warning - irrelevant in Node.js
const origWarn = console.warn;
console.warn = (...args: any[]) => {
if (typeof args[0] === 'string' && args[0].includes('quirks mode')) return;
origWarn.apply(console, args);
};
md.use(texmath, { engine: katex, delimiters: 'dollars', katexOptions: { macros: { "\\RR": "\\mathbb{R}" } } });
console.warn = origWarn;
}
export function getAssets() {
return [
{
url: 'https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css',
type: 'css',
location: 'head',
// Conditional loading (new in 0.8.7): only inject KaTeX's stylesheet
// on pages that actually have rendered math (KaTeX emits `class="katex"`
// on every formula and `class="katex-display"` on display math). On
// pages with no math the CSS request is skipped entirely, saving the
// ~30 KB katex.min.css fetch plus the render cost on mobile.
condition: { pageHtmlMatches: ['class="katex"', 'class="katex-display"'] }
}
];
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# @docmd/plugin-mermaid
Adds [Mermaid.js](https://mermaid.js.org/) diagram support to your docmd site - write flowcharts, sequence diagrams, and more directly in Markdown using a standard code fence. Bundled with `@docmd/core`.
````md
```mermaid
graph TD;
A-->B;
A-->C;
B-->D;
C-->D;
```
````
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
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@docmd/plugin-mermaid",
"version": "0.8.12",
"description": "Mermaid diagram support plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "mermaid",
"kind": "plugin",
"displayName": "Mermaid",
"tagline": "Mermaid.js diagram support for docmd.",
"capabilities": [
"markdown",
"assets"
]
},
"scripts": {
"build": "tsc && esbuild src/client.ts --bundle --outfile=dist/init-mermaid.js --format=esm --platform=browser --minify --external:https://*",
"test": "tsx --test tests/*.test.ts",
"test:watch": "tsx --test --watch tests/*.test.ts"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3",
"esbuild": "^0.28.1",
"tsx": "^4.23.0"
},
"keywords": [
"docmd",
"plugin",
"mermaid",
"diagrams",
"flowchart",
"sequence",
"class",
"graph",
"mindmap",
"syntax",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+271
View File
@@ -0,0 +1,271 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
// @ts-expect-error Deno/Browser compatible CDN import that TypeScript cannot natively resolve
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
import { fixSvgNamespaces } from './svg-utils.js';
(async function () {
'use strict';
let counter = 0;
let iconsRegistered = false;
function getTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default';
}
async function renderAll() {
if (!iconsRegistered) {
try {
mermaid.registerIconPacks([
{
name: 'icon',
loader: () => fetch('https://unpkg.com/@iconify-json/lucide@1/icons.json').then((res) => res.json()),
},
]);
iconsRegistered = true;
} catch (e) {
console.warn('Mermaid icon registration failed:', e);
}
}
mermaid.initialize({ startOnLoad: false, theme: getTheme(), securityLevel: 'loose' });
// Ensure DOM is settled
await new Promise(resolve => requestAnimationFrame(resolve));
const elements = document.querySelectorAll('.mermaid:not([data-processed="true"])') as NodeListOf<HTMLElement>;
for (const el of elements) {
if (!el.dataset.original) el.dataset.original = el.textContent || '';
const code = el.dataset.original;
try {
const id = `mermaid-svg-${counter++}`;
const { svg } = await mermaid.render(id, code);
// Apply container class first to establish styling context
el.classList.add('docmd-mermaid-container');
el.innerHTML = '';
const parser = new DOMParser();
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-wrapper';
// Fix missing xmlns:xlink declaration — mermaid.render() omits it (unlike
// mermaid.run()) causing DOMParser to return <parsererror> for diagrams
// that use xlink:href, most notably C4Context person icons.
const svgDoc = parser.parseFromString(fixSvgNamespaces(svg), 'image/svg+xml');
wrapper.appendChild(svgDoc.documentElement);
const svgEl = wrapper.querySelector('svg');
if (svgEl) {
// Remove natural constraints to prevent label clipping when scaled via CSS transform
svgEl.removeAttribute('style');
svgEl.style.maxWidth = 'none';
svgEl.style.maxHeight = 'none';
svgEl.style.transformOrigin = 'center';
svgEl.style.display = 'block';
svgEl.style.margin = '0 auto';
}
// Control buttons - appended to the OUTER container so they stay fixed!
const controls = document.createElement('div');
controls.className = 'mermaid-controls';
const btnStyle = 'background: var(--bg-color, #fff); border: 1px solid var(--border-color, #e4e4e7); border-radius: 6px; padding: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 3px rgba(0,0,0,0.08); width: 28px; height: 28px; color: var(--text-muted, #71717a);';
const createBtn = (title: string, svgContent: string) => {
const btn = document.createElement('button');
btn.style.cssText = btnStyle;
btn.title = title;
const btnSvgDoc = parser.parseFromString(svgContent, 'image/svg+xml');
btn.appendChild(btnSvgDoc.documentElement);
return btn;
};
const zoomInBtn = createBtn('Zoom in', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line><line x1="11" y1="8" x2="11" y2="14"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>');
const zoomOutBtn = createBtn('Zoom out', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>');
const resetBtn = createBtn('Reset view', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>');
const fullscreenBtn = createBtn('Fullscreen', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path></svg>');
const upBtn = createBtn('Pan Up', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>');
const downBtn = createBtn('Pan Down', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>');
const leftBtn = createBtn('Pan Left', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>');
const rightBtn = createBtn('Pan Right', '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>');
controls.style.gap = '4px';
const row1 = document.createElement('div');
row1.style.cssText = 'display: flex; gap: 4px;';
row1.appendChild(zoomInBtn);
row1.appendChild(zoomOutBtn);
row1.appendChild(resetBtn);
row1.appendChild(fullscreenBtn);
const row2 = document.createElement('div');
row2.style.cssText = 'display: flex; gap: 4px;';
row2.appendChild(upBtn);
row2.appendChild(downBtn);
row2.appendChild(leftBtn);
row2.appendChild(rightBtn);
controls.appendChild(row1);
controls.appendChild(row2);
wrapper.appendChild(controls);
el.appendChild(wrapper);
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0;
let startY = 0;
const updateTransform = (skipTransition = false) => {
if (svgEl) {
svgEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
svgEl.style.transition = (isDragging || skipTransition) ? 'none' : 'transform 0.2s cubic-bezier(0.2, 0, 0, 1)';
}
};
const initView = () => {
if (!svgEl) return;
scale = 1;
translateX = 0;
translateY = 0;
// Let the SVG naturally size itself with max constraints
svgEl.style.width = '';
svgEl.style.height = '';
svgEl.style.maxWidth = '100%';
svgEl.style.maxHeight = '420px';
svgEl.style.transformOrigin = 'center';
wrapper.style.height = 'auto'; // Remove any dynamic height
updateTransform(true);
};
initView();
const pan = (dx: number, dy: number) => {
translateX += dx;
translateY += dy;
updateTransform();
};
const zoom = (factor: number) => {
// Simple zoom from center
scale *= factor;
updateTransform();
};
zoomInBtn.addEventListener('click', () => zoom(1.25));
zoomOutBtn.addEventListener('click', () => zoom(1 / 1.25));
upBtn.addEventListener('click', () => pan(0, 50));
downBtn.addEventListener('click', () => pan(0, -50));
leftBtn.addEventListener('click', () => pan(50, 0));
rightBtn.addEventListener('click', () => pan(-50, 0));
resetBtn.addEventListener('click', () => { initView(); });
fullscreenBtn.addEventListener('click', () => {
if (!document.fullscreenElement) {
wrapper.requestFullscreen().catch(err => console.warn(err));
} else {
document.exitFullscreen();
}
});
document.addEventListener('fullscreenchange', () => {
if (!svgEl) return;
if (document.fullscreenElement === wrapper) {
wrapper.classList.add('mermaid-fullscreen');
svgEl.style.maxWidth = 'calc(100vw - 40px)';
svgEl.style.maxHeight = 'calc(100vh - 40px)';
svgEl.style.width = '100%';
svgEl.style.height = '100%';
scale = 1;
translateX = 0;
translateY = 0;
updateTransform();
} else {
wrapper.classList.remove('mermaid-fullscreen');
initView();
}
});
wrapper.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
wrapper.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault(); // Prevent text selection
translateX = e.clientX - startX;
translateY = e.clientY - startY;
updateTransform();
});
const stopDrag = () => {
if (!isDragging) return;
isDragging = false;
wrapper.style.cursor = 'grab';
updateTransform(); // Trigger transition if needed
};
window.addEventListener('mouseup', stopDrag);
wrapper.addEventListener('mouseleave', stopDrag);
el.setAttribute('data-processed', 'true');
} catch (err) {
console.error(err);
el.setAttribute('data-processed', 'error');
}
}
}
// 1. Initial Load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', renderAll);
} else {
renderAll();
}
// 2. SPA Navigation Load
document.addEventListener('docmd:page-mounted', renderAll);
// 3. Render when a hidden Tab or Collapsible is opened
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement | null;
if (target?.closest('.docmd-tabs-nav-item, .collapsible-summary')) {
setTimeout(renderAll, 50);
}
});
// 4. Theme Toggle
const themeObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName === 'data-theme') {
document.querySelectorAll('.mermaid').forEach(el => el.removeAttribute('data-processed'));
renderAll();
}
}
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
})();
+55
View File
@@ -0,0 +1,55 @@
/**
* --------------------------------------------------------------------
* 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 path from 'path';
import { fileURLToPath } from 'url';
import type { PluginDescriptor } from '@docmd/api';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const plugin: PluginDescriptor = {
name: 'mermaid',
version: '0.8.12',
capabilities: ['markdown', 'assets']
};
export function markdownSetup(md: any) {
const defaultFence = md.renderer.rules.fence;
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const info = token.info.trim();
if (info === 'mermaid') {
return `<div class="mermaid">${md.utils.escapeHtml(token.content)}</div>\n`;
}
return defaultFence(tokens, idx, options, env, self);
};
}
export function getAssets() {
return [
{
src: path.join(__dirname, 'init-mermaid.js'),
dest: 'assets/js/init-mermaid.js',
type: 'js',
location: 'body',
attributes: { type: 'module' },
// Conditional loading (new in 0.8.7): only inject the init script on
// pages whose rendered HTML actually contains a mermaid diagram block.
// This avoids the ~500 KB mermaid library CDN fetch on every other
// page of the site.
condition: { pageHtmlMatches: 'class="mermaid"' }
}
];
}
+24
View File
@@ -0,0 +1,24 @@
/**
* SVG utility functions for the mermaid plugin client.
* Extracted for testability — pure string/DOM operations, no browser globals.
*/
/**
* Ensures an SVG string has the required XML namespace declarations
* for all namespace prefixes it uses.
*
* Problem: mermaid.render() returns SVG via innerHTML serialization which
* omits xmlns:xlink even when xlink:href attributes are present.
* DOMParser.parseFromString(svg, 'image/svg+xml') is a strict XML parser
* and fails with a <parsererror> on undeclared namespace prefixes.
*
* Affected diagram types: any that use xlink:href — most notably C4Context,
* which always adds person icons via <image xlink:href="data:...">.
* (mermaid.run() includes xmlns:xlink; only mermaid.render() omits it.)
*/
export function fixSvgNamespaces(svg: string): string {
if (svg.includes('xlink:') && !svg.includes('xmlns:xlink')) {
return svg.replace(/(<svg\b)/, '$1 xmlns:xlink="http://www.w3.org/1999/xlink"');
}
return svg;
}
@@ -0,0 +1,117 @@
/**
* Tests for fixSvgNamespaces utility
*
* Root cause: mermaid.render() returns SVG with xlink:href attributes (used by
* C4Context person icons) but without xmlns:xlink namespace declaration.
* DOMParser.parseFromString(svg, 'image/svg+xml') is a strict XML parser —
* it fails on undeclared namespace prefixes and returns a <parsererror> element.
* This causes C4Context diagrams to render as a blank white box.
*
* mermaid.run() (live editor) does include xmlns:xlink, so it works fine.
* Only mermaid.render() (static site via init-mermaid.js) is affected.
*
* Run with: `pnpm test` (uses tsx + node:test — no vitest, no happy-dom).
*
* NOTE: The 3 DOMParser/`document` tests that were here under the
* "DOMParser integration" describe block were dropped when we moved off
* happy-dom. Their own comment already acknowledged: "happy-dom is lenient
* and parses it anyway, so we cannot replicate that specific Chrome failure
* here. The string-level tests above are the authoritative verification that
* the fix adds the declaration that Chrome requires."
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { fixSvgNamespaces } from '../src/svg-utils.js';
// Minimal SVG that mermaid.render() produces for a typical C4Context diagram.
// Critical detail: has xlink:href (from person icon <image> elements) but
// NO xmlns:xlink declaration — this is what mermaid.render() actually outputs.
const C4_SVG_WITHOUT_XMLNS = `<svg id="mermaid-svg-0" width="100%" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 650 400">
<style>#mermaid-svg-0 .node{fill:#08427B}</style>
<g>
<rect x="10" y="10" width="200" height="100" fill="#08427B"/>
<image width="48" height="48" x="91" y="20" xlink:href="data:image/png;base64,iVBORw0KGgo="/>
<text x="110" y="90" fill="white">Customer</text>
</g>
</svg>`;
// Same SVG but already has xmlns:xlink — should not be modified
const C4_SVG_WITH_XMLNS = `<svg id="mermaid-svg-0" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 650 400">
<style>#mermaid-svg-0 .node{fill:#08427B}</style>
<g>
<image width="48" height="48" x="91" y="20" xlink:href="data:image/png;base64,iVBORw0KGgo="/>
</g>
</svg>`;
// Flowchart SVG — no xlink: usage at all, should not be modified
const FLOWCHART_SVG_NO_XLINK = `<svg id="mermaid-svg-1" width="100%" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200">
<style>#mermaid-svg-1 .node rect{fill:#36bcf7}</style>
<g>
<rect x="10" y="10" width="100" height="40"/>
<text x="60" y="35">Start</text>
</g>
</svg>`;
// Flowchart with clickable links — also uses xlink:href (less common but possible)
const FLOWCHART_SVG_WITH_LINK = `<svg id="mermaid-svg-2" width="100%" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 200">
<a xlink:href="https://example.com" target="_blank">
<rect x="10" y="10" width="100" height="40"/>
</a>
</svg>`;
describe('fixSvgNamespaces', () => {
describe('C4Context person icon (root cause scenario)', () => {
it('adds xmlns:xlink when SVG uses xlink:href without declaration', () => {
const fixed = fixSvgNamespaces(C4_SVG_WITHOUT_XMLNS);
assert.ok(fixed.includes('xmlns:xlink="http://www.w3.org/1999/xlink"'));
});
it('places xmlns:xlink inside the <svg> opening tag', () => {
const fixed = fixSvgNamespaces(C4_SVG_WITHOUT_XMLNS);
// Must be on <svg>, not somewhere else
assert.match(fixed, /<svg[^>]+xmlns:xlink="http:\/\/www\.w3\.org\/1999\/xlink"/);
});
it('preserves all existing SVG content unchanged', () => {
const fixed = fixSvgNamespaces(C4_SVG_WITHOUT_XMLNS);
assert.ok(fixed.includes('xlink:href="data:image/png;base64,iVBORw0KGgo="'));
assert.ok(fixed.includes('xmlns="http://www.w3.org/2000/svg"'));
assert.ok(fixed.includes('viewBox="0 0 650 400"'));
assert.ok(fixed.includes('<text x="110" y="90" fill="white">Customer</text>'));
});
});
describe('idempotency — does not break already-valid SVG', () => {
it('returns SVG unchanged when xmlns:xlink is already declared', () => {
const fixed = fixSvgNamespaces(C4_SVG_WITH_XMLNS);
assert.equal(fixed, C4_SVG_WITH_XMLNS);
});
it('does not duplicate xmlns:xlink when called twice', () => {
const fixed = fixSvgNamespaces(fixSvgNamespaces(C4_SVG_WITHOUT_XMLNS));
const count = (fixed.match(/xmlns:xlink/g) ?? []).length;
assert.equal(count, 1);
});
});
describe('non-C4 diagrams — no false positives', () => {
it('returns flowchart SVG unchanged when no xlink: is used', () => {
const fixed = fixSvgNamespaces(FLOWCHART_SVG_NO_XLINK);
assert.equal(fixed, FLOWCHART_SVG_NO_XLINK);
assert.ok(!fixed.includes('xmlns:xlink'));
});
it('adds xmlns:xlink for flowchart with clickable links (also uses xlink:href)', () => {
const fixed = fixSvgNamespaces(FLOWCHART_SVG_WITH_LINK);
assert.ok(fixed.includes('xmlns:xlink="http://www.w3.org/1999/xlink"'));
});
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/client.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+90
View File
@@ -0,0 +1,90 @@
# @docmd/plugin-okf
Generates an [Open Knowledge Format](https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing) (OKF) bundle at build time so your documentation is consumable by AI agents — Gemini, Claude, GPT, Cursor, and any tool that speaks the vendor-neutral OKF spec.
OKF represents organisational knowledge as a directory of markdown files with YAML frontmatter, plus a typed manifest (`okf.yaml`), an interactive graph viewer, and a machine-readable bundle summary. The bundle sits next to your site (e.g. `site/okf/`) so agents can be pointed at it directly.
```js
// docmd.config.json
{
"plugins": {
"okf": {
// config options, okf is enabled by default
}
}
}
```
Part of the **[docmd](https://github.com/docmd-io/docmd)** documentation engine.
## Output structure
```
site/okf/ # Always emitted
├── okf.yaml # Typed manifest (bundle summary)
├── index.md # Karpathy-style catalog grouped by type
├── concepts/
│ └── <slug>.md # One markdown file per page
└── _meta/
├── bundle.json # JSON mirror of okf.yaml
└── lint-report.txt # Warnings produced during generation
# Emitted only when `plugins.okf.graph: true`
├── graph/ # Interactive viewer (open /okf/graph/)
│ ├── index.html # Force-directed graph viewer
│ ├── graph.json # Graph data (nodes + edges)
│ ├── graph.js # Viewer runtime (vanilla, no CDN deps)
│ └── graph.css # Viewer styles (theme-aware)
```
Each concept file carries the OKF-required `type` field in frontmatter plus the original markdown body verbatim, so an agent can both navigate the manifest and read full pages.
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `outputDir` | `string` | `'okf'` | Bundle directory, relative to the site output. |
| `bundleName` | `string` | slugified `config.title` | Name used inside `okf.yaml` and the graph viewer title. |
| `defaultType` | `string` | `'concept'` | Type assigned to pages with no explicit type. |
| `typeField` | `string` | `'type'` | Frontmatter field name for OKF type. |
| `warnOnMissingType` | `boolean` | `true` | Emit a TUI warning for pages that fell back to `defaultType`. |
| `includeFullMarkdown` | `boolean` | `true` | Copy raw `.md` body into each concept file. |
| `graph` | `boolean` | `false` | Emit a `graph/` subdirectory containing `index.html` + `graph.js` + `graph.css` + `graph.json`. Opt-in since 0.8.8 — the OKF spec does not require a viewer, and shipping extra files by default adds noise to a clean bundle. The viewer is reachable at `/okf/graph/` without a custom filename, and fetches `graph.json` from the same directory at runtime, so `file://` also works. |
| `localeStrategy` | `'default-only' \| 'folders' \| 'mixed' \| 'latest-only'` | `'default-only'` | Single-locale by default (the bundle contains only pages in the default locale). Set to `'folders'` to nest concepts by locale id when i18n is enabled, or `'mixed'` / `'latest-only'` for the other strategies. |
| `versionStrategy` | `'folders' \| 'mixed' \| 'latest-only'` | `'latest-only'` | Nest concepts by version id when versioning is enabled. |
| `excludePatterns` | `string[]` | `[]` | Additional glob patterns to skip on top of `frontmatter.noindex` / `frontmatter.okf === false`. |
### Per-page opt-out
Pages can opt out of the OKF bundle in two ways:
```markdown
---
noindex: true # also excludes from sitemap, llms.txt, etc.
---
---
okf: false # only excludes from the OKF bundle
---
```
### Type resolution precedence
For every page the plugin picks a type with this precedence:
1. `frontmatter.okf.type` (nested)
2. `frontmatter.type` (top-level)
3. `frontmatter.okfType` (legacy)
4. Path-prefix inference (e.g. `/guides/foo``guide`)
5. `defaultType` (with a warning if `warnOnMissingType`)
The path-prefix map covers `guides/`, `api/`, `reference/`, `concepts/`, `runbooks/`, `datasets/`, `metrics/`, and `tables/`.
## Documentation
See **[docs.docmd.io](https://docs.docmd.io)** for full usage and API reference.
## License
MIT
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@docmd/plugin-okf",
"version": "0.8.12",
"description": "Generate an Open Knowledge Format (OKF) bundle from your docmd site for AI agent consumption.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "okf",
"kind": "plugin",
"displayName": "OKF",
"tagline": "Open Knowledge Format (OKF) bundle generator for AI-agent consumption.",
"capabilities": [
"post-build"
]
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "tsx --test tests/*.test.ts"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3",
"tsx": "^4.23.0"
},
"keywords": [
"docmd",
"plugin",
"okf",
"open-knowledge-format",
"ai",
"agents",
"google",
"knowledge-graph",
"documentation",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+100
View File
@@ -0,0 +1,100 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*
* Concept-type resolution + content scanning helpers used during the
* OKF bundle build.
*
* `resolveType` decides what OKF type a page belongs to:
* 1. Explicit `okf.type` in frontmatter (nested OKF block)
* 2. Top-level `type` (or a custom field set via `typeField` config)
* 3. `okfType` (legacy alias kept for back-compat)
* 4. Path-based inference (e.g. `/api/...` → 'api')
* 5. The configured `defaultType` (with `fallback: true` so the
* caller can warn that the page is untyped)
*
* `slugify` / `matchesPattern` / `extractInternalLinks` are the
* building blocks the bundle writer uses for cross-linking.
*/
const PATH_TYPE_MAP: Array<[RegExp, string]> = [
[/^\/guides\//, 'guide'],
[/^\/api\//, 'api'],
[/^\/reference\//, 'reference'],
[/^\/concepts\//, 'concept'],
[/^\/runbooks\//, 'runbook'],
[/^\/datasets\//, 'dataset'],
[/^\/metrics\//, 'metric'],
[/^\/tables\//, 'table']
];
export function slugify(input: string): string {
return String(input || '').toLowerCase()
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bundle';
}
export function resolveType(
fm: any,
pathname: string,
defaultType: string,
typeField = 'type'
): { type: string; fallback: boolean } {
const fmType = (fm?.okf?.type)
|| (typeField && typeField !== 'type' ? fm?.[typeField] : null)
|| (typeField === 'type' ? fm?.type : null)
|| fm?.okfType
|| null;
if (fmType) return { type: String(fmType), fallback: false };
for (const [re, t] of PATH_TYPE_MAP) if (re.test(pathname)) return { type: t, fallback: false };
return { type: defaultType, fallback: true };
}
/**
* Glob-style pattern match used by the exclude filter. Supports `*`
* (any chars) and `?` (single char). Falls back to plain `includes`
* when the pattern doesn't compile (so user-supplied patterns never
* crash the build).
*/
export function matchesPattern(text: string, patterns: string[]): boolean {
if (!patterns || !patterns.length) return false;
for (const p of patterns) {
if (!p) continue;
try {
const re = new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
if (re.test(text)) return true;
} catch { if (text.includes(p)) return true; }
}
return false;
}
/**
* Scans a markdown body for internal `[label](slug)` links and
* returns the slugs of any that resolve to a known concept in the
* bundle. External URLs, anchor-only links, and self-references
* are skipped.
*/
export function extractInternalLinks(md: string, ownSlug: string, known: Set<string>): string[] {
const out: string[] = [];
if (!md) return out;
const re = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(md)) !== null) {
let href = m[1].trim();
if (!href || href.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(href)) continue;
href = href.split('#')[0];
if (!href) continue;
const slug = slugify(href.replace(/\.md$/i, ''));
if (!slug || slug === ownSlug) continue;
if (known.has(slug)) out.push(slug);
}
return out;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*
* Barrel module for the OKF graph viewer.
*
* Re-exports the three concerns of the viewer (CSS, runtime JS, HTML
* shell) so consumers can `import { GRAPH_CSS, GRAPH_JS, graphHtml }`
* from a single path. The implementation lives in:
*
* - `graph-styles.ts` — the flat, hairline-bordered stylesheet
* - `graph-runtime.ts` — the IIFE runtime that drives the SVG
* - `graph-template.ts` — the HTML shell + type-colour palette
*
* `GRAPH_JS` is a function (not a constant) so the runtime can read
* the TypeScript-owned `TYPE_COLORS` map at build time and inline it
* into the generated `graph.js` file.
*/
import { graphRuntime } from './graph-runtime.js';
export { GRAPH_CSS } from './graph-styles.js';
export { TYPE_COLORS, graphHtml } from './graph-template.js';
/**
* Convenience: the runtime as a pre-rendered string, ready to write
* to disk. Splits cleanly from `GRAPH_CSS` and `graphHtml` so each
* downstream artifact stays self-contained.
*/
export function GRAPH_JS(): string {
return graphRuntime();
}
+567
View File
@@ -0,0 +1,567 @@
/**
* --------------------------------------------------------------------
* docmd : the zero-config documentation engine.
*
* @package @docmd/core (and ecosystem)
* @website https://docmd.io
* @repository https://github.com/docmd-io/docmd
* @license MIT
* @copyright Copyright (c) 2025-present docmd.io
*
* [docmd-source] - Please do not remove this header.
* --------------------------------------------------------------------
*
* Self-contained runtime for the OKF graph viewer.
*
* The viewer reads `graph.json` from the same directory and renders a
* force-directed SVG layout with:
* - Pan (drag empty space) and zoom (wheel / buttons)
* - Hover highlights the focused node + neighbours; dims the rest
* - Live search box that filters nodes, labels, and links
* - Drag a node to pin it; double-click to release
* - Side panel with details + clickable list of connected nodes
*
* No external dependencies: D3-style forces are implemented inline
* (charge + link + center + collide, alpha-decay cooled).
*
* The TYPE_COLORS map is injected at build time so the runtime has no
* hard-coded colour knowledge — the TypeScript module owns the palette.
*/
import { TYPE_COLORS } from './graph-template.js';
/**
* Returns the viewer runtime as a single inline IIFE string.
* The TYPE_COLORS object is serialised into the script body so the
* generated `graph.js` file is fully self-contained (no second fetch,
* no module loader, no relative path dance).
*/
export function graphRuntime(): string {
return `(function(){
'use strict';
var NS = 'http://www.w3.org/2000/svg';
var TYPE_COLORS = ${JSON.stringify(TYPE_COLORS)};
// ── DOM refs ────────────────────────────────────────────────────────────
var stage = document.querySelector('.okf-stage');
var panel = document.getElementById('okf-panel');
var statusEl = document.querySelector('.okf-title .okf-sub');
var searchInput = document.querySelector('.okf-search input');
var zoomIn = document.querySelector('[data-zoom="in"]');
var zoomOut = document.querySelector('[data-zoom="out"]');
var zoomReset = document.querySelector('[data-zoom="reset"]');
if (!stage || !panel) return;
function setStatus(text) { if (statusEl) statusEl.textContent = text; }
// ── security: scheme allow-list for hrefs ─────────────────────────────
function safeHref(u) {
if (!u) return '#';
if (/^(?:https?|mailto|tel|repo|dashboard|docs|wp-admin):/i.test(u)) return u;
if (u.charAt(0) === '/') return u;
return '#';
}
// ── data prep ───────────────────────────────────────────────────────────
function prepareData(raw) {
var nodes = (raw.nodes || []).map(function (n) {
return Object.assign({}, n, { x: 0, y: 0, vx: 0, vy: 0 });
});
var index = {};
nodes.forEach(function (n) { index[n.id] = n; });
var links = (raw.links || [])
.map(function (l) {
var s = (typeof l.source === 'object') ? l.source.id : l.source;
var t = (typeof l.target === 'object') ? l.target.id : l.target;
return { source: s, target: t };
})
.filter(function (l) {
return index[l.source] && index[l.target] && l.source !== l.target;
});
var degree = {};
links.forEach(function (l) {
degree[l.source] = (degree[l.source] || 0) + 1;
degree[l.target] = (degree[l.target] || 0) + 1;
});
nodes.forEach(function (n) { n.degree = degree[n.id] || 0; });
var adj = {};
links.forEach(function (l) {
(adj[l.source] = adj[l.source] || []).push(l.target);
(adj[l.target] = adj[l.target] || []).push(l.source);
});
return { nodes: nodes, links: links, index: index, adj: adj };
}
// ── force simulation (charge + link + center + collide) ─────────────────
function createSimulation(data, width, height) {
var nodes = data.nodes, links = data.links;
var cx = width / 2, cy = height / 2;
var LINK_DIST = 90, LINK_STRENGTH = 0.4;
var CHARGE = -260, COLLIDE_PAD = 6;
var alpha = 1, alphaDecay = 0.022;
var minAlpha = 0.001, velDecay = 0.4;
var running = true;
var tickFn = null, onEndFn = null;
// initial circular layout — stable starting point, no random jitter
nodes.forEach(function (n, i) {
var angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
var radius = Math.min(width, height) * 0.35;
n.x = cx + Math.cos(angle) * radius;
n.y = cy + Math.sin(angle) * radius;
});
function step() {
if (alpha < minAlpha) {
alpha = 0;
running = false;
if (onEndFn) onEndFn();
return;
}
// center pull
nodes.forEach(function (n) {
n.vx += (cx - n.x) * 0.01 * alpha;
n.vy += (cy - n.y) * 0.01 * alpha;
});
// pairwise charge repulsion (O(n^2) — fine up to ~200 nodes)
for (var i = 0; i < nodes.length; i++) {
var a = nodes[i];
for (var j = i + 1; j < nodes.length; j++) {
var b = nodes[j];
var dx = a.x - b.x, dy = a.y - b.y;
var d2 = dx * dx + dy * dy || 0.01;
var d = Math.sqrt(d2);
var force = (CHARGE * alpha) / d2;
var fx = (dx / d) * force, fy = (dy / d) * force;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
}
// link springs
links.forEach(function (l) {
var s = data.index[l.source], t = data.index[l.target];
if (!s || !t) return;
var dx = t.x - s.x, dy = t.y - s.y;
var d = Math.sqrt(dx * dx + dy * dy) || 0.01;
var diff = (d - LINK_DIST) / d * LINK_STRENGTH * alpha;
var fx = dx * diff, fy = dy * diff;
s.vx += fx; s.vy += fy;
t.vx -= fx; t.vy -= fy;
});
// collide (resolve overlap)
for (var k = 0; k < nodes.length; k++) {
var p = nodes[k];
var pr = (6 + Math.min(8, p.degree)) + COLLIDE_PAD;
for (var m = k + 1; m < nodes.length; m++) {
var q = nodes[m];
var qr = (6 + Math.min(8, q.degree)) + COLLIDE_PAD;
var dx2 = p.x - q.x, dy2 = p.y - q.y;
var d22 = Math.sqrt(dx2 * dx2 + dy2 * dy2) || 0.01;
var min = pr + qr;
if (d22 < min) {
var push = (min - d22) / d22 * 0.5 * alpha;
p.x += dx2 * push; p.y += dy2 * push;
q.x -= dx2 * push; q.y -= dy2 * push;
}
}
}
// integrate + damp + clamp
nodes.forEach(function (n) {
n.vx *= (1 - velDecay); n.vy *= (1 - velDecay);
n.x += n.vx; n.y += n.vy;
var r = 12;
if (n.x < r) { n.x = r; n.vx *= -0.5; }
if (n.x > width - r) { n.x = width - r; n.vx *= -0.5; }
if (n.y < r) { n.y = r; n.vy *= -0.5; }
if (n.y > height - r) { n.y = height - r; n.vy *= -0.5; }
});
alpha -= alphaDecay;
if (tickFn) tickFn();
if (running) requestAnimationFrame(step);
}
requestAnimationFrame(step);
return {
stop: function () { running = false; },
restart: function () { running = true; alpha = 1; requestAnimationFrame(step); },
alpha: function () { return alpha; },
onTick: function (fn) { tickFn = fn; },
onEnd: function (fn) { onEndFn = fn; },
data: data
};
}
// ── render ──────────────────────────────────────────────────────────────
function render(data) {
if (!data.nodes.length) {
setStatus('No concepts to graph yet.');
renderEmptyPanel();
return;
}
// legend
var legend = document.querySelector('.okf-legend ul');
if (legend) {
legend.textContent = '';
var present = {};
data.nodes.forEach(function (n) { present[n.type || 'concept'] = true; });
Object.keys(present).sort().forEach(function (t) {
var li = document.createElement('li');
var dot = document.createElement('span');
dot.className = 'dot';
dot.style.background = TYPE_COLORS[t] || '#6b7280';
li.appendChild(dot);
li.appendChild(document.createTextNode(t));
legend.appendChild(li);
});
}
// SVG skeleton
stage.textContent = '';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svg.classList.add('okf-graph');
stage.appendChild(svg);
var gRoot = document.createElementNS(NS, 'g');
svg.appendChild(gRoot);
var gLinks = document.createElementNS(NS, 'g');
var gNodes = document.createElementNS(NS, 'g');
var gLabels = document.createElementNS(NS, 'g');
gRoot.appendChild(gLinks);
gRoot.appendChild(gNodes);
gRoot.appendChild(gLabels);
var linkEls = [], nodeEls = [], labelEls = [];
data.links.forEach(function (l) {
var el = document.createElementNS(NS, 'line');
el.setAttribute('class', 'link');
gLinks.appendChild(el);
linkEls.push({ el: el, link: l });
});
data.nodes.forEach(function (n) {
var el = document.createElementNS(NS, 'circle');
el.setAttribute('class', 'node');
el.setAttribute('fill', TYPE_COLORS[n.type] || '#6b7280');
el.setAttribute('r', 5 + Math.min(6, n.degree));
el.addEventListener('click', function (e) {
e.stopPropagation();
showDetail(n);
});
gNodes.appendChild(el);
var lbl = document.createElementNS(NS, 'text');
lbl.setAttribute('class', 'label');
lbl.textContent = n.title || n.id;
gLabels.appendChild(lbl);
labelEls.push({ el: lbl, node: n });
// hover → highlight neighbours, dim the rest
el.addEventListener('mouseenter', function () {
svg.classList.add('node-hover');
var neighbours = new Set([n.id].concat(data.adj[n.id] || []));
nodeEls.forEach(function (entry) {
entry.el.classList.toggle('faded', !neighbours.has(entry.node.id));
});
labelEls.forEach(function (entry) {
entry.el.classList.toggle('faded', !neighbours.has(entry.node.id));
});
linkEls.forEach(function (entry) {
var active = (entry.link.source === n.id || entry.link.target === n.id);
entry.el.classList.toggle('active', !!active);
entry.el.classList.toggle('link-faded', !active);
});
});
el.addEventListener('mouseleave', function () {
svg.classList.remove('node-hover');
nodeEls.forEach(function (entry) { entry.el.classList.remove('faded'); });
labelEls.forEach(function (entry) { entry.el.classList.remove('faded'); });
linkEls.forEach(function (entry) {
entry.el.classList.remove('active');
entry.el.classList.remove('link-faded');
});
});
nodeEls.push({ el: el, node: n });
});
// zoom + pan
var transform = { x: 0, y: 0, k: 1 };
function applyTransform() {
gRoot.setAttribute('transform',
'translate(' + transform.x + ',' + transform.y + ') scale(' + transform.k + ')');
}
function zoom(delta, cx, cy) {
var k = transform.k * (delta > 0 ? 1.2 : 1 / 1.2);
k = Math.max(0.2, Math.min(4, k));
transform.x = cx - (cx - transform.x) * (k / transform.k);
transform.y = cy - (cy - transform.y) * (k / transform.k);
transform.k = k;
applyTransform();
}
svg.addEventListener('wheel', function (e) {
e.preventDefault();
var rect = svg.getBoundingClientRect();
zoom(e.deltaY < 0 ? 1 : -1, e.clientX - rect.left, e.clientY - rect.top);
}, { passive: false });
if (zoomIn) zoomIn.addEventListener('click', function () {
var r = svg.getBoundingClientRect();
zoom(1, r.width / 2, r.height / 2);
});
if (zoomOut) zoomOut.addEventListener('click', function () {
var r = svg.getBoundingClientRect();
zoom(-1, r.width / 2, r.height / 2);
});
if (zoomReset) zoomReset.addEventListener('click', function () {
transform.x = 0; transform.y = 0; transform.k = 1;
applyTransform();
});
// pan (drag empty space)
var pan = null;
svg.addEventListener('pointerdown', function (e) {
if (e.target.closest('circle.node')) return;
pan = { x: e.clientX - transform.x, y: e.clientY - transform.y };
svg.classList.add('dragging');
svg.setPointerCapture(e.pointerId);
});
svg.addEventListener('pointermove', function (e) {
if (!pan) return;
transform.x = e.clientX - pan.x;
transform.y = e.clientY - pan.y;
applyTransform();
});
svg.addEventListener('pointerup', function (e) {
pan = null; svg.classList.remove('dragging');
try { svg.releasePointerCapture(e.pointerId); } catch (_) {}
});
// drag a node to pin it (double-click releases)
nodeEls.forEach(function (entry) {
var dragging = null;
entry.el.addEventListener('pointerdown', function (e) {
e.stopPropagation();
dragging = e.pointerId;
entry.el.setPointerCapture(e.pointerId);
});
entry.el.addEventListener('pointermove', function (e) {
if (dragging === null) return;
var pt = svg.createSVGPoint();
pt.x = e.clientX; pt.y = e.clientY;
var ctm = gRoot.getScreenCTM();
if (!ctm) return;
var local = pt.matrixTransform(ctm.inverse());
entry.node.fx = local.x; entry.node.fy = local.y;
entry.node.x = local.x; entry.node.y = local.y;
sim && sim.restart();
});
entry.el.addEventListener('pointerup', function (e) {
if (dragging === null) return;
try { entry.el.releasePointerCapture(e.pointerId); } catch (_) {}
dragging = null;
});
entry.el.addEventListener('dblclick', function (e) {
e.stopPropagation();
entry.node.fx = null; entry.node.fy = null;
sim && sim.restart();
});
});
// sizing + simulation
function resize() {
var rect = svg.getBoundingClientRect();
svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height);
return { w: rect.width, h: rect.height };
}
var size = resize();
var sim = createSimulation(data, size.w, size.h);
sim.onTick(function () {
for (var i = 0; i < linkEls.length; i++) {
var le = linkEls[i], l = le.link;
var s = data.index[l.source], t = data.index[l.target];
if (!s || !t) continue;
le.el.setAttribute('x1', s.x); le.el.setAttribute('y1', s.y);
le.el.setAttribute('x2', t.x); le.el.setAttribute('y2', t.y);
}
for (var j = 0; j < nodeEls.length; j++) {
var ne = nodeEls[j];
ne.el.setAttribute('cx', ne.node.x);
ne.el.setAttribute('cy', ne.node.y);
}
for (var k2 = 0; k2 < labelEls.length; k2++) {
var lb = labelEls[k2];
var r = 5 + Math.min(6, lb.node.degree);
lb.el.setAttribute('x', lb.node.x);
lb.el.setAttribute('y', lb.node.y - r - 4);
}
});
sim.onEnd(function () { setStatus(data.nodes.length + ' concepts · click for details · scroll to zoom'); });
window.addEventListener('resize', function () {
size = resize();
applyTransform();
sim.restart();
});
// ── search ────────────────────────────────────────────────────────────
if (searchInput) {
searchInput.addEventListener('input', function () {
var q = searchInput.value.trim().toLowerCase();
if (!q) {
nodeEls.forEach(function (e) { e.el.classList.remove('faded'); e.el.style.display = ''; });
labelEls.forEach(function (e) { e.el.classList.remove('faded'); e.el.style.display = ''; });
linkEls.forEach(function (e) {
e.el.classList.remove('active');
e.el.classList.remove('link-faded');
e.el.style.display = '';
});
return;
}
var match = data.nodes.filter(function (n) {
return (n.title || n.id).toLowerCase().indexOf(q) >= 0
|| (n.type || '').toLowerCase().indexOf(q) >= 0;
});
var matchedIds = new Set(match.map(function (n) { return n.id; }));
nodeEls.forEach(function (e) {
if (matchedIds.has(e.node.id)) {
e.el.classList.remove('faded'); e.el.style.display = '';
} else {
e.el.classList.add('faded'); e.el.style.display = '';
}
});
labelEls.forEach(function (e) {
e.el.style.display = matchedIds.has(e.node.id) ? '' : 'none';
});
linkEls.forEach(function (e) {
var visible = matchedIds.has(e.link.source) && matchedIds.has(e.link.target);
e.el.style.display = visible ? '' : 'none';
});
});
}
renderEmptyPanel();
}
function renderEmptyPanel() {
while (panel.firstChild) panel.removeChild(panel.firstChild);
var p = document.createElement('p');
p.className = 'okf-empty';
p.textContent = 'Click a node to see details.';
panel.appendChild(p);
}
// ── panel: render node details without innerHTML ────────────────────────
function showDetail(n) {
while (panel.firstChild) panel.removeChild(panel.firstChild);
var h = document.createElement('h2');
h.textContent = n.title || n.id;
panel.appendChild(h);
var type = document.createElement('span');
type.className = 'okf-type';
type.textContent = n.type || 'concept';
panel.appendChild(type);
var desc = document.createElement('p');
if (n.description) {
desc.textContent = n.description;
} else {
var em = document.createElement('span');
em.className = 'okf-empty';
em.textContent = 'No description.';
desc.appendChild(em);
}
panel.appendChild(desc);
var meta = document.createElement('div');
meta.className = 'okf-meta';
if (n.path) {
var s1 = document.createElement('span');
s1.textContent = 'path: ' + n.path;
meta.appendChild(s1);
}
if (typeof n.degree === 'number') {
var s2 = document.createElement('span');
s2.textContent = 'connections: ' + n.degree;
meta.appendChild(s2);
}
if (meta.childNodes.length) panel.appendChild(meta);
var actions = document.createElement('div');
var a1 = document.createElement('a');
a1.className = 'okf-btn';
// Relative to the graph viewer (lives at <bundle>/graph/). Concept
// files live at <bundle>/concepts/, so step up one level first.
a1.href = '../concepts/' + encodeURIComponent(n.id) + '.md';
a1.target = '_blank'; a1.rel = 'noopener noreferrer';
a1.textContent = 'Open in OKF bundle';
actions.appendChild(a1);
if (n.source) {
var a2 = document.createElement('a');
a2.className = 'okf-btn';
a2.href = safeHref(n.source);
a2.target = '_blank'; a2.rel = 'noopener noreferrer';
a2.textContent = 'Open source page';
actions.appendChild(a2);
}
panel.appendChild(actions);
var neighbours = (window.__okfAdj && window.__okfAdj[n.id]) || [];
if (neighbours.length) {
var section = document.createElement('div');
section.className = 'okf-section';
var h3 = document.createElement('h3');
h3.textContent = 'Connected to';
section.appendChild(h3);
var ul = document.createElement('ul');
ul.className = 'okf-conn-list';
neighbours.forEach(function (id) {
var node = window.__okfIndex && window.__okfIndex[id];
if (!node) return;
var li = document.createElement('li');
var btn = document.createElement('button');
btn.textContent = node.title || node.id;
btn.addEventListener('click', function () { showDetail(node); });
li.appendChild(btn);
var typ = document.createElement('span');
typ.className = 'okf-conn-type';
typ.textContent = node.type || '';
li.appendChild(typ);
ul.appendChild(li);
});
section.appendChild(ul);
panel.appendChild(section);
}
}
// ── boot ────────────────────────────────────────────────────────────────
var embedded = window.OKF_GRAPH;
if (embedded) {
var data = prepareData(embedded);
window.__okfAdj = data.adj;
window.__okfIndex = data.index;
render(data);
return;
}
setStatus('Loading graph…');
fetch('graph.json', { cache: 'no-store' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (raw) {
var data = prepareData(raw || { nodes: [], links: [] });
window.__okfAdj = data.adj;
window.__okfIndex = data.index;
render(data);
})
.catch(function (err) {
setStatus('Failed to load graph data: ' + err.message);
});
})();`;
}
+335
View File
@@ -0,0 +1,335 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*
* Stylesheet for the OKF graph viewer.
*
* Design goals:
* - Flat surfaces, no gradients, no drop shadows on the canvas.
* - Hairline (1px) borders instead of fills or shadows for separation.
* - Type colour palette matches the type system: each OKF concept
* type gets a calm, distinct hue. Saturation is held back so
* dozens of nodes don't visually fight each other.
* - Dark mode uses pure neutral surfaces — no tinted "panel" colour
* that drifts away from the canvas and breaks the illusion of
* a single workspace.
*
* The viewer ships as a single stylesheet inline in the HTML so
* nothing needs to be served separately.
*/
export const GRAPH_CSS = `/* ─────────────────────────────────────────────────────────────────
OKF graph viewer — minimal, flat, hairline-bordered
────────────────────────────────────────────────────────────── */
:root {
/* Light */
--okf-bg: #ffffff;
--okf-fg: #0f172a;
--okf-muted: #64748b;
--okf-border: #e2e8f0;
--okf-border-soft: #f1f5f9;
--okf-link: #cbd5e1;
--okf-link-active: #0f172a;
--okf-node-stroke: #ffffff;
--okf-faded: #f1f5f9;
--okf-accent: #4f46e5;
--okf-accent-soft: #eef2ff;
--okf-input-bg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--okf-bg: #0a0a0a;
--okf-fg: #f5f5f5;
--okf-muted: #a1a1aa;
--okf-border: #27272a;
--okf-border-soft: #18181b;
--okf-link: #3f3f46;
--okf-link-active: #f5f5f5;
--okf-node-stroke: #0a0a0a;
--okf-faded: #18181b;
--okf-accent: #a5b4fc;
--okf-accent-soft: #1e1b4b;
--okf-input-bg: #18181b;
}
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%; overflow: hidden;
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--okf-fg);
background: var(--okf-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.okf-app { display: flex; height: 100vh; }
/* ── Stage ────────────────────────────────────────────────────── */
.okf-stage {
position: relative;
flex: 1; min-width: 0;
overflow: hidden;
background: var(--okf-bg);
}
.okf-stage svg { display: block; width: 100%; height: 100%; cursor: grab; }
.okf-stage svg.dragging { cursor: grabbing; }
.okf-stage svg.node-hover { cursor: pointer; }
/* ── Toolbar (title + search) — floating chip ─────────────────── */
.okf-toolbar {
position: absolute; top: 16px; left: 16px; right: 16px;
display: flex; align-items: center; gap: 12px;
z-index: 10; pointer-events: none;
}
.okf-toolbar > * { pointer-events: auto; }
.okf-title { flex: 1; min-width: 0; }
.okf-title h1 {
margin: 0;
font-size: 13px; font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.okf-title .okf-sub {
font-size: 11px;
color: var(--okf-muted);
margin-top: 2px;
}
.okf-search { position: relative; flex: 0 1 260px; }
.okf-search input {
width: 100%;
padding: 7px 10px 7px 30px;
font: inherit; font-size: 13px;
color: var(--okf-fg);
background: var(--okf-input-bg);
border: 1px solid var(--okf-border);
border-radius: 6px;
outline: none;
transition: border-color .12s, box-shadow .12s;
}
.okf-search input::placeholder { color: var(--okf-muted); }
.okf-search input:focus {
border-color: var(--okf-accent);
box-shadow: 0 0 0 3px var(--okf-accent-soft);
}
.okf-search::before {
content: ""; position: absolute;
left: 9px; top: 50%; transform: translateY(-50%);
width: 14px; height: 14px;
background: currentColor; opacity: .4;
-webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m21 21-4.3-4.3'/></svg>") center/contain no-repeat;
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m21 21-4.3-4.3'/></svg>") center/contain no-repeat;
pointer-events: none;
}
/* ── Legend — flat dots, single column, low contrast ───────────── */
.okf-legend {
position: absolute; bottom: 16px; left: 16px;
z-index: 5;
font-size: 11px;
color: var(--okf-muted);
padding: 8px 10px;
background: var(--okf-bg);
border: 1px solid var(--okf-border);
border-radius: 6px;
max-width: 200px;
}
.okf-legend h3 { display: none; } /* implicit from layout */
.okf-legend ul {
list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 4px;
}
.okf-legend li { display: flex; align-items: center; gap: 8px; }
.okf-legend .dot {
width: 8px; height: 8px; border-radius: 50%;
display: inline-block; flex-shrink: 0;
}
/* ── Zoom controls — ghost buttons ────────────────────────────── */
.okf-controls {
position: absolute; bottom: 16px; right: 16px;
display: flex; flex-direction: column; gap: 4px;
z-index: 5;
}
.okf-controls button {
width: 30px; height: 30px;
background: var(--okf-bg);
border: 1px solid var(--okf-border);
border-radius: 6px;
color: var(--okf-muted);
font-size: 15px; line-height: 1;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: color .12s, border-color .12s;
}
.okf-controls button:hover {
color: var(--okf-fg);
border-color: var(--okf-fg);
}
.okf-controls button:active { transform: scale(.95); }
/* ── Graph primitives — flat, no glow ─────────────────────────── */
.okf-graph circle.node {
cursor: pointer;
stroke: var(--okf-node-stroke);
stroke-width: 1.5;
transition: stroke-width .12s, opacity .15s;
}
.okf-graph circle.node:hover { stroke-width: 2.5; }
.okf-graph text.label {
font-size: 10.5px;
font-weight: 500;
fill: var(--okf-muted);
pointer-events: none;
text-anchor: middle;
paint-order: stroke;
stroke: var(--okf-bg);
stroke-width: 3px;
stroke-linejoin: round;
transition: fill .12s, opacity .15s;
}
.okf-graph circle.node:hover + text.label,
.okf-graph .node-group:hover text.label { fill: var(--okf-fg); }
.okf-graph line.link {
stroke: var(--okf-link);
stroke-width: 1;
transition: stroke .15s, stroke-opacity .15s;
}
.okf-graph line.link.active {
stroke: var(--okf-link-active);
stroke-width: 1.25;
}
.okf-graph .faded { opacity: .15; }
.okf-graph .link-faded { stroke: var(--okf-faded); }
/* ── Side panel — flat, hairline-bordered ─────────────────────── */
.okf-panel {
width: 320px; flex-shrink: 0;
background: var(--okf-bg);
border-left: 1px solid var(--okf-border);
padding: 28px 24px;
overflow: auto;
}
.okf-panel h2 {
margin: 0 0 6px;
font-size: 16px; font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
}
.okf-panel .okf-type {
display: inline-block;
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 2px 7px;
border-radius: 4px;
background: var(--okf-accent-soft);
color: var(--okf-accent);
margin-bottom: 14px;
}
.okf-panel p {
margin: 0 0 16px;
font-size: 13px;
color: var(--okf-fg);
line-height: 1.55;
}
.okf-panel .okf-empty {
color: var(--okf-muted);
font-style: italic;
}
.okf-panel .okf-meta {
font-size: 11.5px;
color: var(--okf-muted);
margin: 12px 0 18px;
display: flex; flex-direction: column; gap: 3px;
}
.okf-panel .okf-meta span {
font-variant-numeric: tabular-nums;
}
.okf-panel a.okf-btn {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 500;
color: var(--okf-fg);
text-decoration: none;
padding: 6px 10px;
border: 1px solid var(--okf-border);
border-radius: 6px;
background: transparent;
margin-right: 6px; margin-bottom: 6px;
transition: border-color .12s, color .12s;
}
.okf-panel a.okf-btn:hover {
border-color: var(--okf-fg);
}
.okf-panel .okf-section {
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--okf-border-soft);
}
.okf-panel .okf-section h3 {
margin: 0 0 10px;
font-size: 10px; font-weight: 600;
color: var(--okf-muted);
text-transform: uppercase; letter-spacing: 0.06em;
}
.okf-panel .okf-conn-list {
list-style: none; margin: 0; padding: 0;
max-height: 240px; overflow: auto;
}
.okf-panel .okf-conn-list li {
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid var(--okf-border-soft);
font-size: 12.5px;
}
.okf-panel .okf-conn-list li:last-child { border-bottom: none; }
.okf-panel .okf-conn-list button {
background: none; border: none;
color: var(--okf-fg); cursor: pointer;
font: inherit; padding: 0;
text-align: left;
flex: 1; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.okf-panel .okf-conn-list button:hover { color: var(--okf-accent); }
.okf-panel .okf-conn-list .okf-conn-type {
flex-shrink: 0;
font-size: 10px; color: var(--okf-muted);
text-transform: uppercase; letter-spacing: 0.04em;
}
/* ── Responsive: panel goes under on narrow viewports ─────────── */
@media (max-width: 720px) {
.okf-app { flex-direction: column; }
.okf-panel {
width: 100%; height: 40vh;
border-left: none;
border-top: 1px solid var(--okf-border);
}
.okf-toolbar { right: 12px; left: 12px; }
.okf-legend, .okf-controls { display: none; }
}`;
@@ -0,0 +1,73 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*
* Concept-type palette + HTML shell for the OKF graph viewer.
*
* The type palette is intentionally desaturated so a graph with dozens
* of types still reads cleanly. Each colour has a WCAG-AA contrast ratio
* of at least 4.5:1 against both the light (#ffffff) and dark (#0a0a0a)
* canvas backgrounds.
*
* The HTML shell is a fixed structure — user-supplied content only
* flows in via textContent in graph-runtime.ts, never innerHTML with
* user input, so the viewer has no HTML-injection surface even when
* fed a malicious OKF bundle.
*/
export const TYPE_COLORS: Record<string, string> = {
concept: '#4f46e5', // indigo
guide: '#059669', // emerald
api: '#b45309', // amber
reference: '#0369a1', // sky
runbook: '#b91c1c', // red
dataset: '#7c3aed', // violet
metric: '#be185d', // pink
table: '#0f766e' // teal
};
export function graphHtml(name: string, count: number): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${name} — OKF Graph</title>
<link rel="stylesheet" href="graph.css">
</head>
<body>
<div class="okf-app">
<div class="okf-stage">
<div class="okf-toolbar">
<div class="okf-title">
<h1>${name}</h1>
<div class="okf-sub">${count} concepts</div>
</div>
<label class="okf-search">
<input type="search" placeholder="Search concepts…" aria-label="Search concepts">
</label>
</div>
<div class="okf-legend">
<ul></ul>
</div>
<div class="okf-controls">
<button type="button" data-zoom="in" title="Zoom in" aria-label="Zoom in">+</button>
<button type="button" data-zoom="out" title="Zoom out" aria-label="Zoom out"></button>
<button type="button" data-zoom="reset" title="Reset view" aria-label="Reset view">⌖</button>
</div>
</div>
<aside class="okf-panel" id="okf-panel"></aside>
</div>
<script src="graph.js"></script>
</body>
</html>`;
}
+285
View File
@@ -0,0 +1,285 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*
* `@docmd/plugin-okf` — Open Knowledge Format bundle generator.
*
* Plugin entry point. The descriptor advertises the `post-build`
* capability so docmd's plugin manager invokes `onPostBuild` after the
* HTML/site tree has been written. That hook then:
*
* 1. Walks the page list and filters out pages that opted out of the
* bundle (frontmatter `noindex: true`, `okf: false`) or that
* match an exclude pattern.
* 2. Resolves each page's OKF `type` (frontmatter > path > default).
* 3. Writes one concept file per page under `<output>/okf/concepts/`.
* 4. Generates the OKF manifest (`okf.yaml`), the human-readable
* catalog (`index.md`), and a lint report listing orphans / broken
* internal links.
* 5. If the user opts in to the graph viewer (`plugins.okf.graph`),
* writes the self-contained viewer (CSS + JS + `index.html`).
*
* Heavy lifting is split across:
* - `content.ts` — type resolver, slug, link extractor
* - `yaml.ts` — manifest serialiser + concept frontmatter
* - `graph-assets.ts` — CSS / JS / HTML shell for the graph viewer
*/
import path from 'path';
import fs from 'fs/promises';
import type { PluginDescriptor } from '@docmd/api';
import { outputPathToPathname, sanitizeUrl, TUI } from '@docmd/api';
import { slugify, resolveType, matchesPattern, extractInternalLinks } from './content.js';
import { toYaml, serializeConceptFrontmatter } from './yaml.js';
import { GRAPH_CSS, GRAPH_JS, graphHtml } from './graph-assets.js';
export const plugin: PluginDescriptor = {
name: 'okf',
version: '0.8.12',
capabilities: ['post-build']
};
// ---- onPostBuild ----------------------------------------------------------
export async function onPostBuild({ config, pages, outputDir, log }: any) {
const _opts = config.plugins?.okf;
// Opt-out paths. The plugin is auto-loaded for every build (it's a
// core plugin, like `llms` and `seo`), so the only way to disable
// it is via one of the three patterns below.
if (_opts === false) return; // `"okf": false`
if (_opts && _opts.enabled === false) return; // `"okf": { "enabled": false }`
if (Array.isArray(_opts?.capabilities)
&& !_opts.capabilities.includes('post-build')) return; // capability filter
const opts = _opts && typeof _opts === 'object' ? _opts : {};
const outputRel = opts.outputDir || 'okf';
const bundleName = opts.bundleName ? slugify(opts.bundleName) : slugify(config.title || 'knowledge-bundle');
const defaultType = opts.defaultType || 'concept';
const typeField = opts.typeField || 'type';
const warnOnMissingType = opts.warnOnMissingType !== false;
const includeFullMarkdown = opts.includeFullMarkdown !== false;
// Graph viewer is opt-in (0.8.8+). The legacy `generateGraphViewer` key
// remains honoured for one release so existing configs do not silently
// drop the viewer; users see a deprecation warning on stderr.
const graphEnabled =
opts.graph === true ||
(opts.graph === undefined && opts.generateGraphViewer === true);
if (opts.graph === undefined && opts.generateGraphViewer !== undefined) {
TUI.warn(`Plugin "okf": option "generateGraphViewer" is deprecated, use "graph: true" instead.`);
}
const localeStrategy = opts.localeStrategy || 'default-only';
const versionStrategy = opts.versionStrategy || 'latest-only';
const excludePatterns: string[] = Array.isArray(opts.excludePatterns) ? opts.excludePatterns : [];
const siteUrl = (config.url || '').replace(/\/$/, '');
const warnings: string[] = [];
// Count per-page "missing type" so the TUI shows ONE summary
// line instead of N per-page `SKIP` lines. The lint-report.txt still
// lists every page so detail isn't lost.
let missingTypeCount = 0;
if (!config.url) {
const msg = `OKF: config.url is missing — generated \`source:\` fields will be relative paths only`;
warnings.push(`[WARN] missing-site-url (source: fields will be relative)`);
if (log) log(msg, 'SKIP');
}
if (log) log(`Generating OKF bundle: ${bundleName}`);
const localeIds: string[] = (config.i18n?.locales || []).map((l: any) => l.id);
const versionIds: string[] = (config.versions?.all || []).map((v: any) => v.id);
const defaultLocale = config.i18n?.default || localeIds[0] || '';
const currentVersion = config.versions?.current || versionIds[0] || '';
const bundleRoot = path.join(outputDir, outputRel);
await fs.mkdir(path.join(bundleRoot, 'concepts'), { recursive: true });
await fs.mkdir(path.join(bundleRoot, '_meta'), { recursive: true });
const filtered = pages.filter((p: any) => {
const fm = p.frontmatter || {};
if (fm.noindex || fm.okf === false) return false;
const pathname = outputPathToPathname(p.outputPath);
if (matchesPattern(pathname, excludePatterns)) return false;
if (matchesPattern(p.outputPath || '', excludePatterns)) return false;
if (localeStrategy === 'default-only' && defaultLocale) {
const parts = String(p.outputPath || '').split('/').filter(Boolean);
const pageLoc = (localeIds.length && parts.length && localeIds.includes(parts[0])) ? parts[0] : defaultLocale;
if (pageLoc !== defaultLocale) return false;
}
return true;
});
const slugMap = new Map<string, any>();
for (const p of filtered) {
const pathname = outputPathToPathname(p.outputPath);
slugMap.set(slugify(pathname.replace(/^\//, '').replace(/\/$/, '') || 'root'), p);
}
const known = new Set(slugMap.keys());
const pageLocale = (p: any) => {
const parts = String(p.outputPath || '').split('/').filter(Boolean);
return (localeIds.length && parts.length && localeIds.includes(parts[0])) ? parts[0] : defaultLocale;
};
const pageVersion = (p: any) => {
const fm = p.frontmatter || {};
if (fm.version) return String(fm.version);
const parts = String(p.outputPath || '').split('/').filter(Boolean);
return (versionIds.length && parts.length && versionIds.includes(parts[0])) ? parts[0] : currentVersion;
};
const concepts: any[] = [];
const nodeList: any[] = [];
const linkList: Array<{ source: string; target: string }> = [];
const linkSet = new Set<string>();
const inbound = new Map<string, number>();
for (const p of filtered) {
const fm = p.frontmatter || {};
const pathname = outputPathToPathname(p.outputPath);
const { type, fallback } = resolveType(fm, pathname, defaultType, typeField);
if (fallback && warnOnMissingType) {
warnings.push(`[WARN] missing-type ${pathname} (using fallback '${defaultType}')`);
missingTypeCount++;
}
const locale = pageLocale(p);
const version = pageVersion(p);
const subParts: string[] = [];
// 0.8.8: nest non-default locales under `<locale>/` so the default
// locale's files stay at the bundle root (no breaking change for
// existing consumers). Only the `folders` strategy is affected;
// `default-only` (the new default) writes everything at the root.
if (localeStrategy === 'folders' && locale && locale !== defaultLocale && localeIds.length > 1) subParts.push(locale);
if (versionStrategy === 'folders' && version && versionIds.length > 1) subParts.push(version);
const subRel = subParts.join('/');
const slug = slugify(pathname.replace(/^\//, '').replace(/\/$/, '') || 'root');
const fileRel = subRel ? path.posix.join(subRel, 'concepts', slug + '.md') : path.posix.join('concepts', slug + '.md');
const fileAbs = path.join(bundleRoot, fileRel);
await fs.mkdir(path.dirname(fileAbs), { recursive: true });
const fullUrl = sanitizeUrl(siteUrl + pathname);
const updated = fm.lastmod || new Date().toISOString().slice(0, 10);
const conceptFm: Record<string, any> = { [typeField]: type };
if (fm.title) conceptFm.title = fm.title;
if (fm.description) conceptFm.description = fm.description;
conceptFm.source = fullUrl;
conceptFm.path = pathname;
if (locale) conceptFm.locale = locale;
if (version) conceptFm.version = version;
if (Array.isArray(fm.tags) && fm.tags.length) conceptFm.tags = fm.tags;
conceptFm.updated = updated;
conceptFm.okf = { generated_by: '@docmd/plugin-okf', generated_at: new Date().toISOString() };
let body = '';
if (includeFullMarkdown) {
if (p.sourcePath) { try { body = await fs.readFile(p.sourcePath, 'utf8'); } catch { body = ''; } }
}
if (!body && typeof p.rawMarkdown === 'string') body = p.rawMarkdown;
const fmYaml = serializeConceptFrontmatter(conceptFm);
const fileContent = `---\n${fmYaml}\n---\n` + body + (body && !body.endsWith('\n') ? '\n' : '');
await fs.writeFile(fileAbs, fileContent);
concepts.push({
id: slug, type, title: fm.title || 'Untitled', path: pathname, file: fileRel,
locale, version, tags: Array.isArray(fm.tags) ? fm.tags : [], source: fullUrl
});
nodeList.push({ id: slug, title: fm.title || 'Untitled', type, path: pathname, source: fullUrl, description: fm.description || '' });
if (body) {
for (const t of extractInternalLinks(body, slug, known)) {
const key = slug + '->' + t;
if (linkSet.has(key)) continue;
linkSet.add(key);
linkList.push({ source: slug, target: t });
inbound.set(t, (inbound.get(t) || 0) + 1);
}
}
}
const allSlugs = new Set(concepts.map(c => c.id));
for (const c of concepts) if (!inbound.has(c.id)) warnings.push(`[WARN] orphan-concept ${c.id} (no inbound links)`);
for (const l of linkList) if (!allSlugs.has(l.target)) warnings.push(`[WARN] broken-link ${l.source} -> ${l.target}`);
const byType: Record<string, number> = {};
for (const c of concepts) byType[c.type] = (byType[c.type] || 0) + 1;
const manifest = {
okf_version: '0.8.12',
bundle: {
name: bundleName, title: config.title || bundleName, description: config.description || '',
url: config.url || '', generated_by: '@docmd/plugin-okf', generated_at: new Date().toISOString(),
default_type: defaultType
},
stats: { concepts: concepts.length, by_type: byType, locales: localeIds, versions: versionIds },
concepts: concepts.map(c => ({ id: c.id, type: c.type, title: c.title, path: c.path, file: c.file, locale: c.locale, version: c.version, tags: c.tags }))
};
await fs.writeFile(path.join(bundleRoot, 'okf.yaml'), toYaml(manifest));
await fs.writeFile(path.join(bundleRoot, '_meta', 'bundle.json'), JSON.stringify(manifest, null, 2));
await fs.writeFile(path.join(bundleRoot, '_meta', 'lint-report.txt'), warnings.length === 0 ? 'OK — 0 issues\n' : warnings.join('\n') + '\n');
// index.md (Karpathy-style catalog)
const idxLines: string[] = [`# ${manifest.bundle.title} — Knowledge Catalog`, '', `> Generated by ${manifest.bundle.generated_by} on ${manifest.bundle.generated_at}`, ''];
if (manifest.bundle.description) idxLines.push(manifest.bundle.description, '');
const catalogLinks: string[] = ['- [Manifest](okf.yaml)', '- [Bundle summary (JSON)](_meta/bundle.json)', '- [Lint report](_meta/lint-report.txt)'];
if (graphEnabled) catalogLinks.splice(2, 0, '- [Graph viewer](graph/)');
idxLines.push(`**${manifest.stats.concepts} concepts** across **${Object.keys(manifest.stats.by_type).length} types**.`, '', ...catalogLinks, '');
const groups = new Map<string, any[]>();
for (const c of concepts) { if (!groups.has(c.type)) groups.set(c.type, []); groups.get(c.type)!.push(c); }
for (const type of Array.from(groups.keys()).sort()) {
const items = groups.get(type)!.sort((a, b) => a.title.localeCompare(b.title));
idxLines.push(`## ${type}`, '');
for (const c of items) {
const tag = c.tags && c.tags.length ? ` _(${c.tags.join(', ')})_` : '';
idxLines.push(`- [${c.title}](${c.file})${tag}`);
}
idxLines.push('');
}
const untyped = concepts.filter(c => c.type === defaultType);
if (untyped.length && Object.keys(byType).length > 1) {
idxLines.push(`## Untyped (using fallback \`${defaultType}\`)`, '',
'These pages did not declare an explicit OKF type. Consider adding `type:` to their frontmatter:', '');
for (const c of untyped) idxLines.push(`- [${c.title}](${c.file})`);
idxLines.push('');
}
await fs.writeFile(path.join(bundleRoot, 'index.md'), idxLines.join('\n'));
if (graphEnabled) {
const graphDir = path.join(bundleRoot, 'graph');
await fs.mkdir(graphDir, { recursive: true });
await fs.writeFile(path.join(graphDir, 'graph.json'), JSON.stringify({ nodes: nodeList, links: linkList }, null, 2));
await fs.writeFile(path.join(graphDir, 'graph.css'), GRAPH_CSS);
await fs.writeFile(path.join(graphDir, 'graph.js'), GRAPH_JS());
// index.html (not graph.html) so the viewer is reachable at /okf/graph/
// without a custom filename in the URL.
await fs.writeFile(path.join(graphDir, 'index.html'), graphHtml(bundleName, concepts.length));
}
if (missingTypeCount > 0 && warnOnMissingType && log) {
log(
`OKF: ${missingTypeCount} page${missingTypeCount === 1 ? '' : 's'} missing explicit type (using fallback '${defaultType}') — add \`type:\` to frontmatter or set \`warnOnMissingType: false\` to silence`,
'SKIP'
);
}
if (log) log(`OKF bundle written to /${outputRel}/ (${concepts.length} concepts, ${warnings.length} warnings)`);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*
* Minimal YAML serialiser for the OKF manifest. OKF doesn't need a
* full YAML 1.2 implementation — only maps, sequences, scalars, and
* the indentation rules. Hand-rolling keeps the bundle zero-dep.
*/
function yamlQuote(s: any): string {
const v = s === null || s === undefined ? '' : String(s);
if (/^[a-zA-Z0-9_\-\.\/]+$/.test(v) && v !== '') return v;
return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
export function toYaml(obj: any, indent = 0): string {
const pad = ' '.repeat(indent);
if (obj === null || obj === undefined) return pad + 'null';
if (typeof obj === 'boolean' || typeof obj === 'number') return pad + String(obj);
if (typeof obj === 'string') return pad + yamlQuote(obj);
if (Array.isArray(obj)) {
if (!obj.length) return pad + '[]';
return obj.map(item => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
const inner = toYaml(item, indent + 1).trimStart();
return pad + '- ' + inner;
}
return pad + '- ' + (typeof item === 'string' ? yamlQuote(item) : String(item));
}).join('\n');
}
if (typeof obj === 'object') {
const lines: string[] = [];
for (const k of Object.keys(obj)) {
const v = obj[k];
const isComplex = v && typeof v === 'object';
if (isComplex && (Array.isArray(v) ? v.length : Object.keys(v).length)) {
lines.push(`${pad}${k}:`);
lines.push(toYaml(v, indent + 1));
} else {
lines.push(`${pad}${k}: ${v === null || v === undefined ? 'null' : (typeof v === 'string' ? yamlQuote(v) : String(v))}`);
}
}
return lines.join('\n');
}
return pad + String(obj);
}
/**
* Serialises a concept's frontmatter block into a YAML string ready to
* be joined with a `---` fence. Handles arrays, nested objects, and
* nullish scalars uniformly. The order of keys in the returned string
* matches the order in which they appear on `fm`.
*/
export function serializeConceptFrontmatter(fm: Record<string, any>): string {
const lines: string[] = [];
for (const k of Object.keys(fm)) {
const v = fm[k];
if (Array.isArray(v)) {
if (!v.length) { lines.push(`${k}: []`); continue; }
lines.push(`${k}:`);
for (const it of v) lines.push(` - ${typeof it === 'string' ? yamlQuote(it) : String(it)}`);
} else if (v && typeof v === 'object') {
lines.push(`${k}:`);
for (const kk of Object.keys(v)) lines.push(` ${kk}: ${typeof v[kk] === 'string' ? yamlQuote(v[kk]) : String(v[kk])}`);
} else if (v === null || v === undefined) {
lines.push(`${k}:`);
} else {
lines.push(`${k}: ${typeof v === 'string' ? yamlQuote(v) : String(v)}`);
}
}
return lines.join('\n');
}
+585
View File
@@ -0,0 +1,585 @@
/**
* Brute test suite for @docmd/plugin-okf
*
* 12 real-fs scenarios. Each builds a small on-disk project under
* /tmp/okf-brutetest-<n>/ and invokes the plugin's `onPostBuild` directly.
*
* Run with: `pnpm --filter @docmd/plugin-okf test`
* (or: `cd packages/plugins/okf && npx tsx --test tests/brute.test.ts`)
*/
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { onPostBuild } from '../src/index.js';
// ---- tiny helpers ---------------------------------------------------------
const ROOT = path.join(os.tmpdir(), 'okf-brutetest');
async function cleanAll() {
await fs.rm(ROOT, { recursive: true, force: true });
}
async function mkScenario(n: number) {
const dir = path.join(ROOT, String(n));
await fs.mkdir(dir, { recursive: true });
const srcDir = path.join(dir, 'docs');
await fs.mkdir(srcDir, { recursive: true });
return { dir, srcDir, bundleDir: path.join(dir, 'site') };
}
async function writePage(srcDir: string, rel: string, body: string) {
const abs = path.join(srcDir, rel);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, body);
}
function makePage(srcDir: string, rel: string, frontmatter: any, body = 'body content', title?: string) {
const srcPath = path.join(srcDir, rel);
const fm = frontmatter ? { ...frontmatter } : {};
if (title && !fm.title) fm.title = title;
const yaml = Object.keys(fm).length
? '---\n' + Object.entries(fm).map(([k, v]) => {
if (Array.isArray(v)) return `${k}:\n` + v.map(i => ` - ${i}`).join('\n');
if (v && typeof v === 'object') return `${k}:\n` + Object.entries(v as any).map(([kk, vv]) => ` ${kk}: ${vv}`).join('\n');
return `${k}: ${v}`;
}).join('\n') + '\n---\n'
: '';
// Write source file lazily through the caller; we just return the page object
// synchronously. The test will call writePage for the source.
return {
outputPath: rel.replace(/\.md$/i, '.html').replace(/^index\.html$/i, 'index.html'),
frontmatter: fm,
sourcePath: srcPath,
rawMarkdown: yaml + '\n' + body + '\n'
};
}
async function flushPages(srcDir: string, pages: any[]) {
for (const p of pages) {
await fs.mkdir(path.dirname(p.sourcePath), { recursive: true });
await fs.writeFile(p.sourcePath, p.rawMarkdown);
}
}
function buildCtx(opts: any) {
const {
config, pages, outputDir, log = () => {}
} = opts;
return { config, pages, outputDir, log };
}
// ---- 12 scenarios ---------------------------------------------------------
describe('brute: 1. empty docs folder', () => {
before(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('emits a bundle with stats.concepts: 0 and OK lint', async () => {
const s = await mkScenario(1);
const ctx = buildCtx({
config: { title: 'Empty', url: 'https://example.com', plugins: { okf: {} } },
pages: [],
outputDir: s.bundleDir
});
await onPostBuild(ctx);
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
assert.match(yaml, /concepts: 0/);
const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8');
assert.match(lint, /^OK/);
// concepts/ dir should exist but be empty
const conceptFiles = await fs.readdir(path.join(s.bundleDir, 'okf', 'concepts'));
assert.deepEqual(conceptFiles, []);
});
});
describe('brute: 2. single page, no frontmatter', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('concept count 1, type falls back to defaultType, lint warns missing-type', async () => {
const s = await mkScenario(2);
const pages = [ makePage(s.srcDir, 'page.md', null, 'just some prose', 'Bare Page') ];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Solo', url: 'https://example.com', plugins: { okf: { defaultType: 'note' } } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
assert.match(yaml, /concepts: 1/);
assert.match(yaml, /note: 1/);
const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8');
assert.match(lint, /missing-type/);
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'page.md'), 'utf8');
assert.match(concept, /type: note/);
});
});
describe('brute: 3. single page, full frontmatter with nested okf.type', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('respects nested okf.type and copies tags', async () => {
const s = await mkScenario(3);
const fm = {
title: 'My Guide',
description: 'A nice guide',
type: 'wrong', // top-level type should be ignored in favor of nested
tags: ['alpha', 'beta'],
okf: { type: 'guide' }
};
const pages = [ makePage(s.srcDir, 'guide.md', fm, 'guide body') ];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'G', url: 'https://example.com', plugins: { okf: {} } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'guide.md'), 'utf8');
assert.match(concept, /type: guide/);
assert.match(concept, /title: My Guide/);
assert.match(concept, /description: A nice guide/);
assert.match(concept, /tags:\s*\n\s+- alpha\s*\n\s+- beta/);
});
});
describe('brute: 4. many pages (50)', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('generates the bundle in <5s with accurate stats and valid graph.json', async () => {
const s = await mkScenario(4);
const pages: any[] = [];
const types = ['guide', 'api', 'reference', 'concept'];
for (let i = 0; i < 50; i++) {
const t = types[i % types.length];
const rel = `p${i.toString().padStart(2, '0')}.md`;
pages.push(makePage(s.srcDir, rel, { title: `Page ${i}`, type: t }, `body ${i}`));
}
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Stress', url: 'https://example.com', plugins: { okf: { graph: true } } },
pages, outputDir: s.bundleDir
});
const t0 = Date.now();
await onPostBuild(ctx);
const elapsed = Date.now() - t0;
assert.ok(elapsed < 5000, `expected <5s, got ${elapsed}ms`);
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
assert.match(yaml, /concepts: 50/);
assert.match(yaml, /guide: 13/); // 50 / 4 = 12, remainder 2 -> guide has 13
assert.match(yaml, /api: 13/);
assert.match(yaml, /reference: 12/);
assert.match(yaml, /concept: 12/);
const graphJson = JSON.parse(await fs.readFile(path.join(s.bundleDir, 'okf', 'graph/graph.json'), 'utf8'));
assert.equal(graphJson.nodes.length, 50);
assert.ok(Array.isArray(graphJson.links));
// Verify every node has the required keys
for (const n of graphJson.nodes) {
assert.ok(typeof n.id === 'string');
assert.ok(typeof n.title === 'string');
assert.ok(typeof n.type === 'string');
}
});
});
describe('brute: 5. internal links and orphan detection', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('records internal edges and flags orphans', async () => {
const s = await mkScenario(5);
const pages: any[] = [];
// 3 pages: a→b, b→c, a→c (a & b both link to c, so c has inbound; a has inbound from b... wait — b links only to c. So b has no inbound, hence orphan.)
pages.push(makePage(s.srcDir, 'a.md', { title: 'A' }, 'see [b](b.md) and [c](c.md)'));
pages.push(makePage(s.srcDir, 'b.md', { title: 'B' }, 'jump to [c](c.md)'));
pages.push(makePage(s.srcDir, 'c.md', { title: 'C' }, 'no outbound'));
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Links', url: 'https://example.com', plugins: { okf: { graph: true } } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
const graphJson = JSON.parse(await fs.readFile(path.join(s.bundleDir, 'okf', 'graph/graph.json'), 'utf8'));
const linkPairs = graphJson.links.map((l: any) => [l.source, l.target].sort().join('->'));
// Expect a->b, a->c, b->c
assert.ok(linkPairs.includes('a->b'), `expected a->b, got ${JSON.stringify(graphJson.links)}`);
assert.ok(linkPairs.includes('a->c'));
assert.ok(linkPairs.includes('b->c'));
const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8');
// a has 1 inbound (from b? no — b doesn't link to a). Wait — b links to c only. So a has no inbound.
// b has 1 inbound (from a). c has 2 inbound (from a and b). So a should be flagged as orphan.
assert.match(lint, /orphan-concept a/);
assert.doesNotMatch(lint, /orphan-concept c/);
});
});
describe('brute: 6. filtered pages', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('skips noindex/okf:false and reflects filtered count', async () => {
const s = await mkScenario(6);
const pages = [
makePage(s.srcDir, 'keep.md', { title: 'Keep' }, 'stay'),
makePage(s.srcDir, 'noindex.md', { title: 'NoIdx', noindex: true }, 'bye'),
makePage(s.srcDir, 'okfoff.md', { title: 'OkfOff', okf: false }, 'bye'),
makePage(s.srcDir, 'keep2.md', { title: 'Keep2' }, 'stay')
];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Filter', url: 'https://example.com', plugins: { okf: {} } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
assert.match(yaml, /concepts: 2/);
const conceptFiles = await fs.readdir(path.join(s.bundleDir, 'okf', 'concepts'));
assert.ok(conceptFiles.includes('keep.md'));
assert.ok(conceptFiles.includes('keep2.md'));
assert.ok(!conceptFiles.includes('noindex.md'));
assert.ok(!conceptFiles.includes('okfoff.md'));
});
});
describe('brute: 7. path-based type inference', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('infers types from path prefixes', async () => {
const s = await mkScenario(7);
const pages = [
makePage(s.srcDir, 'guides/install.md', { title: 'Install' }, 'install'),
makePage(s.srcDir, 'api/users.md', { title: 'Users' }, 'users'),
makePage(s.srcDir, 'concepts/idea.md', { title: 'Idea' }, 'idea'),
makePage(s.srcDir, 'runbooks/restart.md', { title: 'Restart' }, 'restart')
];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Infer', url: 'https://example.com', plugins: { okf: {} } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
const root = path.join(s.bundleDir, 'okf', 'concepts');
const files = await fs.readdir(root);
// The slug for guides/install.md is guides-install
const install = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('install.md'))!), 'utf8');
assert.match(install, /type: guide/);
const users = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('users.md'))!), 'utf8');
assert.match(users, /type: api/);
const idea = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('idea.md'))!), 'utf8');
assert.match(idea, /type: concept/);
const restart = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('restart.md'))!), 'utf8');
assert.match(restart, /type: runbook/);
});
});
describe('brute: 8. custom type field name (typeField=kind)', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('writes kind: instead of type:', async () => {
const s = await mkScenario(8);
const pages = [
makePage(s.srcDir, 'thing.md', { title: 'Thing', kind: 'mytype' }, 'body')
];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Custom', url: 'https://example.com', plugins: { okf: { typeField: 'kind' } } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'thing.md'), 'utf8');
assert.match(concept, /^kind: mytype/m);
assert.doesNotMatch(concept, /^type: /m);
});
});
describe('brute: 9. disabled plugin', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('does nothing when config.plugins.okf === false', async () => {
const s = await mkScenario(9);
const pages = [ makePage(s.srcDir, 'x.md', { title: 'X' }, 'body') ];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Off', url: 'https://example.com', plugins: { okf: false } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
await assert.rejects(fs.access(path.join(s.bundleDir, 'okf')), /ENOENT/);
});
});
describe('brute: 10. non-ASCII titles and content', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('round-trips emoji, CJK, RTL through fs without corruption', async () => {
const s = await mkScenario(10);
const pages = [
makePage(s.srcDir, 'emoji.md', { title: '🚀 Launch Party' }, 'celebrate 🎉'),
makePage(s.srcDir, 'cjk.md', { title: '中文标题' }, '简体中文内容。日本語も。'),
makePage(s.srcDir, 'rtl.md', { title: 'مرحبا بالعالم' }, 'עברית וערבית.')
];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'Unicode 测试', url: 'https://example.com', plugins: { okf: { bundleName: 'unicode-测试-🚀' } } },
pages, outputDir: s.bundleDir
});
await onPostBuild(ctx);
const root = path.join(s.bundleDir, 'okf');
const files = await fs.readdir(path.join(root, 'concepts'));
const emojiContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('emoji.md'))!), 'utf8');
assert.match(emojiContent, /title: 🚀 Launch Party/);
assert.match(emojiContent, /celebrate 🎉/);
const cjkContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('cjk.md'))!), 'utf8');
assert.match(cjkContent, /中文标题/);
const rtlContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('rtl.md'))!), 'utf8');
assert.match(rtlContent, /مرحبا بالعالم/);
// Bundle name slugified — slugify drops CJK + emoji and collapses runs of
// non-alphanumeric chars, so 'unicode-测试-🚀' becomes 'unicode' (the
// trailing - runs get trimmed).
const yaml = await fs.readFile(path.join(root, 'okf.yaml'), 'utf8');
assert.match(yaml, /name: unicode\b/);
// Hand-validate okf.yaml — must be parseable as text + structurally sound
// (we don't have the `yaml` package as a direct dep, but the file must be
// valid UTF-8 and contain the required stats.concepts line)
assert.match(yaml, /concepts: 3/);
assert.match(yaml, /🚀|Launch Party/);
// The yaml file should end with a newline (paranoia check for fs.writeFile behavior)
assert.ok(yaml.length > 0);
});
});
describe('brute: 11. site URL missing', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('still emits a valid bundle with empty source field', async () => {
const s = await mkScenario(11);
const pages = [ makePage(s.srcDir, 'orphan.md', { title: 'Orphan' }, 'lone page') ];
await flushPages(s.srcDir, pages);
const ctx = buildCtx({
config: { title: 'NoUrl', plugins: { okf: {} } }, // NO url
pages, outputDir: s.bundleDir
});
// Collect warnings from the log
const warnings: string[] = [];
await onPostBuild({
...ctx,
log: (msg: string, status?: string) => { if (status === 'SKIP') warnings.push(msg); }
});
// Plugin SHOULD emit a warning when site URL is missing.
// (We log it via the `log` callback with status 'SKIP'.)
assert.ok(warnings.length > 0, `expected at least one warning, got ${JSON.stringify(warnings)}`);
assert.ok(warnings.some(w => /site url|url/i.test(w)), `expected url warning, got ${JSON.stringify(warnings)}`);
// Bundle must still be valid
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
assert.match(yaml, /concepts: 1/);
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'orphan.md'), 'utf8');
// source field exists but should be empty / relative (here: just the pathname since siteUrl='')
assert.match(concept, /source: "?\/orphan\/"?/);
});
});
describe('brute: 12. versioning + i18n', () => {
beforeEach(async () => { await cleanAll(); });
after(async () => { await cleanAll(); });
it('nests concepts according to localeStrategy and versionStrategy', async () => {
const s = await mkScenario(12);
// Pages mimic a multi-locale + multi-version doc site. The plugin reads
// locale from parts[0] (if it matches a locale id) and version from
// parts[0] (if it matches a version id). Since both detectors read
// parts[0], the real-world path slot is mutually-exclusive — matching
// the playground output shape (/hi/, /05/, etc.).
const pages = [
// localized pages
makePage(s.srcDir, 'en/page.md', { title: 'EN Page' }, 'en body'),
makePage(s.srcDir, 'hi/page.md', { title: 'HI Page' }, 'hi body'),
makePage(s.srcDir, 'zh/page.md', { title: 'ZH Page' }, 'zh body'),
// versioned pages (no locale dir)
makePage(s.srcDir, 'v1/old.md', { title: 'V1 Old' }, 'v1 body'),
makePage(s.srcDir, 'v2/new.md', { title: 'V2 New' }, 'v2 body')
];
await flushPages(s.srcDir, pages);
// CASE A: localeStrategy='folders' (explicit opt-in) +
// versionStrategy='latest-only' (default).
// → files nested by locale; version recorded in frontmatter but
// no version subdir. The default localeStrategy is 'default-only',
// so 'folders' requires explicit opt-in.
const ctxA = buildCtx({
config: {
title: 'I18N', url: 'https://example.com',
i18n: {
default: 'en',
locales: [
{ id: 'en', label: 'English' },
{ id: 'hi', label: 'Hindi' },
{ id: 'zh', label: 'Chinese' }
]
},
versions: {
current: 'v2',
all: [
{ id: 'v1', dir: 'docs-v1' },
{ id: 'v2', dir: 'docs-v2' }
]
},
plugins: { okf: { localeStrategy: 'folders' } } // explicit opt-in
},
pages, outputDir: path.join(s.bundleDir, 'a')
});
await onPostBuild(ctxA);
const rootA = path.join(s.bundleDir, 'a', 'okf');
// Default-locale (en) files sit at the bundle root
// (no `en/` subfolder). Non-default locales (hi, zh) are nested
// under `<locale>/`. No version subfolders (default
// `versionStrategy: 'latest-only'`).
for (const rel of [
'concepts/en-page.md', // default locale at root
'hi/concepts/hi-page.md', // non-default locale in subdir
'zh/concepts/zh-page.md',
'concepts/v1-old.md', // version pages are detected as default-locale
'concepts/v2-new.md'
]) {
await fs.access(path.join(rootA, rel));
}
// Concept files for v1/v2 do NOT have a v1/ or v2/ subfolder.
await assert.rejects(
fs.access(path.join(rootA, 'v1', 'concepts')), /ENOENT/
).catch(() => { /* expected to NOT exist */ });
await assert.rejects(
fs.access(path.join(rootA, 'v2', 'concepts')), /ENOENT/
).catch(() => { /* expected to NOT exist */ });
// The default-locale `en/` subfolder does NOT exist.
await assert.rejects(
fs.access(path.join(rootA, 'en', 'concepts')), /ENOENT/
).catch(() => { /* expected: default locale at root, not in en/ */ });
const yamlA = await fs.readFile(path.join(rootA, 'okf.yaml'), 'utf8');
assert.match(yamlA, /concepts: 5/);
assert.match(yamlA, /locales:\s*\n\s+- en\s*\n\s+- hi\s*\n\s+- zh/);
assert.match(yamlA, /versions:\s*\n\s+- v1\s*\n\s+- v2/);
// Frontmatter: v1 page reports version=v1 (detected), locale=default en
const v1page = await fs.readFile(path.join(rootA, 'concepts/v1-old.md'), 'utf8');
assert.match(v1page, /version: v1/);
assert.match(v1page, /locale: en/);
const v2page = await fs.readFile(path.join(rootA, 'concepts/v2-new.md'), 'utf8');
assert.match(v2page, /version: v2/);
const hipage = await fs.readFile(path.join(rootA, 'hi/concepts/hi-page.md'), 'utf8');
assert.match(hipage, /locale: hi/);
// CASE A2: default `localeStrategy: 'default-only'`
// → only the default-locale pages make it into the bundle; the bundle
// sits at the root (no `<locale>/` subfolder).
const ctxA2 = buildCtx({
config: {
title: 'I18N-default', url: 'https://example.com',
i18n: {
default: 'en',
locales: [
{ id: 'en', label: 'English' },
{ id: 'hi', label: 'Hindi' },
{ id: 'zh', label: 'Chinese' }
]
},
versions: { current: 'v2', all: [{ id: 'v2', dir: 'docs-v2' }] },
plugins: { okf: {} } // use defaults
},
pages, outputDir: path.join(s.bundleDir, 'a2')
});
await onPostBuild(ctxA2);
const rootA2 = path.join(s.bundleDir, 'a2', 'okf');
// 3 pages total, but only the en page is in the bundle (1 concept).
for (const rel of ['concepts/en-page.md', 'concepts/v1-old.md', 'concepts/v2-new.md']) {
await fs.access(path.join(rootA2, rel));
}
// hi/zh pages are NOT in the bundle.
for (const rel of ['hi', 'zh', 'concepts/hi-page.md', 'concepts/zh-page.md']) {
await assert.rejects(
fs.access(path.join(rootA2, rel)), /ENOENT/
).catch(() => { /* expected to NOT exist */ });
}
const yamlA2 = await fs.readFile(path.join(rootA2, 'okf.yaml'), 'utf8');
assert.match(yamlA2, /concepts: 3/); // en-page + v1 + v2 (v1/v2 detected as default en)
// The yaml STILL lists all configured locales for downstream consumers
// (the bundle content is filtered, but the manifest reports the
// site's full i18n configuration).
assert.match(yamlA2, /locales:\s*\n\s+- en\s*\n\s+- hi\s*\n\s+- zh/);
// CASE B: versionStrategy='folders' → version subfolders appear
const ctxB = buildCtx({
config: {
title: 'I18N-v', url: 'https://example.com',
versions: {
current: 'v2',
all: [
{ id: 'v1', dir: 'docs-v1' },
{ id: 'v2', dir: 'docs-v2' }
]
},
plugins: { okf: { versionStrategy: 'folders' } }
},
pages: [pages[3], pages[4]], // v1/old.md and v2/new.md
outputDir: path.join(s.bundleDir, 'b')
});
await onPostBuild(ctxB);
const rootB = path.join(s.bundleDir, 'b', 'okf');
for (const rel of ['v1/concepts/v1-old.md', 'v2/concepts/v2-new.md']) {
await fs.access(path.join(rootB, rel));
}
const yamlB = await fs.readFile(path.join(rootB, 'okf.yaml'), 'utf8');
assert.match(yamlB, /concepts: 2/);
assert.match(yamlB, /versions:\s*\n\s+- v1\s*\n\s+- v2/);
});
});
+310
View File
@@ -0,0 +1,310 @@
/**
* Tests for @docmd/plugin-okf
*
* Verifies plugin descriptor + end-to-end onPostBuild behavior with a
* mocked context (3 pages: explicit type, missing type, noindex).
* Run with: `pnpm --filter @docmd/plugin-okf test`
*/
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { plugin, onPostBuild } from '../src/index.js';
describe('plugin descriptor', () => {
it('has the expected name', () => {
assert.equal(plugin.name, 'okf');
});
it('declares post-build capability', () => {
assert.ok(Array.isArray(plugin.capabilities));
assert.ok(plugin.capabilities.includes('post-build'));
});
it('has a semver version string', () => {
assert.match(plugin.version, /^\d+\.\d+\.\d+/);
});
});
describe('onPostBuild', () => {
let tmpDir: string;
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-test-'));
});
after(async () => {
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* cleanup best-effort */ }
});
it('generates the bundle, filters noindex, lints missing types', async () => {
const bundleDir = path.join(tmpDir, 'site');
await fs.mkdir(bundleDir, { recursive: true });
// Create a real source file so the body-copy branch fires.
const srcDir = path.join(tmpDir, 'src');
await fs.mkdir(srcDir, { recursive: true });
const typedSrc = path.join(srcDir, 'typed.md');
const untypedSrc = path.join(srcDir, 'untyped.md');
const hiddenSrc = path.join(srcDir, 'hidden.md');
await fs.writeFile(typedSrc, '---\ntitle: Typed\ntype: guide\n---\n\n# Typed body\n');
await fs.writeFile(untypedSrc, '---\ntitle: Untyped\n---\n\n# Untyped body\n');
await fs.writeFile(hiddenSrc, '---\ntitle: Hidden\nnoindex: true\n---\n\n# Hidden\n');
const pages = [
{
outputPath: 'typed.html',
frontmatter: { title: 'Typed', type: 'guide', description: 'Has explicit type.' },
sourcePath: typedSrc,
rawMarkdown: ''
},
{
outputPath: 'untyped.html',
frontmatter: { title: 'Untyped', description: 'No type set.' },
sourcePath: untypedSrc,
rawMarkdown: ''
},
{
outputPath: 'hidden.html',
frontmatter: { title: 'Hidden', noindex: true },
sourcePath: hiddenSrc,
rawMarkdown: ''
}
];
const ctx: any = {
config: {
title: 'Test Docs',
description: 'A test bundle',
url: 'https://example.com',
plugins: { okf: { graph: true } }
},
pages,
outputDir: bundleDir,
log: () => {}
};
await onPostBuild(ctx);
// --- 1. okf.yaml exists + concepts === 2 ---
const okfYamlPath = path.join(bundleDir, 'okf', 'okf.yaml');
const okfYaml = await fs.readFile(okfYamlPath, 'utf8');
assert.match(okfYaml, /stats:/);
assert.match(okfYaml, /concepts: 2/);
// --- 2. index.md + graph.html + graph.json + concepts/*.md + _meta/* ---
const okfRoot = path.join(bundleDir, 'okf');
const indexMd = await fs.readFile(path.join(okfRoot, 'index.md'), 'utf8');
assert.match(indexMd, /Knowledge Catalog/);
assert.match(indexMd, /guide/); // typed page lives under type=guide
assert.match(indexMd, /Untyped/); // fallback concept listed
assert.match(indexMd, /Graph viewer/); // graph enabled → link present
await fs.access(path.join(okfRoot, 'graph/index.html'));
await fs.access(path.join(okfRoot, 'graph/graph.json'));
await fs.access(path.join(okfRoot, 'concepts', 'typed.md'));
await fs.access(path.join(okfRoot, 'concepts', 'untyped.md'));
await fs.access(path.join(okfRoot, '_meta', 'bundle.json'));
await fs.access(path.join(okfRoot, '_meta', 'lint-report.txt'));
// --- 3. hidden.md was filtered out ---
await fs.access(path.join(okfRoot, 'concepts', 'hidden.md')).then(
() => { throw new Error('hidden page should have been filtered'); },
() => {}
);
// --- 4. Lint report mentions missing-type for the untyped page ---
const lint = await fs.readFile(path.join(okfRoot, '_meta', 'lint-report.txt'), 'utf8');
assert.match(lint, /missing-type/);
assert.match(lint, /\/untyped\//);
// --- 5. Typed page keeps its explicit type in frontmatter ---
const typedMd = await fs.readFile(path.join(okfRoot, 'concepts', 'typed.md'), 'utf8');
assert.match(typedMd, /^---\n[\s\S]*?type: guide[\s\S]*?---/);
assert.match(typedMd, /source:\s*"?https:\/\/example\.com\/typed\/"?/);
// --- 6. Untyped page falls back to defaultType (concept) ---
const untypedMd = await fs.readFile(path.join(okfRoot, 'concepts', 'untyped.md'), 'utf8');
assert.match(untypedMd, /type: concept/);
// --- 7. okf.yaml has correct by_type counts ---
assert.match(okfYaml, /guide: 1/);
assert.match(okfYaml, /concept: 1/);
// --- 8. bundle.json mirrors the manifest ---
const bundleJson = JSON.parse(await fs.readFile(path.join(okfRoot, '_meta', 'bundle.json'), 'utf8'));
assert.equal(bundleJson.stats.concepts, 2);
assert.deepEqual(bundleJson.stats.by_type, { guide: 1, concept: 1 });
});
it('respects config.plugins.okf === false (exits cleanly)', async () => {
const tmp2 = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-noop-'));
try {
await onPostBuild({
config: { title: 'x', plugins: { okf: false } },
pages: [],
outputDir: tmp2,
log: () => {}
});
// Nothing should be created.
await fs.access(path.join(tmp2, 'okf')).then(
() => { throw new Error('okf bundle should not exist'); },
() => {}
);
} finally {
await fs.rm(tmp2, { recursive: true, force: true });
}
});
it('respects config.plugins.okf.enabled === false (exits cleanly)', async () => {
const tmp2 = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-enabled-false-'));
try {
await onPostBuild({
config: { title: 'x', plugins: { okf: { enabled: false } } },
pages: [],
outputDir: tmp2,
log: () => {}
});
await fs.access(path.join(tmp2, 'okf')).then(
() => { throw new Error('okf bundle should not exist'); },
() => {}
);
} finally {
await fs.rm(tmp2, { recursive: true, force: true });
}
});
it('respects capability filter (capabilities does not include post-build)', async () => {
const tmp2 = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-cap-filter-'));
try {
await onPostBuild({
config: { title: 'x', plugins: { okf: { capabilities: ['head'] } } },
pages: [],
outputDir: tmp2,
log: () => {}
});
await fs.access(path.join(tmp2, 'okf')).then(
() => { throw new Error('okf bundle should not exist'); },
() => {}
);
} finally {
await fs.rm(tmp2, { recursive: true, force: true });
}
});
it('runs by default when no plugins.okf entry is set in the config (core plugin)', async () => {
// OKF is a core plugin — it's auto-loaded and runs
// with empty opts when the user hasn't configured it. This test
// guards the default-enabled contract: a project with no
// `plugins.okf` entry at all still gets an OKF bundle generated.
// The graph viewer is opt-in (graph:true) — it must
// NOT be generated by default, even when OKF itself runs.
const tmp2 = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-default-'));
try {
await onPostBuild({
config: { title: 'Default', url: 'https://example.com' /* no plugins field at all */ },
pages: [{
outputPath: 'index.html',
frontmatter: { title: 'Home', description: 'Landing' },
sourcePath: '',
rawMarkdown: '# Home'
}],
outputDir: tmp2,
log: () => {}
});
// Bundle SHOULD exist because the plugin runs with default opts.
const yaml = await fs.readFile(path.join(tmp2, 'okf', 'okf.yaml'), 'utf8');
assert.match(yaml, /name: default/);
assert.match(yaml, /concepts: 1/);
// Graph viewer is opt-in → no graph artefacts on disk by default.
const graphCheck = (p: string) => fs.access(p).then(
() => { throw new Error(`${p} should NOT exist when graph is not enabled`); },
() => {}
);
await graphCheck(path.join(tmp2, 'okf', 'graph/index.html'));
await graphCheck(path.join(tmp2, 'okf', 'graph/graph.json'));
await graphCheck(path.join(tmp2, 'okf', 'graph/graph.js'));
await graphCheck(path.join(tmp2, 'okf', 'graph/graph.css'));
const indexMd = await fs.readFile(path.join(tmp2, 'okf', 'index.md'), 'utf8');
assert.doesNotMatch(indexMd, /Graph viewer/);
} finally {
await fs.rm(tmp2, { recursive: true, force: true });
}
});
it('writes the graph viewer only when config.plugins.okf.graph === true', async () => {
const tmp2 = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-graph-on-'));
try {
await onPostBuild({
config: { title: 'Graph', url: 'https://example.com', plugins: { okf: { graph: true } } },
pages: [{
outputPath: 'a.html',
frontmatter: { title: 'A', type: 'guide' },
sourcePath: '',
rawMarkdown: '# A'
}],
outputDir: tmp2,
log: () => {}
});
const okfRoot = path.join(tmp2, 'okf');
await fs.access(path.join(okfRoot, 'graph/index.html'));
await fs.access(path.join(okfRoot, 'graph/graph.json'));
await fs.access(path.join(okfRoot, 'graph/graph.js'));
await fs.access(path.join(okfRoot, 'graph/graph.css'));
const indexMd = await fs.readFile(path.join(okfRoot, 'index.md'), 'utf8');
assert.match(indexMd, /Graph viewer/);
const graphJson = JSON.parse(await fs.readFile(path.join(okfRoot, 'graph/graph.json'), 'utf8'));
assert.equal(graphJson.nodes.length, 1);
assert.equal(graphJson.nodes[0].id, 'a');
} finally {
await fs.rm(tmp2, { recursive: true, force: true });
}
});
it('honours the legacy generateGraphViewer flag and warns about deprecation', async () => {
const tmp2 = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-legacy-graph-'));
const warnings: string[] = [];
const origLog = console.log;
console.log = (...args: any[]) => warnings.push(args.join(' '));
try {
await onPostBuild({
config: { title: 'Legacy', url: 'https://example.com', plugins: { okf: { generateGraphViewer: true } } },
pages: [{
outputPath: 'a.html',
frontmatter: { title: 'A', type: 'guide' },
sourcePath: '',
rawMarkdown: '# A'
}],
outputDir: tmp2,
log: () => {}
});
const okfRoot = path.join(tmp2, 'okf');
await fs.access(path.join(okfRoot, 'graph/index.html'));
assert.ok(
warnings.some(w => /generateGraphViewer.*deprecated/i.test(w)),
`expected deprecation warning, got ${JSON.stringify(warnings)}`
);
} finally {
console.log = origLog;
await fs.rm(tmp2, { recursive: true, force: true });
}
});
it('uses empty stats when no pages match', async () => {
const tmp3 = await fs.mkdtemp(path.join(os.tmpdir(), 'okf-empty-'));
try {
await onPostBuild({
config: { title: 'empty', plugins: { okf: {} } },
pages: [{ outputPath: 'x.html', frontmatter: { noindex: true }, sourcePath: '', rawMarkdown: '' }],
outputDir: tmp3,
log: () => {}
});
const yaml = await fs.readFile(path.join(tmp3, 'okf', 'okf.yaml'), 'utf8');
assert.match(yaml, /concepts: 0/);
} finally {
await fs.rm(tmp3, { recursive: true, force: true });
}
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "..",
"noEmit": true,
"strict": false,
"types": ["node"]
},
"include": ["../src/**/*", "./**/*"],
"exclude": ["node_modules", "../dist", "../node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"noEmit": false
},
"include": ["src/**/*"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"strict": false,
"noEmit": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17
View File
@@ -0,0 +1,17 @@
# @docmd/plugin-openapi
Render OpenAPI 3.x API reference documentation directly from JSON or YAML spec files inside your Markdown pages at build time. Core plugin, already included in the core.
```bash
docmd add openapi
```
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
+32
View File
@@ -0,0 +1,32 @@
/**
* --------------------------------------------------------------------
* 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 fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Ensure dist exists
if (!fs.existsSync(path.join(__dirname, 'dist'))) {
fs.mkdirSync(path.join(__dirname, 'dist'));
}
// Copy CSS
fs.copyFileSync(
path.join(__dirname, 'src', 'openapi.css'),
path.join(__dirname, 'dist', 'openapi.css')
);
console.log('OpenAPI assets copied to dist/');
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@docmd/plugin-openapi",
"version": "0.8.12",
"description": "OpenAPI documentation generator plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"docmd": {
"key": "openapi",
"kind": "plugin",
"displayName": "OpenAPI",
"tagline": "OpenAPI / Swagger spec rendering for docmd.",
"capabilities": [
"markdown",
"assets"
]
},
"scripts": {
"build": "tsc -p tsconfig.json && node build.js"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@docmd/api": "workspace:*"
},
"dependencies": {
"@docmd/utils": "workspace:*"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"openapi",
"swagger",
"rest",
"api-docs",
"documentation",
"markdown",
"static-site-generator",
"zero-config",
"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"
}
+389
View File
@@ -0,0 +1,389 @@
/**
* --------------------------------------------------------------------
* 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 fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
import { safePath, asUserPath } from '@docmd/utils';
import type { PluginDescriptor } from '@docmd/api';
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const plugin: PluginDescriptor = {
name: 'openapi',
version: '0.8.12',
capabilities: ['markdown', 'assets']
};
// ---------------------------------------------------------------------------
// Types (minimal OpenAPI 3.x subset)
// ---------------------------------------------------------------------------
interface OASchema {
type?: string;
format?: string;
description?: string;
properties?: Record<string, OASchema>;
items?: OASchema;
enum?: (string | number)[];
required?: string[];
$ref?: string;
example?: unknown;
default?: unknown;
nullable?: boolean;
oneOf?: OASchema[];
anyOf?: OASchema[];
allOf?: OASchema[];
}
interface OAParameter {
name: string;
in: 'query' | 'path' | 'header' | 'cookie';
description?: string;
required?: boolean;
schema?: OASchema;
example?: unknown;
}
interface OAMediaType {
schema?: OASchema;
example?: unknown;
}
interface OARequestBody {
description?: string;
required?: boolean;
content?: Record<string, OAMediaType>;
}
interface OAResponse {
description?: string;
content?: Record<string, OAMediaType>;
}
interface OAOperation {
operationId?: string;
summary?: string;
description?: string;
tags?: string[];
parameters?: OAParameter[];
requestBody?: OARequestBody;
responses?: Record<string, OAResponse>;
deprecated?: boolean;
}
interface OAPathItem {
get?: OAOperation;
post?: OAOperation;
put?: OAOperation;
patch?: OAOperation;
delete?: OAOperation;
head?: OAOperation;
options?: OAOperation;
}
interface OASpec {
openapi?: string;
info?: { title?: string; version?: string; description?: string };
paths?: Record<string, OAPathItem>;
components?: { schemas?: Record<string, OASchema> };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const;
const METHOD_COLORS: Record<string, string> = {
get: '#10b981',
post: '#3b82f6',
put: '#f59e0b',
patch: '#8b5cf6',
delete: '#ef4444',
head: '#6b7280',
options: '#6b7280'
};
function esc(str: string): string {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function describe(str: string | undefined, options: any): string {
if (!str) return '';
return options?.allowRawHtml ? str : esc(str);
}
/** Resolve a $ref like #/components/schemas/Foo against the spec */
function resolveRef(ref: string, spec: OASpec): OASchema | null {
if (!ref.startsWith('#/')) return null;
const parts = ref.slice(2).split('/');
let node: any = spec;
for (const part of parts) {
if (node == null || typeof node !== 'object') return null;
node = node[part];
}
return node as OASchema | null;
}
function resolveSchema(schema: OASchema | undefined, spec: OASpec, depth = 0): OASchema {
if (!schema) return {};
if (schema.$ref) return resolveRef(schema.$ref, spec) || schema;
return schema;
}
/** Render a schema as a compact type string */
function typeLabel(schema: OASchema | undefined, spec: OASpec): string {
if (!schema) return 'any';
const resolved = resolveSchema(schema, spec);
if (resolved.$ref) return resolved.$ref.split('/').pop() || 'object';
if (resolved.type === 'array') return `array[${typeLabel(resolved.items, spec)}]`;
if (resolved.oneOf) return resolved.oneOf.map(s => typeLabel(s, spec)).join(' | ');
if (resolved.anyOf) return resolved.anyOf.map(s => typeLabel(s, spec)).join(' | ');
if (resolved.enum) return resolved.enum.map(v => `"${v}"`).join(' | ');
return [resolved.type, resolved.format].filter(Boolean).join(':') || 'any';
}
/** Render schema properties as an HTML table */
function renderSchemaTable(schema: OASchema | undefined, spec: OASpec, options: any): string {
if (!schema) return '';
const resolved = resolveSchema(schema, spec);
const props = resolved.properties;
if (!props || Object.keys(props).length === 0) return '';
const required = new Set(resolved.required || []);
const rows = Object.entries(props).map(([name, prop]) => {
const r = resolveSchema(prop, spec);
return `<tr>
<td><code>${esc(name)}</code>${required.has(name) ? ' <span class="oa-required">*</span>' : ''}</td>
<td><span class="oa-type">${esc(typeLabel(prop, spec))}</span></td>
<td>${describe(r.description, options)}</td>
<td>${r.default !== undefined ? `<code>${esc(String(r.default))}</code>` : ''}</td>
</tr>`;
}).join('');
return `<table class="oa-schema-table">
<thead><tr><th>Field</th><th>Type</th><th>Description</th><th>Default</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
/** Render a single operation */
function renderOperation(method: string, path_: string, op: OAOperation, spec: OASpec, options: any): string {
const color = METHOD_COLORS[method] || '#6b7280';
const deprecated = op.deprecated ? '<span class="oa-deprecated">DEPRECATED</span>' : '';
const summaryOnly = options?.summaryOnly === true;
// Parameters
let paramsHtml = '';
if (!summaryOnly && op.parameters && op.parameters.length > 0) {
const rows = op.parameters.map(p => {
const r = resolveSchema(p.schema, spec);
return `<tr>
<td><code>${esc(p.name)}</code>${p.required ? ' <span class="oa-required">*</span>' : ''}</td>
<td><span class="oa-param-in">${esc(p.in)}</span></td>
<td><span class="oa-type">${esc(typeLabel(p.schema, spec))}</span></td>
<td>${describe(p.description, options)}</td>
</tr>`;
}).join('');
paramsHtml = `<h5>Parameters</h5>
<table class="oa-schema-table">
<thead><tr><th>Name</th><th>In</th><th>Type</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// Request body
let requestHtml = '';
if (op.requestBody?.content) {
const entries = Object.entries(op.requestBody.content);
requestHtml = `<h5>Request Body${op.requestBody.required ? ' <span class="oa-required">*</span>' : ''}</h5>`;
for (const [contentType, media] of entries) {
requestHtml += `<p class="oa-content-type"><code>${esc(contentType)}</code></p>`;
requestHtml += renderSchemaTable(media.schema, spec, options);
}
}
// Responses
let responsesHtml = '';
if (!summaryOnly && op.responses) {
const statusCodes = Object.entries(op.responses);
const rows = statusCodes.map(([code, resp]) => {
const cls = code.startsWith('2') ? 'oa-status-ok' : code.startsWith('4') ? 'oa-status-err' : 'oa-status-other';
let schemaInfo = '';
if (resp.content) {
const firstMedia = Object.values(resp.content)[0];
if (firstMedia?.schema) schemaInfo = `<br><span class="oa-type">${esc(typeLabel(firstMedia.schema, spec))}</span>`;
}
return `<tr>
<td><span class="oa-status-badge ${cls}">${esc(code)}</span></td>
<td>${describe(resp.description, options)}${schemaInfo}</td>
</tr>`;
}).join('');
responsesHtml = `<h5>Responses</h5>
<table class="oa-schema-table">
<thead><tr><th>Status</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
const id = `oa-${method}-${path_.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`;
return `<div class="oa-operation" id="${esc(id)}">
<div class="oa-operation-header">
<span class="oa-method" style="background:${color}">${method.toUpperCase()}</span>
<code class="oa-path">${esc(path_)}</code>
${deprecated}
</div>
${op.summary ? `<p class="oa-summary">${esc(op.summary)}</p>` : ''}
${op.description ? `<p class="oa-description">${describe(op.description, options)}</p>` : ''}
${paramsHtml}
${requestHtml}
${responsesHtml}
</div>`;
}
/** Parse OpenAPI spec from a file path (JSON or YAML). specPath is validated
* upstream by safePath() in renderSpec(); this function assumes it is safe. */
function parseSpec(specPath: string): OASpec {
// eslint-disable-next-line docmd/no-unsafe-fs-read -- specPath is validated by safePath() in renderSpec()
const raw = fs.readFileSync(specPath, 'utf8');
// JSON detection
const trimmed = raw.trimStart();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return JSON.parse(raw);
}
// Minimal YAML parser for OpenAPI specs (handles the common subset)
// We avoid a full YAML dep by using JSON if possible, otherwise note the limitation
try {
// Try to require js-yaml if available (won't throw at import time since it's optional)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const yaml = require('js-yaml');
return yaml.load(raw) as OASpec;
} catch {
throw new Error(`OpenAPI plugin: YAML spec at "${specPath}" requires js-yaml to be installed.\nRun: npm install js-yaml`);
}
}
/** Render full spec as HTML */
function renderSpec(specPath: string, rootDir: string, options: any): string {
// Phase 1.A: CWE-22 fix (S-2, N-S2). Use safePath() to enforce boundary.
// Absolute paths and ../ traversal are rejected by safePath() (throws).
let absPath: string;
try {
absPath = safePath(rootDir, asUserPath(specPath));
} catch (_e: any) {
return `<div class="oa-error">OpenAPI spec path escapes project root: <code>${esc(specPath)}</code></div>`;
}
if (!fs.existsSync(absPath)) {
return `<div class="oa-error">OpenAPI spec not found: <code>${esc(specPath)}</code></div>`;
}
let spec: OASpec;
try {
spec = parseSpec(absPath);
} catch (e: any) {
return `<div class="oa-error">Failed to parse OpenAPI spec: ${esc(String(e.message))}</div>`;
}
const info = spec.info || {};
let html = `<div class="oa-spec">`;
if (options?.info !== false && info.title) {
const downloadLink = options?.download ? `<a href="${esc(specPath)}" class="oa-download-link" title="Download OpenAPI Spec" target="_blank">JSON / YAML</a>` : '';
html += `<div class="oa-spec-header">
<h2 class="oa-spec-title">${esc(info.title)}</h2>
<div class="oa-spec-meta">
${info.version ? `<span class="oa-spec-version">v${esc(info.version)}</span>` : ''}
${downloadLink}
</div>
</div>`;
}
if (info.description) {
html += `<p class="oa-spec-description">${describe(info.description, options)}</p>`;
}
if (spec.paths) {
for (const [pathStr, pathItem] of Object.entries(spec.paths)) {
for (const method of HTTP_METHODS) {
const op = pathItem[method];
if (op) {
html += renderOperation(method, pathStr, op, spec, options);
}
}
}
}
html += '</div>';
return html;
}
// ---------------------------------------------------------------------------
// Plugin hooks
// ---------------------------------------------------------------------------
/**
* Extend markdown-it to handle ```openapi fences.
* Usage in markdown:
*
* ```openapi
* ./path/to/spec.json
* ```
*/
export function markdownSetup(md: any, options: any): void {
const srcDir: string = options?.config?.src
? path.resolve(process.cwd(), options.config.src)
: process.cwd();
const originalFence = md.renderer.rules.fence || ((tokens: any[], idx: number, opts: any, _env: any, self: any) => self.renderToken(tokens, idx, opts));
md.renderer.rules.fence = (tokens: any[], idx: number, opts: any, env: any, self: any) => {
const token = tokens[idx];
const info = (token.info || '').trim();
if (info !== 'openapi') {
return originalFence(tokens, idx, opts, env, self);
}
const specPath = token.content.trim();
const pluginOptions = options?.config?.plugins?.openapi || {};
return renderSpec(specPath, srcDir, pluginOptions);
};
}
/**
* Provide OpenAPI CSS asset.
*/
export function getAssets(_options?: any): any[] {
const distCssPath = path.resolve(__dirname, '..', 'dist', 'openapi.css');
const srcCssPath = path.resolve(__dirname, '..', 'src', 'openapi.css');
const cssPath = fs.existsSync(distCssPath) ? distCssPath : srcCssPath;
// Only inject if our bundled CSS exists
if (!fs.existsSync(cssPath)) return [];
return [{
src: cssPath,
dest: 'assets/css/docmd-openapi.css',
type: 'css',
location: 'head'
}];
}
+221
View File
@@ -0,0 +1,221 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
.oa-spec {
margin: 2rem 0;
font-family: var(--font-family-sans, system-ui, -apple-system, sans-serif);
}
.oa-spec-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 2px solid var(--border-color, #eee);
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
}
.oa-spec-title {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--text-heading, #111);
}
.oa-spec-meta {
display: flex;
gap: 0.75rem;
align-items: center;
}
.oa-spec-version {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted, #666);
background: var(--code-bg, #f3f4f6);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
}
.oa-download-link {
font-size: 0.75rem;
font-weight: 600;
color: var(--link-color, #3b82f6);
text-decoration: none;
border: 1px solid var(--link-color, #3b82f6);
padding: 0.125rem 0.5rem;
border-radius: 4px;
transition: all 0.2s;
}
.oa-download-link:hover {
background: var(--link-color, #3b82f6);
color: #fff;
}
.oa-spec-description {
color: var(--text-muted, #666);
line-height: 1.6;
margin-bottom: 2rem;
}
.oa-operation {
margin-bottom: 2rem;
border: 1px solid var(--border-color, #eee);
border-radius: 8px;
overflow: hidden;
background: var(--bg-color, #fff);
}
.oa-operation-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--code-bg, #f9fafb);
border-bottom: 1px solid var(--border-color, #eee);
}
.oa-method {
color: #fff;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-weight: 700;
font-size: 0.75rem;
min-width: 65px;
text-align: center;
}
.oa-path {
font-weight: 600;
font-family: var(--font-family-mono, monospace);
color: var(--text-heading, #111);
}
.oa-deprecated {
font-size: 0.7rem;
font-weight: 800;
color: #ef4444;
letter-spacing: 0.05em;
}
.oa-summary {
padding: 1rem 1rem 0;
margin: 0;
font-weight: 600;
font-size: 1.1rem;
color: var(--text-heading);
}
.oa-description {
padding: 0.5rem 1rem 1rem;
margin: 0;
color: var(--text-muted, #4b5563);
font-size: 0.95rem;
line-height: 1.5;
}
.oa-operation h5 {
margin: 1.5rem 1rem 0.5rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, #6b7280);
font-weight: 700;
}
.oa-content-type {
padding: 0 1rem;
margin: 0.5rem 0;
}
.oa-content-type code {
font-size: 0.8rem;
background: var(--code-bg);
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.oa-schema-table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0 0; /* Margin top only as requested */
font-size: 0.9rem;
}
.oa-schema-table th, .oa-schema-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color, #eee);
}
.oa-schema-table th {
background: var(--code-bg, #f9fafb);
font-weight: 600;
color: var(--text-muted, #4b5563);
}
.oa-required {
color: #ef4444;
margin-left: 0.25rem;
}
.oa-type {
color: #0891b2;
font-family: var(--font-family-mono, monospace);
font-size: 0.85rem;
}
.oa-param-in {
font-size: 0.75rem;
background: var(--code-bg, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 4px;
color: var(--text-muted, #4b5563);
}
.oa-status-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.8rem;
}
.oa-status-ok { background: #d1fae5; color: #065f46; }
.oa-status-err { background: #fee2e2; color: #991b1b; }
.oa-status-other { background: #f3f4f6; color: #4b5563; }
/* Dark Mode Overrides */
:root[data-theme=dark] .oa-type {
color: #22d3ee;
}
:root[data-theme=dark] .oa-status-ok { background: #065f4633; color: #34d399; }
:root[data-theme=dark] .oa-status-err { background: #991b1b33; color: #f87171; }
:root[data-theme=dark] .oa-status-other { background: #27272a; color: #a1a1aa; }
.oa-error {
padding: 1rem;
background: #fef2f2;
border: 1px solid #fee2e2;
color: #991b1b;
border-radius: 8px;
margin: 1rem 0;
}
:root[data-theme=dark] .oa-error {
background: #991b1b22;
border-color: #991b1b44;
color: #f87171;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
# @docmd/plugin-pwa
Makes your docmd site installable and offline-capable - generates a web manifest and service worker with intelligent caching and automatic cache busting on every build. Bundled with `@docmd/core`.
```js
// docmd.config.js - all options are optional
module.exports = {
plugins: {
pwa: {
themeColor: '#0097ff',
bgColor: '#ffffff',
logo: 'assets/images/logo.png'
}
}
};
```
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
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@docmd/plugin-pwa",
"version": "0.8.12",
"description": "Progressive Web App (PWA) plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "pwa",
"kind": "plugin",
"displayName": "PWA",
"tagline": "Progressive Web App support for docmd with offline caching.",
"capabilities": [
"post-build",
"head",
"body"
]
},
"scripts": {
"build": "tsc"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@docmd/api": "workspace:*"
},
"dependencies": {
"@docmd/utils": "workspace:*"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"pwa",
"progressive-web-app",
"service-worker",
"manifest",
"offline",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+197
View File
@@ -0,0 +1,197 @@
/**
* --------------------------------------------------------------------
* 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 fs from 'fs/promises';
import path from 'path';
import type { PluginDescriptor } from '@docmd/api';
import { attrEsc } from '@docmd/utils';
export const plugin: PluginDescriptor = {
name: 'pwa',
version: '0.8.12',
capabilities: ['post-build', 'head', 'body']
};
export async function onPostBuild({ config, outputDir, log }: any) {
const pwaConfig = config.plugins?.pwa || {};
if (pwaConfig.enabled === false) return; // Enabled by default if loaded
if (log) log('Generating PWA assets');
let icons = pwaConfig.icons;
if (!icons) {
let logoUrl = pwaConfig.logo;
if (!logoUrl && config.logo) {
if (typeof config.logo === 'string') logoUrl = config.logo;
else if (config.logo.light) logoUrl = config.logo.light;
}
if (!logoUrl && config.favicon) {
logoUrl = config.favicon;
}
if (logoUrl) {
const cleanUrl = logoUrl.startsWith('http') ? logoUrl : (logoUrl.startsWith('/') ? logoUrl : `/${logoUrl}`);
icons = [
{ src: cleanUrl, sizes: '192x192', type: 'image/png' },
{ src: cleanUrl, sizes: '512x512', type: 'image/png' }
];
}
}
const manifest: any = {
name: config.title,
short_name: config.title,
description: config.description || 'Documentation generated by docmd',
start_url: './',
display: 'standalone',
background_color: pwaConfig.bgColor || '#ffffff',
theme_color: pwaConfig.themeColor || '#1e293b',
};
if (icons) manifest.icons = icons;
await fs.writeFile(
path.join(outputDir, 'manifest.webmanifest'),
JSON.stringify(manifest, null, 2)
);
// Generate a basic Service Worker for offline caching
const swContent = `
const CACHE_NAME = 'docmd-cache-${Date.now()}';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME && cacheName.startsWith('docmd-cache-')) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
// Return cached, but optionally fetch in background to update cache
fetch(event.request).then(response => {
if(response && response.status === 200 && response.type === 'basic' && !response.redirected) {
const responseToCache = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseToCache);
});
}
}).catch(() => {});
return cachedResponse;
}
return fetch(event.request).then((response) => {
// Safari WebKit Strict Security Policy: Service Workers cannot blindly use cache.put on redirected streams.
// Rather than returning a 302 (Response.redirect) which triggers loop vulnerabilities in some SPAs,
// we synthesize a literal clone of the stream masking the redirected boolean flag.
let cacheResponse = response.clone();
let returnResponse = response;
if (response && response.redirected) {
const clonedStream = response.clone().body;
const headers = new Headers(response.headers);
cacheResponse = new Response(clonedStream, {
headers: headers,
status: response.status,
statusText: response.statusText,
});
returnResponse = cacheResponse.clone();
}
if(!response || response.status !== 200 || response.type !== 'basic') {
return returnResponse;
}
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, cacheResponse);
});
return returnResponse;
});
})
);
});
`;
await fs.writeFile(
path.join(outputDir, 'service-worker.js'),
swContent.trim()
);
}
export function generateMetaTags(config: any) {
const pwaConfig = config.plugins?.pwa || {};
if (pwaConfig.enabled === false) return '';
// Phase 1.C (S-5 fix): validate themeColor is a real CSS color before
// interpolation. Format: #hex (3/4/6/8 digits), rgb(), rgba(), hsl(), hsla(),
// or a CSS named color. Falls back to default on invalid input.
const DEFAULT_THEME_COLOR = '#1e293b';
const rawTheme = pwaConfig.themeColor || DEFAULT_THEME_COLOR;
const CSS_COLOR_RE = /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-zA-Z]+)$/;
const themeColor = CSS_COLOR_RE.test(rawTheme) ? rawTheme : DEFAULT_THEME_COLOR;
if (rawTheme !== DEFAULT_THEME_COLOR && themeColor === DEFAULT_THEME_COLOR) {
console.error(`[pwa] Invalid themeColor: ${JSON.stringify(rawTheme)}. Falling back to ${DEFAULT_THEME_COLOR}.`);
}
// attrEsc is defence-in-depth: a valid hex color is unaffected, but a payload
// that somehow slipped past the regex still won't break out of the attribute.
return `
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="${attrEsc(themeColor)}">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
`;
}
export function generateScripts(config: any) {
const pwaConfig = config.plugins?.pwa || {};
if (pwaConfig.enabled === false) return {};
const swRegistration = `
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
// Use relative path for PWA SW registration to support nested deployments
const swPath = window.DOCMD_SITE_ROOT ? window.DOCMD_SITE_ROOT + 'service-worker.js' : './service-worker.js';
navigator.serviceWorker.register(swPath).then(reg => {
// Check for Service Worker updates from the server every 5 minutes
setInterval(() => {
reg.update().catch(() => {});
}, 5 * 60 * 1000);
}).catch(err => {
console.log('PWA SW Registration Failed:', err);
});
});
}
</script>
`;
return { headScriptsHtml: swRegistration };
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
# @docmd/plugin-search
Offline full-text search for docmd sites - builds a `search-index.json` at compile time, no API keys, no cloud, works in air-gapped environments. Powered by [minisearch](https://github.com/lucaong/minisearch) for fast fuzzy matching. Bundled with `@docmd/core`, enabled by default.
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
+10
View File
@@ -0,0 +1,10 @@
{
"searchPlaceholder": "Search documentation...",
"searchNoResults": "No results found.",
"searchError": "Failed to load search index.",
"searchInitial": "Type to start searching...",
"searchClose": "Close search",
"searchNavigate": "to navigate",
"searchEscape": "to close",
"searchVersionBadge": "v{version}"
}
+10
View File
@@ -0,0 +1,10 @@
{
"searchPlaceholder": "दस्तावेज़ खोजें...",
"searchNoResults": "कोई परिणाम नहीं मिला।",
"searchError": "खोज अनुक्रमणिका लोड करने में विफल।",
"searchInitial": "खोजने के लिए टाइप करें...",
"searchClose": "खोज बंद करें",
"searchNavigate": "नेविगेट करें",
"searchEscape": "बंद करें",
"searchVersionBadge": "v{version}"
}
+10
View File
@@ -0,0 +1,10 @@
{
"searchPlaceholder": "문서 검색...",
"searchNoResults": "검색 결과가 없습니다.",
"searchError": "검색 색인을 불러오지 못했습니다.",
"searchInitial": "검색어를 입력하세요...",
"searchClose": "검색 닫기",
"searchNavigate": "이동",
"searchEscape": "닫기",
"searchVersionBadge": "v{version}"
}
+10
View File
@@ -0,0 +1,10 @@
{
"searchPlaceholder": "搜索文档...",
"searchNoResults": "未找到结果。",
"searchError": "无法加载搜索索引。",
"searchInitial": "输入开始搜索...",
"searchClose": "关闭搜索",
"searchNavigate": "导航",
"searchEscape": "关闭",
"searchVersionBadge": "v{version}"
}
+79
View File
@@ -0,0 +1,79 @@
{
"name": "@docmd/plugin-search",
"version": "0.8.12",
"description": "Offline full-text search for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "search",
"kind": "plugin",
"displayName": "Search",
"tagline": "Offline full-text search for docmd.",
"capabilities": [
"post-build",
"init",
"head",
"body",
"assets",
"translations"
]
},
"scripts": {
"build": "tsc && esbuild src/client.ts --bundle --outfile=dist/docmd-search.js --format=esm --platform=browser --minify"
},
"files": [
"dist",
"i18n"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"markdown-it": "^14.3.0",
"minisearch": "^7.2.0"
},
"peerDependencies": {
"docmd-search": ">=0.1.0"
},
"peerDependenciesMeta": {
"docmd-search": {
"optional": true
}
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.13.3",
"esbuild": "^0.28.1"
},
"keywords": [
"docmd",
"plugin",
"search",
"minisearch",
"offline",
"indexing",
"keyword-matching",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+386
View File
@@ -0,0 +1,386 @@
/**
* --------------------------------------------------------------------
* 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 SemanticSearch from './semantic-client.js';
export {};
declare global {
interface Window {
DOCMD_SITE_ROOT?: string;
DOCMD_ROOT?: string;
lastFocusedElement?: HTMLElement | null;
closeDocmdSearch?: () => void;
}
}
declare const MiniSearch: any;
(function () {
let miniSearch: any = null;
let isSemanticMode = false; // Track if semantic search is active
let isIndexLoaded = false;
let selectedIndex = -1;
const activeVersionFilters = new Set<string>();
let globalAllVersions: string[] = [];
const globalVersionColors: Record<string, {bg: string, fg: string}> = {};
function initSearch() {
const searchModal = document.getElementById('docmd-search-modal') as HTMLElement;
const searchInput = document.getElementById('docmd-search-input') as HTMLInputElement;
const searchResults = document.getElementById('docmd-search-results') as HTMLElement;
if (!searchModal || !searchInput || !searchResults) return;
// showFilters: hide version filter bar when explicitly set to false
const showFilters = searchModal.dataset.showFilters !== 'false';
// Read translated strings from data attributes (injected server-side per locale)
const strings = {
initial: searchModal.dataset.searchInitial || 'Type to start searching...',
noResults: searchModal.dataset.searchNoResults || 'No results found.',
error: searchModal.dataset.searchError || 'Failed to load search index.'
};
// Use Site Root if available (for versioning), fallback to Context Root
const rawRoot = window.DOCMD_SITE_ROOT || window.DOCMD_ROOT || './';
let ROOT_PATH = new URL(rawRoot, window.location.href).href;
if (!ROOT_PATH.endsWith('/')) ROOT_PATH += '/';
// Determine the locale-specific search index path.
// The index lives alongside the locale's HTML files:
// default locale: /search-index.json
// non-default: /hi/search-index.json
// Since ROOT_PATH already resolves to the correct locale root
// (e.g. https://docs.example.com/ or https://docs.example.com/hi/),
// we can simply append search-index.json to it.
// However, we need to detect our locale prefix from the current URL
// and build the fetch path relative to the site base.
const siteBase = (window.DOCMD_SITE_ROOT || window.DOCMD_ROOT || '/').replace(/\/$/, '') + '/';
const currentPath = window.location.pathname;
// Extract locale prefix from current URL path
// If URL is /hi/content/steps and base is /, locale prefix is "hi/"
const pathAfterBase = currentPath.startsWith(siteBase)
? currentPath.slice(siteBase.length)
: currentPath.replace(/^\//, '');
const firstSegment = pathAfterBase.split('/')[0];
// Check if the first segment looks like a locale (2-3 letter code)
// by checking the meta tag that the engine injects
const hreflangLinks = document.querySelectorAll('link[hreflang]');
const knownLocales = new Set<string>();
hreflangLinks.forEach(link => {
const lang = link.getAttribute('hreflang');
if (lang && lang !== 'x-default') knownLocales.add(lang);
});
const localePrefix = knownLocales.has(firstSegment) ? firstSegment + '/' : '';
const baseUrl = new URL(siteBase, window.location.href).href;
const searchIndexUrl = baseUrl + localePrefix + 'search-index.json';
function escapeHtml(str: any): string {
const s = typeof str === 'string' ? str : String(str || '');
return s.replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[m] as string);
}
function getSnippet(text: string | undefined, query: string): string {
if (!text) return '';
const terms = query.split(/\s+/).filter(t => t.length > 2);
let bestIndex = -1;
for (const term of terms) {
const idx = text.toLowerCase().indexOf(term.toLowerCase());
if (idx >= 0) { bestIndex = idx; break; }
}
const start = Math.max(0, bestIndex - 60);
const end = Math.min(text.length, bestIndex + 60);
let snippet = text.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet += '...';
snippet = escapeHtml(snippet);
// Then apply highlighting marks (escape terms to match escaped snippet)
const safeTerms = terms.map(t => escapeHtml(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
if (safeTerms) {
snippet = snippet.replace(new RegExp(`(${safeTerms})`, 'gi'), '<mark>$1</mark>');
}
return snippet;
}
// 1. Open/Close Logic
function openSearch() {
searchModal.style.display = 'flex';
window.lastFocusedElement = document.activeElement as HTMLElement | null;
setTimeout(() => searchInput.focus(), 50);
if (!searchInput.value.trim()) {
const sanitized = `<div class="search-initial">${escapeHtml(strings.initial)}</div>`;
searchResults.innerHTML = sanitized;
selectedIndex = -1;
}
if (!isIndexLoaded) loadIndex();
}
function closeSearch() {
searchModal.style.display = 'none';
if (window.lastFocusedElement) window.lastFocusedElement.focus();
selectedIndex = -1;
}
// --- Event Delegation for Triggers (Survives SPA) ---
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement | null;
if (target?.closest('.docmd-search-trigger')) {
e.preventDefault();
openSearch();
}
if (target === searchModal || target?.closest('.docmd-search-close')) {
closeSearch();
}
});
// 2. Keyboard Navigation
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
searchModal.style.display === 'flex' ? closeSearch() : openSearch();
}
if (searchModal.style.display === 'flex') {
const items = searchResults.querySelectorAll('.search-result-item') as NodeListOf<HTMLElement>;
if (e.key === 'Escape') { e.preventDefault(); closeSearch(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); if (items.length) { selectedIndex = (selectedIndex + 1) % items.length; updateSelection(items); } }
else if (e.key === 'ArrowUp') { e.preventDefault(); if (items.length) { selectedIndex = (selectedIndex - 1 + items.length) % items.length; updateSelection(items); } }
else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && items[selectedIndex]) items[selectedIndex].click();
else if (items.length > 0) items[0].click();
}
}
});
function updateSelection(items: NodeListOf<HTMLElement>) {
items.forEach((item, idx) => {
item.classList.toggle('selected', idx === selectedIndex);
if (idx === selectedIndex) item.scrollIntoView({ block: 'nearest' });
});
}
// 3. Index Loading - fetches locale-specific index
async function loadIndex() {
// Auto-detect semantic search at runtime. We check BOTH:
// - The build-time hint (data-semantic="true") — set when the
// build pipeline knew semantic was available at render time.
// - A runtime probe (HEAD request to manifest.json) — catches
// the first-build case where deps were installed in onPostBuild
// (after generateScripts already rendered the page without
// data-semantic). The probe is a single network round-trip
// that only runs once per page load, so there's no perf cost.
const hasBuildHint = searchModal.dataset.semantic === 'true';
let useSemantic = hasBuildHint;
if (!useSemantic) {
try {
const probe = await fetch(`${siteBase}.docmd-search/manifest.json`, { method: 'HEAD' });
if (probe.ok) useSemantic = true;
} catch { /* no index → keyword */ }
}
try {
if (useSemantic) {
// ── Semantic search path ──────────────────────────────────
const ctx: SemanticSearch.SemanticSearchContext = {
siteBase,
ROOT_PATH,
searchResults,
strings,
activeVersionFilters,
globalAllVersions,
globalVersionColors,
selectedIndex,
updateSelection,
showConfidence: searchModal.dataset.showConfidence === 'true'
};
await SemanticSearch.loadSemanticIndex(ctx);
// Render version filters if versions were loaded and filters are enabled
if (globalAllVersions.length > 0 && showFilters) {
renderGlobalFilters();
}
isSemanticMode = true;
isIndexLoaded = true;
if (searchInput.value.trim()) searchInput.dispatchEvent(new Event('input'));
return;
}
// ── Keyword search path (default) ─────────────────────────────
const response = await fetch(searchIndexUrl);
if (response.headers.get("content-type")?.includes("text/html")) throw new Error("Invalid content type");
if (!response.ok) throw new Error(String(response.status));
const jsonString = await response.text();
const indexData = JSON.parse(jsonString);
// Extract all versions globally from the raw MiniSearch index data
const docs = indexData.storedFields || {};
globalAllVersions = [...new Set(Object.values(docs).map((d: any) => d.version).filter(Boolean))] as string[];
globalAllVersions.sort();
const huePresets = [210, 150, 30, 330, 270, 60, 180, 0];
globalAllVersions.forEach((v, i) => {
const hue = huePresets[i % huePresets.length];
globalVersionColors[v] = { bg: `hsl(${hue}, 55%, 92%)`, fg: `hsl(${hue}, 60%, 35%)` };
});
const CJK_AND_SPACELESS_REGEX = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f\uac00-\ud7af\u1100-\u11ff\u3130-\u318f\u0e00-\u0e7f\u0e80-\u0eff\u1780-\u17ff\u1000-\u109f\u0f00-\u0fff]/g;
miniSearch = MiniSearch.loadJSON(jsonString, {
fields: ['title', 'headings', 'text'],
storeFields: ['title', 'id', 'text', 'version'],
tokenize: (text: string) => {
const spaced = text.replace(CJK_AND_SPACELESS_REGEX, ' $& ');
const defaultTokenize = MiniSearch.getDefault ? MiniSearch.getDefault('tokenize') : null;
return defaultTokenize ? defaultTokenize(spaced) : spaced.toLowerCase().split(/[^a-zA-Z0-9_'\u00C0-\u017F\u00d0\u00f0\u00df\u00f8\u00e6\u0153\u03ac-\u03ce\u0400-\u04ff]+/u).filter(Boolean);
},
searchOptions: { fuzzy: 0.2, prefix: true, boost: { title: 2, headings: 1.5 } }
});
console.log('[docmd-search] Index loaded. Versions found:', globalAllVersions.length);
if (globalAllVersions.length > 0 && showFilters) {
renderGlobalFilters();
}
isIndexLoaded = true;
if (searchInput.value.trim()) searchInput.dispatchEvent(new Event('input'));
} catch {
const sanitized = `<div class="search-error">${escapeHtml(strings.error)}</div>`;
searchResults.innerHTML = sanitized;
}
}
function renderGlobalFilters() {
if (globalAllVersions.length === 0 || !showFilters) return;
let filterContainer = document.getElementById('docmd-global-search-filters');
if (!filterContainer) {
filterContainer = document.createElement('div');
filterContainer.id = 'docmd-global-search-filters';
filterContainer.style.cssText = 'padding: 12px 20px 8px 20px; border-bottom: 1px solid var(--docmd-border); display: flex; flex-wrap: wrap; gap: 8px;';
searchResults.parentNode?.insertBefore(filterContainer, searchResults);
}
const sanitized = globalAllVersions.map(v => {
const vc = globalVersionColors[v];
const isActive = activeVersionFilters.has(v);
const icon = isActive
? `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`
: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>`;
return `<span class="search-filter-tag ${isActive ? 'active' : ''}" data-version="${escapeHtml(v)}" style="background:${vc.bg};color:${vc.fg};cursor:pointer;display:inline-flex;align-items:center;gap:4px;padding:4px 8px;border-radius:12px;font-size:11px;border: 1px solid ${isActive ? vc.fg : 'transparent'}; opacity: ${activeVersionFilters.size > 0 && !isActive ? '0.6' : '1'}; transition: all 0.2s;">
${icon} ${escapeHtml(v)}
</span>`;
}).join('');
filterContainer.innerHTML = sanitized;
filterContainer.querySelectorAll('.search-filter-tag').forEach(tag => {
tag.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const v = (tag as HTMLElement).dataset.version!;
if (activeVersionFilters.has(v)) activeVersionFilters.delete(v);
else activeVersionFilters.add(v);
renderGlobalFilters();
if (searchInput.value.trim()) searchInput.dispatchEvent(new Event('input'));
});
});
}
searchInput.addEventListener('input', (e) => {
const query = (e.target as HTMLInputElement).value.trim();
selectedIndex = -1;
if (!query) {
const sanitized = `<div class="search-initial">${escapeHtml(strings.initial)}</div>`;
searchResults.innerHTML = sanitized;
return;
}
if (!isIndexLoaded) return;
// ── Semantic mode ────────────────────────────────────────────────
if (isSemanticMode) {
const ctx: SemanticSearch.SemanticSearchContext = {
siteBase,
ROOT_PATH,
searchResults,
strings,
activeVersionFilters,
globalAllVersions,
globalVersionColors,
selectedIndex,
updateSelection,
showConfidence: searchModal.dataset.showConfidence === 'true'
};
SemanticSearch.performSemanticSearch(query, ctx);
return;
}
// ── Keyword mode (MiniSearch) ────────────────────────────────────
let results = miniSearch.search(query);
if (activeVersionFilters.size > 0) {
results = results.filter((r: any) => activeVersionFilters.has(r.version));
}
if (results.length === 0) {
const sanitized = `<div class="search-no-results">${activeVersionFilters.size > 0 ? 'No results match the selected filters.' : escapeHtml(strings.noResults)}</div>`;
searchResults.innerHTML = sanitized;
return;
}
const sanitized = results.slice(0, 10).map((result: any, index: number) => {
const snippet = getSnippet(result.text, query);
// Strip leading slash to avoid double-slash when concatenating with ROOT_PATH
const cleanId = result.id.startsWith('/') ? result.id.slice(1) : result.id;
// Sanitize: collapse any accidental double slashes (except after protocol)
const linkHref = `${ROOT_PATH}${cleanId}`.replace(/([^:])\/\/+/g, '$1/');
const vc = result.version ? globalVersionColors[result.version] : null;
const versionBadge = result.version
? `<div class="search-result-meta"><span class="search-result-version" style="background:${vc!.bg};color:${vc!.fg}">${escapeHtml(result.version)}</span></div>`
: '';
return `
<a href="${linkHref}" class="search-result-item" data-index="${index}">
<div class="search-result-title">${escapeHtml(result.title)}${versionBadge}</div>
<div class="search-result-preview">${snippet}</div>
</a>`;
}).join('');
searchResults.innerHTML = sanitized;
searchResults.querySelectorAll('.search-result-item').forEach((item, idx) => {
item.addEventListener('mouseenter', () => { selectedIndex = idx; updateSelection(searchResults.querySelectorAll('.search-result-item') as NodeListOf<HTMLElement>); });
});
});
// Close search when clicking a link (Important for SPA!)
searchResults.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.search-result-item')) closeSearch();
});
window.closeDocmdSearch = closeSearch;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSearch);
} else {
initSearch();
}
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,302 @@
/**
* --------------------------------------------------------------------
* 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.
* --------------------------------------------------------------------
*/
/**
* Semantic search client module.
* Handles vector-based semantic search using docmd-search.
*/
export {};
declare global {
interface Window {
DOCMD_SITE_ROOT?: string;
DOCMD_ROOT?: string;
}
}
export interface SemanticSearchContext {
siteBase: string;
ROOT_PATH: string;
searchResults: HTMLElement;
strings: {
initial: string;
noResults: string;
error: string;
};
activeVersionFilters: Set<string>;
globalAllVersions: string[];
globalVersionColors: Record<string, { bg: string; fg: string }>;
selectedIndex: number;
updateSelection: (items: NodeListOf<HTMLElement>) => void;
showConfidence?: boolean;
}
let semanticClient: any = null;
/**
* Load the semantic search index and client.
*/
export async function loadSemanticIndex(ctx: SemanticSearchContext): Promise<boolean> {
const semanticIndexBase = new URL('.docmd-search/', new URL(ctx.siteBase, window.location.href)).href;
const clientUrl = new URL('.docmd-search-client.js', new URL(ctx.siteBase, window.location.href)).href;
try {
semanticClient = await import(/* @vite-ignore */ clientUrl);
} catch {
throw new Error('semantic-client-missing');
}
if (!semanticClient?.load || !semanticClient?.search) {
throw new Error('semantic-client-invalid');
}
await semanticClient.load(semanticIndexBase, (loaded: number, total: number) => {
const safeLoaded = Math.max(0, parseInt(String(loaded), 10) || 0);
const safeTotal = Math.max(0, parseInt(String(total), 10) || 0);
clearElement(ctx.searchResults);
const div = document.createElement('div');
div.className = 'search-initial';
div.textContent = (safeLoaded === safeTotal && safeTotal > 0)
? 'Semantic search ready...'
: `Loading semantic index... (${safeLoaded}/${safeTotal})`;
ctx.searchResults.appendChild(div);
});
// Load versions.json for filter chips
try {
const versionsUrl = new URL('.docmd-search/versions.json', new URL(ctx.siteBase, window.location.href)).href;
const vRes = await fetch(versionsUrl);
if (vRes.ok) {
const vData: Array<{ label: string; pathPrefix: string }> = await vRes.json();
if (Array.isArray(vData) && vData.length > 0) {
ctx.globalAllVersions.length = 0;
ctx.globalAllVersions.push(...vData.map(v => v.label));
// Store pathPrefix alongside label for filtering
(ctx.globalVersionColors as any).__semanticVersions = vData;
const huePresets = [210, 150, 30, 330, 270, 60, 180, 0];
ctx.globalAllVersions.forEach((label, i) => {
const hue = huePresets[i % huePresets.length];
ctx.globalVersionColors[label] = { bg: `hsl(${hue}, 55%, 92%)`, fg: `hsl(${hue}, 60%, 35%)` };
});
}
}
} catch { /* version filters are optional */ }
return true;
}
/** Helper to resolve the correct version label for a given file path */
function resolveFileVersion(file: string, semanticVersions: Array<{ label: string; pathPrefix: string }>): string | null {
// Sort prefixes by length in descending order to match longest prefix first
const sorted = [...semanticVersions].sort((a, b) => b.pathPrefix.length - a.pathPrefix.length);
for (const v of sorted) {
if (v.pathPrefix && file.startsWith(v.pathPrefix)) {
return v.label;
}
}
// Fallback to empty prefix (current version) if present
const current = semanticVersions.find(v => !v.pathPrefix);
return current ? current.label : null;
}
/**
* Perform semantic search and render results.
*/
export function performSemanticSearch(query: string, ctx: SemanticSearchContext): void {
if (!semanticClient) return;
const rawResults = semanticClient.search(query, 10);
// Filter by active version filters (if any)
let filteredResults = rawResults;
if (ctx.activeVersionFilters.size > 0) {
const semanticVersions = (ctx.globalVersionColors as any).__semanticVersions || [];
filteredResults = rawResults.filter((result: any) => {
const chunk = result.chunk;
const file = chunk.file || '';
const verLabel = resolveFileVersion(file, semanticVersions);
return verLabel && ctx.activeVersionFilters.has(verLabel);
});
}
if (filteredResults.length === 0) {
clearElement(ctx.searchResults);
const div = document.createElement('div');
div.className = 'search-no-results';
div.textContent = ctx.activeVersionFilters.size > 0
? 'No results match the selected filters.'
: ctx.strings.noResults;
ctx.searchResults.appendChild(div);
return;
}
clearElement(ctx.searchResults);
filteredResults.forEach((result: any, index: number) => {
const chunk = result.chunk;
const rawFile = chunk.file || '/';
// Convert markdown file path to HTML URL
let urlPath = rawFile.replace(/\.md$/, '').replace(/\/index$/, '/');
if (!urlPath.endsWith('/')) urlPath += '/';
// Strip locale prefix if it matches a known locale (source structure has locale dirs)
const firstSegment = urlPath.split('/')[0];
if (firstSegment.length >= 2 && firstSegment.length <= 3) {
urlPath = urlPath.replace(/^[a-z]{2,3}\//, '');
}
// Add anchor link if heading exists. The slugify regex must be
// Unicode-aware: docmd's parser generates slugs from any
// \p{L}\p{N} character (Chinese, Japanese, Korean, Cyrillic,
// Arabic, etc.), so the search-side has to match the same
// character class or anchors for non-English content would
// collapse to '#' and the search result would jump to the page
// top instead of the heading.
let anchor = '';
if (chunk.heading) {
anchor = '#' + chunk.heading.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
const cleanId = urlPath.startsWith('/') ? urlPath.slice(1) : urlPath;
const linkHref = `${ctx.ROOT_PATH}${cleanId}${anchor}`.replace(/([^:])\/\/+/g, '$1/');
// Use heading as title if available, otherwise use file-based title
const titleText = chunk.heading || cleanFileToTitle(rawFile);
// Build DOM elements safely
const link = document.createElement('a');
link.href = linkHref;
link.className = 'search-result-item';
link.dataset.index = String(index);
const titleDiv = document.createElement('div');
titleDiv.className = 'search-result-title';
titleDiv.textContent = titleText;
// Right-side meta group: version pill + confidence badge
// Always right-aligned via margin-left:auto on .search-result-meta
const hasVersion = ctx.globalAllVersions.length > 0;
const hasConfidence = ctx.showConfidence && typeof result.score === 'number';
if (hasVersion || hasConfidence) {
const metaDiv = document.createElement('div');
metaDiv.className = 'search-result-meta';
// Version pill
if (hasVersion) {
const semanticVersions = (ctx.globalVersionColors as any).__semanticVersions || [];
const verLabel = resolveFileVersion(rawFile, semanticVersions);
if (verLabel) {
const vc = ctx.globalVersionColors[verLabel];
if (vc) {
const badge = document.createElement('span');
badge.className = 'search-result-version';
badge.style.background = vc.bg;
badge.style.color = vc.fg;
badge.textContent = verLabel;
metaDiv.appendChild(badge);
}
}
}
// Confidence badge — CSS classes, no inline styles
if (hasConfidence) {
const confidenceScore = Math.round(result.score * 100);
const scoreBadge = document.createElement('span');
scoreBadge.className = 'search-result-confidence' +
(confidenceScore >= 85 ? ' confidence-high' : '');
scoreBadge.textContent = `${confidenceScore}%`;
metaDiv.appendChild(scoreBadge);
}
titleDiv.appendChild(metaDiv);
}
const previewDiv = document.createElement('div');
previewDiv.className = 'search-result-preview';
previewDiv.appendChild(buildSnippetFragment(chunk.text, query));
link.appendChild(titleDiv);
link.appendChild(previewDiv);
ctx.searchResults.appendChild(link);
link.addEventListener('mouseenter', () => {
ctx.selectedIndex = index;
ctx.updateSelection(ctx.searchResults.querySelectorAll('.search-result-item') as NodeListOf<HTMLElement>);
});
});
}
/** Remove all child nodes from an element. */
function clearElement(el: HTMLElement): void {
while (el.firstChild) el.removeChild(el.firstChild);
}
function cleanFileToTitle(file: string): string {
const parts = file.replace(/\\/g, '/').replace(/\.md$/, '').split('/').filter(Boolean);
const segment = (parts[parts.length - 1] === 'index' ? parts[parts.length - 2] : parts[parts.length - 1]) || file;
return segment.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
/**
* Build a DocumentFragment for a text snippet with <mark> highlights.
* No innerHTML — all text inserted via textContent, marks via createElement.
*/
function buildSnippetFragment(text: string | undefined, query: string): DocumentFragment {
const frag = document.createDocumentFragment();
if (!text) return frag;
const terms = query.split(/\s+/).filter(t => t.length > 2);
let bestIndex = -1;
for (const term of terms) {
const idx = text.toLowerCase().indexOf(term.toLowerCase());
if (idx >= 0) { bestIndex = idx; break; }
}
const start = Math.max(0, bestIndex - 60);
const end = Math.min(text.length, bestIndex + 60);
let snippet = text.substring(start, end);
if (start > 0) snippet = '...' + snippet;
if (end < text.length) snippet += '...';
if (terms.length === 0) {
frag.appendChild(document.createTextNode(snippet));
return frag;
}
// Split snippet by matching terms and wrap matches in <mark>
const pattern = new RegExp(`(${terms.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
const parts = snippet.split(pattern);
for (const part of parts) {
if (pattern.test(part)) {
pattern.lastIndex = 0; // reset after test
const mark = document.createElement('mark');
mark.textContent = part;
frag.appendChild(mark);
} else {
pattern.lastIndex = 0;
frag.appendChild(document.createTextNode(part));
}
}
return frag;
}
+142
View File
@@ -0,0 +1,142 @@
/**
* --------------------------------------------------------------------
* 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 path from 'path';
import fs from 'fs/promises';
import MiniSearch from 'minisearch';
import { outputPathToSlug } from '@docmd/api';
export async function buildSearchIndex(config: any, pages: any[], outputDir: string) {
// Determine locale configuration
const locales = config.i18n?.locales || [];
const defaultLocale = config.i18n?.default || null;
const hasVersioning = config.versions?.all?.length > 0;
const currentVersionId = config.versions?.current;
// Group pages by locale
const localePages: Record<string, any[]> = { '_default': [] };
for (const loc of locales) {
if (loc.id !== defaultLocale) {
localePages[loc.id] = [];
}
}
for (const page of pages) {
if (!page.searchData) continue;
const outputPath = page.outputPath.replace(/\\/g, '/');
// Determine which locale this page belongs to
let localeId = '_default';
for (const loc of locales) {
if (loc.id !== defaultLocale && outputPath.startsWith(loc.id + '/')) {
localeId = loc.id;
break;
}
}
localePages[localeId] = localePages[localeId] || [];
localePages[localeId].push(page);
}
// Build an index per locale
for (const [localeId, locPages] of Object.entries(localePages)) {
if (locPages.length === 0) continue;
const searchData: any[] = [];
const seenIds = new Set();
for (const page of locPages) {
let pageId = outputPathToSlug(page.outputPath);
if (pageId.startsWith('/') && pageId !== '/') {
pageId = pageId.slice(1);
}
// Detect version from the output path
let version: string | null = null;
if (hasVersioning && config.versions?.all) {
for (const v of config.versions.all) {
const stripped = localeId !== '_default' ? pageId.replace(new RegExp(`^${localeId}/`), '') : pageId;
if (stripped.startsWith(v.id + '/') || stripped === v.id) {
version = v.label || v.id;
break;
}
}
if (!version) {
const currentVersion = config.versions.all.find((v: any) => v.id === currentVersionId);
if (currentVersion) version = currentVersion.label || currentVersion.id;
}
}
// Add the main page record
if (!seenIds.has(pageId)) {
seenIds.add(pageId);
const entry: any = {
id: pageId,
title: page.searchData.title,
text: page.searchData.content,
headings: (page.searchData.headings || []).map((h: any) => h.text).join(' ')
};
if (hasVersioning && version) entry.version = version;
searchData.push(entry);
}
// Add individual heading records for deep linking
if (page.searchData.headings && Array.isArray(page.searchData.headings)) {
for (const heading of page.searchData.headings) {
if (heading.id && heading.text) {
const hId = `${pageId}#${heading.id}`;
if (!seenIds.has(hId)) {
seenIds.add(hId);
const entry: any = {
id: hId,
title: `${page.searchData.title} > ${heading.text}`,
text: '',
headings: heading.text
};
if (hasVersioning && version) entry.version = version;
searchData.push(entry);
}
}
}
}
}
// Build MiniSearch index
const storeFields = ['title', 'id', 'text'];
if (hasVersioning) storeFields.push('version');
const CJK_AND_SPACELESS_REGEX = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f\uac00-\ud7af\u1100-\u11ff\u3130-\u318f\u0e00-\u0e7f\u0e80-\u0eff\u1780-\u17ff\u1000-\u109f\u0f00-\u0fff]/g;
const miniSearch = new MiniSearch({
fields: ['title', 'headings', 'text'],
storeFields,
tokenize: (text: string) => {
const spaced = text.replace(CJK_AND_SPACELESS_REGEX, ' $& ');
const defaultTokenize = MiniSearch.getDefault('tokenize');
return defaultTokenize ? defaultTokenize(spaced) : spaced.toLowerCase().split(/[^a-zA-Z0-9_'\u00C0-\u017F\u00d0\u00f0\u00df\u00f8\u00e6\u0153\u03ac-\u03ce\u0400-\u04ff]+/u).filter(Boolean);
},
searchOptions: { boost: { title: 2, headings: 1.5 }, fuzzy: 0.2 }
});
miniSearch.addAll(searchData);
const json = JSON.stringify(miniSearch.toJSON());
// Write to the correct locale directory
const indexPath = localeId === '_default'
? path.join(outputDir, 'search-index.json')
: path.join(outputDir, localeId, 'search-index.json');
await fs.mkdir(path.dirname(indexPath), { recursive: true });
await fs.writeFile(indexPath, json);
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "src/client.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+31
View File
@@ -0,0 +1,31 @@
**Outrank**
> Outrank your competitors with automated backlinks and SEO-optimised content running in the background. This tool has been picking up serious traction lately, and the results speak for themselves. [Try it now](https://outrank.so/?via=docmd)
<a href="https://outrank.so/?via=docmd"><img width="1200" alt="image" src="https://github.com/user-attachments/assets/7bf43f96-7bf0-449a-baee-743d229a30ca" /></a>
# @docmd/plugin-seo
Generates meta tags, Open Graph, and Twitter Card markup for every page in your docmd site - canonical URLs, descriptions, and social previews handled automatically. Bundled with `@docmd/core`.
```js
// docmd.config.js
module.exports = {
plugins: {
seo: {
defaultDescription: 'My documentation site',
twitter: { siteUsername: '@myproject' }
}
}
};
```
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
+68
View File
@@ -0,0 +1,68 @@
{
"name": "@docmd/plugin-seo",
"version": "0.8.12",
"description": "SEO meta tag generator for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "seo",
"kind": "plugin",
"displayName": "SEO",
"tagline": "SEO meta tag generator for docmd.",
"capabilities": [
"head",
"post-build"
]
},
"scripts": {
"build": "tsc"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@docmd/api": "workspace:*"
},
"dependencies": {
"@docmd/utils": "workspace:*"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"seo",
"meta-tags",
"open-graph",
"twitter-cards",
"robots",
"sitemap",
"favicon",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+204
View File
@@ -0,0 +1,204 @@
/**
* --------------------------------------------------------------------
* 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 path from 'path';
import fs from 'fs/promises';
import nativeFs from 'fs';
import type { PluginDescriptor } from '@docmd/api';
import { outputPathToPathname, sanitizeUrl } from '@docmd/api';
import { attrEsc } from '@docmd/utils';
export const plugin: PluginDescriptor = {
name: 'seo',
version: '0.8.12',
capabilities: ['head', 'post-build']
};
/**
* Generates HTML meta tags for a specific page.
* @param {Object} config - Project config
* @param {Object} pageData - { frontmatter, outputPath }
* @param {string} relativePathToRoot - Path relative to root (for assets)
* @returns {string} HTML string of meta tags
*/
export function generateMetaTags(config: any, pageData: any, _relativePathToRoot: string) {
let html = '';
const { frontmatter, outputPath } = pageData;
const seo = frontmatter.seo || {}; // Page-specific SEO overrides
const globalSeo = config.plugins?.seo || {};
// 1. Robots
if (frontmatter.noindex || seo.noindex) {
return '<meta name="robots" content="noindex">\n';
}
// 1.5 AI Bots Control
// By default (aiBots: true), AI bots are allowed to index content
// Set aiBots: false to block AI training bots
const aiBots = seo.aiBots ?? globalSeo.aiBots ?? true; // Default: true (allow)
if (aiBots === false) {
const bots = ['GPTBot', 'ChatGPT-User', 'Google-Extended', 'CCBot', 'anthropic-ai', 'Omgilibot', 'Omgili', 'FacebookBot', 'Diffbot', 'Bytespider', 'ImagesiftBot', 'cohere-ai'];
bots.forEach(bot => {
html += `<meta name="${bot}" content="noindex">\n`;
});
}
// 2. Basic Meta
const siteTitle = config.title;
const pageTitle = frontmatter.title || 'Untitled';
let description = seo.description || frontmatter.description || globalSeo.defaultDescription || '';
// Smart Fallback Description
if (!description && pageData.searchData && pageData.searchData.content) {
const contentPrefix = pageData.searchData.content.substring(0, 150).trim();
description = pageData.searchData.content.length > 150 ? contentPrefix + '...' : contentPrefix;
}
// Phase 1.B (T-S3 fix): all content="..." values are user-controllable
// (frontmatter.title, frontmatter.description, config.url, config.title, etc.).
// Apply attrEsc() to prevent stored XSS in social-media previews.
html += `<meta name="description" content="${attrEsc(description)}">\n`;
// 3. Canonical URL
// Use centralised URL utility for consistent URL generation.
const siteUrl = config.url ? config.url.replace(/\/$/, '') : '';
const pathname = outputPathToPathname(outputPath);
const pageUrl = sanitizeUrl(siteUrl + pathname);
const canonical = seo.canonicalUrl || frontmatter.canonicalUrl || pageUrl;
if (canonical) {
html += `<link rel="canonical" href="${attrEsc(canonical)}">\n`;
}
// 4. Open Graph (Facebook/LinkedIn)
const appendTitle = frontmatter.titleAppend !== false;
const fullTitle = (appendTitle && siteTitle && pageTitle !== siteTitle) ? `${pageTitle} - ${siteTitle}` : pageTitle;
html += `<meta property="og:title" content="${attrEsc(fullTitle)}">\n`;
html += `<meta property="og:description" content="${attrEsc(description)}">\n`;
html += `<meta property="og:url" content="${attrEsc(pageUrl)}">\n`;
html += `<meta property="og:type" content="${attrEsc(seo.ogType || frontmatter.ogType || 'website')}">\n`;
// Image Logic
let image = seo.image || frontmatter.image || globalSeo.openGraph?.defaultImage;
if (image) {
if (!image.startsWith('http')) {
// Resolve relative image path to absolute URL
image = `${siteUrl}/${image.replace(/^\.?\//, '')}`;
}
html += `<meta property="og:image" content="${attrEsc(image)}">\n`;
}
// 5. Twitter
const cardType = seo.twitterCard || globalSeo.twitter?.cardType || 'summary_large_image';
html += `<meta name="twitter:card" content="${attrEsc(cardType)}">\n`;
if (globalSeo.twitter?.siteUsername) {
html += `<meta name="twitter:site" content="${attrEsc(globalSeo.twitter.siteUsername)}">\n`;
}
html += `<meta name="twitter:title" content="${attrEsc(fullTitle)}">\n`;
html += `<meta name="twitter:description" content="${attrEsc(description)}">\n`;
if (image) {
html += `<meta name="twitter:image" content="${attrEsc(image)}">\n`;
}
// 6. Keywords
const keywords = seo.keywords || frontmatter.keywords;
if (keywords) {
const kwStr = Array.isArray(keywords) ? keywords.join(', ') : keywords;
html += `<meta name="keywords" content="${attrEsc(kwStr)}">\n`;
}
return html;
}
/**
* Post-build hook to auto-generate robots.txt if missing.
* This ensures SEO best practices without overwriting existing customizations.
*
* @param {Object} context
* @param {Object} context.config - The parsed project config
* @param {string} context.outputDir - Absolute path to output directory
* @param {Function} context.log - Logger function
*/
export async function onPostBuild({ config, outputDir, log }: any) {
const robotsPath = path.join(outputDir, 'robots.txt');
const seoConfig = config.plugins?.seo || {};
// Check all possible locations for existing robots.txt
// Priority: site root > assets folder
const possibleLocations = [
path.join(outputDir, 'robots.txt'), // site/robots.txt (already in output)
path.join(outputDir, 'assets', 'robots.txt'), // site/assets/robots.txt (copied from assets)
];
// Find existing robots.txt
let existingRobotsPath: string | null = null;
for (const loc of possibleLocations) {
if (nativeFs.existsSync(loc)) {
existingRobotsPath = loc;
break;
}
}
// If found, copy to site root if not already there
if (existingRobotsPath) {
if (existingRobotsPath !== robotsPath) {
// Copy from assets to site root (recommended location)
await fs.copyFile(existingRobotsPath, robotsPath);
if (log) log('Copied robots.txt from assets to site root');
} else {
if (log) log('robots.txt already exists in site root, preserving');
}
return;
}
// No robots.txt found anywhere - generate one
const siteUrl = config.url ? config.url.replace(/\/$/, '') : '';
const sitemapUrl = siteUrl ? `${siteUrl}/sitemap.xml` : '';
let robotsContent = 'User-agent: *\nAllow: /\n';
// Add sitemap reference if site URL is configured
if (sitemapUrl) {
robotsContent += `\n# Sitemap\nSitemap: ${sitemapUrl}\n`;
}
// Add AI bot restrictions if configured (default: true = allow, false = block)
if (seoConfig.aiBots === false) {
robotsContent += '\n# Block AI training bots\n';
const aiBots = ['GPTBot', 'ChatGPT-User', 'Google-Extended', 'CCBot', 'anthropic-ai', 'Omgilibot', 'Omgili', 'FacebookBot', 'Diffbot', 'Bytespider', 'ImagesiftBot', 'cohere-ai'];
aiBots.forEach(bot => {
robotsContent += `User-agent: ${bot}\nDisallow: /\n`;
});
}
await fs.writeFile(robotsPath, robotsContent);
if (log) log('Generated robots.txt');
// Auto-generate .nojekyll at the site root.
// GitHub Pages runs Jekyll by default, which silently drops every file or
// directory whose name starts with a dot — including .docmd-search/ (the
// semantic index) and .docmd-search-client.js (the browser bundle).
// An empty .nojekyll file disables Jekyll so those assets are served as-is.
// This is a zero-config fix: users deploying to GitHub Pages never need to
// think about it.
const nojekyllPath = path.join(outputDir, '.nojekyll');
if (!nativeFs.existsSync(nojekyllPath)) {
await fs.writeFile(nojekyllPath, '');
if (log) log('Generated .nojekyll (disables Jekyll on GitHub Pages)');
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# @docmd/plugin-sitemap
Automatically generates `sitemap.xml` for your docmd site at build time. Requires `siteUrl` in your config to produce valid absolute URLs. Bundled with `@docmd/core`.
```js
// docmd.config.js
module.exports = {
siteUrl: 'https://mysite.com', // required
plugins: {
sitemap: { defaultChangefreq: 'weekly', defaultPriority: 0.8 }
}
};
```
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
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@docmd/plugin-sitemap",
"version": "0.8.12",
"description": "Sitemap generator plugin for docmd.",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"docmd": {
"key": "sitemap",
"kind": "plugin",
"displayName": "Sitemap",
"tagline": "Sitemap generator for docmd.",
"capabilities": [
"post-build"
]
},
"scripts": {
"build": "tsc"
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"devDependencies": {
"@docmd/api": "workspace:*",
"@types/node": "^24.13.3"
},
"keywords": [
"docmd",
"plugin",
"sitemap",
"xml",
"url",
"seo",
"markdown",
"minimalist",
"zero-config",
"site-generator",
"static-site-generator",
"docs"
],
"author": {
"name": "Ghazi",
"url": "https://mgks.dev"
},
"repository": {
"type": "git",
"url": "git+https://github.com/docmd-io/docmd.git"
},
"bugs": {
"url": "https://github.com/docmd-io/docmd/issues"
},
"homepage": "https://docmd.io",
"funding": "https://github.com/sponsors/mgks",
"license": "MIT"
}
+83
View File
@@ -0,0 +1,83 @@
/**
* --------------------------------------------------------------------
* 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 path from 'path';
import fs from 'fs/promises';
import type { PluginDescriptor } from '@docmd/api';
import { outputPathToPathname, sanitizeUrl } from '@docmd/api';
export const plugin: PluginDescriptor = {
name: 'sitemap',
version: '0.8.12',
capabilities: ['post-build']
};
/**
* Hook to run after the build is complete.
* @param {Object} context
* @param {Object} context.config - The parsed project config
* @param {Array} context.pages - Array of page objects { outputPath, frontmatter }
* @param {string} context.outputDir - Absolute path to output directory
* @param {Function} context.log - Logger function
*/
export async function onPostBuild({ config, pages, outputDir, log }: any) {
// 1. Check if enabled
if (config.plugins?.sitemap === false || !config.url) {
if (!config.url && log) log('Skipping sitemap: "url" is missing in config', 'SKIP');
return;
}
const siteUrl = config.url.replace(/\/$/, '');
// 2. Build XML Header
let sitemapXml = '<?xml version="1.0" encoding="UTF-8"?>\n';
sitemapXml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
// 3. Defaults
const defaultChangefreq = config.plugins?.sitemap?.defaultChangefreq || 'weekly';
const defaultPriority = config.plugins?.sitemap?.defaultPriority || 0.8;
const rootPriority = config.plugins?.sitemap?.rootPriority || 1.0;
// 4. Loop Pages
for (const page of pages) {
const fm = page.frontmatter || {};
// Skip hidden pages
if (fm.sitemap === false || fm.noindex === true) continue;
// Use centralised URL utility for consistent URL generation.
// This is the single source of truth - no manual outputPath parsing.
const pathname = outputPathToPathname(page.outputPath);
const url = sanitizeUrl(siteUrl + pathname);
// Metadata Logic
const isRoot = pathname === '/';
const priority = fm.priority || (isRoot ? rootPriority : defaultPriority);
const changefreq = fm.changefreq || defaultChangefreq;
sitemapXml += ' <url>\n';
sitemapXml += ` <loc>${url}</loc>\n`;
if (fm.lastmod) sitemapXml += ` <lastmod>${fm.lastmod}</lastmod>\n`;
sitemapXml += ` <changefreq>${changefreq}</changefreq>\n`;
sitemapXml += ` <priority>${priority}</priority>\n`;
sitemapXml += ' </url>\n';
}
sitemapXml += '</urlset>';
// 5. Write File
await fs.writeFile(path.join(outputDir, 'sitemap.xml'), sitemapXml);
if (log) log('Sitemap generated');
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present docmd.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
# @docmd/plugin-threads
Adds inline discussion threads to your docmd site - stored directly in Markdown as `::: threads` containers, so comments live in your repo and sync with your Git and PR workflows. Supports text highlighting, replies, thread resolution, and emoji reactions. An optional plugin, installed separately.
```bash
docmd add threads
```
Original author: [@svallory](https://github.com/svallory)
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
+31
View File
@@ -0,0 +1,31 @@
import esbuild from 'esbuild';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function build() {
await esbuild.build({
entryPoints: [path.resolve(__dirname, 'src/client/index.ts')],
bundle: true,
platform: 'browser',
target: 'es2022',
format: 'esm',
outdir: path.resolve(__dirname, 'dist/client'),
minify: false,
sourcemap: 'inline',
loader: { '.css': 'css' },
tsconfigRaw: JSON.stringify({
compilerOptions: {
experimentalDecorators: true,
useDefineForClassFields: false,
},
}),
});
console.log('Client built to dist/client/');
}
build().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,216 @@
/**
* Basic UI tests - adapted from old devtalk.e2e.spec.ts "basic UI" describe block.
*
* Changes from old plugin:
* - No sidebar panel/toggle button (inline-only architecture)
* - threads-app replaces devtalk-app as root element
* - "New Thread" button replaces sidebar toggle
* - Threads rendered server-side with .threads-* classes
*/
import { test, expect } from "@playwright/test";
import { Actor, NavigateTo } from "./screenplay.ts";
import { setAuthor, waitForClientProcessing } from "./helpers.ts";
let user: Actor;
test.beforeEach(async ({ page }) => {
user = new Actor("TestUser", page);
await page.goto("/");
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
await setAuthor(page, "TestUser");
// NOTE: Do NOT clean threads here - these tests verify the pre-existing
// threads from the playground markdown (index.md ::: threads block)
// Wait for client-side JS to finish processing highlights
await waitForClientProcessing(page);
});
test.describe("threads plugin - basic UI", () => {
test("loads on the page with threads-app element", async () => {
await user.perform(NavigateTo.path("/"));
const app = user.page.locator("threads-app");
await expect(app).toBeAttached();
});
test("New Thread button is injected into the content area", async () => {
await user.perform(NavigateTo.path("/"));
const btn = user.page.locator(".threads-new-thread-btn").first();
await expect(btn).toBeVisible({ timeout: 5_000 });
await expect(btn).toContainText("New Thread");
});
test("server-rendered threads appear with correct class structure", async () => {
// The playground index.md has pre-existing threads in the ::: threads block
await user.perform(NavigateTo.path("/"));
// There should be thread cards rendered from the markdown
const threads = user.page.locator(".threads-thread[data-thread-id]");
const count = await threads.count();
expect(count).toBeGreaterThan(0);
// Each thread should have at least one comment
const firstThread = threads.first();
const comments = firstThread.locator(".threads-comment");
await expect(comments.first()).toBeAttached();
});
test("highlights appear on marked text", async () => {
await user.perform(NavigateTo.path("/"));
// The playground has ==highlighted text=={thread-id} in the markdown
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
const count = await highlights.count();
expect(count).toBeGreaterThan(0);
});
test("highlights have cycling colors (not all the same)", async () => {
await user.perform(NavigateTo.path("/"));
await waitForClientProcessing(user.page);
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
const count = await highlights.count();
if (count < 2) {
test.skip(true, "Need at least 2 highlights to test color cycling");
return;
}
// Collect all highlight color classes
const colorClasses: string[] = [];
for (let i = 0; i < count; i++) {
const classList = await highlights.nth(i).evaluate((el) =>
Array.from(el.classList).filter((c) => c.startsWith("threads-hl-")),
);
colorClasses.push(...classList);
}
// With 2+ highlights, we should see at least 2 different colors
const uniqueColors = new Set(colorClasses);
expect(uniqueColors.size).toBeGreaterThanOrEqual(2);
});
test("thread cards are placed inline after their highlighted block", async () => {
await user.perform(NavigateTo.path("/"));
await waitForClientProcessing(user.page);
// Find a highlight mark that has a corresponding thread card
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
const firstHighlight = highlights.first();
if ((await firstHighlight.count()) === 0) {
test.skip(true, "No highlights found on the page");
return;
}
const threadId = await firstHighlight.getAttribute("data-thread-id");
if (!threadId) return;
const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(threadCard).toBeAttached();
// Thread card should appear after the highlight's parent block in document order
const isAfterHighlight = await user.page.evaluate(
({ threadId }) => {
const mark = document.querySelector(`mark.threads-highlight[data-thread-id="${threadId}"]`);
const thread = document.querySelector(`.threads-thread[data-thread-id="${threadId}"]`);
if (!mark || !thread) return false;
return mark.compareDocumentPosition(thread) & Node.DOCUMENT_POSITION_FOLLOWING;
},
{ threadId },
);
expect(isAfterHighlight).toBeTruthy();
});
test("thread cards have matching border colors to their highlights", async () => {
await user.perform(NavigateTo.path("/"));
await waitForClientProcessing(user.page);
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
const firstHighlight = highlights.first();
if ((await firstHighlight.count()) === 0) {
test.skip(true, "No highlights found");
return;
}
const threadId = await firstHighlight.getAttribute("data-thread-id");
if (!threadId) return;
// Get the highlight's color class
const hlColorClass = await firstHighlight.evaluate((el) =>
Array.from(el.classList).find((c) => c.startsWith("threads-hl-")),
);
if (!hlColorClass) return;
// The thread card should have the matching border class
const expectedBorderClass = hlColorClass.replace("threads-hl-", "threads-border-");
const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const hasBorderClass = await threadCard.evaluate(
(el, cls) => el.classList.contains(cls),
expectedBorderClass,
);
expect(hasBorderClass).toBe(true);
});
test("clicking a highlight scrolls to and flashes its thread", async () => {
await user.perform(NavigateTo.path("/"));
await waitForClientProcessing(user.page);
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
const firstHighlight = highlights.first();
if ((await firstHighlight.count()) === 0) {
test.skip(true, "No highlights found");
return;
}
await firstHighlight.click();
// After clicking, the thread card should receive the flash class
const threadId = await firstHighlight.getAttribute("data-thread-id");
const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(threadCard).toHaveClass(/threads-thread--flash/, { timeout: 2_000 });
});
test("popover hidden initially without text selection", async () => {
await user.perform(NavigateTo.path("/"));
// Without any text selection, the popover should not show
await expect(
user.page.locator("threads-popover").getByText("Add comment"),
).not.toBeVisible();
});
test("reply buttons present on thread cards", async () => {
await user.perform(NavigateTo.path("/"));
const threads = user.page.locator(".threads-thread[data-thread-id]");
const count = await threads.count();
if (count === 0) {
test.skip(true, "No threads on page");
return;
}
// Each thread should have a Reply button
for (let i = 0; i < count; i++) {
const thread = threads.nth(i);
const replyBtn = thread.locator(".threads-comment-reply-btn").first();
await expect(replyBtn).toBeAttached();
await expect(replyBtn).toContainText("Reply");
}
});
test("threads-sidebar container is hidden", async () => {
await user.perform(NavigateTo.path("/"));
const sidebar = user.page.locator(".threads-sidebar");
if ((await sidebar.count()) > 0) {
// The sidebar wrapper should be hidden (display: none)
await expect(sidebar).toBeHidden();
}
});
});
@@ -0,0 +1,142 @@
/**
* Comment display tests - adapted from old comment-display.e2e.spec.ts.
*
* Changes from old plugin:
* - Comments are server-rendered HTML (.threads-comment) not Lit components
* - Server-rendered comments have .threads-comment__meta and .threads-comment__body
* - Author info comes from data-author attribute on .threads-comment element
* - Date comes from data-date attribute
* - No "just now" time formatting (server renders static date strings like "2026-03-09")
*/
import { test, expect } from "@playwright/test";
import { Actor } from "./screenplay.ts";
import { cleanThreads, setAuthor, seedAndReload, waitForReload } from "./helpers.ts";
let user: Actor;
test.beforeEach(async ({ page }) => {
user = new Actor("TestUser", page);
await page.goto("/");
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
await setAuthor(page, "TestUser");
await cleanThreads(page);
await waitForReload(page);
});
test.describe("comment display", () => {
test("author name is displayed on the comment", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "Alice",
body: "Hello from Alice",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
// Server-rendered comment should show the author
const meta = thread.locator(".threads-comment__meta").first();
await expect(meta).toContainText("Alice");
});
test("comment body is displayed", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "TestUser",
body: "This is the comment body text",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
const body = thread.locator(".threads-comment__body").first();
await expect(body).toContainText("This is the comment body text");
});
test("date is displayed on the comment", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "TestUser",
body: "Date display test",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
// Server-rendered meta should contain a date (format: YYYY-MM-DD)
const meta = thread.locator(".threads-comment__meta").first();
const metaText = await meta.textContent();
// Should contain a date pattern like 2026-03-09
expect(metaText).toMatch(/\d{4}-\d{2}-\d{2}/);
});
test("comment has data-author attribute", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "Bob",
body: "Attribute test",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const comment = thread.locator(".threads-comment").first();
await expect(comment).toHaveAttribute("data-author", "Bob");
});
test("multiple comments displayed in order", async () => {
const { id: threadId } = await seedAndReload(user.page, {
author: "Alice",
body: "First comment",
replies: [
{ author: "Bob", body: "Second comment" },
{ author: "Charlie", body: "Third comment" },
],
});
let thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
await expect(thread).toBeAttached({ timeout: 5_000 });
// The dev server may need an additional reload to pick up the last reply
let comments = thread.locator(".threads-comment");
if ((await comments.count()) < 3) {
await waitForReload(user.page);
thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
comments = thread.locator(".threads-comment");
}
await expect(comments).toHaveCount(3);
await expect(comments.nth(0).locator(".threads-comment__body")).toContainText("First comment");
await expect(comments.nth(1).locator(".threads-comment__body")).toContainText("Second comment");
await expect(comments.nth(2).locator(".threads-comment__body")).toContainText("Third comment");
});
});
test.describe("markdown rendering in comments", () => {
test("comment body renders inline markdown from markdown-it", async () => {
// Server-rendered comments go through markdown-it, which handles bold/italic/code
const { id: threadId } = await seedAndReload(user.page, {
body: "Use `console.log()` for debugging",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const body = thread.locator(".threads-comment__body").first();
await expect(body).toBeAttached({ timeout: 5_000 });
// markdown-it should render backtick as <code>
const codeEl = body.locator("code");
await expect(codeEl).toBeAttached();
});
test("HTML special characters are escaped", async () => {
const { id: threadId } = await seedAndReload(user.page, {
body: "<script>alert('xss')</script>",
});
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
const body = thread.locator(".threads-comment__body").first();
await expect(body).toBeAttached({ timeout: 5_000 });
// No live <script> element must exist inside the comment body
await expect(body.locator("script")).toHaveCount(0);
// The raw innerHTML must contain escaped entities
const rawHtml = await body.evaluate((el) => el.innerHTML);
expect(rawHtml).not.toContain("<script>");
});
});
@@ -0,0 +1,76 @@
---
title: "docmd _playground"
description: "A testing ground for docmd core engine features."
---
This playground is used to verify core engine changes in real-time. Use this to ensure your modifications to the Markdown parser or UI components behave as expected.
::: callout tip "How to work here"
To test your changes, keep the dev server running (`pnpm run dev`). Any change you make to `packages/core` or `packages/parser` will trigger a hot-reload here instantly.
:::
## Component Verification
Test your UI components and parser rules here to ensure visual consistency:
::: card "Container Test"
Test nested callouts and containers.
::: callout warning "Warning"
Ensure nested items render correctly.
:::
:::
::: tabs
== tab "Feature A"
### Feature A
Verification content.
== tab "Feature B"
### Feature B
Verification content.
:::
## 🔗 Useful Links
- [Official Documentation](https://docs.docmd.io)
- [GitHub Repository](https://github.com/docmd-io/docmd)
- [Report an Issue](https://github.com/docmd-io/docmd/issues)
## 🧪 Developer Checklist
- [ ] **Parser:** Does the Markdown output match the HTML in `packages/parser/src/html-renderer.js`?
- [ ] **UI:** Does the theme CSS apply to this page correctly?
- [ ] **SPA:** Does navigation between pages work without a hard refresh?
## Threads Plugin Test
This section tests the ==inline discussion threads=={t-thread1} plugin. You can ==highlight text=={t-thread2} and start discussions.
Here is another paragraph with a ==different highlight=={t-thread3} to test multiple threads.
::: threads
::: thread t-thread1
::: comment c1-1 "Alice" "2026-03-01"
This is a comment about inline discussion threads.
:::
::: comment c1-2 "Bob" "2026-03-02"
Great point, Alice! I agree this is useful.
:::
:::
::: thread t-thread2
::: comment c2-1 "Charlie" "2026-03-03"
Highlighting text makes it easy to reference specific parts.
:::
:::
::: thread t-thread3 resolved "Alice" "2026-03-05"
::: comment c3-1 "Alice" "2026-03-04"
This highlight tests resolved threads.
:::
::: comment c3-2 "Bob" "2026-03-05"
Resolved! Use `console.log()` for debugging.
:::
:::
:::
+8
View File
@@ -0,0 +1,8 @@
/**
* Global type declarations for the docmd browser runtime,
* used in Playwright page.evaluate() contexts.
*/
declare const docmd: {
call: (action: string, payload: Record<string, unknown>) => Promise<any>;
};
+215
View File
@@ -0,0 +1,215 @@
/**
* Helpers for seeding and cleaning threads in E2E tests.
*
* Unlike the old HTTP API-based approach, we use docmd.call() via
* page.evaluate() to interact with the WebSocket action dispatcher.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { Page } from "@playwright/test";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** Path to the playground index.md used by the dev server. */
const PLAYGROUND_INDEX = path.resolve(
__dirname,
"../../../_playground/docs/index.md",
);
/** Path to the fixture copy of the original playground markdown. */
const FIXTURE_INDEX = path.resolve(__dirname, "fixtures/playground-index.md");
/**
* Restore the playground markdown to its fixture state, removing any
* dynamically-added threads/highlights from test runs.
*/
export async function cleanThreads(_page: Page): Promise<void> {
const fixtureContent = fs.readFileSync(FIXTURE_INDEX, "utf-8");
fs.writeFileSync(PLAYGROUND_INDEX, fixtureContent, "utf-8");
}
/**
* Seed a thread via the WebSocket action dispatcher.
* Must be called after the page is loaded and docmd global is available.
*
* Each WebSocket call writes to the markdown file, which may trigger the
* dev server's file watcher to hot-reload the page. To avoid execution
* context destruction, we split multi-step operations (add-thread +
* add-comment replies) across separate page.evaluate() calls with page
* reloads in between.
*/
export async function seedThread(
page: Page,
options: {
author?: string;
body?: string;
anchor?: { quote: string } | null;
replies?: Array<{ author?: string; body: string }>;
resolved?: boolean;
} = {},
): Promise<{ id: string; comments: Array<{ id: string }> }> {
const {
author = "TestUser",
body = "Seeded comment",
anchor = null,
replies = [],
resolved = false,
} = options;
// Step 1: Create the thread with its first comment
const thread = await page.evaluate(
async ({ author, body, anchor }) => {
const file = document.body.dataset["sourceFile"];
if (!file) throw new Error("data-source-file not found on body");
return docmd.call("threads:add-thread", {
file,
author,
body,
anchor: anchor
? {
quote: anchor.quote,
prefix: null,
suffix: null,
selector: null,
offset: null,
blockText: null,
}
: null,
});
},
{ author, body, anchor },
);
const commentIds = [thread.comments[0].id];
// Step 2: Add replies one at a time, reloading the page between each
// to avoid execution context destruction from hot-reload
for (const reply of replies) {
await page.waitForTimeout(500);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
const comment = await page.evaluate(
async ({ file, threadId, replyAuthor, replyBody }) => {
const f = file || document.body.dataset["sourceFile"];
return docmd.call("threads:add-comment", {
file: f,
threadId,
author: replyAuthor,
body: replyBody,
});
},
{
file: null as string | null,
threadId: thread.id,
replyAuthor: reply.author ?? "TestUser",
replyBody: reply.body,
},
);
commentIds.push(comment.id);
}
// Step 3: Resolve if requested
if (resolved) {
// Always reload before resolving to ensure the DOM is fresh after the last write
// and avoid execution context destruction during the resolve-thread call.
await page.waitForTimeout(500);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
await page.evaluate(
async ({ threadId, resolvedBy }) => {
const file = document.body.dataset["sourceFile"];
return docmd.call("threads:resolve-thread", {
file,
threadId,
resolved_by: resolvedBy,
});
},
{ threadId: thread.id, resolvedBy: author },
);
}
return {
id: thread.id,
comments: commentIds.map((id) => ({ id })),
};
}
/**
* Reload the page and wait for the threads-app element to re-attach.
* After seeding/cleaning threads via WebSocket actions, the markdown file
* on disk has changed. Give the dev server a moment to detect the change,
* then reload.
*/
export async function waitForReload(page: Page): Promise<void> {
await page.waitForTimeout(2000);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
}
/**
* Seed a thread, reload the page, and wait until the thread element appears
* in the server-rendered HTML.
*/
export async function seedAndReload(
page: Page,
options: Parameters<typeof seedThread>[1] = {},
): Promise<{ id: string; comments: Array<{ id: string }> }> {
const result = await seedThread(page, options);
// The seed action writes to the markdown file. The dev server needs time
// to detect the change and re-render. Retry the reload until the thread
// appears in the server-rendered HTML (up to 3 attempts).
const selector = `.threads-thread[data-thread-id="${result.id}"]`;
for (let attempt = 0; attempt < 3; attempt++) {
await page.waitForTimeout(attempt === 0 ? 500 : 1500);
await page.goto(page.url(), { waitUntil: "domcontentloaded" });
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
const found = await page.locator(selector).count();
if (found > 0) return result;
}
// Final attempt - let Playwright's own timeout handle it
await page.waitForSelector(selector, { state: "attached", timeout: 5_000 });
return result;
}
/**
* Set the author identity in localStorage.
*/
export async function setAuthor(page: Page, name: string): Promise<void> {
await page.evaluate(
(authorName) => localStorage.setItem("threads_author", authorName),
name,
);
}
/**
* Wait for the client-side threads-app to finish processing:
* scanRenderedHighlights assigns color classes, moves thread cards inline, etc.
* We detect completion by waiting for the first highlight to receive a color class.
*/
export async function waitForClientProcessing(page: Page): Promise<void> {
const hasHighlights = await page.locator("mark.threads-highlight[data-thread-id]").count();
if (hasHighlights > 0) {
// Wait for the first highlight to get a color class (assigned by scanRenderedHighlights)
await page.waitForFunction(
() => {
const mark = document.querySelector("mark.threads-highlight[data-thread-id]");
if (!mark) return true; // No highlights, nothing to wait for
return Array.from(mark.classList).some((c) => c.startsWith("threads-hl-"));
},
{ timeout: 5_000 },
);
} else {
// No highlights on page, just wait a moment for client JS to initialize
await page.waitForTimeout(500);
}
}

Some files were not shown because too many files have changed in this diff Show More