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.
@@ -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
+ `;
@@ -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
+ }