bacon-tracker 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE +21 -0
- data/README.md +430 -0
- data/bin/tracker-dashboard +47 -0
- data/bin/tracker-init +267 -0
- data/lib/bacon_tracker/assets/app.js +1258 -0
- data/lib/bacon_tracker/assets/decisions.js +382 -0
- data/lib/bacon_tracker/assets/docs.js +456 -0
- data/lib/bacon_tracker/assets/logic.js +85 -0
- data/lib/bacon_tracker/assets/theme.js +33 -0
- data/lib/bacon_tracker/commands/tracker.md +269 -0
- data/lib/bacon_tracker/dashboard.rb +168 -0
- data/lib/bacon_tracker/launcher.rb +64 -0
- data/lib/bacon_tracker/server.rb +476 -0
- data/lib/bacon_tracker/tasks.rb +465 -0
- data/lib/bacon_tracker/version.rb +3 -0
- data/lib/bacon_tracker/views/_board_css.erb +122 -0
- data/lib/bacon_tracker/views/_detail_css.erb +109 -0
- data/lib/bacon_tracker/views/_header_css.erb +82 -0
- data/lib/bacon_tracker/views/_markdown_css.erb +81 -0
- data/lib/bacon_tracker/views/_root_css.erb +41 -0
- data/lib/bacon_tracker/views/_theme_boot.erb +1 -0
- data/lib/bacon_tracker/views/dashboard.erb +281 -0
- data/lib/bacon_tracker/views/decisions.erb +211 -0
- data/lib/bacon_tracker/views/docs.erb +234 -0
- data/lib/bacon_tracker/views/index.erb +1065 -0
- data/lib/bacon_tracker.rb +1993 -0
- metadata +213 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// Docs column browser (BT-139). Finder-style: one column per selected folder,
|
|
2
|
+
// the rendered page pinned right. Deep nesting pushes columns left - no tree,
|
|
3
|
+
// no breadcrumbs, by design (§13.2).
|
|
4
|
+
(function () {
|
|
5
|
+
const API_BASE = window.BT_API_BASE || '';
|
|
6
|
+
const columnsEl = document.getElementById('doc-columns');
|
|
7
|
+
const previewEl = document.getElementById('doc-preview');
|
|
8
|
+
let tree = [];
|
|
9
|
+
let selection = []; // node per depth
|
|
10
|
+
|
|
11
|
+
function nodesAt(depth) {
|
|
12
|
+
let nodes = tree;
|
|
13
|
+
for (let i = 0; i < depth; i++) nodes = selection[i].children || [];
|
|
14
|
+
return nodes;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function render() {
|
|
18
|
+
columnsEl.textContent = '';
|
|
19
|
+
for (let depth = 0; depth <= selection.length; depth++) {
|
|
20
|
+
const nodes = nodesAt(depth);
|
|
21
|
+
if (depth > 0 && selection[depth - 1].type !== 'dir') break;
|
|
22
|
+
if (!nodes.length) break;
|
|
23
|
+
const col = document.createElement('ul');
|
|
24
|
+
col.className = 'doc-col';
|
|
25
|
+
nodes.forEach((node) => {
|
|
26
|
+
const li = document.createElement('li');
|
|
27
|
+
li.textContent = (node.type === 'dir' ? '▸ ' : '') + node.name;
|
|
28
|
+
li.className = 'doc-entry doc-' + node.type;
|
|
29
|
+
if (selection[depth] === node) li.classList.add('selected');
|
|
30
|
+
li.addEventListener('click', () => select(depth, node));
|
|
31
|
+
col.appendChild(li);
|
|
32
|
+
});
|
|
33
|
+
columnsEl.appendChild(col);
|
|
34
|
+
}
|
|
35
|
+
columnsEl.scrollLeft = columnsEl.scrollWidth; // keep the preview visible
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function select(depth, node) {
|
|
39
|
+
if (node.decisions) {
|
|
40
|
+
// A tracked subtree has a board; the browser hands over rather than
|
|
41
|
+
// column-walking the records (BT-176).
|
|
42
|
+
window.location = (API_BASE || '') + '/docs/decisions';
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
selection = selection.slice(0, depth);
|
|
46
|
+
selection.push(node);
|
|
47
|
+
render();
|
|
48
|
+
if (node.type === 'page') await showPage(node.path, node.name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let currentPage = null;
|
|
52
|
+
|
|
53
|
+
function pageDom(data) {
|
|
54
|
+
const frag = document.createDocumentFragment();
|
|
55
|
+
const keys = Object.keys(data.frontmatter || {});
|
|
56
|
+
if (keys.length) {
|
|
57
|
+
const dl = document.createElement('dl');
|
|
58
|
+
dl.className = 'doc-fm';
|
|
59
|
+
keys.forEach((k) => {
|
|
60
|
+
const dt = document.createElement('dt');
|
|
61
|
+
dt.textContent = k;
|
|
62
|
+
const dd = document.createElement('dd');
|
|
63
|
+
dd.textContent = Array.isArray(data.frontmatter[k])
|
|
64
|
+
? data.frontmatter[k].join(', ')
|
|
65
|
+
: data.frontmatter[k];
|
|
66
|
+
dl.append(dt, dd);
|
|
67
|
+
});
|
|
68
|
+
frag.appendChild(dl);
|
|
69
|
+
}
|
|
70
|
+
const body = document.createElement('div');
|
|
71
|
+
body.innerHTML = data.html; // our own server rendering local files
|
|
72
|
+
frag.appendChild(body);
|
|
73
|
+
return frag;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Whatever fills the preview (page, search, front) takes a ticket; a
|
|
77
|
+
// response that arrives after a later request bails, so a slow earlier
|
|
78
|
+
// page can never overwrite the selection the reader has moved on to.
|
|
79
|
+
let previewSeq = 0;
|
|
80
|
+
|
|
81
|
+
async function showPage(path, name) {
|
|
82
|
+
const my = ++previewSeq;
|
|
83
|
+
previewEl.textContent = 'loading…';
|
|
84
|
+
let res;
|
|
85
|
+
try {
|
|
86
|
+
res = await fetch(API_BASE + '/api/docs/page?path=' + encodeURIComponent(path));
|
|
87
|
+
} catch {
|
|
88
|
+
if (my === previewSeq) notice(`${name || path}: network error`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (my !== previewSeq) return;
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const err = (await res.json().catch(() => ({}))).error || 'could not load';
|
|
94
|
+
if (my === previewSeq) notice(`${name || path}: ${err}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const data = await res.json().catch(() => null);
|
|
98
|
+
if (my !== previewSeq) return;
|
|
99
|
+
if (!data || typeof data.html !== 'string') {
|
|
100
|
+
notice(`${name || path}: could not load`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
currentPage = data;
|
|
104
|
+
previewEl.textContent = '';
|
|
105
|
+
previewEl.appendChild(pageDom(data));
|
|
106
|
+
previewEl.dataset.path = data.path;
|
|
107
|
+
appendBacklinks(data.path);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// What links here (BT-152) - always stated, so an empty list reads as an
|
|
111
|
+
// answer rather than an omission.
|
|
112
|
+
async function appendBacklinks(path) {
|
|
113
|
+
const res = await fetch(API_BASE + '/api/docs/backlinks?path=' + encodeURIComponent(path));
|
|
114
|
+
const links = await res.json().catch(() => []);
|
|
115
|
+
if (previewEl.dataset.path !== path) return; // the reader moved on
|
|
116
|
+
const head = document.createElement('div');
|
|
117
|
+
head.className = 'sh-folder';
|
|
118
|
+
head.textContent = 'linked from';
|
|
119
|
+
previewEl.appendChild(head);
|
|
120
|
+
if (!links.length) {
|
|
121
|
+
const none = document.createElement('div');
|
|
122
|
+
none.className = 'sh-line';
|
|
123
|
+
none.textContent = 'nothing links to this page';
|
|
124
|
+
previewEl.appendChild(none);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
links.forEach((b) => {
|
|
128
|
+
const row = document.createElement('div');
|
|
129
|
+
row.className = 'search-hit';
|
|
130
|
+
const p = document.createElement('div');
|
|
131
|
+
p.className = 'sh-path';
|
|
132
|
+
p.textContent = b.path;
|
|
133
|
+
row.appendChild(p);
|
|
134
|
+
row.addEventListener('click', () => {
|
|
135
|
+
selectByPath(b.path);
|
|
136
|
+
showPage(b.path);
|
|
137
|
+
});
|
|
138
|
+
previewEl.appendChild(row);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Act on the real file (BT-142/143): the buttons follow the deepest
|
|
143
|
+
// selection - a page or a folder - and there is deliberately no in-browser
|
|
144
|
+
// editing anywhere; the editor already open is better than any textarea.
|
|
145
|
+
async function fileAction(action) {
|
|
146
|
+
const node = selection[selection.length - 1];
|
|
147
|
+
if (!node) return;
|
|
148
|
+
if (action === 'editor' && node.type !== 'page') return;
|
|
149
|
+
try {
|
|
150
|
+
const res = await fetch(API_BASE + '/api/docs/' + action, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ path: node.path }),
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
// 400 = scope refusal, 501 = no launcher on this platform.
|
|
157
|
+
const err = (await res.json().catch(() => ({}))).error;
|
|
158
|
+
flash(err || `${action} failed (HTTP ${res.status})`);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
flash(`network error - ${action} failed`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
document.getElementById('doc-editor').addEventListener('click', () => fileAction('editor'));
|
|
165
|
+
document.getElementById('doc-reveal').addEventListener('click', () => fileAction('reveal'));
|
|
166
|
+
|
|
167
|
+
// Detail view (BT-141): the board's overlay component, one reused element.
|
|
168
|
+
const detailEl = document.getElementById('doc-detail');
|
|
169
|
+
document.getElementById('doc-expand').addEventListener('click', () => {
|
|
170
|
+
if (!currentPage) return;
|
|
171
|
+
document.getElementById('doc-detail-path').textContent = currentPage.path;
|
|
172
|
+
document.getElementById('doc-detail-title').textContent = currentPage.title;
|
|
173
|
+
const body = document.getElementById('doc-detail-body');
|
|
174
|
+
body.textContent = '';
|
|
175
|
+
body.appendChild(pageDom(currentPage));
|
|
176
|
+
// The overlay already shows the title - the body's own # heading would
|
|
177
|
+
// say it twice (BT-172).
|
|
178
|
+
const h1 = body.querySelector('h1');
|
|
179
|
+
if (h1) h1.remove();
|
|
180
|
+
detailEl.classList.add('open');
|
|
181
|
+
});
|
|
182
|
+
function closeDetail() {
|
|
183
|
+
detailEl.classList.remove('open');
|
|
184
|
+
}
|
|
185
|
+
document.getElementById('doc-detail-close').addEventListener('click', closeDetail);
|
|
186
|
+
detailEl.addEventListener('click', (e) => {
|
|
187
|
+
if (e.target === detailEl) closeDetail();
|
|
188
|
+
});
|
|
189
|
+
document.addEventListener('keydown', (e) => {
|
|
190
|
+
if (e.key === 'Escape') closeDetail();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
function notice(text) {
|
|
194
|
+
previewEl.textContent = '';
|
|
195
|
+
const p = document.createElement('p');
|
|
196
|
+
p.className = 'doc-notice';
|
|
197
|
+
p.textContent = text;
|
|
198
|
+
previewEl.appendChild(p);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A transient bar for a failed action - unlike notice() it leaves the page
|
|
202
|
+
// in the preview alone.
|
|
203
|
+
function flash(text) {
|
|
204
|
+
const bar = document.createElement('div');
|
|
205
|
+
bar.className = 'doc-flash';
|
|
206
|
+
bar.textContent = text;
|
|
207
|
+
document.body.appendChild(bar);
|
|
208
|
+
setTimeout(() => bar.remove(), 6000);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Walk the tree to a docs-relative path so a followed link also drives the
|
|
212
|
+
// columns, keeping selection and preview in step (BT-140).
|
|
213
|
+
function selectByPath(path) {
|
|
214
|
+
const segs = path.split('/');
|
|
215
|
+
selection = [];
|
|
216
|
+
let nodes = tree;
|
|
217
|
+
for (const seg of segs) {
|
|
218
|
+
const node = (nodes || []).find((n) => n.name === seg);
|
|
219
|
+
if (!node) {
|
|
220
|
+
render();
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
selection.push(node);
|
|
224
|
+
nodes = node.children;
|
|
225
|
+
}
|
|
226
|
+
render();
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Relative links inside a rendered page resolve inside the browser
|
|
231
|
+
// (BT-140). A link that climbs out of the docs root is refused with the
|
|
232
|
+
// page it came from named; the server refuses independently (BT-159) -
|
|
233
|
+
// this check just makes the refusal instant and honest.
|
|
234
|
+
previewEl.addEventListener('click', (e) => {
|
|
235
|
+
const a = e.target.closest('a');
|
|
236
|
+
if (!a || !previewEl.contains(a)) return;
|
|
237
|
+
const href = a.getAttribute('href') || '';
|
|
238
|
+
if (window.BTLogic.isExternalHref(href)) return; // http(s)/mailto behave normally
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
const from = previewEl.dataset.path || '';
|
|
241
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href.trim())) {
|
|
242
|
+
// javascript:, data:, ... - never handed to the browser. The server
|
|
243
|
+
// sanitises too; this makes the refusal visible.
|
|
244
|
+
notice(`refused: the link "${href}" in ${from} uses a scheme the browser must not follow`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const target = window.BTLogic.resolveDocPath(from, href);
|
|
248
|
+
if (target === null) {
|
|
249
|
+
notice(`refused: the link "${href}" in ${from} leaves the docs root`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (selectByPath(target)) {
|
|
253
|
+
showPage(target);
|
|
254
|
+
} else {
|
|
255
|
+
// Not in the tree - a non-renderable file or a missing one. Ask the
|
|
256
|
+
// server anyway so the refusal carries its reason; reveal lands with
|
|
257
|
+
// BT-143.
|
|
258
|
+
showPage(target, target);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Search (BT-150): results replace the preview grouped by folder; opening a
|
|
263
|
+
// hit drives the columns and scrolls the first match into view.
|
|
264
|
+
const searchEl = document.getElementById('doc-search');
|
|
265
|
+
let searchTimer = null;
|
|
266
|
+
searchEl.addEventListener('input', () => {
|
|
267
|
+
clearTimeout(searchTimer);
|
|
268
|
+
searchTimer = setTimeout(runSearch, 250);
|
|
269
|
+
});
|
|
270
|
+
searchEl.addEventListener('keydown', (e) => {
|
|
271
|
+
if (e.key === 'Escape') {
|
|
272
|
+
searchEl.value = '';
|
|
273
|
+
showFront();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
async function runSearch() {
|
|
278
|
+
const q = searchEl.value.trim();
|
|
279
|
+
if (!q) {
|
|
280
|
+
showFront();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const my = ++previewSeq;
|
|
284
|
+
let hits = null;
|
|
285
|
+
try {
|
|
286
|
+
const res = await fetch(API_BASE + '/api/docs/search?q=' + encodeURIComponent(q));
|
|
287
|
+
if (res.ok) hits = await res.json().catch(() => null);
|
|
288
|
+
} catch {
|
|
289
|
+
hits = null;
|
|
290
|
+
}
|
|
291
|
+
if (my !== previewSeq) return;
|
|
292
|
+
if (!Array.isArray(hits)) {
|
|
293
|
+
notice('search failed');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
previewEl.textContent = '';
|
|
297
|
+
previewEl.dataset.path = '';
|
|
298
|
+
if (!hits.length) {
|
|
299
|
+
notice(`no matches for "${q}"`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
let lastFolder = null;
|
|
303
|
+
hits.forEach((h) => {
|
|
304
|
+
if (h.folder !== lastFolder) {
|
|
305
|
+
lastFolder = h.folder;
|
|
306
|
+
const f = document.createElement('div');
|
|
307
|
+
f.className = 'sh-folder';
|
|
308
|
+
f.textContent = h.folder === '.' ? 'docs' : h.folder;
|
|
309
|
+
previewEl.appendChild(f);
|
|
310
|
+
}
|
|
311
|
+
const hit = document.createElement('div');
|
|
312
|
+
hit.className = 'search-hit';
|
|
313
|
+
const path = document.createElement('div');
|
|
314
|
+
path.className = 'sh-path';
|
|
315
|
+
path.textContent = h.path + ':' + h.lineno;
|
|
316
|
+
const line = document.createElement('div');
|
|
317
|
+
line.className = 'sh-line';
|
|
318
|
+
const idx = h.line.toLowerCase().indexOf(q.toLowerCase());
|
|
319
|
+
if (idx >= 0) {
|
|
320
|
+
line.append(h.line.slice(0, idx));
|
|
321
|
+
const mark = document.createElement('mark');
|
|
322
|
+
mark.textContent = h.line.slice(idx, idx + q.length);
|
|
323
|
+
line.append(mark, h.line.slice(idx + q.length));
|
|
324
|
+
} else {
|
|
325
|
+
line.textContent = h.line;
|
|
326
|
+
}
|
|
327
|
+
hit.append(path, line);
|
|
328
|
+
hit.addEventListener('click', () => openHit(h.path, q));
|
|
329
|
+
previewEl.appendChild(hit);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function openHit(path, q) {
|
|
334
|
+
selectByPath(path);
|
|
335
|
+
await showPage(path);
|
|
336
|
+
// Scroll the first element containing the match into view.
|
|
337
|
+
const needle = q.toLowerCase();
|
|
338
|
+
const walker = document.createTreeWalker(previewEl, NodeFilter.SHOW_TEXT);
|
|
339
|
+
let node;
|
|
340
|
+
while ((node = walker.nextNode())) {
|
|
341
|
+
if (node.textContent.toLowerCase().includes(needle)) {
|
|
342
|
+
(node.parentElement || previewEl).scrollIntoView({ block: 'center' });
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The landing surface (BT-149): the README is what you see before you
|
|
349
|
+
// select anything, the version is a badge, and the changelog opens in the
|
|
350
|
+
// overlay collapsed by version - newest expanded, never one wall.
|
|
351
|
+
let front = {};
|
|
352
|
+
let recent = [];
|
|
353
|
+
function showFront() {
|
|
354
|
+
++previewSeq; // a page or search still in flight must not land on top
|
|
355
|
+
previewEl.textContent = '';
|
|
356
|
+
previewEl.dataset.path = '';
|
|
357
|
+
if (recent.length) {
|
|
358
|
+
const head = document.createElement('div');
|
|
359
|
+
head.className = 'sh-folder';
|
|
360
|
+
head.textContent = 'recently changed';
|
|
361
|
+
previewEl.appendChild(head);
|
|
362
|
+
recent.slice(0, 8).forEach((r) => {
|
|
363
|
+
const row = document.createElement('div');
|
|
364
|
+
row.className = 'search-hit';
|
|
365
|
+
const p = document.createElement('div');
|
|
366
|
+
p.className = 'sh-path';
|
|
367
|
+
p.textContent = r.path;
|
|
368
|
+
const d = document.createElement('div');
|
|
369
|
+
d.className = 'sh-line';
|
|
370
|
+
d.textContent = r.date;
|
|
371
|
+
row.append(p, d);
|
|
372
|
+
row.addEventListener('click', () => {
|
|
373
|
+
selectByPath(r.path);
|
|
374
|
+
showPage(r.path);
|
|
375
|
+
});
|
|
376
|
+
previewEl.appendChild(row);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (front.readme_html) {
|
|
380
|
+
const readme = document.createElement('div');
|
|
381
|
+
readme.innerHTML = front.readme_html;
|
|
382
|
+
previewEl.appendChild(readme);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function changelogDom(html) {
|
|
387
|
+
const src = document.createElement('div');
|
|
388
|
+
src.innerHTML = html;
|
|
389
|
+
const out = document.createDocumentFragment();
|
|
390
|
+
let details = null,
|
|
391
|
+
first = true;
|
|
392
|
+
[...src.childNodes].forEach((node) => {
|
|
393
|
+
if (node.nodeName === 'H2') {
|
|
394
|
+
details = document.createElement('details');
|
|
395
|
+
if (first) {
|
|
396
|
+
details.open = true;
|
|
397
|
+
first = false;
|
|
398
|
+
}
|
|
399
|
+
const summary = document.createElement('summary');
|
|
400
|
+
summary.textContent = node.textContent;
|
|
401
|
+
details.appendChild(summary);
|
|
402
|
+
out.appendChild(details);
|
|
403
|
+
} else if (details) {
|
|
404
|
+
details.appendChild(node);
|
|
405
|
+
} else {
|
|
406
|
+
out.appendChild(node);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function load() {
|
|
413
|
+
let treeRes, frontRes, recentRes;
|
|
414
|
+
try {
|
|
415
|
+
[treeRes, frontRes, recentRes] = await Promise.all([
|
|
416
|
+
fetch(API_BASE + '/api/docs/tree'),
|
|
417
|
+
fetch(API_BASE + '/api/docs/front'),
|
|
418
|
+
fetch(API_BASE + '/api/docs/recent'),
|
|
419
|
+
]);
|
|
420
|
+
} catch {
|
|
421
|
+
notice('network error - could not load docs');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
tree = await treeRes.json().catch(() => null);
|
|
425
|
+
front = await frontRes.json().catch(() => ({}));
|
|
426
|
+
recent = await recentRes.json().catch(() => []);
|
|
427
|
+
if (!Array.isArray(tree)) {
|
|
428
|
+
tree = [];
|
|
429
|
+
notice('could not load the docs tree');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!Array.isArray(recent)) recent = [];
|
|
433
|
+
render();
|
|
434
|
+
if (front.version) document.getElementById('doc-version').textContent = 'v' + front.version;
|
|
435
|
+
if (front.version_ambiguous) {
|
|
436
|
+
const v = document.getElementById('doc-version');
|
|
437
|
+
v.textContent = 'version?';
|
|
438
|
+
v.title =
|
|
439
|
+
'Ambiguous: ' + front.version_ambiguous.join(' and ') + ' - set version: in dashboard.md';
|
|
440
|
+
}
|
|
441
|
+
if (front.changelog_html) {
|
|
442
|
+
const link = document.getElementById('doc-changelog');
|
|
443
|
+
link.style.display = '';
|
|
444
|
+
link.addEventListener('click', () => {
|
|
445
|
+
document.getElementById('doc-detail-path').textContent = 'CHANGELOG.md';
|
|
446
|
+
document.getElementById('doc-detail-title').textContent = 'Changelog';
|
|
447
|
+
const body = document.getElementById('doc-detail-body');
|
|
448
|
+
body.textContent = '';
|
|
449
|
+
body.appendChild(changelogDom(front.changelog_html));
|
|
450
|
+
detailEl.classList.add('open');
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
showFront();
|
|
454
|
+
}
|
|
455
|
+
load();
|
|
456
|
+
})();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Pure, DOM-free client helpers - extracted so they can be unit-tested directly
|
|
2
|
+
// under Node (see spec/js/), and loaded before app.js in the browser where they
|
|
3
|
+
// attach to window.BTLogic (BT-112).
|
|
4
|
+
(function (global) {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// The trailing numeric part of a story id ("B2B-042" -> 42), so column
|
|
8
|
+
// ordering is correct even when the namespace itself contains digits (BT-104).
|
|
9
|
+
function storyNumber(id) {
|
|
10
|
+
const m = String(id).match(/(\d+)$/);
|
|
11
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Comparator ordering stories by their trailing number.
|
|
15
|
+
function byStoryNumber(a, b) {
|
|
16
|
+
return storyNumber(a.id) - storyNumber(b.id);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Resolve a relative markdown link against the directory of the page it sits
|
|
20
|
+
// in, docs-root-relative. Returns null when the link is not relative or
|
|
21
|
+
// climbs out of the root - the caller must refuse, not render (BT-140/BT-159).
|
|
22
|
+
function resolveDocPath(pagePath, href) {
|
|
23
|
+
if (/^([a-z]+:|\/|#)/i.test(href)) return null;
|
|
24
|
+
const base = String(pagePath).split('/').slice(0, -1);
|
|
25
|
+
for (const seg of String(href).split('/')) {
|
|
26
|
+
if (seg === '' || seg === '.') continue;
|
|
27
|
+
if (seg === '..') {
|
|
28
|
+
if (base.length === 0) return null;
|
|
29
|
+
base.pop();
|
|
30
|
+
} else {
|
|
31
|
+
base.push(seg);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return base.join('/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Candidates for "which decision supersedes this one": the accepted records,
|
|
38
|
+
// minus the one being superseded - a record cannot supersede itself, and
|
|
39
|
+
// only an accepted record is in force to supersede anything (BT-145).
|
|
40
|
+
function supersedeCandidates(records, id) {
|
|
41
|
+
return records.filter((r) => r.status === 'accepted' && r.id !== id).map((r) => r.id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The namespace prefix of any record or story id ("TST-ADR-0004" and
|
|
45
|
+
// "TST-129" are both "TST"). Cross-namespace ids render as plain text -
|
|
46
|
+
// this checkout cannot resolve them either way (BT-ADR-0013), so it must
|
|
47
|
+
// not offer a link that lies (BT-148).
|
|
48
|
+
function idNamespace(id) {
|
|
49
|
+
const m = String(id).match(/^([A-Z][A-Z0-9]*)-/);
|
|
50
|
+
return m ? m[1] : null;
|
|
51
|
+
}
|
|
52
|
+
function sameNamespace(a, b) {
|
|
53
|
+
const na = idNamespace(a);
|
|
54
|
+
return na !== null && na === idNamespace(b);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Only http(s) and mailto links leave the docs browser for the browser
|
|
58
|
+
// proper; anything else (javascript:, data:, ...) is refused by the caller.
|
|
59
|
+
function isExternalHref(href) {
|
|
60
|
+
return /^(https?|mailto):/i.test(String(href || '').trim());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The id in a location hash ("#TST-042" -> "TST-042"); '' when absent or
|
|
64
|
+
// malformed - a bad hash must not throw at boot.
|
|
65
|
+
function hashId(hash) {
|
|
66
|
+
try {
|
|
67
|
+
return decodeURIComponent(String(hash || '').replace(/^#/, ''));
|
|
68
|
+
} catch {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const api = {
|
|
74
|
+
storyNumber,
|
|
75
|
+
byStoryNumber,
|
|
76
|
+
resolveDocPath,
|
|
77
|
+
supersedeCandidates,
|
|
78
|
+
idNamespace,
|
|
79
|
+
sameNamespace,
|
|
80
|
+
isExternalHref,
|
|
81
|
+
hashId,
|
|
82
|
+
};
|
|
83
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
84
|
+
else global.BTLogic = api;
|
|
85
|
+
})(typeof self !== 'undefined' ? self : this);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function initTheme() {
|
|
2
|
+
const saved = localStorage.getItem('bt-theme');
|
|
3
|
+
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
4
|
+
const isDark = saved ? saved === 'dark' : sysDark;
|
|
5
|
+
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
|
|
6
|
+
updateThemeBtn(isDark);
|
|
7
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
|
8
|
+
if (!localStorage.getItem('bt-theme')) {
|
|
9
|
+
const dark = e.matches;
|
|
10
|
+
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
|
11
|
+
updateThemeBtn(dark);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function toggleTheme() {
|
|
17
|
+
const isDark = document.documentElement.dataset.theme !== 'dark';
|
|
18
|
+
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
|
|
19
|
+
const sysDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
20
|
+
if (isDark === sysDark) {
|
|
21
|
+
localStorage.removeItem('bt-theme');
|
|
22
|
+
} else {
|
|
23
|
+
localStorage.setItem('bt-theme', isDark ? 'dark' : 'light');
|
|
24
|
+
}
|
|
25
|
+
updateThemeBtn(isDark);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function updateThemeBtn(isDark) {
|
|
29
|
+
document.querySelectorAll('.theme-toggle').forEach((b) => {
|
|
30
|
+
b.textContent = isDark ? '◑' : '◐';
|
|
31
|
+
b.title = isDark ? 'light mode' : 'dark mode';
|
|
32
|
+
});
|
|
33
|
+
}
|