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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// live.mjs - the part of the page only `lowkey-doc --watch` serves.
|
|
2
|
+
// Listens for rebuilds, fetches the new documents and hands them to the page,
|
|
3
|
+
// which patches itself (applyUpdate() in app.mjs). A small pill in the corner says
|
|
4
|
+
// what happened: what changed and where, a build that failed and why, or a
|
|
5
|
+
// watcher that has gone away. None of this is in the built index.html.
|
|
6
|
+
|
|
7
|
+
export const LIVE_CSS = `
|
|
8
|
+
.live{position:fixed;right:16px;bottom:16px;z-index:12;display:flex;align-items:center;gap:7px;
|
|
9
|
+
max-width:min(520px,calc(100vw - 32px));padding:5px 11px 5px 9px;border:1px solid var(--line);
|
|
10
|
+
border-radius:999px;background:var(--surface);color:var(--muted);font-size:12px;line-height:1.4;
|
|
11
|
+
box-shadow:0 4px 16px rgba(0,0,0,.45);opacity:.72;transition:opacity .2s,border-color .2s,color .2s}
|
|
12
|
+
.live:hover,.live.news,.live.bad,.live.off{opacity:1}
|
|
13
|
+
.live-dot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent-ring)}
|
|
14
|
+
.live.off .live-dot{background:var(--faint);box-shadow:none}
|
|
15
|
+
.live-msg{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
16
|
+
.live.go{cursor:pointer}
|
|
17
|
+
.live.go:hover{color:var(--ink);border-color:var(--line-2)}
|
|
18
|
+
.live.news{color:var(--ink)}
|
|
19
|
+
.live.bad{align-items:flex-start;border-color:rgba(255,110,110,.4);border-radius:14px;color:#ff9a9a}
|
|
20
|
+
.live.bad .live-dot{margin-top:5px;background:#ef4444;box-shadow:none}
|
|
21
|
+
.live.bad .live-msg{white-space:pre-wrap;font-family:var(--mono);font-size:11.5px}
|
|
22
|
+
.dc-changed{animation:dc-changed 2.6s ease-out 1}
|
|
23
|
+
@keyframes dc-changed{
|
|
24
|
+
0%,30%{background-color:rgba(77,163,255,.2);box-shadow:0 0 0 4px rgba(77,163,255,.2)}
|
|
25
|
+
100%{background-color:rgba(77,163,255,0);box-shadow:0 0 0 4px rgba(77,163,255,0)}}
|
|
26
|
+
.diagram.dc-redraw .diagram-out{opacity:.45;transition:opacity .2s}
|
|
27
|
+
@media print{.live{display:none}}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export const LIVE_JS = String.raw`
|
|
31
|
+
(function(){
|
|
32
|
+
var LIVE = window.__LOWKEY_DOC_LIVE__ || {};
|
|
33
|
+
var app = window.__lowkey_doc;
|
|
34
|
+
var version = LIVE.version, shell = LIVE.shell;
|
|
35
|
+
|
|
36
|
+
/* ---------------- the pill ---------------- */
|
|
37
|
+
|
|
38
|
+
var pill = document.createElement('div');
|
|
39
|
+
pill.className = 'live';
|
|
40
|
+
pill.innerHTML = '<span class="live-dot"></span><span class="live-msg"></span>';
|
|
41
|
+
document.body.appendChild(pill);
|
|
42
|
+
var msg = pill.querySelector('.live-msg');
|
|
43
|
+
var action = null, calm = 0, failing = false, down = false;
|
|
44
|
+
|
|
45
|
+
function say(text, cls, onClick, ms){
|
|
46
|
+
pill.className = 'live' + (cls ? ' ' + cls : '') + (onClick ? ' go' : '');
|
|
47
|
+
msg.textContent = text;
|
|
48
|
+
pill.title = onClick ? 'Show me' : (cls === 'bad' ? '' : 'Rebuilt on every save');
|
|
49
|
+
action = onClick || null;
|
|
50
|
+
clearTimeout(calm);
|
|
51
|
+
if (ms) calm = setTimeout(idle, ms);
|
|
52
|
+
}
|
|
53
|
+
function idle(){ say('Live', '', null, 0); }
|
|
54
|
+
idle();
|
|
55
|
+
|
|
56
|
+
pill.addEventListener('click', function(){
|
|
57
|
+
if (!action) return;
|
|
58
|
+
var go = action;
|
|
59
|
+
idle();
|
|
60
|
+
go();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// a block marked as changed loses the mark once it has faded, so the next
|
|
64
|
+
// edit to the same block can flash again
|
|
65
|
+
document.addEventListener('animationend', function(e){
|
|
66
|
+
if (e.animationName === 'dc-changed') e.target.classList.remove('dc-changed');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/* ---------------- pulling ---------------- */
|
|
70
|
+
|
|
71
|
+
var pulling = false, again = false;
|
|
72
|
+
|
|
73
|
+
function pull(){
|
|
74
|
+
if (pulling){ again = true; return; }
|
|
75
|
+
pulling = true;
|
|
76
|
+
fetch('/__lowkey-doc/data.json', { cache: 'no-store' }).then(function(r){
|
|
77
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
78
|
+
return r.json();
|
|
79
|
+
}).then(function(data){
|
|
80
|
+
if (!app) return location.reload();
|
|
81
|
+
version = data.version;
|
|
82
|
+
tell(app.apply(data.docs));
|
|
83
|
+
}).catch(function(err){
|
|
84
|
+
say('Could not load the update: ' + (err && err.message || err), 'bad', null, 0);
|
|
85
|
+
}).then(function(){
|
|
86
|
+
pulling = false;
|
|
87
|
+
if (again){ again = false; pull(); }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function plural(n, one){ return n + ' ' + one + (n === 1 ? '' : 's'); }
|
|
92
|
+
|
|
93
|
+
function tell(r){
|
|
94
|
+
if (!r || failing) return;
|
|
95
|
+
if (r.moved) return say('Renamed or removed — now showing ' + titleOf(r.current), 'news', null, 5000);
|
|
96
|
+
var here = r.changed.indexOf(r.current) >= 0;
|
|
97
|
+
var others = r.changed.filter(function(id){ return id !== r.current; });
|
|
98
|
+
if (here && r.first){
|
|
99
|
+
var first = r.first;
|
|
100
|
+
return say('Updated · ' + plural(r.count, 'change') + ' ↓', 'news', function(){ app.bring(first); }, 6000);
|
|
101
|
+
}
|
|
102
|
+
if (others.length === 1){
|
|
103
|
+
var id = others[0];
|
|
104
|
+
return say('Updated: ' + titleOf(id) + ' →', 'news', function(){ location.hash = '#/' + id; }, 6000);
|
|
105
|
+
}
|
|
106
|
+
if (others.length) return say(plural(others.length, 'document') + ' updated', 'news', null, 4000);
|
|
107
|
+
if (r.removed.length) return say(plural(r.removed.length, 'document') + ' removed', 'news', null, 4000);
|
|
108
|
+
if (here) return say('Updated', 'news', null, 2500);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function titleOf(id){ var d = app && app.doc(id); return d ? d.title : id; }
|
|
112
|
+
|
|
113
|
+
/* ---------------- saving settings ---------------- */
|
|
114
|
+
|
|
115
|
+
// The settings panel keeps its changes through here. Saving renders the page
|
|
116
|
+
// again, which changes the shell: this page already shows the new settings,
|
|
117
|
+
// so it takes the shell it is sent instead of reloading like the others.
|
|
118
|
+
var saving = 0;
|
|
119
|
+
if (app) app.save = function(settings){
|
|
120
|
+
saving++;
|
|
121
|
+
return fetch('/__lowkey-doc/settings', {
|
|
122
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings)
|
|
123
|
+
}).then(function(r){
|
|
124
|
+
return r.json().catch(function(){ return {}; }).then(function(body){
|
|
125
|
+
if (!r.ok) throw new Error(body.error || 'HTTP ' + r.status);
|
|
126
|
+
if (body.shell) shell = body.shell;
|
|
127
|
+
});
|
|
128
|
+
}).then(function(){ saving--; }, function(err){ saving--; throw err; });
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/* ---------------- listening ---------------- */
|
|
132
|
+
|
|
133
|
+
if (!window.EventSource) return say('Live updates need a newer browser', 'off', null, 0);
|
|
134
|
+
|
|
135
|
+
var es = new EventSource('/__lowkey-doc/events');
|
|
136
|
+
es.addEventListener('state', function(e){
|
|
137
|
+
var s;
|
|
138
|
+
try { s = JSON.parse(e.data); } catch (x) { return; }
|
|
139
|
+
if (s.shell !== shell && !saving) return location.reload();
|
|
140
|
+
if (s.error){
|
|
141
|
+
failing = true;
|
|
142
|
+
say('Build failed — the page shows the last good version.\n' + s.error, 'bad', null, 0);
|
|
143
|
+
} else if (failing || down){
|
|
144
|
+
failing = false;
|
|
145
|
+
idle();
|
|
146
|
+
}
|
|
147
|
+
down = false;
|
|
148
|
+
if (s.version !== version) pull();
|
|
149
|
+
});
|
|
150
|
+
es.onerror = function(){
|
|
151
|
+
if (es.readyState === EventSource.CLOSED) return say('Live updates stopped — reload the page', 'off', null, 0);
|
|
152
|
+
down = true;
|
|
153
|
+
say('Disconnected — is lowkey-doc --watch still running?', 'off', null, 0);
|
|
154
|
+
};
|
|
155
|
+
})();
|
|
156
|
+
`;
|
package/src/collect.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// collect.mjs
|
|
2
|
+
// ------------------------------------------------------------------
|
|
3
|
+
// Turn a folder of files into the ordered, grouped list the sidebar shows,
|
|
4
|
+
// then render each one.
|
|
5
|
+
//
|
|
6
|
+
// Ordering: a leading number wins ("2. CATALOG.md" before "10. TESTS.md",
|
|
7
|
+
// which a plain sort gets backwards); everything else follows alphabetically.
|
|
8
|
+
// Grouping: files sitting in the folder itself are one group, each sub-folder
|
|
9
|
+
// is another named after itself, and anything that is not Markdown is grouped
|
|
10
|
+
// by what it is (a schema dump, a data file) rather than shown as prose.
|
|
11
|
+
// ------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { esc, plain, slugify, inline, parkFences, renderBlocks } from './markdown.mjs';
|
|
16
|
+
import { nestSections, SECNUM_RE } from './sections.mjs';
|
|
17
|
+
import { titleCase } from './config.mjs';
|
|
18
|
+
|
|
19
|
+
function orderKey(name) {
|
|
20
|
+
const m = name.match(/^(\d+)/);
|
|
21
|
+
return m ? String(+m[1]).padStart(4, '0') : 'zzz' + name.toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const extOf = (f) => (f.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
|
25
|
+
|
|
26
|
+
function readDir(dir) {
|
|
27
|
+
if (!fs.existsSync(dir)) return { files: [], dirs: [] };
|
|
28
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => !e.name.startsWith('.'));
|
|
29
|
+
return {
|
|
30
|
+
files: entries.filter((e) => e.isFile()).map((e) => e.name),
|
|
31
|
+
dirs: entries.filter((e) => e.isDirectory()).map((e) => e.name),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listDocs(cfg) {
|
|
36
|
+
const known = new Set([...cfg.extensions, ...Object.keys(cfg.verbatim)].map((e) => e.toLowerCase()));
|
|
37
|
+
const isDoc = (f) => cfg.extensions.includes(extOf(f));
|
|
38
|
+
const pick = (dir, rel) => readDir(dir).files
|
|
39
|
+
.filter((f) => known.has(extOf(f)))
|
|
40
|
+
.map((f) => ({ file: f, abs: path.join(dir, f), rel: rel ? rel + '/' + f : f }))
|
|
41
|
+
.sort((a, b) => orderKey(a.file).localeCompare(orderKey(b.file)));
|
|
42
|
+
|
|
43
|
+
const { dirs } = readDir(cfg.docsDir);
|
|
44
|
+
const root = pick(cfg.docsDir, '');
|
|
45
|
+
|
|
46
|
+
const out = [];
|
|
47
|
+
// 1. the documents themselves
|
|
48
|
+
for (const d of root.filter((d) => isDoc(d.file))) out.push({ ...d, group: cfg.rootGroup });
|
|
49
|
+
|
|
50
|
+
// 2. one group per sub-folder, in the configured order then alphabetically
|
|
51
|
+
const rank = (name) => {
|
|
52
|
+
const i = cfg.groupOrder.findIndex((g) => g.toLowerCase() === name.toLowerCase());
|
|
53
|
+
return i < 0 ? cfg.groupOrder.length : i;
|
|
54
|
+
};
|
|
55
|
+
const subs = dirs.slice().sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
56
|
+
for (const sub of subs) {
|
|
57
|
+
const label = titleCase(sub);
|
|
58
|
+
for (const d of pick(path.join(cfg.docsDir, sub), sub)) out.push({ ...d, group: label });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. whatever is not a document, grouped by what it is
|
|
62
|
+
for (const d of root.filter((d) => !isDoc(d.file))) {
|
|
63
|
+
out.push({ ...d, group: cfg.verbatim[extOf(d.file)] || 'Files' });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Documents are addressed two ways: by the path a link actually writes
|
|
69
|
+
// ("./audits/one.md") and, for convenience, by bare filename ("one.md") - the
|
|
70
|
+
// second only where it is unambiguous, so two files called the same thing in
|
|
71
|
+
// different folders never silently resolve to each other.
|
|
72
|
+
export function assignIds(entries) {
|
|
73
|
+
const taken = new Set();
|
|
74
|
+
const map = new Map();
|
|
75
|
+
const seenName = new Map();
|
|
76
|
+
for (const e of entries) seenName.set(e.file, (seenName.get(e.file) || 0) + 1);
|
|
77
|
+
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const stem = (seenName.get(e.file) > 1 ? e.rel : e.file).replace(/\.[^.]+$/, '');
|
|
80
|
+
let id = slugify(stem.replace(/\//g, '-')) || 'doc';
|
|
81
|
+
let n = 2;
|
|
82
|
+
const base = id;
|
|
83
|
+
while (taken.has(id)) id = base + '-' + n++;
|
|
84
|
+
taken.add(id);
|
|
85
|
+
map.set(e.rel, id);
|
|
86
|
+
if (seenName.get(e.file) === 1) map.set(e.file, id);
|
|
87
|
+
}
|
|
88
|
+
return map;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildDoc(entry, byFile, cfg, raw = fs.readFileSync(entry.abs, 'utf8')) {
|
|
92
|
+
const name = entry.file.replace(/\.[^.]+$/, '');
|
|
93
|
+
const id = byFile.get(entry.file);
|
|
94
|
+
const seen = new Map();
|
|
95
|
+
const ctx = {
|
|
96
|
+
blocks: [], headings: [], byFile, numbers: new Map(),
|
|
97
|
+
uniqueSlug(base) {
|
|
98
|
+
const key = base || 'section';
|
|
99
|
+
const n = seen.get(key) || 0;
|
|
100
|
+
seen.set(key, n + 1);
|
|
101
|
+
return n ? key + '-' + n : key;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let title = name, subtitle = '', html;
|
|
106
|
+
|
|
107
|
+
if (cfg.extensions.includes(extOf(entry.file))) {
|
|
108
|
+
const lines = parkFences(raw, ctx);
|
|
109
|
+
|
|
110
|
+
// H1 becomes the page title
|
|
111
|
+
const h1 = lines.findIndex((l) => /^# +\S/.test(l));
|
|
112
|
+
if (h1 >= 0) {
|
|
113
|
+
title = plain(lines[h1].replace(/^# +/, ''));
|
|
114
|
+
lines.splice(h1, 1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// the lede paragraph of the blockquote under it becomes the subtitle
|
|
118
|
+
let j = 0;
|
|
119
|
+
while (j < lines.length && !lines[j].trim()) j++;
|
|
120
|
+
if (j < lines.length && /^ {0,3}>/.test(lines[j])) {
|
|
121
|
+
const lede = [];
|
|
122
|
+
while (j < lines.length && /^ {0,3}>/.test(lines[j]) && lines[j].replace(/^ {0,3}> ?/, '').trim()) {
|
|
123
|
+
lede.push(lines[j].replace(/^ {0,3}> ?/, '').trim());
|
|
124
|
+
j++;
|
|
125
|
+
}
|
|
126
|
+
subtitle = inline(lede.join(' '), ctx);
|
|
127
|
+
lines.splice(0, j);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
html = nestSections(renderBlocks(lines, ctx, true), ctx);
|
|
131
|
+
} else {
|
|
132
|
+
const kind = extOf(entry.file).slice(1);
|
|
133
|
+
subtitle = 'Kept verbatim as <code>' + esc(kind) + '</code>.';
|
|
134
|
+
html = '<div class="code-wrap"><span class="code-lang">' + esc(kind) +
|
|
135
|
+
'</span><pre class="code"><code>' + esc(raw) + '</code></pre></div>';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const badge = (name.match(/^(\d+)/) || [])[1] || (name.trim()[0] || '?').toUpperCase();
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id, title, subtitle, html, badge,
|
|
142
|
+
group: entry.group,
|
|
143
|
+
file: entry.rel || entry.file,
|
|
144
|
+
words: raw.split(/\s+/).filter(Boolean).length,
|
|
145
|
+
// counted from the rendered page, not from the fences found: a diagram
|
|
146
|
+
// inside an HTML comment is parked like any other but never emitted
|
|
147
|
+
diagrams: (html.match(/data-diagram/g) || []).length,
|
|
148
|
+
sections: ctx.headings.filter((h) => h.level <= 2).map((h) => {
|
|
149
|
+
const num = ctx.numbers.get(h.id) || '';
|
|
150
|
+
const own = h.text.match(SECNUM_RE);
|
|
151
|
+
return { id: h.id, num, text: own && own[1] === num ? h.text.slice(own[0].length) : h.text };
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// config.mjs
|
|
2
|
+
// ------------------------------------------------------------------
|
|
3
|
+
// Everything the builder needs to know about one project, resolved from
|
|
4
|
+
// (in increasing priority): the defaults below, a docs.config.json found
|
|
5
|
+
// next to the documents or at the project root, and the command line.
|
|
6
|
+
//
|
|
7
|
+
// Zero configuration is a supported way to run: point the tool at a folder
|
|
8
|
+
// of Markdown and it renders.
|
|
9
|
+
// ------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
// the range layout.margin can take, and the page's slider with it
|
|
15
|
+
export const MARGIN_MIN = 20, MARGIN_MAX = 480;
|
|
16
|
+
|
|
17
|
+
export const CONFIG_NAMES = ['docs.config.json', '.lowkey-doc.json'];
|
|
18
|
+
|
|
19
|
+
export const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
20
|
+
|
|
21
|
+
// What to tell people to type: npm sets npm_command to "exec" under npx, and
|
|
22
|
+
// someone running it that way has no lowkey-doc on their PATH.
|
|
23
|
+
export const CLI = process.env.npm_command === 'exec' ? 'npx lowkey-doc' : 'lowkey-doc';
|
|
24
|
+
|
|
25
|
+
const DEFAULTS = {
|
|
26
|
+
// Shown in the sidebar, the browser tab, and used to namespace the
|
|
27
|
+
// per-reader state (which document was open, folded sections, comments).
|
|
28
|
+
title: null, // null -> derived from the project folder
|
|
29
|
+
// Where the documents are, relative to the project root.
|
|
30
|
+
docs: 'docs',
|
|
31
|
+
// Where the built page goes, relative to the project root.
|
|
32
|
+
out: '.lowkey-doc',
|
|
33
|
+
// Files taken as documents. Markdown is rendered; everything else is shown
|
|
34
|
+
// verbatim in a code block, which is how schema dumps stay readable.
|
|
35
|
+
extensions: ['.md', '.markdown'],
|
|
36
|
+
verbatim: {
|
|
37
|
+
'.dbml': 'Schema',
|
|
38
|
+
'.dbdiagram': 'Schema',
|
|
39
|
+
'.sql': 'Schema',
|
|
40
|
+
'.json': 'Data',
|
|
41
|
+
'.yaml': 'Data',
|
|
42
|
+
'.yml': 'Data',
|
|
43
|
+
'.tsv': 'Data',
|
|
44
|
+
'.csv': 'Data',
|
|
45
|
+
},
|
|
46
|
+
// The sidebar heading for documents sitting directly in the docs folder.
|
|
47
|
+
rootGroup: 'Documents',
|
|
48
|
+
// Sub-folders become their own group, named after the folder. List them here
|
|
49
|
+
// to fix the order; anything not listed follows, alphabetically.
|
|
50
|
+
groupOrder: [],
|
|
51
|
+
diagrams: {
|
|
52
|
+
// 'clean' - re-lay flowcharts and ER diagrams out with ELK: right-angled
|
|
53
|
+
// routes, every edge label given its own reserved space and tied
|
|
54
|
+
// to its arc, and entities drawn as tables rather than grids.
|
|
55
|
+
// 'mermaid' - mermaid's own renderer for everything.
|
|
56
|
+
// Either way the reader can switch per diagram from the page.
|
|
57
|
+
engine: 'clean',
|
|
58
|
+
// Cap on how wide a node's text runs before it wraps, in pixels.
|
|
59
|
+
nodeTextWidth: 210,
|
|
60
|
+
edgeTextWidth: 200,
|
|
61
|
+
},
|
|
62
|
+
layout: {
|
|
63
|
+
// The room either side of the text, from the edge of the reading area to
|
|
64
|
+
// where the text starts, in pixels. null keeps the column at its reading
|
|
65
|
+
// width and centres it, so the room either side is whatever is left over.
|
|
66
|
+
// Set from the page itself too: the settings button in the sidebar.
|
|
67
|
+
margin: null,
|
|
68
|
+
},
|
|
69
|
+
// Reader features. Turn off what a given project does not want.
|
|
70
|
+
comments: true,
|
|
71
|
+
search: true,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const titleCase = (s) => s
|
|
75
|
+
.replace(/[-_]+/g, ' ')
|
|
76
|
+
.replace(/\s+/g, ' ')
|
|
77
|
+
.trim()
|
|
78
|
+
.replace(/^\p{Ll}/u, (c) => c.toUpperCase());
|
|
79
|
+
|
|
80
|
+
function deepMerge(base, over) {
|
|
81
|
+
if (!over || typeof over !== 'object' || Array.isArray(over)) return over === undefined ? base : over;
|
|
82
|
+
const out = Array.isArray(base) ? base.slice() : { ...base };
|
|
83
|
+
for (const [k, v] of Object.entries(over)) {
|
|
84
|
+
out[k] = (v && typeof v === 'object' && !Array.isArray(v) && base && typeof base[k] === 'object')
|
|
85
|
+
? deepMerge(base[k], v)
|
|
86
|
+
: v;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Look for a config file next to the documents, then up the tree from there.
|
|
92
|
+
export function findConfig(startDir) {
|
|
93
|
+
let dir = path.resolve(startDir);
|
|
94
|
+
for (let hop = 0; hop < 12; hop++) {
|
|
95
|
+
for (const name of CONFIG_NAMES) {
|
|
96
|
+
const file = path.join(dir, name);
|
|
97
|
+
if (fs.existsSync(file)) return file;
|
|
98
|
+
}
|
|
99
|
+
const up = path.dirname(dir);
|
|
100
|
+
if (up === dir) break;
|
|
101
|
+
dir = up;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// `folder` is a documents folder named on the command line. It replaces the
|
|
107
|
+
// configured `docs` and nothing else: the config is still looked for, from
|
|
108
|
+
// that folder upwards, and the page still goes to the project root - the
|
|
109
|
+
// config's folder, or else wherever the command was run.
|
|
110
|
+
export function loadConfig({ root, docs, folder, out, title, configPath, engine }) {
|
|
111
|
+
let file = configPath ? path.resolve(configPath) : null;
|
|
112
|
+
if (file && !fs.existsSync(file)) throw new Error('No config file at ' + file);
|
|
113
|
+
if (!file) file = findConfig(folder || root || process.cwd());
|
|
114
|
+
|
|
115
|
+
let fromFile = {};
|
|
116
|
+
if (file) {
|
|
117
|
+
try {
|
|
118
|
+
fromFile = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
throw new Error('Could not read ' + file + ': ' + err.message);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The project root is the config file's folder, or wherever we were pointed.
|
|
125
|
+
const projectRoot = path.resolve(root || (file ? path.dirname(file) : process.cwd()));
|
|
126
|
+
|
|
127
|
+
const cfg = deepMerge(DEFAULTS, fromFile);
|
|
128
|
+
// `verbatim` is a list of what to include, not a bag of settings: writing one
|
|
129
|
+
// replaces the defaults, so a project can narrow it rather than only add to it.
|
|
130
|
+
if (fromFile.verbatim) cfg.verbatim = fromFile.verbatim;
|
|
131
|
+
if (docs) cfg.docs = docs;
|
|
132
|
+
if (folder) cfg.docs = path.relative(projectRoot, folder) || '.';
|
|
133
|
+
if (out) cfg.out = out;
|
|
134
|
+
if (title) cfg.title = title;
|
|
135
|
+
if (engine) cfg.diagrams = { ...cfg.diagrams, engine };
|
|
136
|
+
|
|
137
|
+
cfg.root = projectRoot;
|
|
138
|
+
cfg.docsDir = path.resolve(projectRoot, cfg.docs);
|
|
139
|
+
cfg.outDir = path.resolve(projectRoot, cfg.out);
|
|
140
|
+
cfg.configFile = file;
|
|
141
|
+
if (!cfg.title) cfg.title = titleCase(path.basename(projectRoot)) + ' Docs';
|
|
142
|
+
|
|
143
|
+
// Namespace for everything the reader's browser remembers. Two dashboards
|
|
144
|
+
// opened from file:// can otherwise land in the same storage bucket.
|
|
145
|
+
cfg.ns = 'lowkey-doc:' + cfg.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
146
|
+
|
|
147
|
+
const margin = cfg.layout && cfg.layout.margin;
|
|
148
|
+
if (margin != null && !(Number.isFinite(margin) && margin >= MARGIN_MIN && margin <= MARGIN_MAX)) {
|
|
149
|
+
throw new Error('layout.margin must be null or a number of pixels from ' + MARGIN_MIN + ' to ' +
|
|
150
|
+
MARGIN_MAX + ', got: ' + JSON.stringify(margin));
|
|
151
|
+
}
|
|
152
|
+
if (!['clean', 'mermaid'].includes(cfg.diagrams.engine)) {
|
|
153
|
+
throw new Error("diagrams.engine must be 'clean' or 'mermaid', got: " + cfg.diagrams.engine);
|
|
154
|
+
}
|
|
155
|
+
return cfg;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A config object as text, written the way the file already was: its indent,
|
|
159
|
+
// its line endings. `before` is the file's old text, or null for a new file.
|
|
160
|
+
export function jsonLike(before, obj) {
|
|
161
|
+
const indent = (before && (before.match(/\n([ \t]+)"/) || [])[1]) || 2;
|
|
162
|
+
const eol = before && before.includes('\r\n') ? '\r\n' : '\n';
|
|
163
|
+
return (JSON.stringify(obj, null, indent) + '\n').replace(/\n/g, eol);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { DEFAULTS, titleCase };
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// init.mjs - wire an existing project up to the dashboard.
|
|
2
|
+
//
|
|
3
|
+
// Writes a docs.config.json if there isn't one, creates the documents folder
|
|
4
|
+
// it names, and installs the Claude Code command and skills into the
|
|
5
|
+
// project's .claude/ folder so the agent working in that repo knows how to
|
|
6
|
+
// write and render its documents.
|
|
7
|
+
//
|
|
8
|
+
// A config that already exists is never replaced: --docs and --title edit just
|
|
9
|
+
// those fields in it. --force is about the Claude files only - it replaces
|
|
10
|
+
// them with this version's copies.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { CONFIG_NAMES, DEFAULTS, VERSION, jsonLike, titleCase } from './config.mjs';
|
|
16
|
+
|
|
17
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PKG = path.resolve(HERE, '..');
|
|
19
|
+
|
|
20
|
+
// The Claude files run the version that installed them, never whatever is
|
|
21
|
+
// newest on npm: a new release reaches a project when someone there runs
|
|
22
|
+
// init --force, not the next time an agent renders the docs.
|
|
23
|
+
function copyPinned(from, to) {
|
|
24
|
+
if (!from.endsWith('.md')) return fs.copyFileSync(from, to);
|
|
25
|
+
fs.writeFileSync(to, fs.readFileSync(from, 'utf8').replace(/npx lowkey-doc(?!@)/g, 'npx lowkey-doc@' + VERSION));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function copyTree(from, to) {
|
|
29
|
+
fs.mkdirSync(to, { recursive: true });
|
|
30
|
+
for (const e of fs.readdirSync(from, { withFileTypes: true })) {
|
|
31
|
+
const a = path.join(from, e.name), b = path.join(to, e.name);
|
|
32
|
+
if (e.isDirectory()) copyTree(a, b);
|
|
33
|
+
else copyPinned(a, b);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function init(target, { docs = null, title = null, force = false } = {}) {
|
|
38
|
+
const root = path.resolve(target || process.cwd());
|
|
39
|
+
const done = [];
|
|
40
|
+
const skipped = [];
|
|
41
|
+
let kept = false; // a Claude file was left as it was
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(root)) {
|
|
44
|
+
fs.mkdirSync(root, { recursive: true });
|
|
45
|
+
done.push(path.relative(process.cwd(), root) + '/');
|
|
46
|
+
} else if (!fs.statSync(root).isDirectory()) {
|
|
47
|
+
throw new Error('Not a folder: ' + root);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const existing = CONFIG_NAMES.map((n) => path.join(root, n)).find((f) => fs.existsSync(f));
|
|
51
|
+
let config;
|
|
52
|
+
if (existing) {
|
|
53
|
+
const before = fs.readFileSync(existing, 'utf8');
|
|
54
|
+
try {
|
|
55
|
+
config = JSON.parse(before);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new Error('Could not read ' + existing + ': ' + err.message);
|
|
58
|
+
}
|
|
59
|
+
const changed = Object.entries({ docs, title }).filter(([k, v]) => v != null && config[k] !== v);
|
|
60
|
+
for (const [k, v] of changed) config[k] = v;
|
|
61
|
+
if (changed.length) {
|
|
62
|
+
fs.writeFileSync(existing, jsonLike(before, config));
|
|
63
|
+
done.push(path.basename(existing) + ' (' + changed.map(([k, v]) => k + ': ' + JSON.stringify(v)).join(', ') + ')');
|
|
64
|
+
} else {
|
|
65
|
+
skipped.push(path.basename(existing) + ' (already there)');
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
config = {
|
|
69
|
+
title: title || titleCase(path.basename(root)) + ' Docs',
|
|
70
|
+
docs: docs || DEFAULTS.docs,
|
|
71
|
+
out: DEFAULTS.out,
|
|
72
|
+
};
|
|
73
|
+
fs.writeFileSync(path.join(root, CONFIG_NAMES[0]), jsonLike(null, config));
|
|
74
|
+
done.push(CONFIG_NAMES[0]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// the folder the config names, so the next build has somewhere to read
|
|
78
|
+
const docsDir = path.resolve(root, config.docs || DEFAULTS.docs);
|
|
79
|
+
if (!fs.existsSync(docsDir)) {
|
|
80
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
81
|
+
fs.writeFileSync(path.join(docsDir, '0. ABOUT.md'),
|
|
82
|
+
'# ABOUT\n\n> What this project is, in plain language.\n\n---\n\n## Start here\n\nReplace this file with the real thing.\n');
|
|
83
|
+
done.push(path.relative(root, docsDir) + '/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// the agent-facing half: one command to render, two skills to write and audit
|
|
87
|
+
const claude = path.join(root, '.claude');
|
|
88
|
+
for (const [src, dest] of [
|
|
89
|
+
[path.join(PKG, 'commands', 'docs.md'), path.join(claude, 'commands', 'docs.md')],
|
|
90
|
+
[path.join(PKG, 'skills', 'write-doc'), path.join(claude, 'skills', 'write-doc')],
|
|
91
|
+
[path.join(PKG, 'skills', 'audit-docs'), path.join(claude, 'skills', 'audit-docs')],
|
|
92
|
+
]) {
|
|
93
|
+
if (!fs.existsSync(src)) continue;
|
|
94
|
+
const there = fs.existsSync(dest);
|
|
95
|
+
if (there && !force) { skipped.push(path.relative(root, dest) + ' (already there)'); kept = true; continue; }
|
|
96
|
+
// replaced whole, so a file an older version shipped does not linger
|
|
97
|
+
if (fs.statSync(src).isDirectory()) { fs.rmSync(dest, { recursive: true, force: true }); copyTree(src, dest); }
|
|
98
|
+
else { fs.mkdirSync(path.dirname(dest), { recursive: true }); copyPinned(src, dest); }
|
|
99
|
+
done.push(path.relative(root, dest) + (there ? ' (replaced)' : ''));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { root, done, skipped, kept };
|
|
103
|
+
}
|