@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 +42 -0
- package/assets/client.js +120 -0
- package/assets/styles.css +216 -0
- package/bin/cli.js +64 -0
- package/package.json +43 -0
- package/src/build.js +192 -0
- package/src/config.js +34 -0
- package/src/layout.js +133 -0
- package/src/links.js +56 -0
- package/src/markdown.js +72 -0
- package/src/nav.js +68 -0
- package/src/serve.js +63 -0
- package/src/watch.js +41 -0
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
|
package/assets/client.js
ADDED
|
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
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 }
|
package/src/markdown.js
ADDED
|
@@ -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(/&/g, '&')
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
.replace(/"/g, '"')
|
|
22
|
+
.replace(/'/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
|
+
}
|