chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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');
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"lastUpdated": "마지막 업데이트",
|
||||
"recentCommits": "최근 커밋",
|
||||
"viewCommitHistory": "커밋 기록 보기",
|
||||
"editOnGitHub": "GitHub에서 편집",
|
||||
"editOnGitLab": "GitLab에서 편집",
|
||||
"editOnBitbucket": "Bitbucket에서 편집",
|
||||
"editThisPage": "이 페이지 편집",
|
||||
"justNow": "방금 전",
|
||||
"minutesAgo": "{n}분 전",
|
||||
"hoursAgo": "{n}시간 전",
|
||||
"daysAgo": "{n}일 전"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"lastUpdated": "最后更新",
|
||||
"recentCommits": "最近提交",
|
||||
"viewCommitHistory": "查看提交历史",
|
||||
"editOnGitHub": "在 GitHub 上编辑",
|
||||
"editOnGitLab": "在 GitLab 上编辑",
|
||||
"editOnBitbucket": "在 Bitbucket 上编辑",
|
||||
"editThisPage": "编辑此页",
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{n}分钟前",
|
||||
"hoursAgo": "{n}小时前",
|
||||
"daysAgo": "{n}天前"
|
||||
}
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 };
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 */ });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"'] }
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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'] });
|
||||
|
||||
})();
|
||||
@@ -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"' }
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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"'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/client.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
})();`;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "..",
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["../src/**/*", "./**/*"],
|
||||
"exclude": ["node_modules", "../dist", "../node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"strict": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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/');
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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'
|
||||
}];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,13 @@
|
||||
# @docmd/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
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"searchPlaceholder": "दस्तावेज़ खोजें...",
|
||||
"searchNoResults": "कोई परिणाम नहीं मिला।",
|
||||
"searchError": "खोज अनुक्रमणिका लोड करने में विफल।",
|
||||
"searchInitial": "खोजने के लिए टाइप करें...",
|
||||
"searchClose": "खोज बंद करें",
|
||||
"searchNavigate": "नेविगेट करें",
|
||||
"searchEscape": "बंद करें",
|
||||
"searchVersionBadge": "v{version}"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"searchPlaceholder": "문서 검색...",
|
||||
"searchNoResults": "검색 결과가 없습니다.",
|
||||
"searchError": "검색 색인을 불러오지 못했습니다.",
|
||||
"searchInitial": "검색어를 입력하세요...",
|
||||
"searchClose": "검색 닫기",
|
||||
"searchNavigate": "이동",
|
||||
"searchEscape": "닫기",
|
||||
"searchVersionBadge": "v{version}"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"searchPlaceholder": "搜索文档...",
|
||||
"searchNoResults": "未找到结果。",
|
||||
"searchError": "无法加载搜索索引。",
|
||||
"searchInitial": "输入开始搜索...",
|
||||
"searchClose": "关闭搜索",
|
||||
"searchNavigate": "导航",
|
||||
"searchEscape": "关闭",
|
||||
"searchVersionBadge": "v{version}"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
@@ -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>;
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user