lowkey-doc 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +52 -2
- package/bin/lowkey-doc.mjs +171 -0
- package/commands/docs.md +38 -0
- package/licenses/elkjs.txt +263 -0
- package/licenses/inter.txt +93 -0
- package/licenses/mermaid.txt +21 -0
- package/package.json +44 -4
- package/skills/audit-docs/SKILL.md +71 -0
- package/skills/write-doc/SKILL.md +97 -0
- package/src/build.mjs +131 -0
- package/src/client/api.mjs +102 -0
- package/src/client/app.mjs +1184 -0
- package/src/client/css.mjs +853 -0
- package/src/client/diagrams.mjs +2477 -0
- package/src/client/live.mjs +156 -0
- package/src/collect.mjs +154 -0
- package/src/config.mjs +166 -0
- package/src/init.mjs +103 -0
- package/src/markdown.mjs +525 -0
- package/src/page.mjs +134 -0
- package/src/sections.mjs +63 -0
- package/src/vendor.mjs +151 -0
- package/src/watch.mjs +374 -0
package/src/markdown.mjs
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// markdown.mjs
|
|
2
|
+
// ------------------------------------------------------------------
|
|
3
|
+
// A small, dependency-free Markdown renderer, tuned for the kind of
|
|
4
|
+
// document this dashboard shows: prose, tables, fenced code, mermaid
|
|
5
|
+
// diagrams, and links that point at sibling documents.
|
|
6
|
+
//
|
|
7
|
+
// It is deliberately not a general-purpose CommonMark implementation.
|
|
8
|
+
// It handles the constructs documents actually use, and leaves anything
|
|
9
|
+
// exotic as literal text rather than guessing.
|
|
10
|
+
// ------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
// Printable, collision-proof placeholders used while parsing.
|
|
15
|
+
const SPAN_OPEN = '@@span7f3a:';
|
|
16
|
+
const SPAN_CLOSE = '@@';
|
|
17
|
+
const BLOCK_RE = /^@@block7f3a:(\d+)@@$/;
|
|
18
|
+
const SPAN_RE = /@@span7f3a:(\d+)@@/g;
|
|
19
|
+
|
|
20
|
+
// What a diagram's header calls it: its kind, and the title its front matter
|
|
21
|
+
// gives it, if any (a `title:` between two `---` lines, as mermaid reads it).
|
|
22
|
+
const DIAGRAM_KINDS = {
|
|
23
|
+
flowchart: 'Flowchart', graph: 'Flowchart', erdiagram: 'ER diagram', sequencediagram: 'Sequence diagram',
|
|
24
|
+
classdiagram: 'Class diagram', statediagram: 'State diagram', 'statediagram-v2': 'State diagram',
|
|
25
|
+
gantt: 'Gantt chart', pie: 'Pie chart', journey: 'User journey', mindmap: 'Mind map',
|
|
26
|
+
timeline: 'Timeline', gitgraph: 'Git graph', quadrantchart: 'Quadrant chart',
|
|
27
|
+
};
|
|
28
|
+
function diagramName(code) {
|
|
29
|
+
let title = '';
|
|
30
|
+
const body = String(code).replace(/^\s*---\r?\n([\s\S]*?)\r?\n---[ \t]*(\r?\n|$)/, (_, fm) => {
|
|
31
|
+
const t = fm.match(/^\s*title\s*:\s*(.+?)\s*$/m);
|
|
32
|
+
if (t) title = t[1].replace(/^(["'])(.*)\1$/, '$2');
|
|
33
|
+
return '';
|
|
34
|
+
});
|
|
35
|
+
const first = body.split(/\r?\n/).map((l) => l.trim()).find((l) => l && !l.startsWith('%%')) || '';
|
|
36
|
+
const kind = DIAGRAM_KINDS[first.split(/\s/)[0].toLowerCase()] || 'Diagram';
|
|
37
|
+
return '<span class="diagram-name"><span class="diagram-kind">' + esc(kind) + '</span>' +
|
|
38
|
+
(title ? '<span class="diagram-title">' + esc(title) + '</span>' : '') + '</span>';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const esc = (s) => s
|
|
42
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
43
|
+
|
|
44
|
+
// GitHub-compatible heading slugs, so ./3.%20SCHEMA.md#op-7 resolves.
|
|
45
|
+
export function slugify(text) {
|
|
46
|
+
return text.trim().toLowerCase()
|
|
47
|
+
.replace(/[^\p{L}\p{N}\-_ ]+/gu, '')
|
|
48
|
+
.replace(/ /g, '-'); // one hyphen per space, exactly like GitHub
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Heading text minus its markdown formatting (for slugs and the sidebar tree).
|
|
52
|
+
export const plain = (s) => s
|
|
53
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
54
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
55
|
+
.replace(/<[^>]+>/g, '')
|
|
56
|
+
.replace(/\*+/g, '')
|
|
57
|
+
.replace(/~~/g, '')
|
|
58
|
+
.replace(/(^|[^\w\\])_+(?!\s)([^_\n]+?)(?<!\s)_+(?!\w)/g, '$1$2') // _emphasis_, never user_private
|
|
59
|
+
.trim();
|
|
60
|
+
|
|
61
|
+
/* ------------------------------------------------------------ http calls */
|
|
62
|
+
|
|
63
|
+
// The verbs an API call is made with. Each has its own colour, in the call
|
|
64
|
+
// blocks and wherever a code span names one in the prose.
|
|
65
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
66
|
+
const METHOD_RE = new RegExp('^(' + METHODS.join('|') + ')(?:\\s+(\\S.*))?$');
|
|
67
|
+
|
|
68
|
+
// A url with its {placeholders} set apart from the fixed text around them.
|
|
69
|
+
const urlHtml = (url) => esc(url).replace(/\{[^}\s]+\}/g, (v) => '<span class="c-var">' + v + '</span>');
|
|
70
|
+
|
|
71
|
+
// A code span. One that is a verb, or a verb and a url, reads as a call.
|
|
72
|
+
function codeSpan(code) {
|
|
73
|
+
const m = code.match(METHOD_RE);
|
|
74
|
+
if (!m) return '<code>' + urlHtml(code) + '</code>';
|
|
75
|
+
const verb = '<span class="c-verb m-' + m[1].toLowerCase() + '">' + m[1] + '</span>';
|
|
76
|
+
return m[2]
|
|
77
|
+
? '<code class="c-call">' + verb + ' ' + urlHtml(m[2]) + '</code>'
|
|
78
|
+
: '<code class="c-verb-only m-' + m[1].toLowerCase() + '">' + m[1] + '</code>';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// JSON, coloured by what each token is: keys, strings, numbers, the three
|
|
82
|
+
// literals, and the punctuation faded back so the values carry the eye.
|
|
83
|
+
const JSON_RE = /("(?:\\.|[^"\\\n])*")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}[\],:])/g;
|
|
84
|
+
function jsonHtml(code) {
|
|
85
|
+
let out = '', pos = 0, m;
|
|
86
|
+
JSON_RE.lastIndex = 0;
|
|
87
|
+
while ((m = JSON_RE.exec(code))) {
|
|
88
|
+
out += esc(code.slice(pos, m.index));
|
|
89
|
+
if (m[1]) out += '<span class="j-' + (m[2] ? 'key' : 'str') + '">' + esc(m[1]) + '</span>' +
|
|
90
|
+
(m[2] ? '<span class="j-pun">' + esc(m[2]) + '</span>' : '');
|
|
91
|
+
else if (m[3]) out += '<span class="j-lit">' + m[3] + '</span>';
|
|
92
|
+
else if (m[4]) out += '<span class="j-num">' + m[4] + '</span>';
|
|
93
|
+
else out += '<span class="j-pun">' + esc(m[5]) + '</span>';
|
|
94
|
+
pos = m.index + m[0].length;
|
|
95
|
+
}
|
|
96
|
+
return out + esc(code.slice(pos));
|
|
97
|
+
}
|
|
98
|
+
const looksJson = (code) => /^\s*[{[]/.test(code);
|
|
99
|
+
|
|
100
|
+
// SQL, coloured the way an editor would: keywords, data types, function
|
|
101
|
+
// calls, strings, numbers and comments, with @variables and [bracketed] or
|
|
102
|
+
// "quoted" names kept whole. It knows the words, not the grammar - enough
|
|
103
|
+
// for a statement in a document, across the T-SQL, Postgres and ANSI flavours.
|
|
104
|
+
const SQL_KEYWORDS = new Set(('select from where and or not in is null as on join inner left right full outer cross ' +
|
|
105
|
+
'apply group by order having distinct top limit offset fetch next rows only union all except intersect ' +
|
|
106
|
+
'insert into values update set delete merge using matched then output inserted deleted returning ' +
|
|
107
|
+
'create alter drop table view index unique clustered nonclustered include primary key foreign references ' +
|
|
108
|
+
'constraint default check identity schema procedure proc function trigger returns return begin end ' +
|
|
109
|
+
'declare if else while case when exists between like with nolock rowlock updlock holdlock readpast ' +
|
|
110
|
+
'transaction tran commit rollback save go exec execute asc desc over partition cascade to grant revoke ' +
|
|
111
|
+
'truncate for of nocount xact_abort try catch throw raiserror print sequence temporary temp').split(' '));
|
|
112
|
+
const SQL_TYPES = new Set(('int bigint smallint tinyint bit decimal numeric money float real char varchar nchar ' +
|
|
113
|
+
'nvarchar text ntext date time datetime datetime2 datetimeoffset smalldatetime uniqueidentifier binary ' +
|
|
114
|
+
'varbinary xml json boolean bool serial bigserial integer timestamp timestamptz interval uuid jsonb max').split(' '));
|
|
115
|
+
const SQL_RE = /(--[^\n]*|\/\*[\s\S]*?\*\/)|(N?'(?:''|[^'])*')|("[^"\n]*"|\[[^\]\n]*\])|(@@?\w+)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_][\w$#]*)(?=(\()?)|(<>|!=|<=|>=|[=<>+\-*/%,;()])/g;
|
|
116
|
+
function sqlHtml(code) {
|
|
117
|
+
let out = '', pos = 0, m;
|
|
118
|
+
SQL_RE.lastIndex = 0;
|
|
119
|
+
const span = (cls, text) => '<span class="s-' + cls + '">' + esc(text) + '</span>';
|
|
120
|
+
while ((m = SQL_RE.exec(code))) {
|
|
121
|
+
out += esc(code.slice(pos, m.index));
|
|
122
|
+
const w = m[6] && m[6].toLowerCase();
|
|
123
|
+
out += m[1] ? span('com', m[1])
|
|
124
|
+
: m[2] ? span('str', m[2])
|
|
125
|
+
: m[3] ? span('id', m[3])
|
|
126
|
+
: m[4] ? span('var', m[4])
|
|
127
|
+
: m[5] ? span('num', m[5])
|
|
128
|
+
: m[6] ? (SQL_KEYWORDS.has(w) ? span('kw', m[6]) : SQL_TYPES.has(w) ? span('type', m[6])
|
|
129
|
+
: m[7] ? span('fn', m[6]) : esc(m[6]))
|
|
130
|
+
: span('pun', m[8]);
|
|
131
|
+
pos = m.index + m[0].length;
|
|
132
|
+
}
|
|
133
|
+
return out + esc(code.slice(pos));
|
|
134
|
+
}
|
|
135
|
+
const SQL_LANGS = new Set(['sql', 'tsql', 'mssql', 'postgres', 'postgresql', 'pgsql', 'mysql', 'plsql', 'sqlite']);
|
|
136
|
+
|
|
137
|
+
// A status code's colour: the verbs' palette, reused by what the code means.
|
|
138
|
+
const statusTone = (n) => (n < 300 ? 'post' : n < 400 ? 'get' : n < 500 ? 'put' : 'delete');
|
|
139
|
+
|
|
140
|
+
// A fenced http block, drawn as the exchange it shows. Each message - the
|
|
141
|
+
// request, and any response written after it, starting at its status line -
|
|
142
|
+
// gets a header like an ```api block's (the verb and the url, or the status
|
|
143
|
+
// and its reason), then its HTTP headers as a list of names and values, then
|
|
144
|
+
// its body, coloured when it is JSON. A block of responses alone works too.
|
|
145
|
+
// Anything that does not start with a request or a status line stays an
|
|
146
|
+
// ordinary code block.
|
|
147
|
+
const REQUEST_RE = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/[\d.]+)?$/;
|
|
148
|
+
const STATUS_RE = /^HTTP\/[\d.]+\s+(\d{3})\s*(.*)$/;
|
|
149
|
+
function httpBlock(code) {
|
|
150
|
+
const lines = code.replace(/^\s*\n/, '').replace(/\s+$/, '').split('\n');
|
|
151
|
+
const first = (lines[0] || '').trim();
|
|
152
|
+
const req = first.match(REQUEST_RE);
|
|
153
|
+
if (!(req && METHODS.includes(req[1])) && !STATUS_RE.test(first)) return null;
|
|
154
|
+
|
|
155
|
+
// a status line after a blank one starts the next message
|
|
156
|
+
const messages = [];
|
|
157
|
+
let cur = [];
|
|
158
|
+
lines.forEach((line, n) => {
|
|
159
|
+
if (n > 0 && STATUS_RE.test(line.trim()) && !lines[n - 1].trim()) { messages.push(cur); cur = []; }
|
|
160
|
+
cur.push(line);
|
|
161
|
+
});
|
|
162
|
+
messages.push(cur);
|
|
163
|
+
return '<div class="http">' + messages.map(httpMessage).join('') + '</div>';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function httpMessage(lines) {
|
|
167
|
+
const first = lines[0].trim();
|
|
168
|
+
const req = first.match(REQUEST_RE);
|
|
169
|
+
const res = !req && first.match(STATUS_RE);
|
|
170
|
+
|
|
171
|
+
let i = 1;
|
|
172
|
+
const headers = [];
|
|
173
|
+
for (; i < lines.length && lines[i].trim(); i++) {
|
|
174
|
+
const h = lines[i].match(/^\s*([\w-]+)\s*:\s*(.*)$/);
|
|
175
|
+
if (!h) break;
|
|
176
|
+
headers.push(h);
|
|
177
|
+
}
|
|
178
|
+
const body = lines.slice(i).join('\n').replace(/^\s*\n/, '').replace(/\s+$/, '');
|
|
179
|
+
|
|
180
|
+
const tone = req ? req[1].toLowerCase() : statusTone(+res[1]);
|
|
181
|
+
const badge = req ? esc(req[1]) : res[1];
|
|
182
|
+
const what = req
|
|
183
|
+
? '<code class="api-url">' + urlHtml(req[2]).replace(/^(https?:\/\/)/, '<span class="api-scheme">$1</span>') + '</code>'
|
|
184
|
+
: '<span class="http-reason">' + esc(res[2]) + '</span>';
|
|
185
|
+
return '<div class="http-msg ' + (req ? 'is-req' : 'is-res') + ' m-' + tone + '">' +
|
|
186
|
+
'<div class="http-head"><span class="api-verb">' + badge + '</span>' + what +
|
|
187
|
+
'<span class="http-kind">' + (req ? 'Request' : 'Response') + '</span></div>' +
|
|
188
|
+
(headers.length ? '<dl class="http-headers">' + headers.map((h) =>
|
|
189
|
+
'<div><dt>' + esc(h[1]) + '</dt><dd>' + esc(h[2]) + '</dd></div>').join('') + '</dl>' : '') +
|
|
190
|
+
(body ? '<pre class="http-body"><code>' + (looksJson(body) ? jsonHtml(body) : esc(body)) + '</code></pre>' : '') +
|
|
191
|
+
'</div>';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// An ```api block: one call, as a box whose header - verb and url - is always
|
|
195
|
+
// there, and whose body, folded away until asked for, says what each part of
|
|
196
|
+
// the url is. The source is the request line, then one `part: description`
|
|
197
|
+
// per part, where the part is a {placeholder} or a fixed piece of the path;
|
|
198
|
+
// lines indented under a part carry on its description, and a `- ` line
|
|
199
|
+
// among them is a bullet (what the value may be, what it may not).
|
|
200
|
+
//
|
|
201
|
+
// PUT https://{host}/api/v1/voyages/{voyageId}
|
|
202
|
+
//
|
|
203
|
+
// {host}: the dock's own server
|
|
204
|
+
// {voyageId}: the voyage booked earlier
|
|
205
|
+
// - a positive integer
|
|
206
|
+
// - never reused
|
|
207
|
+
function apiBlock(code, ctx) {
|
|
208
|
+
const lines = code.split('\n');
|
|
209
|
+
let i = 0;
|
|
210
|
+
while (i < lines.length && !lines[i].trim()) i++;
|
|
211
|
+
const req = (lines[i] || '').trim().match(/^([A-Za-z]+)\s+(\S+)\s*(.*)$/);
|
|
212
|
+
if (!req) return null;
|
|
213
|
+
const method = req[1].toUpperCase();
|
|
214
|
+
const url = req[2];
|
|
215
|
+
const title = req[3].replace(/^[-·—:]\s*/, '');
|
|
216
|
+
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (i++; i < lines.length; i++) {
|
|
219
|
+
const line = lines[i];
|
|
220
|
+
if (!line.trim()) continue;
|
|
221
|
+
const def = !/^\s/.test(line) && line.match(/^`?([^`\s][^`]*?)`?\s*:\s+(.*)$/);
|
|
222
|
+
if (def) { parts.push({ key: def[1].trim(), text: [def[2]] }); continue; }
|
|
223
|
+
if (parts.length) parts[parts.length - 1].text.push(line.trim());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Where each part sits in the url: a placeholder anywhere, a fixed piece
|
|
227
|
+
// only between separators, so `close` never lights up inside `closed`.
|
|
228
|
+
const spans = [];
|
|
229
|
+
parts.forEach((p, n) => {
|
|
230
|
+
let from = 0, at;
|
|
231
|
+
while ((at = url.indexOf(p.key, from)) !== -1) {
|
|
232
|
+
const end = at + p.key.length;
|
|
233
|
+
const edge = p.key.startsWith('{') ||
|
|
234
|
+
((at === 0 || /[/.:?&=]/.test(url[at - 1])) && (end === url.length || /[/.?&=#]/.test(url[end])));
|
|
235
|
+
if (edge && !spans.some((s) => at < s.end && end > s.at)) { spans.push({ at, end, n }); break; }
|
|
236
|
+
from = at + 1;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
spans.sort((a, b) => a.at - b.at);
|
|
240
|
+
let urlOut = '', pos = 0;
|
|
241
|
+
for (const s of spans) {
|
|
242
|
+
urlOut += urlHtml(url.slice(pos, s.at)) +
|
|
243
|
+
'<span class="api-part" data-part="' + s.n + '">' + urlHtml(url.slice(s.at, s.end)) + '</span>';
|
|
244
|
+
pos = s.end;
|
|
245
|
+
}
|
|
246
|
+
urlOut += urlHtml(url.slice(pos));
|
|
247
|
+
urlOut = urlOut.replace(/^(https?:\/\/)/, '<span class="api-scheme">$1</span>');
|
|
248
|
+
|
|
249
|
+
const desc = (text) => {
|
|
250
|
+
const prose = [], bullets = [];
|
|
251
|
+
for (const t of text) {
|
|
252
|
+
if (/^[-*]\s+/.test(t)) bullets.push(t.replace(/^[-*]\s+/, ''));
|
|
253
|
+
else if (bullets.length) bullets[bullets.length - 1] += ' ' + t;
|
|
254
|
+
else prose.push(t);
|
|
255
|
+
}
|
|
256
|
+
return (prose.length ? '<p>' + inline(prose.join(' '), ctx) + '</p>' : '') +
|
|
257
|
+
(bullets.length ? '<ul>' + bullets.map((b) => '<li>' + inline(b, ctx) + '</li>').join('') + '</ul>' : '');
|
|
258
|
+
};
|
|
259
|
+
const rows = parts.map((p, n) =>
|
|
260
|
+
'<div class="api-row" data-part="' + n + '">' +
|
|
261
|
+
'<dt><code>' + urlHtml(p.key) + '</code></dt>' +
|
|
262
|
+
'<dd>' + desc(p.text) + '</dd></div>').join('');
|
|
263
|
+
|
|
264
|
+
const chev = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
|
|
265
|
+
'stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l4 4-4 4"/></svg>';
|
|
266
|
+
const open = parts.length > 0;
|
|
267
|
+
return '<div class="api m-' + method.toLowerCase() + '" data-api>' +
|
|
268
|
+
'<div class="api-head"' + (open ? ' role="button" tabindex="0" aria-expanded="false"' : '') + '>' +
|
|
269
|
+
'<span class="api-verb">' + esc(method) + '</span>' +
|
|
270
|
+
'<code class="api-url">' + urlOut + '</code>' +
|
|
271
|
+
(title ? '<span class="api-title">' + inline(title, ctx) + '</span>' : '') +
|
|
272
|
+
(open ? '<span class="api-tog" aria-hidden="true">' + chev + '</span>' : '') +
|
|
273
|
+
'</div>' +
|
|
274
|
+
(open ? '<dl class="api-body">' + rows + '</dl>' : '') +
|
|
275
|
+
'</div>';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* ------------------------------------------------------- inline markdown */
|
|
279
|
+
|
|
280
|
+
export function inline(src, ctx) {
|
|
281
|
+
const stash = [];
|
|
282
|
+
const put = (html) => SPAN_OPEN + (stash.push(html) - 1) + SPAN_CLOSE;
|
|
283
|
+
|
|
284
|
+
// 1. code spans first - nothing inside them is markdown
|
|
285
|
+
let s = src.replace(/(`+)([\s\S]*?)\1/g, (_, _t, code) => put(codeSpan(code.trim())));
|
|
286
|
+
|
|
287
|
+
// 2. the only raw HTML documents are expected to use: link targets and hard breaks
|
|
288
|
+
s = s.replace(/<a id="([\w.-]+)"><\/a>/g, (_, id) => put('<a id="' + id + '"></a>'));
|
|
289
|
+
s = s.replace(/<br\s*\/?>/gi, () => put('<br>'));
|
|
290
|
+
|
|
291
|
+
// 3. everything that survives is text
|
|
292
|
+
s = esc(s);
|
|
293
|
+
|
|
294
|
+
// 4. images, then links (both become opaque so later rules cannot touch them)
|
|
295
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g,
|
|
296
|
+
(_, alt, url) => put('<img src="' + url + '" alt="' + alt + '" loading="lazy">'));
|
|
297
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g,
|
|
298
|
+
(_, text, href) => put(link(href, text, ctx)));
|
|
299
|
+
s = s.replace(/<(https?:\/\/[^\s&]+)>/g,
|
|
300
|
+
(_, url) => put('<a href="' + url + '" target="_blank" rel="noreferrer">' + url + '</a>'));
|
|
301
|
+
|
|
302
|
+
// 5. emphasis
|
|
303
|
+
s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>');
|
|
304
|
+
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>');
|
|
305
|
+
s = s.replace(/(^|[^\w])__(\S[\s\S]*?\S|\S)__(?!\w)/g, '$1<strong>$2</strong>');
|
|
306
|
+
s = s.replace(/(^|[^*])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)/g, '$1<em>$2</em>');
|
|
307
|
+
s = s.replace(/(^|[^\w\\])_(?!\s)([^_\n]+?)(?<!\s)_(?!\w)/g, '$1<em>$2</em>');
|
|
308
|
+
|
|
309
|
+
// 6. bare urls left in plain text
|
|
310
|
+
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+[^\s<).,;:])/g,
|
|
311
|
+
(_, pre, url) => pre + '<a href="' + url + '" target="_blank" rel="noreferrer">' + url + '</a>');
|
|
312
|
+
|
|
313
|
+
// restore repeatedly: a link's own text can hold stashed code spans
|
|
314
|
+
for (let pass = 0; pass < 12 && s.includes(SPAN_OPEN); pass++) {
|
|
315
|
+
s = s.replace(SPAN_RE, (_, i) => stash[+i]);
|
|
316
|
+
}
|
|
317
|
+
return s;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Rewrite links between documents into dashboard routes; leave the rest alone.
|
|
321
|
+
function link(href, text, ctx) {
|
|
322
|
+
const raw = href.replace(/^"|"$/g, '');
|
|
323
|
+
if (raw.startsWith('#')) return '<a href="' + raw + '" class="x-anchor">' + text + '</a>';
|
|
324
|
+
if (/^[a-z]+:/i.test(raw)) return '<a href="' + raw + '" target="_blank" rel="noreferrer">' + text + '</a>';
|
|
325
|
+
|
|
326
|
+
// "./audits/one.md" first, then the bare filename - see assignIds
|
|
327
|
+
const [file, hash] = decodeURIComponent(raw).split('#');
|
|
328
|
+
const rel = file.replace(/^\.\//, '').replace(/^\//, '');
|
|
329
|
+
const id = ctx.byFile.get(rel) || ctx.byFile.get(path.basename(file));
|
|
330
|
+
if (id) return '<a href="#/' + id + (hash ? '/' + hash : '') + '" class="x-doc">' + text + '</a>';
|
|
331
|
+
return '<a href="' + raw + '">' + text + '</a>';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* -------------------------------------------------------- block markdown */
|
|
335
|
+
|
|
336
|
+
function splitRow(line) {
|
|
337
|
+
const cells = [];
|
|
338
|
+
let cur = '', tick = 0;
|
|
339
|
+
for (let i = 0; i < line.length; i++) {
|
|
340
|
+
const ch = line[i];
|
|
341
|
+
if (ch === '\\' && line[i + 1] === '|') { cur += '|'; i++; continue; }
|
|
342
|
+
if (ch === '`') {
|
|
343
|
+
let n = 0; while (line[i + n] === '`') n++;
|
|
344
|
+
if (tick === 0) tick = n; else if (tick === n) tick = 0;
|
|
345
|
+
cur += '`'.repeat(n); i += n - 1; continue;
|
|
346
|
+
}
|
|
347
|
+
if (ch === '|' && tick === 0) { cells.push(cur); cur = ''; continue; }
|
|
348
|
+
cur += ch;
|
|
349
|
+
}
|
|
350
|
+
cells.push(cur);
|
|
351
|
+
if (cells.length && !cells[0].trim()) cells.shift();
|
|
352
|
+
if (cells.length && !cells[cells.length - 1].trim()) cells.pop();
|
|
353
|
+
return cells.map((c) => c.trim());
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const isDelim = (line) => {
|
|
357
|
+
const cells = splitRow(line);
|
|
358
|
+
return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c));
|
|
359
|
+
};
|
|
360
|
+
const listMark = (line) => line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
|
361
|
+
const indentOf = (line) => (line.match(/^\s*/) || [''])[0].length;
|
|
362
|
+
|
|
363
|
+
export function renderBlocks(lines, ctx, asArray) {
|
|
364
|
+
const out = [];
|
|
365
|
+
let i = 0;
|
|
366
|
+
|
|
367
|
+
const para = [];
|
|
368
|
+
const flush = () => {
|
|
369
|
+
if (!para.length) return;
|
|
370
|
+
out.push('<p>' + inline(para.join('\n'), ctx) + '</p>');
|
|
371
|
+
para.length = 0;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
while (i < lines.length) {
|
|
375
|
+
const line = lines[i];
|
|
376
|
+
|
|
377
|
+
if (!line.trim()) { flush(); i++; continue; }
|
|
378
|
+
|
|
379
|
+
// fenced code / mermaid, parked earlier as a placeholder line
|
|
380
|
+
const park = line.match(BLOCK_RE);
|
|
381
|
+
if (park) { flush(); out.push(ctx.blocks[+park[1]]); i++; continue; }
|
|
382
|
+
|
|
383
|
+
const head = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
384
|
+
if (head) {
|
|
385
|
+
flush();
|
|
386
|
+
const level = head[1].length;
|
|
387
|
+
const text = head[2];
|
|
388
|
+
const id = ctx.uniqueSlug(slugify(plain(text)));
|
|
389
|
+
ctx.headings.push({ id, level, text: plain(text) });
|
|
390
|
+
out.push('<h' + level + ' id="' + id + '">' +
|
|
391
|
+
'<a class="h-anchor" href="#' + id + '" aria-hidden="true">#</a>' +
|
|
392
|
+
inline(text, ctx) + '</h' + level + '>');
|
|
393
|
+
i++; continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (/^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) { flush(); out.push('<hr>'); i++; continue; }
|
|
397
|
+
|
|
398
|
+
if (/^ {0,3}>/.test(line)) {
|
|
399
|
+
flush();
|
|
400
|
+
const buf = [];
|
|
401
|
+
while (i < lines.length && (/^ {0,3}>/.test(lines[i]) || (buf.length && lines[i].trim() && !listMark(lines[i])))) {
|
|
402
|
+
buf.push(lines[i].replace(/^ {0,3}> ?/, ''));
|
|
403
|
+
i++;
|
|
404
|
+
}
|
|
405
|
+
out.push('<blockquote>' + renderBlocks(buf, ctx) + '</blockquote>');
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// table: a pipe row followed by a delimiter row
|
|
410
|
+
if (line.includes('|') && i + 1 < lines.length && isDelim(lines[i + 1])) {
|
|
411
|
+
flush();
|
|
412
|
+
const header = splitRow(line);
|
|
413
|
+
const align = splitRow(lines[i + 1]).map((c) =>
|
|
414
|
+
c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : (c.startsWith(':') ? 'left' : ''));
|
|
415
|
+
i += 2;
|
|
416
|
+
const rows = [];
|
|
417
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { rows.push(splitRow(lines[i])); i++; }
|
|
418
|
+
const at = (n) => (align[n] ? ' style="text-align:' + align[n] + '"' : '');
|
|
419
|
+
const th = header.map((c, n) => '<th' + at(n) + '>' + inline(c, ctx) + '</th>').join('');
|
|
420
|
+
const tb = rows.map((r) =>
|
|
421
|
+
'<tr>' + header.map((_, n) => '<td' + at(n) + '>' + inline(r[n] || '', ctx) + '</td>').join('') + '</tr>').join('');
|
|
422
|
+
out.push('<div class="table-wrap"><table><thead><tr>' + th + '</tr></thead><tbody>' + tb + '</tbody></table></div>');
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const mark = listMark(line);
|
|
427
|
+
if (mark) {
|
|
428
|
+
flush();
|
|
429
|
+
const base = mark[1].length;
|
|
430
|
+
const ordered = /\d/.test(mark[2]);
|
|
431
|
+
const buf = [];
|
|
432
|
+
while (i < lines.length) {
|
|
433
|
+
const l = lines[i];
|
|
434
|
+
if (!l.trim()) {
|
|
435
|
+
// a blank line only ends the list if the next line leaves it
|
|
436
|
+
const next = lines[i + 1];
|
|
437
|
+
if (next === undefined) break;
|
|
438
|
+
if (next.trim() && indentOf(next) <= base && !listMark(next)) break;
|
|
439
|
+
buf.push(l); i++; continue;
|
|
440
|
+
}
|
|
441
|
+
if (indentOf(l) < base) break;
|
|
442
|
+
if (indentOf(l) === base && !listMark(l) && buf.length) break;
|
|
443
|
+
buf.push(l); i++;
|
|
444
|
+
}
|
|
445
|
+
while (buf.length && !buf[buf.length - 1].trim()) buf.pop();
|
|
446
|
+
out.push(renderList(buf, base, ordered, ctx));
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
para.push(line);
|
|
451
|
+
i++;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
flush();
|
|
455
|
+
return asArray ? out : out.join('\n');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderList(lines, base, ordered, ctx) {
|
|
459
|
+
const items = [];
|
|
460
|
+
let cur = null;
|
|
461
|
+
for (const l of lines) {
|
|
462
|
+
const m = listMark(l);
|
|
463
|
+
if (m && m[1].length === base) {
|
|
464
|
+
if (cur) items.push(cur);
|
|
465
|
+
cur = [m[3]];
|
|
466
|
+
} else if (cur) {
|
|
467
|
+
cur.push(l.length >= base ? l.slice(base) : l.trim());
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (cur) items.push(cur);
|
|
471
|
+
|
|
472
|
+
const html = items.map((item) => {
|
|
473
|
+
// dedent continuation lines to the item's own content column
|
|
474
|
+
const body = [item[0], ...item.slice(1).map((l) => l.replace(/^ {1,4}/, ''))];
|
|
475
|
+
let h = renderBlocks(body, ctx);
|
|
476
|
+
if (/^<p>[\s\S]*<\/p>$/.test(h) && h.indexOf('<p>', 1) === -1 && !/<(ul|ol|pre|table|blockquote|h[1-6]|div)/.test(h)) {
|
|
477
|
+
h = h.slice(3, -4);
|
|
478
|
+
}
|
|
479
|
+
return '<li>' + h + '</li>';
|
|
480
|
+
}).join('');
|
|
481
|
+
|
|
482
|
+
return ordered ? '<ol>' + html + '</ol>' : '<ul>' + html + '</ul>';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/* ------------------------------------------------------------ fenced code */
|
|
486
|
+
|
|
487
|
+
// Pull fenced blocks out before line parsing so their contents stay literal.
|
|
488
|
+
export function parkFences(md, ctx) {
|
|
489
|
+
const lines = md.replace(/\r\n?/g, '\n').split('\n');
|
|
490
|
+
const out = [];
|
|
491
|
+
for (let i = 0; i < lines.length; i++) {
|
|
492
|
+
const open = lines[i].match(/^ {0,3}(`{3,}|~{3,})\s*([\w-]*)/);
|
|
493
|
+
if (!open) { out.push(lines[i]); continue; }
|
|
494
|
+
const fence = open[1][0].repeat(open[1].length);
|
|
495
|
+
const lang = (open[2] || '').toLowerCase();
|
|
496
|
+
const body = [];
|
|
497
|
+
i++;
|
|
498
|
+
while (i < lines.length && !new RegExp('^ {0,3}' + fence[0] + '{' + fence.length + ',}\\s*$').test(lines[i])) {
|
|
499
|
+
body.push(lines[i]); i++;
|
|
500
|
+
}
|
|
501
|
+
const code = body.join('\n');
|
|
502
|
+
const api = lang === 'api' ? apiBlock(code, ctx) : lang === 'http' ? httpBlock(code) : null;
|
|
503
|
+
const n = ctx.blocks.push(
|
|
504
|
+
api ? api
|
|
505
|
+
: lang === 'mermaid'
|
|
506
|
+
? '<figure class="diagram" data-diagram><pre class="diagram-src">' + esc(code) + '</pre>' +
|
|
507
|
+
'<div class="diagram-head">' + diagramName(code) +
|
|
508
|
+
'<div class="diagram-bar">' +
|
|
509
|
+
'<button class="diagram-btn" data-act="fold" type="button" title="Fold the diagram away">Collapse</button>' +
|
|
510
|
+
'<button class="diagram-btn" data-act="zoom" type="button" title="Open full size">Expand</button>' +
|
|
511
|
+
'<button class="diagram-btn" data-act="engine" type="button" title="Switch layout engine"></button>' +
|
|
512
|
+
'</div>' +
|
|
513
|
+
'</div>' +
|
|
514
|
+
'<div class="diagram-out"></div></figure>'
|
|
515
|
+
: '<div class="code-wrap">' + (lang ? '<span class="code-lang">' + esc(lang) + '</span>' : '') +
|
|
516
|
+
'<pre class="code"><code>' + (lang === 'json' || (lang === 'http' && looksJson(code)) ? jsonHtml(code)
|
|
517
|
+
: SQL_LANGS.has(lang) ? sqlHtml(code) : esc(code)) + '</code></pre></div>'
|
|
518
|
+
) - 1;
|
|
519
|
+
out.push('@@block7f3a:' + n + '@@');
|
|
520
|
+
}
|
|
521
|
+
// An HTML comment is hidden content, not text. Strip it here - after the
|
|
522
|
+
// fences are parked, so a comment inside a code block survives - and a
|
|
523
|
+
// commented-out section renders as nothing at all.
|
|
524
|
+
return out.join('\n').replace(/<!--[\s\S]*?-->/g, '').split('\n');
|
|
525
|
+
}
|
package/src/page.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// page.mjs - the HTML shell the built dashboard lives in.
|
|
2
|
+
|
|
3
|
+
import { allCss } from './client/css.mjs';
|
|
4
|
+
import { APP_JS } from './client/app.mjs';
|
|
5
|
+
import { DIAGRAMS_JS } from './client/diagrams.mjs';
|
|
6
|
+
import { API_JS } from './client/api.mjs';
|
|
7
|
+
import { MARGIN_MIN, MARGIN_MAX } from './config.mjs';
|
|
8
|
+
|
|
9
|
+
const escAttr = (s) => String(s)
|
|
10
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
11
|
+
|
|
12
|
+
export function renderPage({ cfg, docs, fontCss, mermaidTag, elkTag }) {
|
|
13
|
+
const runtime = {
|
|
14
|
+
title: cfg.title,
|
|
15
|
+
ns: cfg.ns,
|
|
16
|
+
engine: cfg.diagrams.engine,
|
|
17
|
+
nodeTextWidth: cfg.diagrams.nodeTextWidth,
|
|
18
|
+
edgeTextWidth: cfg.diagrams.edgeTextWidth,
|
|
19
|
+
comments: cfg.comments !== false,
|
|
20
|
+
margin: cfg.layout.margin,
|
|
21
|
+
};
|
|
22
|
+
// fixed margins are set on the root, so the page is laid out right from its first paint
|
|
23
|
+
const rootAttrs = cfg.layout.margin == null ? ''
|
|
24
|
+
: ' class="fixed-margin" style="--margin:' + Number(cfg.layout.margin) + 'px"';
|
|
25
|
+
|
|
26
|
+
const data = JSON.stringify({ docs }).replace(/</g, '\\u003c');
|
|
27
|
+
|
|
28
|
+
// a script from the CDN carries its pinned hash: the browser refuses any other bytes
|
|
29
|
+
const script = (tag) => !tag ? '' : '<script src="' + escAttr(tag.src) + '"' +
|
|
30
|
+
(tag.integrity ? ' integrity="' + escAttr(tag.integrity) + '" crossorigin="anonymous"' : '') + '><\/script>';
|
|
31
|
+
|
|
32
|
+
const searchBox = cfg.search === false ? '' : `
|
|
33
|
+
<div class="search">
|
|
34
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
35
|
+
<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>
|
|
36
|
+
</svg>
|
|
37
|
+
<input id="search" type="search" placeholder="Search documents" spellcheck="false" autocomplete="off">
|
|
38
|
+
</div>`;
|
|
39
|
+
|
|
40
|
+
const commentRow = cfg.comments === false ? '' : `
|
|
41
|
+
<div class="settings-row">
|
|
42
|
+
<div class="settings-label">
|
|
43
|
+
<label for="cmt-tog">Comments <span class="cmt-n" id="cmt-n">0</span></label>
|
|
44
|
+
<button class="switch" id="cmt-tog" type="button" role="switch" aria-checked="true"></button>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="settings-hint"><span>Your margin notes, kept in this browser</span></div>
|
|
47
|
+
</div>`;
|
|
48
|
+
|
|
49
|
+
const commentBits = cfg.comments === false ? '' : `
|
|
50
|
+
<button class="cmt-add" id="cmt-add" type="button" hidden>Comment</button>
|
|
51
|
+
<div class="cmt-pop" id="cmt-pop" hidden></div>
|
|
52
|
+
<div class="cmt-toast" id="cmt-toast" hidden></div>`;
|
|
53
|
+
|
|
54
|
+
return `<!doctype html>
|
|
55
|
+
<html lang="en"${rootAttrs}>
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8">
|
|
58
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
59
|
+
<title>${escAttr(cfg.title)}</title>
|
|
60
|
+
<style>${fontCss}${allCss()}</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<div class="layout" id="layout">
|
|
64
|
+
<aside class="side">
|
|
65
|
+
<div class="side-top">
|
|
66
|
+
<div class="brand">
|
|
67
|
+
<div class="brand-row">
|
|
68
|
+
<span class="brand-name">${escAttr(cfg.title)}</span>
|
|
69
|
+
<button class="icon-btn" id="toggle" type="button" title="Toggle sidebar [">
|
|
70
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
|
71
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="2.5"/><path d="M6 2.5v11"/>
|
|
72
|
+
</svg>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>${searchBox}
|
|
77
|
+
<nav class="nav" id="nav"></nav>
|
|
78
|
+
<div class="side-foot">
|
|
79
|
+
<button class="foot-btn" id="settings-btn" type="button" title="Settings" aria-haspopup="dialog">
|
|
80
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
|
|
81
|
+
<path d="M2.5 4.5h6M12 4.5h1.5M2.5 11.5h1.5M7.5 11.5h6"/><circle cx="10.2" cy="4.5" r="1.7"/><circle cx="5.8" cy="11.5" r="1.7"/>
|
|
82
|
+
</svg>
|
|
83
|
+
<span class="foot-lbl">Settings</span>
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</aside>
|
|
87
|
+
<div class="main">
|
|
88
|
+
<div class="topbar" id="topbar"><div class="topbar-in"><b id="topbar-title"></b></div></div>
|
|
89
|
+
<div id="main"></div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
${commentBits}
|
|
93
|
+
<div class="settings-veil" id="settings-veil" hidden></div>
|
|
94
|
+
<div class="settings" id="settings" role="dialog" aria-modal="true" aria-label="Settings" hidden>
|
|
95
|
+
<div class="settings-head">
|
|
96
|
+
<b>Settings</b>
|
|
97
|
+
<button class="icon-btn" id="settings-close" type="button" title="Close Esc">
|
|
98
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="settings-row">
|
|
102
|
+
<div class="settings-label">
|
|
103
|
+
<label for="set-margin">Side margins</label>
|
|
104
|
+
<span class="settings-val" id="set-margin-val"></span>
|
|
105
|
+
</div>
|
|
106
|
+
<input type="range" id="set-margin" min="${MARGIN_MIN}" max="${MARGIN_MAX}" step="4">
|
|
107
|
+
<div class="settings-hint">
|
|
108
|
+
<span>From the edge of the page to the text</span>
|
|
109
|
+
<button class="settings-link" id="set-margin-auto" type="button">Automatic</button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>${commentRow}
|
|
112
|
+
<div class="settings-foot" id="settings-status"></div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="lightbox" id="lightbox">
|
|
115
|
+
<div class="lightbox-stage" id="lightbox-stage"></div>
|
|
116
|
+
<div class="lightbox-bar">
|
|
117
|
+
<button id="zoom-out" type="button">−</button>
|
|
118
|
+
<span id="zoom-label">100%</span>
|
|
119
|
+
<button id="zoom-in" type="button">+</button>
|
|
120
|
+
<button id="zoom-close" type="button">Close</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<script id="doc-data" type="application/json">${data}</script>
|
|
125
|
+
<script>window.__LOWKEY_DOC__=${JSON.stringify(runtime).replace(/</g, '\\u003c')};<\/script>
|
|
126
|
+
${script(mermaidTag)}
|
|
127
|
+
${script(elkTag)}
|
|
128
|
+
<script>${DIAGRAMS_JS}<\/script>
|
|
129
|
+
<script>${APP_JS}<\/script>
|
|
130
|
+
<script>${API_JS}<\/script>
|
|
131
|
+
</body>
|
|
132
|
+
</html>
|
|
133
|
+
`;
|
|
134
|
+
}
|