@inovus-medical/docs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @inovus-medical/docs
2
+
3
+ Turn a folder of markdown into a themed, Cloudflare-ready documentation site.
4
+
5
+ A small, self-contained static-site engine: it renders markdown into a themed `dist/`
6
+ that any static host serves. Designed for one deployment per docs repo, public or
7
+ private, with push-to-deploy on Cloudflare Workers.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install --save-dev @inovus-medical/docs
13
+ ```
14
+
15
+ ## Use
16
+
17
+ Put markdown in `docs/`, add a `docs.config.json`, then:
18
+
19
+ ```bash
20
+ npx inovus-docs build # docs/ -> dist/
21
+ npx inovus-docs dev # build + serve + watch (http://localhost:8788)
22
+ npx inovus-docs serve # serve an existing dist/
23
+ ```
24
+
25
+ Flags: `--root <dir>` `--in <docs>` `--out <dist>` `--port <n>`.
26
+
27
+ ## What it does
28
+
29
+ - Renders markdown with `markdown-it` + `@shikijs/markdown-it` (dual light/dark
30
+ syntax highlighting), heading anchors, tables, and frontmatter.
31
+ - Generates sidebar navigation from your folder structure.
32
+ - Builds a client-side search index, light/dark theme, on-page table of contents,
33
+ and prev/next paging.
34
+ - Emits clean-URL static HTML plus a `404.html`.
35
+
36
+ ## Config
37
+
38
+ See [`docs.config.json` and frontmatter reference](https://github.com/inovus-ltd/git-docs).
39
+
40
+ ## License
41
+
42
+ MIT
@@ -0,0 +1,120 @@
1
+ (function () {
2
+ var root = document.documentElement
3
+
4
+ // Theme toggle
5
+ var toggle = document.getElementById('theme-toggle')
6
+ if (toggle) {
7
+ toggle.addEventListener('click', function () {
8
+ var next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'
9
+ root.setAttribute('data-theme', next)
10
+ try { localStorage.setItem('theme', next) } catch (e) {}
11
+ })
12
+ }
13
+
14
+ // Mobile sidebar
15
+ var menuBtn = document.getElementById('menu-toggle')
16
+ var sidebar = document.querySelector('.sidebar')
17
+ var backdrop = document.getElementById('sidebar-backdrop')
18
+ function closeSidebar() { if (sidebar) sidebar.classList.remove('open') }
19
+ if (menuBtn && sidebar) {
20
+ menuBtn.addEventListener('click', function () { sidebar.classList.toggle('open') })
21
+ }
22
+ if (backdrop) backdrop.addEventListener('click', closeSidebar)
23
+
24
+ // Search
25
+ var input = document.getElementById('search-input')
26
+ var results = document.getElementById('search-results')
27
+ var index = null
28
+ var activeIdx = -1
29
+ var current = []
30
+
31
+ function ensureIndex() {
32
+ if (index) return Promise.resolve(index)
33
+ return fetch('/_assets/search-index.json')
34
+ .then(function (r) { return r.json() })
35
+ .then(function (data) { index = data; return index })
36
+ .catch(function () { index = []; return index })
37
+ }
38
+
39
+ function escapeHtml(s) {
40
+ return s.replace(/[&<>"]/g, function (c) {
41
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
42
+ })
43
+ }
44
+
45
+ function snippet(text, q) {
46
+ var i = text.toLowerCase().indexOf(q)
47
+ if (i < 0) return escapeHtml(text.slice(0, 120))
48
+ var start = Math.max(0, i - 40)
49
+ var slice = text.slice(start, start + 140)
50
+ var re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig')
51
+ return (start > 0 ? '…' : '') + escapeHtml(slice).replace(re, '<mark>$1</mark>')
52
+ }
53
+
54
+ function search(q) {
55
+ q = q.trim().toLowerCase()
56
+ if (!q || !index) return []
57
+ var scored = []
58
+ for (var p = 0; p < index.length; p++) {
59
+ var page = index[p]
60
+ var score = 0
61
+ var title = (page.title || '').toLowerCase()
62
+ if (title.indexOf(q) >= 0) score += 10
63
+ var heads = (page.headings || []).join(' \n ').toLowerCase()
64
+ if (heads.indexOf(q) >= 0) score += 5
65
+ var body = (page.text || '').toLowerCase()
66
+ if (body.indexOf(q) >= 0) score += 1
67
+ if (score > 0) scored.push({ page: page, score: score })
68
+ }
69
+ scored.sort(function (a, b) { return b.score - a.score })
70
+ return scored.slice(0, 8).map(function (s) { return s.page })
71
+ }
72
+
73
+ function render(q) {
74
+ current = search(q)
75
+ activeIdx = -1
76
+ if (!q.trim()) { results.hidden = true; results.innerHTML = ''; return }
77
+ if (!current.length) {
78
+ results.hidden = false
79
+ results.innerHTML = '<div class="r-empty">No results for “' + escapeHtml(q) + '”</div>'
80
+ return
81
+ }
82
+ results.innerHTML = current
83
+ .map(function (page) {
84
+ return (
85
+ '<a href="' + page.route + '">' +
86
+ '<div class="r-title">' + escapeHtml(page.title) + '</div>' +
87
+ '<div class="r-snippet">' + snippet(page.text || '', q.toLowerCase()) + '</div>' +
88
+ '</a>'
89
+ )
90
+ })
91
+ .join('')
92
+ results.hidden = false
93
+ }
94
+
95
+ if (input && results) {
96
+ input.addEventListener('focus', ensureIndex)
97
+ input.addEventListener('input', function () {
98
+ ensureIndex().then(function () { render(input.value) })
99
+ })
100
+ input.addEventListener('keydown', function (e) {
101
+ var links = results.querySelectorAll('a')
102
+ if (e.key === 'ArrowDown') { e.preventDefault(); activeIdx = Math.min(activeIdx + 1, links.length - 1) }
103
+ else if (e.key === 'ArrowUp') { e.preventDefault(); activeIdx = Math.max(activeIdx - 1, 0) }
104
+ else if (e.key === 'Enter' && activeIdx >= 0 && links[activeIdx]) { window.location.href = links[activeIdx].getAttribute('href') }
105
+ else if (e.key === 'Escape') { input.blur(); results.hidden = true; return }
106
+ else return
107
+ links.forEach(function (l, i) { l.classList.toggle('active', i === activeIdx) })
108
+ if (links[activeIdx]) links[activeIdx].scrollIntoView({ block: 'nearest' })
109
+ })
110
+ document.addEventListener('click', function (e) {
111
+ if (!results.contains(e.target) && e.target !== input) results.hidden = true
112
+ })
113
+ document.addEventListener('keydown', function (e) {
114
+ if (e.key === '/' && document.activeElement !== input) {
115
+ e.preventDefault()
116
+ input.focus()
117
+ }
118
+ })
119
+ }
120
+ })()
@@ -0,0 +1,216 @@
1
+ :root {
2
+ --accent: #2563eb;
3
+ --bg: #ffffff;
4
+ --bg-soft: #f6f8fa;
5
+ --bg-sidebar: #fbfcfd;
6
+ --text: #1f2328;
7
+ --text-soft: #59636e;
8
+ --border: #d8dee4;
9
+ --code-bg: #f6f8fa;
10
+ --header-h: 56px;
11
+ --sidebar-w: 272px;
12
+ --toc-w: 220px;
13
+ --shiki-bg: var(--shiki-light-bg);
14
+ font-synthesis: none;
15
+ }
16
+
17
+ [data-theme='dark'] {
18
+ --bg: #0d1117;
19
+ --bg-soft: #161b22;
20
+ --bg-sidebar: #0f141a;
21
+ --text: #e6edf3;
22
+ --text-soft: #9198a1;
23
+ --border: #2a313c;
24
+ --code-bg: #161b22;
25
+ }
26
+
27
+ * { box-sizing: border-box; }
28
+
29
+ html, body { margin: 0; padding: 0; }
30
+
31
+ body {
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
35
+ font-size: 16px;
36
+ line-height: 1.65;
37
+ -webkit-font-smoothing: antialiased;
38
+ }
39
+
40
+ a { color: var(--accent); text-decoration: none; }
41
+ a:hover { text-decoration: underline; }
42
+
43
+ /* ---------- Header ---------- */
44
+ .site-header {
45
+ position: sticky;
46
+ top: 0;
47
+ z-index: 40;
48
+ height: var(--header-h);
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 14px;
52
+ padding: 0 18px;
53
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
54
+ backdrop-filter: blur(8px);
55
+ border-bottom: 1px solid var(--border);
56
+ }
57
+ .brand { display: flex; align-items: center; gap: 9px; font-weight: 650; color: var(--text); }
58
+ .brand:hover { text-decoration: none; }
59
+ .logo { height: 26px; width: auto; }
60
+ .header-spacer { flex: 1; }
61
+ .header-link { color: var(--text-soft); font-size: 14px; }
62
+ .vis-badge {
63
+ font-size: 11px; font-weight: 600; letter-spacing: .02em; text-transform: uppercase;
64
+ padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border);
65
+ }
66
+ .vis-private { color: #b45309; border-color: color-mix(in srgb, #b45309 40%, var(--border)); }
67
+ .vis-public { color: #15803d; border-color: color-mix(in srgb, #15803d 40%, var(--border)); }
68
+ [data-theme='dark'] .vis-private { color: #f0b429; }
69
+ [data-theme='dark'] .vis-public { color: #4ade80; }
70
+
71
+ .theme-toggle, .menu-toggle {
72
+ background: transparent; border: 1px solid var(--border); color: var(--text);
73
+ border-radius: 8px; height: 34px; min-width: 34px; cursor: pointer; font-size: 15px;
74
+ }
75
+ .theme-toggle:hover, .menu-toggle:hover { background: var(--bg-soft); }
76
+ .menu-toggle { display: none; }
77
+ .theme-icon-dark { display: none; }
78
+ [data-theme='dark'] .theme-icon-light { display: none; }
79
+ [data-theme='dark'] .theme-icon-dark { display: inline; }
80
+
81
+ /* ---------- Search ---------- */
82
+ .search { position: relative; width: min(360px, 38vw); }
83
+ #search-input {
84
+ width: 100%; height: 34px; padding: 0 12px; border-radius: 8px;
85
+ border: 1px solid var(--border); background: var(--bg-soft); color: var(--text);
86
+ font-size: 14px;
87
+ }
88
+ #search-input:focus { outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); border-color: var(--accent); }
89
+ .search-results {
90
+ position: absolute; top: 42px; left: 0; right: 0; max-height: 60vh; overflow-y: auto;
91
+ background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
92
+ box-shadow: 0 12px 40px rgba(0,0,0,.18); padding: 6px;
93
+ }
94
+ .search-results a { display: block; padding: 8px 10px; border-radius: 7px; color: var(--text); }
95
+ .search-results a:hover, .search-results a.active { background: var(--bg-soft); text-decoration: none; }
96
+ .search-results .r-title { font-weight: 600; font-size: 14px; }
97
+ .search-results .r-snippet { font-size: 12.5px; color: var(--text-soft); margin-top: 2px; }
98
+ .search-results .r-empty { padding: 10px; color: var(--text-soft); font-size: 13px; }
99
+ .search-results mark { background: color-mix(in srgb, var(--accent) 30%, transparent); color: inherit; border-radius: 3px; }
100
+
101
+ /* ---------- Layout ---------- */
102
+ .layout {
103
+ display: grid;
104
+ grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--toc-w);
105
+ max-width: 1400px;
106
+ margin: 0 auto;
107
+ }
108
+ .sidebar {
109
+ position: sticky; top: var(--header-h); align-self: start;
110
+ height: calc(100vh - var(--header-h)); overflow-y: auto;
111
+ padding: 22px 14px 60px; border-right: 1px solid var(--border);
112
+ background: var(--bg-sidebar);
113
+ }
114
+ .content { min-width: 0; padding: 36px 48px 80px; }
115
+ .toc {
116
+ position: sticky; top: var(--header-h); align-self: start;
117
+ height: calc(100vh - var(--header-h)); overflow-y: auto;
118
+ padding: 36px 18px; font-size: 13.5px;
119
+ }
120
+
121
+ /* ---------- Nav ---------- */
122
+ .nav-list { list-style: none; margin: 0; padding: 0; }
123
+ .nav-list .nav-list { margin-left: 10px; padding-left: 8px; border-left: 1px solid var(--border); }
124
+ .nav-link {
125
+ display: block; padding: 5px 10px; border-radius: 7px; color: var(--text-soft);
126
+ font-size: 14px; line-height: 1.4;
127
+ }
128
+ .nav-link:hover { background: var(--bg-soft); color: var(--text); text-decoration: none; }
129
+ .nav-link.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); font-weight: 600; }
130
+ .nav-section { margin-top: 14px; }
131
+ .nav-section-label, .nav-section-link {
132
+ display: block; padding: 4px 10px; font-size: 12px; font-weight: 700;
133
+ text-transform: uppercase; letter-spacing: .04em; color: var(--text);
134
+ }
135
+ .nav-section-link:hover { text-decoration: none; }
136
+
137
+ /* ---------- TOC ---------- */
138
+ .toc-title { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-soft); margin-bottom: 10px; }
139
+ .toc ul { list-style: none; margin: 0; padding: 0; }
140
+ .toc li { margin: 4px 0; }
141
+ .toc a { color: var(--text-soft); }
142
+ .toc a:hover { color: var(--accent); text-decoration: none; }
143
+ .toc-l3 { padding-left: 12px; font-size: 13px; }
144
+
145
+ /* ---------- Prose ---------- */
146
+ .prose { max-width: 760px; }
147
+ .prose h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .6em; letter-spacing: -.02em; }
148
+ .prose h2 { font-size: 1.45rem; margin: 2em 0 .6em; padding-bottom: .3em; border-bottom: 1px solid var(--border); letter-spacing: -.01em; }
149
+ .prose h3 { font-size: 1.18rem; margin: 1.6em 0 .5em; }
150
+ .prose h4 { font-size: 1rem; margin: 1.4em 0 .4em; }
151
+ .prose p, .prose li { color: var(--text); }
152
+ .prose a { font-weight: 500; }
153
+ .prose code {
154
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
155
+ font-size: .88em; background: var(--code-bg); padding: .15em .4em; border-radius: 5px;
156
+ border: 1px solid var(--border);
157
+ }
158
+ .prose pre {
159
+ background: var(--shiki-light-bg, var(--code-bg)); border: 1px solid var(--border);
160
+ border-radius: 10px; padding: 16px 18px; overflow-x: auto; line-height: 1.55; font-size: 13.5px;
161
+ }
162
+ .prose pre code { background: none; border: none; padding: 0; font-size: inherit; }
163
+ .prose blockquote {
164
+ margin: 1.2em 0; padding: .4em 1.1em; border-left: 3px solid var(--accent);
165
+ background: var(--bg-soft); border-radius: 0 8px 8px 0; color: var(--text-soft);
166
+ }
167
+ .prose table { border-collapse: collapse; width: 100%; margin: 1.2em 0; font-size: 14.5px; }
168
+ .prose th, .prose td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
169
+ .prose th { background: var(--bg-soft); }
170
+ .prose img { max-width: 100%; border-radius: 8px; }
171
+ .prose hr { border: none; border-top: 1px solid var(--border); margin: 2.5em 0; }
172
+ .prose ul, .prose ol { padding-left: 1.4em; }
173
+ .prose li { margin: .3em 0; }
174
+ .header-anchor { color: var(--text-soft); opacity: 0; margin-left: .3em; font-weight: 400; text-decoration: none; }
175
+ .prose h2:hover .header-anchor, .prose h3:hover .header-anchor { opacity: 1; }
176
+
177
+ /* Shiki dual-theme color swap */
178
+ [data-theme='dark'] .shiki,
179
+ [data-theme='dark'] .shiki span {
180
+ color: var(--shiki-dark) !important;
181
+ background-color: var(--shiki-dark-bg) !important;
182
+ }
183
+
184
+ /* ---------- Pager ---------- */
185
+ .pager { display: flex; gap: 14px; margin-top: 56px; }
186
+ .pager-link {
187
+ flex: 1; display: flex; flex-direction: column; gap: 4px; padding: 14px 18px;
188
+ border: 1px solid var(--border); border-radius: 10px; color: var(--text);
189
+ }
190
+ .pager-link:hover { border-color: var(--accent); text-decoration: none; }
191
+ .pager-next { text-align: right; }
192
+ .pager-dir { font-size: 12px; color: var(--text-soft); }
193
+ .pager-title { font-weight: 600; }
194
+
195
+ .page-footer { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--text-soft); font-size: 13.5px; }
196
+
197
+ .sidebar-backdrop { display: none; }
198
+
199
+ /* ---------- Responsive ---------- */
200
+ @media (max-width: 1100px) {
201
+ .layout { grid-template-columns: var(--sidebar-w) minmax(0, 1fr); }
202
+ .toc { display: none; }
203
+ }
204
+ @media (max-width: 860px) {
205
+ .layout { grid-template-columns: 1fr; }
206
+ .menu-toggle { display: inline-block; }
207
+ .content { padding: 28px 22px 70px; }
208
+ .search { width: auto; flex: 1; }
209
+ .vis-badge, .header-link { display: none; }
210
+ .sidebar {
211
+ position: fixed; top: var(--header-h); left: 0; bottom: 0; width: 84vw; max-width: 320px;
212
+ z-index: 50; transform: translateX(-100%); transition: transform .2s ease;
213
+ }
214
+ .sidebar.open { transform: translateX(0); }
215
+ .sidebar.open ~ .sidebar-backdrop { display: block; position: fixed; inset: var(--header-h) 0 0 0; background: rgba(0,0,0,.4); z-index: 45; }
216
+ }
package/bin/cli.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path'
3
+ import { build } from '../src/build.js'
4
+ import { serve } from '../src/serve.js'
5
+ import { watchAndRebuild } from '../src/watch.js'
6
+
7
+ function parseFlags(args) {
8
+ const opts = {}
9
+ for (let i = 0; i < args.length; i++) {
10
+ const a = args[i]
11
+ if (a.startsWith('--')) {
12
+ const key = a.slice(2)
13
+ const next = args[i + 1]
14
+ if (next === undefined || next.startsWith('--')) {
15
+ opts[key] = true
16
+ } else {
17
+ opts[key] = next
18
+ i++
19
+ }
20
+ }
21
+ }
22
+ return opts
23
+ }
24
+
25
+ const argv = process.argv.slice(2)
26
+ const cmd = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'build'
27
+ const flags = parseFlags(cmd === argv[0] ? argv.slice(1) : argv)
28
+
29
+ const root = path.resolve(flags.root || process.cwd())
30
+ const inDir = flags.in // undefined -> build() auto-detects (docs/ or repo root)
31
+ const outDir = flags.out || 'dist'
32
+ const port = Number(flags.port || 8788)
33
+
34
+ async function run() {
35
+ if (cmd === 'build') {
36
+ const { pageCount, outPath, contentDir } = await build({ root, inDir, outDir })
37
+ console.log(`✓ Built ${pageCount} page(s) from ${contentDir === '.' ? 'repo root' : contentDir + '/'} -> ${path.relative(process.cwd(), outPath) || outPath}`)
38
+ return
39
+ }
40
+
41
+ if (cmd === 'dev') {
42
+ const { pageCount, outPath, contentDir } = await build({ root, inDir, outDir })
43
+ console.log(`✓ Built ${pageCount} page(s) from ${contentDir === '.' ? 'repo root' : contentDir + '/'}`)
44
+ serve({ dir: outPath, port })
45
+ console.log(`→ Serving http://localhost:${port} (watching ${contentDir === '.' ? 'repo root' : contentDir + '/'} for changes)`)
46
+ watchAndRebuild({ root, inDir: contentDir, outDir })
47
+ return
48
+ }
49
+
50
+ if (cmd === 'serve') {
51
+ const outPath = path.join(root, outDir)
52
+ serve({ dir: outPath, port })
53
+ console.log(`→ Serving ${outDir}/ at http://localhost:${port}`)
54
+ return
55
+ }
56
+
57
+ console.error(`Unknown command: ${cmd}\nUsage: inovus-docs <build|dev|serve> [--root .] [--in docs] [--out dist] [--port 8788]`)
58
+ process.exit(1)
59
+ }
60
+
61
+ run().catch((err) => {
62
+ console.error(err)
63
+ process.exit(1)
64
+ })
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@inovus-medical/docs",
3
+ "version": "0.1.0",
4
+ "description": "Turn a folder of markdown into a themed, Cloudflare-ready docs site.",
5
+ "type": "module",
6
+ "bin": {
7
+ "inovus-docs": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "assets",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Inovus Medical",
20
+ "keywords": [
21
+ "docs",
22
+ "documentation",
23
+ "markdown",
24
+ "cloudflare",
25
+ "static-site-generator"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/inovus-ltd/git-docs.git",
30
+ "directory": "packages/docs-engine"
31
+ },
32
+ "homepage": "https://github.com/inovus-ltd/git-docs#readme",
33
+ "bugs": "https://github.com/inovus-ltd/git-docs/issues",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@shikijs/markdown-it": "^3.4.0",
39
+ "gray-matter": "^4.0.3",
40
+ "markdown-it": "^14.1.0",
41
+ "markdown-it-anchor": "^9.2.0"
42
+ }
43
+ }
package/src/build.js ADDED
@@ -0,0 +1,192 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import matter from 'gray-matter'
5
+ import { createRenderer } from './markdown.js'
6
+ import { buildNav, flattenNav } from './nav.js'
7
+ import { renderPage, render404 } from './layout.js'
8
+ import { loadConfig } from './config.js'
9
+ import { buildRouteMap, rewriteLinks } from './links.js'
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
12
+ const ASSET_SRC = path.join(__dirname, '..', 'assets')
13
+
14
+ const DEFAULT_IGNORE_DIRS = new Set([
15
+ 'node_modules', '.git', '.github', '.svn', '.hg', '.cache', '.vscode', '.idea', '.wrangler'
16
+ ])
17
+
18
+ // Only these non-markdown files get copied into the site. Keeps a whole-repo scan from
19
+ // shipping source, lockfiles, or secrets (e.g. .env) to a public site.
20
+ const MEDIA_EXT = new Set([
21
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif', '.bmp', '.ico',
22
+ '.pdf', '.mp4', '.webm', '.mov', '.mp3', '.ogg',
23
+ '.woff', '.woff2', '.ttf', '.otf'
24
+ ])
25
+
26
+ function titleCase(s) {
27
+ return String(s).replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
28
+ }
29
+
30
+ function isIndexName(name) {
31
+ const n = name.toLowerCase()
32
+ return n === 'index' || n === 'readme'
33
+ }
34
+
35
+ function makeIgnore(config, outBase) {
36
+ const extra = new Set(config.ignore || [])
37
+ return (name) =>
38
+ DEFAULT_IGNORE_DIRS.has(name) ||
39
+ extra.has(name) ||
40
+ name === outBase ||
41
+ name.startsWith('.')
42
+ }
43
+
44
+ async function walk(dir, ignore) {
45
+ let out = []
46
+ let entries
47
+ try {
48
+ entries = await fs.readdir(dir, { withFileTypes: true })
49
+ } catch (err) {
50
+ if (err.code === 'ENOENT') return []
51
+ throw err
52
+ }
53
+ for (const e of entries) {
54
+ if (e.isDirectory()) {
55
+ if (ignore(e.name)) continue
56
+ out = out.concat(await walk(path.join(dir, e.name), ignore))
57
+ } else {
58
+ if (e.name.startsWith('.')) continue
59
+ out.push(path.join(dir, e.name))
60
+ }
61
+ }
62
+ return out
63
+ }
64
+
65
+ async function dirHasMarkdown(dir, ignore) {
66
+ const files = await walk(dir, ignore)
67
+ return files.some((f) => f.toLowerCase().endsWith('.md'))
68
+ }
69
+
70
+ /** flags.in > config.content > auto-detect (docs/ if it has markdown, else repo root). */
71
+ async function resolveContentDir({ root, inDir, config, ignore }) {
72
+ if (inDir) return inDir
73
+ if (config.content) return config.content
74
+ const docsDir = path.join(root, 'docs')
75
+ if (await dirHasMarkdown(docsDir, ignore)) return 'docs'
76
+ return '.'
77
+ }
78
+
79
+ function toRoute(routeSegments) {
80
+ if (routeSegments.length === 0) return '/'
81
+ return '/' + routeSegments.join('/') + '/'
82
+ }
83
+
84
+ function routeToFile(outPath, route) {
85
+ if (route === '/') return path.join(outPath, 'index.html')
86
+ const rel = route.replace(/^\//, '').replace(/\/$/, '')
87
+ return path.join(outPath, rel, 'index.html')
88
+ }
89
+
90
+ export async function build({ root = process.cwd(), inDir, outDir = 'dist' } = {}) {
91
+ const config = await loadConfig(root)
92
+ const outPath = path.join(root, outDir)
93
+ const ignore = makeIgnore(config, path.basename(outPath))
94
+
95
+ const contentDir = await resolveContentDir({ root, inDir, config, ignore })
96
+ const contentRoot = path.resolve(root, contentDir)
97
+
98
+ const renderer = await createRenderer()
99
+ const all = await walk(contentRoot, ignore)
100
+ if (all.length === 0) {
101
+ throw new Error(`No files found in ${contentRoot}. Add markdown to the repo (or a docs/ folder).`)
102
+ }
103
+
104
+ const mdFiles = all.filter((f) => f.toLowerCase().endsWith('.md'))
105
+ const assetFiles = all.filter((f) => MEDIA_EXT.has(path.extname(f).toLowerCase()))
106
+
107
+ const byRoute = new Map()
108
+ for (const file of mdFiles) {
109
+ const raw = await fs.readFile(file, 'utf8')
110
+ const { data, content } = matter(raw)
111
+ const relSegments = path.relative(contentRoot, file).split(path.sep)
112
+ const rawSegments = relSegments.map((s) => s.replace(/\.md$/i, ''))
113
+ const leaf = rawSegments[rawSegments.length - 1]
114
+ const leafLower = leaf.toLowerCase()
115
+ const isIndex = isIndexName(leaf)
116
+ const dirSegments = rawSegments.slice(0, -1)
117
+ const routeSegments = isIndex ? dirSegments : rawSegments
118
+ const route = toRoute(routeSegments)
119
+
120
+ const { html, headings, text, title } = renderer.render(content)
121
+ const pageTitle = data.title || title || (isIndex ? titleCase(dirSegments[dirSegments.length - 1] || config.title) : titleCase(leaf))
122
+
123
+ const page = {
124
+ file,
125
+ route,
126
+ isIndex,
127
+ leafLower,
128
+ dirSegments,
129
+ fromDir: dirSegments.join('/'),
130
+ title: pageTitle,
131
+ navTitle: data.navTitle || pageTitle,
132
+ order: typeof data.order === 'number' ? data.order : 999,
133
+ hidden: data.hidden === true,
134
+ description: data.description || '',
135
+ html,
136
+ headings,
137
+ text
138
+ }
139
+
140
+ // On a route collision (index.md and README.md in the same folder), prefer index.md.
141
+ const existing = byRoute.get(route)
142
+ if (!existing || (existing.leafLower !== 'index' && page.leafLower === 'index')) {
143
+ byRoute.set(route, page)
144
+ }
145
+ }
146
+
147
+ const pages = [...byRoute.values()]
148
+ const visible = pages.filter((p) => !p.hidden)
149
+ const nav = buildNav(visible, config)
150
+ const flat = flattenNav(nav)
151
+ const routeMap = buildRouteMap(pages, contentRoot)
152
+
153
+ await fs.rm(outPath, { recursive: true, force: true })
154
+ await fs.mkdir(outPath, { recursive: true })
155
+
156
+ for (const page of pages) {
157
+ page.html = rewriteLinks(page.html, page.fromDir, routeMap)
158
+ const idx = flat.findIndex((f) => f.route === page.route)
159
+ const prev = idx > 0 ? flat[idx - 1] : null
160
+ const next = idx >= 0 && idx < flat.length - 1 ? flat[idx + 1] : null
161
+ const doc = renderPage({ config, page, nav, prev, next })
162
+ const outFile = routeToFile(outPath, page.route)
163
+ await fs.mkdir(path.dirname(outFile), { recursive: true })
164
+ await fs.writeFile(outFile, doc)
165
+ }
166
+
167
+ const assetsOut = path.join(outPath, '_assets')
168
+ await fs.mkdir(assetsOut, { recursive: true })
169
+
170
+ await fs.writeFile(path.join(outPath, '404.html'), render404({ config, nav }))
171
+
172
+ const searchIndex = visible.map((p) => ({
173
+ route: p.route,
174
+ title: p.title,
175
+ headings: p.headings.map((h) => h.text),
176
+ text: p.text.slice(0, 2000)
177
+ }))
178
+ await fs.writeFile(path.join(assetsOut, 'search-index.json'), JSON.stringify(searchIndex))
179
+
180
+ for (const name of ['styles.css', 'client.js']) {
181
+ await fs.copyFile(path.join(ASSET_SRC, name), path.join(assetsOut, name))
182
+ }
183
+
184
+ for (const file of assetFiles) {
185
+ const rel = path.relative(contentRoot, file)
186
+ const dest = path.join(outPath, rel)
187
+ await fs.mkdir(path.dirname(dest), { recursive: true })
188
+ await fs.copyFile(file, dest)
189
+ }
190
+
191
+ return { pageCount: pages.length, outPath, contentDir }
192
+ }
package/src/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ const DEFAULTS = {
5
+ title: 'Documentation',
6
+ description: '',
7
+ logo: null,
8
+ accent: '#2563eb',
9
+ repo: null,
10
+ editBase: null,
11
+ visibility: 'private',
12
+ // Where content lives, relative to the repo root. null = auto-detect:
13
+ // use "docs/" if it contains markdown, otherwise the whole repo root.
14
+ content: null,
15
+ // Extra directory/file names to skip when scanning (added to the defaults).
16
+ ignore: [],
17
+ nav: { home: 'Overview' },
18
+ footer: null
19
+ }
20
+
21
+ export async function loadConfig(root) {
22
+ const file = path.join(root, 'docs.config.json')
23
+ let user = {}
24
+ try {
25
+ user = JSON.parse(await fs.readFile(file, 'utf8'))
26
+ } catch (err) {
27
+ if (err.code !== 'ENOENT') throw err
28
+ }
29
+ return {
30
+ ...DEFAULTS,
31
+ ...user,
32
+ nav: { ...DEFAULTS.nav, ...(user.nav || {}) }
33
+ }
34
+ }
package/src/layout.js ADDED
@@ -0,0 +1,133 @@
1
+ function esc(s) {
2
+ return String(s == null ? '' : s)
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ }
8
+
9
+ function renderNavItems(items, currentRoute) {
10
+ if (!items || !items.length) return ''
11
+ const lis = items
12
+ .map((it) => {
13
+ const hasChildren = it.children && it.children.length
14
+ const isActive = it.route && it.route === currentRoute
15
+ if (hasChildren) {
16
+ const label = it.route
17
+ ? `<a class="nav-link nav-section-link${isActive ? ' active' : ''}" href="${esc(it.route)}">${esc(it.title)}</a>`
18
+ : `<span class="nav-section-label">${esc(it.title)}</span>`
19
+ return `<li class="nav-section">${label}${renderNavItems(it.children, currentRoute)}</li>`
20
+ }
21
+ return `<li><a class="nav-link${isActive ? ' active' : ''}" href="${esc(it.route)}">${esc(it.title)}</a></li>`
22
+ })
23
+ .join('')
24
+ return `<ul class="nav-list">${lis}</ul>`
25
+ }
26
+
27
+ function renderToc(headings) {
28
+ const items = (headings || []).filter((h) => h.level === 2 || h.level === 3)
29
+ if (items.length < 2) return ''
30
+ const lis = items
31
+ .map((h) => `<li class="toc-l${h.level}"><a href="#${esc(h.id)}">${esc(h.text)}</a></li>`)
32
+ .join('')
33
+ return `<aside class="toc"><div class="toc-title">On this page</div><ul>${lis}</ul></aside>`
34
+ }
35
+
36
+ function renderPager(prev, next) {
37
+ if (!prev && !next) return ''
38
+ const p = prev
39
+ ? `<a class="pager-link pager-prev" href="${esc(prev.route)}"><span class="pager-dir">← Previous</span><span class="pager-title">${esc(prev.title)}</span></a>`
40
+ : '<span></span>'
41
+ const n = next
42
+ ? `<a class="pager-link pager-next" href="${esc(next.route)}"><span class="pager-dir">Next →</span><span class="pager-title">${esc(next.title)}</span></a>`
43
+ : '<span></span>'
44
+ return `<nav class="pager">${p}${n}</nav>`
45
+ }
46
+
47
+ function header(config) {
48
+ const logo = config.logo
49
+ ? `<img class="logo" src="${esc(config.logo)}" alt="">`
50
+ : ''
51
+ const repoLink = config.repo
52
+ ? `<a class="header-link" href="${esc(config.repo)}" target="_blank" rel="noopener" aria-label="Repository">↗ Repo</a>`
53
+ : ''
54
+ const badge =
55
+ config.visibility === 'public'
56
+ ? '<span class="vis-badge vis-public">Public</span>'
57
+ : '<span class="vis-badge vis-private">Private</span>'
58
+ return `
59
+ <header class="site-header">
60
+ <button id="menu-toggle" class="menu-toggle" aria-label="Menu">☰</button>
61
+ <a class="brand" href="/">${logo}<span>${esc(config.title)}</span></a>
62
+ ${badge}
63
+ <div class="header-spacer"></div>
64
+ <div class="search">
65
+ <input id="search-input" type="search" placeholder="Search /" autocomplete="off" aria-label="Search">
66
+ <div id="search-results" class="search-results" hidden></div>
67
+ </div>
68
+ ${repoLink}
69
+ <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
70
+ <span class="theme-icon-light">☀</span><span class="theme-icon-dark">☾</span>
71
+ </button>
72
+ </header>`
73
+ }
74
+
75
+ function shell({ config, pageTitle, description, navHtml, main }) {
76
+ const accent = config.accent ? `<style>:root{--accent:${esc(config.accent)}}</style>` : ''
77
+ const desc = description || config.description || ''
78
+ return `<!doctype html>
79
+ <html lang="en" data-theme="light">
80
+ <head>
81
+ <meta charset="utf-8">
82
+ <meta name="viewport" content="width=device-width, initial-scale=1">
83
+ <title>${esc(pageTitle)}</title>
84
+ ${desc ? `<meta name="description" content="${esc(desc)}">` : ''}
85
+ <link rel="stylesheet" href="/_assets/styles.css">
86
+ ${accent}
87
+ <script>(function(){try{var t=localStorage.getItem('theme');if(!t)t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
88
+ </head>
89
+ <body>
90
+ ${header(config)}
91
+ <div class="layout">
92
+ <aside class="sidebar"><nav class="nav">${navHtml}</nav></aside>
93
+ ${main}
94
+ </div>
95
+ <div class="sidebar-backdrop" id="sidebar-backdrop"></div>
96
+ <script src="/_assets/client.js" defer></script>
97
+ </body>
98
+ </html>`
99
+ }
100
+
101
+ export function renderPage({ config, page, nav, prev, next }) {
102
+ const navHtml = renderNavItems(nav, page.route)
103
+ const toc = renderToc(page.headings)
104
+ const footer = config.footer
105
+ ? `<footer class="page-footer">${esc(config.footer)}</footer>`
106
+ : ''
107
+ const main = `
108
+ <main class="content">
109
+ <article class="prose">${page.html}</article>
110
+ ${renderPager(prev, next)}
111
+ ${footer}
112
+ </main>
113
+ ${toc}`
114
+ return shell({
115
+ config,
116
+ pageTitle: `${page.title} · ${config.title}`,
117
+ description: page.description,
118
+ navHtml,
119
+ main
120
+ })
121
+ }
122
+
123
+ export function render404({ config, nav }) {
124
+ const navHtml = renderNavItems(nav, null)
125
+ const main = `
126
+ <main class="content">
127
+ <article class="prose">
128
+ <h1>Page not found</h1>
129
+ <p>That page doesn't exist. Try the navigation, or head back <a href="/">home</a>.</p>
130
+ </article>
131
+ </main>`
132
+ return shell({ config, pageTitle: `404 · ${config.title}`, description: '', navHtml, main })
133
+ }
package/src/links.js ADDED
@@ -0,0 +1,56 @@
1
+ import path from 'node:path'
2
+
3
+ // Absolute URLs, protocol-relative, in-page anchors, root-absolute, and special
4
+ // schemes are left untouched — only repo-relative links get rewritten.
5
+ const SKIP = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#|\/|\?)/i
6
+
7
+ /** Resolve a relative target against the source file's directory, posix-style. */
8
+ function resolveRel(fromDir, target) {
9
+ const out = fromDir ? fromDir.split('/').filter(Boolean) : []
10
+ for (const seg of target.split('/')) {
11
+ if (seg === '' || seg === '.') continue
12
+ if (seg === '..') out.pop()
13
+ else out.push(seg)
14
+ }
15
+ return out.join('/')
16
+ }
17
+
18
+ /**
19
+ * Map every source path (posix, lowercased, relative to the content root) to its
20
+ * published route, so relative links can be resolved to clean URLs.
21
+ */
22
+ export function buildRouteMap(pages, contentRoot) {
23
+ const map = new Map()
24
+ for (const p of pages) {
25
+ const rel = path.relative(contentRoot, p.file).split(path.sep).join('/').toLowerCase()
26
+ map.set(rel, p.route)
27
+ if (p.isIndex) map.set((p.fromDir || '').toLowerCase(), p.route)
28
+ }
29
+ return map
30
+ }
31
+
32
+ /**
33
+ * Rewrite repo-relative href/src values in rendered HTML:
34
+ * - links to a known markdown page -> its clean route
35
+ * - everything else (images, files) -> a root-absolute path from the content root
36
+ * Unknown .md targets are left as-is so broken links stay visible rather than silently wrong.
37
+ */
38
+ export function rewriteLinks(html, fromDir, routeMap) {
39
+ return html.replace(/\b(href|src)="([^"]*)"/gi, (full, attr, url) => {
40
+ if (!url || SKIP.test(url)) return full
41
+
42
+ const cut = url.search(/[#?]/)
43
+ const pathPart = cut >= 0 ? url.slice(0, cut) : url
44
+ const suffix = cut >= 0 ? url.slice(cut) : ''
45
+ if (!pathPart) return full
46
+
47
+ const resolved = resolveRel(fromDir, pathPart)
48
+ const key = resolved.toLowerCase()
49
+
50
+ if (routeMap.has(key)) return `${attr}="${routeMap.get(key)}${suffix}"`
51
+ if (/\.(md|markdown)$/i.test(pathPart)) return full // unresolved page link — leave visible
52
+ return `${attr}="/${resolved}${suffix}"`
53
+ })
54
+ }
55
+
56
+ export { resolveRel }
@@ -0,0 +1,72 @@
1
+ import MarkdownIt from 'markdown-it'
2
+ import Shiki from '@shikijs/markdown-it'
3
+ import anchor from 'markdown-it-anchor'
4
+
5
+ function slugify(s) {
6
+ return String(s)
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/[^\w\s-]/g, '')
10
+ .replace(/\s+/g, '-')
11
+ .replace(/-+/g, '-')
12
+ }
13
+
14
+ function stripHtml(html) {
15
+ return html
16
+ .replace(/<pre[\s\S]*?<\/pre>/gi, ' ')
17
+ .replace(/<[^>]+>/g, ' ')
18
+ .replace(/&amp;/g, '&')
19
+ .replace(/&lt;/g, '<')
20
+ .replace(/&gt;/g, '>')
21
+ .replace(/&quot;/g, '"')
22
+ .replace(/&#39;/g, "'")
23
+ .replace(/\s+/g, ' ')
24
+ .trim()
25
+ }
26
+
27
+ /**
28
+ * Builds a reusable markdown renderer. Async because Shiki loads grammars/themes.
29
+ */
30
+ export async function createRenderer() {
31
+ const md = new MarkdownIt({ html: true, linkify: true, typographer: true })
32
+
33
+ md.use(anchor, {
34
+ slugify,
35
+ permalink: anchor.permalink.linkInsideHeader({
36
+ symbol: '#',
37
+ placement: 'before',
38
+ class: 'header-anchor'
39
+ })
40
+ })
41
+
42
+ md.use(
43
+ await Shiki({
44
+ themes: { light: 'github-light', dark: 'github-dark' },
45
+ defaultColor: false
46
+ })
47
+ )
48
+
49
+ return {
50
+ render(content) {
51
+ const tokens = md.parse(content, {})
52
+ const headings = []
53
+ let title = null
54
+
55
+ for (let i = 0; i < tokens.length; i++) {
56
+ const t = tokens[i]
57
+ if (t.type === 'heading_open') {
58
+ const level = Number(t.tag.slice(1))
59
+ const inline = tokens[i + 1]
60
+ const text = inline && inline.type === 'inline' ? inline.content : ''
61
+ if (level === 1 && !title) title = text
62
+ if (level >= 2 && level <= 3) headings.push({ level, text, id: slugify(text) })
63
+ }
64
+ }
65
+
66
+ const html = md.renderer.render(tokens, md.options, {})
67
+ return { html, headings, text: stripHtml(html), title }
68
+ }
69
+ }
70
+ }
71
+
72
+ export { slugify }
package/src/nav.js ADDED
@@ -0,0 +1,68 @@
1
+ function titleCase(s) {
2
+ return String(s)
3
+ .replace(/[-_]/g, ' ')
4
+ .replace(/\b\w/g, (c) => c.toUpperCase())
5
+ }
6
+
7
+ function makeNode(name) {
8
+ return { name, children: new Map(), pages: [], index: null }
9
+ }
10
+
11
+ /**
12
+ * Build a nested nav tree from the flat page list.
13
+ * A directory = a section; an index page (index.md or README.md) inside it becomes
14
+ * that section's landing page.
15
+ */
16
+ export function buildNav(pages, config) {
17
+ const root = makeNode('')
18
+
19
+ for (const p of pages) {
20
+ let node = root
21
+ for (const d of p.dirSegments) {
22
+ if (!node.children.has(d)) node.children.set(d, makeNode(d))
23
+ node = node.children.get(d)
24
+ }
25
+ if (p.isIndex) node.index = p
26
+ else node.pages.push(p)
27
+ }
28
+
29
+ return nodeToItems(root, config, true)
30
+ }
31
+
32
+ function nodeToItems(node, config, isRoot) {
33
+ const items = []
34
+
35
+ if (isRoot && node.index) {
36
+ items.push({
37
+ title: (config.nav && config.nav.home) || 'Overview',
38
+ route: node.index.route,
39
+ order: -Infinity
40
+ })
41
+ }
42
+
43
+ for (const p of node.pages) {
44
+ items.push({ title: p.navTitle, route: p.route, order: p.order })
45
+ }
46
+
47
+ for (const [name, child] of node.children) {
48
+ const children = nodeToItems(child, config, false)
49
+ items.push({
50
+ title: child.index ? child.index.navTitle : titleCase(name),
51
+ route: child.index ? child.index.route : null,
52
+ order: child.index ? child.index.order : 999,
53
+ children
54
+ })
55
+ }
56
+
57
+ items.sort((a, b) => (a.order ?? 999) - (b.order ?? 999) || a.title.localeCompare(b.title))
58
+ return items
59
+ }
60
+
61
+ /** Depth-first list of every linkable page, in sidebar order (for prev/next). */
62
+ export function flattenNav(items, acc = []) {
63
+ for (const it of items) {
64
+ if (it.route) acc.push({ title: it.title, route: it.route })
65
+ if (it.children) flattenNav(it.children, acc)
66
+ }
67
+ return acc
68
+ }
package/src/serve.js ADDED
@@ -0,0 +1,63 @@
1
+ import http from 'node:http'
2
+ import { promises as fs, createReadStream } from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ const TYPES = {
6
+ '.html': 'text/html; charset=utf-8',
7
+ '.css': 'text/css; charset=utf-8',
8
+ '.js': 'text/javascript; charset=utf-8',
9
+ '.json': 'application/json; charset=utf-8',
10
+ '.svg': 'image/svg+xml',
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg',
13
+ '.jpeg': 'image/jpeg',
14
+ '.gif': 'image/gif',
15
+ '.webp': 'image/webp',
16
+ '.ico': 'image/x-icon',
17
+ '.woff2': 'font/woff2'
18
+ }
19
+
20
+ async function resolveFile(dir, urlPath) {
21
+ let p = decodeURIComponent(urlPath.split('?')[0])
22
+ if (p.endsWith('/')) p += 'index.html'
23
+ let full = path.join(dir, p)
24
+ try {
25
+ const stat = await fs.stat(full)
26
+ if (stat.isDirectory()) full = path.join(full, 'index.html')
27
+ await fs.access(full)
28
+ return full
29
+ } catch {
30
+ if (!path.extname(full)) {
31
+ const asDir = path.join(dir, p, 'index.html')
32
+ try {
33
+ await fs.access(asDir)
34
+ return asDir
35
+ } catch {
36
+ /* fall through */
37
+ }
38
+ }
39
+ return null
40
+ }
41
+ }
42
+
43
+ export function serve({ dir, port = 8788 }) {
44
+ const server = http.createServer(async (req, res) => {
45
+ const file = await resolveFile(dir, req.url || '/')
46
+ if (file) {
47
+ res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
48
+ createReadStream(file).pipe(res)
49
+ return
50
+ }
51
+ const notFound = path.join(dir, '404.html')
52
+ try {
53
+ const body = await fs.readFile(notFound)
54
+ res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' })
55
+ res.end(body)
56
+ } catch {
57
+ res.writeHead(404, { 'content-type': 'text/plain' })
58
+ res.end('Not found')
59
+ }
60
+ })
61
+ server.listen(port)
62
+ return server
63
+ }
package/src/watch.js ADDED
@@ -0,0 +1,41 @@
1
+ import { watch } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { build } from './build.js'
4
+
5
+ const IGNORED = /(^|[\\/])(node_modules|\.git|dist)([\\/]|$)/
6
+
7
+ /**
8
+ * Rebuild on any change under the content directory. Debounced so a burst of
9
+ * filesystem events triggers a single rebuild. Output and dependency directories are
10
+ * ignored so writing dist/ doesn't trigger an infinite rebuild loop (matters when the
11
+ * content root is the whole repo).
12
+ */
13
+ export function watchAndRebuild({ root, inDir, outDir }) {
14
+ const watchPath = inDir === '.' ? root : path.join(root, inDir)
15
+ const outBase = path.basename(outDir)
16
+ let timer = null
17
+ let building = false
18
+
19
+ const onChange = (_event, filename) => {
20
+ if (filename && (IGNORED.test(filename) || filename.split(/[\\/]/).includes(outBase))) return
21
+ if (timer) clearTimeout(timer)
22
+ timer = setTimeout(async () => {
23
+ if (building) return
24
+ building = true
25
+ try {
26
+ const { pageCount } = await build({ root, inDir: inDir === '.' ? '.' : inDir, outDir })
27
+ console.log(`↻ Rebuilt ${pageCount} page(s)`)
28
+ } catch (err) {
29
+ console.error('Build failed:', err.message)
30
+ } finally {
31
+ building = false
32
+ }
33
+ }, 150)
34
+ }
35
+
36
+ try {
37
+ watch(watchPath, { recursive: true }, onChange)
38
+ } catch {
39
+ watch(watchPath, onChange)
40
+ }
41
+ }