@alfacapital-tech/c4builder 0.7.0 → 0.9.0-rc.1
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/dist/cli/commands/site.comments.js +378 -0
- package/dist/cli/commands/site.js +99 -2
- package/dist/cli/dispatch.js +13 -7
- package/dist/config/defaults.js +2 -1
- package/dist/config/schema.js +3 -0
- package/dist/core/build.js +3 -3
- package/dist/core/compose/markdown.js +5 -1
- package/dist/core/plugins/tree.js +3 -1
- package/dist/core/questions.js +227 -0
- package/dist/core/scan/tree.js +3 -1
- package/dist/plugins/openspec/index.js +40 -13
- package/dist/plugins/openspec/layout.js +60 -5
- package/dist/plugins/openspec/render.js +45 -19
- package/dist/plugins/openspec/scan.js +4 -2
- package/dist/util/lock.js +23 -0
- package/package.json +1 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
export const COMMENTS_PATH = '/__c4builder/comments';
|
|
2
|
+
const CSS = [
|
|
3
|
+
'.c4b-cbtn{position:absolute;z-index:1001;padding:4px 10px;border:1px solid #42b983;border-radius:4px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.15)}',
|
|
4
|
+
'.c4b-cpop{position:absolute;z-index:1002;width:380px;max-width:calc(100vw - 32px);padding:10px;border:1px solid #ddd;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.2)}',
|
|
5
|
+
'.c4b-cpop blockquote{margin:0 0 8px;padding:4px 8px;border-left:3px solid #42b983;background:#f8f8f8;max-height:80px;overflow:auto;font-size:12px;white-space:pre-wrap}',
|
|
6
|
+
'.c4b-cpop textarea{width:100%;box-sizing:border-box;min-height:70px;padding:6px;border:1px solid #ccc;border-radius:4px;font:13px/1.4 sans-serif;resize:vertical}',
|
|
7
|
+
'.c4b-cpop .c4b-row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:8px}',
|
|
8
|
+
'.c4b-cpop small{color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
9
|
+
'.c4b-cpop button{padding:4px 12px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;cursor:pointer;font:13px sans-serif}',
|
|
10
|
+
'.c4b-cpop button.c4b-primary{border-color:#42b983;background:#42b983;color:#fff}',
|
|
11
|
+
'.c4b-cpop button:disabled{opacity:.6;cursor:default}',
|
|
12
|
+
'.c4b-ctoast{position:fixed;left:16px;bottom:16px;z-index:1003;max-width:420px;padding:8px 14px;border-radius:6px;background:#2c3e50;color:#fff;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.3)}',
|
|
13
|
+
'.c4b-ctoast.c4b-err{background:#c0392b}',
|
|
14
|
+
'mark.c4b-hl{background:#fff3a0;color:inherit;padding:0;border-radius:2px}',
|
|
15
|
+
'sup.c4b-ref{font-size:.7em;line-height:0;margin-left:1px;user-select:none}',
|
|
16
|
+
'sup.c4b-ref a{color:#b58900;text-decoration:none;font-weight:700;cursor:pointer}',
|
|
17
|
+
'.c4b-qrow{transition:background .3s}',
|
|
18
|
+
'.c4b-qflash{background:#fff3a0!important}',
|
|
19
|
+
'.c4b-del{margin-left:6px;padding:0 5px;border:1px solid transparent;border-radius:3px;background:none;color:#999;font:12px/1.4 sans-serif;cursor:pointer;opacity:0;vertical-align:middle}',
|
|
20
|
+
'.c4b-qrow:hover .c4b-del{opacity:1}',
|
|
21
|
+
'.c4b-del:hover{color:#fff;background:#c0392b;border-color:#c0392b}',
|
|
22
|
+
'.c4b-del:disabled{opacity:.5;cursor:default}'
|
|
23
|
+
].join('');
|
|
24
|
+
const jsString = (s) => JSON.stringify(s)
|
|
25
|
+
.replace(/</g, '\\u003c')
|
|
26
|
+
.replace(/[\u0080-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
27
|
+
export const commentsSnippet = (heading) => `
|
|
28
|
+
<script>
|
|
29
|
+
(function () {
|
|
30
|
+
if (window.__c4builderComments) return;
|
|
31
|
+
window.__c4builderComments = true;
|
|
32
|
+
var ENDPOINT = '${COMMENTS_PATH}';
|
|
33
|
+
var HEADING = ${jsString(heading)};
|
|
34
|
+
var TOAST_KEY = '__c4builderCommentToast';
|
|
35
|
+
var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
|
|
36
|
+
var style = document.createElement('style');
|
|
37
|
+
style.textContent = ${jsString(CSS)};
|
|
38
|
+
document.head.appendChild(style);
|
|
39
|
+
|
|
40
|
+
var collapse = function (s) { return String(s).replace(/\\s+/g, ' ').trim(); };
|
|
41
|
+
var same = function (a, b) { return collapse(a).toLowerCase() === collapse(b).toLowerCase(); };
|
|
42
|
+
|
|
43
|
+
var toastEl = null, toastTimer = 0;
|
|
44
|
+
var toast = function (msg, err) {
|
|
45
|
+
if (toastEl) toastEl.remove();
|
|
46
|
+
toastEl = document.createElement('div');
|
|
47
|
+
toastEl.className = 'c4b-ctoast' + (err ? ' c4b-err' : '');
|
|
48
|
+
toastEl.textContent = msg;
|
|
49
|
+
document.body.appendChild(toastEl);
|
|
50
|
+
clearTimeout(toastTimer);
|
|
51
|
+
toastTimer = setTimeout(function () { if (toastEl) { toastEl.remove(); toastEl = null; } }, err ? 8000 : 5000);
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
var saved = sessionStorage.getItem(TOAST_KEY);
|
|
55
|
+
if (saved) { sessionStorage.removeItem(TOAST_KEY); toast(saved); }
|
|
56
|
+
} catch (e) {}
|
|
57
|
+
|
|
58
|
+
var content = function () { return document.querySelector('.markdown-section'); };
|
|
59
|
+
var headingLevel = function (el) { return /^H[1-6]$/.test(el.tagName) ? Number(el.tagName[1]) : 0; };
|
|
60
|
+
var sectionOf = function (h) {
|
|
61
|
+
var out = [], lvl = headingLevel(h);
|
|
62
|
+
for (var el = h.nextElementSibling; el; el = el.nextElementSibling) {
|
|
63
|
+
var l = headingLevel(el);
|
|
64
|
+
if (l && l <= lvl) break;
|
|
65
|
+
out.push(el);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
};
|
|
69
|
+
var findHeading = function (root, text) {
|
|
70
|
+
var hs = root.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
71
|
+
for (var i = 0; i < hs.length; i++) if (same(hs[i].textContent, text)) return hs[i];
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
var SEP = ' \\u2014 \\u00ab';
|
|
76
|
+
var parseQuestions = function (text) {
|
|
77
|
+
var s = collapse(text), out = [];
|
|
78
|
+
for (var at = s.indexOf(SEP); at >= 0; at = s.indexOf(SEP, at + 1)) {
|
|
79
|
+
var m = s.slice(at + SEP.length).match(/^([\\s\\S]*)\\u00bb(?: \\(\\u00a7 (.*)\\))?$/);
|
|
80
|
+
var quote = m ? m[1].replace(/\\u2026$/, '').trim() : '';
|
|
81
|
+
if (quote) out.push({ comment: s.slice(0, at), quote: quote, section: m[2] || '' });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
var textIndex = function (roots, skip) {
|
|
86
|
+
var chars = [], map = [], lastBlock = null, prevSpace = true;
|
|
87
|
+
var walk = function (node) {
|
|
88
|
+
if (node.nodeType === 1) {
|
|
89
|
+
if (skip.indexOf(node) >= 0 || (node.tagName === 'SUP' && node.classList.contains('c4b-ref'))) return;
|
|
90
|
+
if (/^(SCRIPT|STYLE)$/.test(node.tagName)) return;
|
|
91
|
+
for (var c = node.firstChild; c; c = c.nextSibling) walk(c);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (node.nodeType !== 3) return;
|
|
95
|
+
var block = node.parentNode.closest ? node.parentNode.closest(BLOCKS) : null;
|
|
96
|
+
if (block !== lastBlock && !prevSpace && chars.length) { chars.push(' '); map.push(null); prevSpace = true; }
|
|
97
|
+
lastBlock = block;
|
|
98
|
+
var s = node.nodeValue;
|
|
99
|
+
for (var i = 0; i < s.length; i++) {
|
|
100
|
+
var ch = s[i];
|
|
101
|
+
if (/\\s/.test(ch)) {
|
|
102
|
+
if (prevSpace) continue;
|
|
103
|
+
chars.push(' '); map.push(null); prevSpace = true;
|
|
104
|
+
} else {
|
|
105
|
+
chars.push(ch); map.push({ node: node, offset: i }); prevSpace = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
for (var r = 0; r < roots.length; r++) walk(roots[r]);
|
|
110
|
+
return { text: chars.join(''), map: map };
|
|
111
|
+
};
|
|
112
|
+
var wrapRange = function (idx, from, to, title) {
|
|
113
|
+
var first = null, last = null, i;
|
|
114
|
+
for (i = from; i < to && !first; i++) first = idx.map[i];
|
|
115
|
+
for (i = to - 1; i >= from && !last; i--) last = idx.map[i];
|
|
116
|
+
if (!first || !last) return null;
|
|
117
|
+
var nodes = [], cur = first.node;
|
|
118
|
+
var walker = document.createTreeWalker(content(), NodeFilter.SHOW_TEXT);
|
|
119
|
+
walker.currentNode = cur;
|
|
120
|
+
while (cur) { nodes.push(cur); if (cur === last.node) break; cur = walker.nextNode(); }
|
|
121
|
+
var marks = [];
|
|
122
|
+
for (i = nodes.length - 1; i >= 0; i--) {
|
|
123
|
+
var n = nodes[i];
|
|
124
|
+
var start = n === first.node ? first.offset : 0;
|
|
125
|
+
var end = n === last.node ? last.offset + 1 : n.nodeValue.length;
|
|
126
|
+
if (end < n.nodeValue.length) n.splitText(end);
|
|
127
|
+
var mid = start > 0 ? n.splitText(start) : n;
|
|
128
|
+
if (!mid.nodeValue.trim()) continue;
|
|
129
|
+
var mark = document.createElement('mark');
|
|
130
|
+
mark.className = 'c4b-hl';
|
|
131
|
+
mark.title = title;
|
|
132
|
+
mid.parentNode.insertBefore(mark, mid);
|
|
133
|
+
mark.appendChild(mid);
|
|
134
|
+
marks.unshift(mark);
|
|
135
|
+
}
|
|
136
|
+
return marks;
|
|
137
|
+
};
|
|
138
|
+
var flash = function (row) {
|
|
139
|
+
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
140
|
+
row.classList.add('c4b-qflash');
|
|
141
|
+
setTimeout(function () { row.classList.remove('c4b-qflash'); }, 1500);
|
|
142
|
+
};
|
|
143
|
+
var highlight = function () {
|
|
144
|
+
var root = content();
|
|
145
|
+
if (!root || root.querySelector('mark.c4b-hl, .c4b-del')) return;
|
|
146
|
+
var h = findHeading(root, HEADING);
|
|
147
|
+
if (!h) return;
|
|
148
|
+
var section = sectionOf(h);
|
|
149
|
+
var items = [];
|
|
150
|
+
section.forEach(function (el) {
|
|
151
|
+
if (el.tagName === 'TABLE') {
|
|
152
|
+
var rows = el.querySelectorAll('tbody tr');
|
|
153
|
+
for (var i = 0; i < rows.length; i++) {
|
|
154
|
+
var cells = rows[i].querySelectorAll('td');
|
|
155
|
+
if (!cells.length) continue;
|
|
156
|
+
var numeric = /^\\d+$/.test(collapse(cells[0].textContent));
|
|
157
|
+
var textCell = numeric && cells.length > 1 ? cells[1] : cells[0];
|
|
158
|
+
if (!collapse(textCell.textContent)) continue;
|
|
159
|
+
items.push({ row: rows[i], cell: textCell, number: numeric ? collapse(cells[0].textContent) : String(items.length + 1), text: textCell.textContent });
|
|
160
|
+
}
|
|
161
|
+
} else if (/^[UO]L$/.test(el.tagName)) {
|
|
162
|
+
var lis = el.children;
|
|
163
|
+
for (var j = 0; j < lis.length; j++) items.push({ row: lis[j], cell: lis[j], number: String(items.length + 1), text: lis[j].textContent });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
var addDelete = function (item) {
|
|
167
|
+
var del = document.createElement('button');
|
|
168
|
+
del.type = 'button';
|
|
169
|
+
del.className = 'c4b-del';
|
|
170
|
+
del.textContent = '\\u00d7';
|
|
171
|
+
del.title = 'Remove this question';
|
|
172
|
+
del.addEventListener('click', function (e) {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
e.stopPropagation();
|
|
175
|
+
del.disabled = true;
|
|
176
|
+
send({ page: pageFile(), number: Number(item.number) || null, text: collapse(item.text) }, 'DELETE').then(function (r) {
|
|
177
|
+
var msg = 'Removed from ' + r.file;
|
|
178
|
+
try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
|
|
179
|
+
toast(msg);
|
|
180
|
+
}, function (err) {
|
|
181
|
+
del.disabled = false;
|
|
182
|
+
toast('Remove failed: ' + (err && err.message ? err.message : err), true);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
item.cell.appendChild(del);
|
|
186
|
+
};
|
|
187
|
+
var skip = [h].concat(section);
|
|
188
|
+
var tryIn = function (idx, q) {
|
|
189
|
+
var at = idx.text.indexOf(q.quote);
|
|
190
|
+
var marks = at < 0 ? null : wrapRange(idx, at, at + q.quote.length, q.comment);
|
|
191
|
+
return marks && marks.length ? marks : null;
|
|
192
|
+
};
|
|
193
|
+
items.forEach(function (item) {
|
|
194
|
+
item.row.classList.add('c4b-qrow');
|
|
195
|
+
item.row.id = item.row.id || 'c4b-q-' + item.number;
|
|
196
|
+
addDelete(item);
|
|
197
|
+
// Index is rebuilt per try: wrapping splits text nodes and stales the previous map.
|
|
198
|
+
var q = null, marks = null;
|
|
199
|
+
parseQuestions(item.text).some(function (c) {
|
|
200
|
+
if (c.section) {
|
|
201
|
+
var sh = findHeading(root, c.section);
|
|
202
|
+
if (sh && skip.indexOf(sh) < 0) marks = tryIn(textIndex(sectionOf(sh), skip), c);
|
|
203
|
+
}
|
|
204
|
+
marks = marks || tryIn(textIndex([root], skip), c);
|
|
205
|
+
q = marks ? c : null;
|
|
206
|
+
return !!marks;
|
|
207
|
+
});
|
|
208
|
+
if (!marks) return;
|
|
209
|
+
var sup = document.createElement('sup');
|
|
210
|
+
sup.className = 'c4b-ref';
|
|
211
|
+
var a = document.createElement('a');
|
|
212
|
+
a.setAttribute('role', 'button');
|
|
213
|
+
a.tabIndex = 0;
|
|
214
|
+
a.textContent = item.number;
|
|
215
|
+
a.title = q.comment;
|
|
216
|
+
a.addEventListener('click', function (e) { e.preventDefault(); flash(item.row); });
|
|
217
|
+
sup.appendChild(a);
|
|
218
|
+
var lastMark = marks[marks.length - 1];
|
|
219
|
+
lastMark.parentNode.insertBefore(sup, lastMark.nextSibling);
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
var schedule = function () { setTimeout(function () { try { highlight(); } catch (e) {} }, 50); };
|
|
223
|
+
var docsifyVm = null;
|
|
224
|
+
if (window.$docsify) {
|
|
225
|
+
window.$docsify.plugins = [].concat(window.$docsify.plugins || [], function (hook, vm) {
|
|
226
|
+
docsifyVm = vm || null;
|
|
227
|
+
hook.doneEach(schedule);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
window.addEventListener('hashchange', function () { setTimeout(schedule, 300); });
|
|
231
|
+
window.addEventListener('load', function () { setTimeout(schedule, 500); });
|
|
232
|
+
|
|
233
|
+
var btn = document.createElement('button');
|
|
234
|
+
btn.type = 'button';
|
|
235
|
+
btn.className = 'c4b-cbtn';
|
|
236
|
+
btn.textContent = 'Comment';
|
|
237
|
+
btn.hidden = true;
|
|
238
|
+
document.body.appendChild(btn);
|
|
239
|
+
var pop = null;
|
|
240
|
+
|
|
241
|
+
var selection = function () {
|
|
242
|
+
var sel = window.getSelection();
|
|
243
|
+
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return null;
|
|
244
|
+
var range = sel.getRangeAt(0);
|
|
245
|
+
var root = content();
|
|
246
|
+
if (!root) return null;
|
|
247
|
+
var node = range.commonAncestorContainer;
|
|
248
|
+
var el = node.nodeType === 1 ? node : node.parentNode;
|
|
249
|
+
if (!root.contains(el) || (pop && pop.contains(el))) return null;
|
|
250
|
+
var text = sel.toString().trim();
|
|
251
|
+
return text ? { range: range, text: text } : null;
|
|
252
|
+
};
|
|
253
|
+
var headingBefore = function (range) {
|
|
254
|
+
var root = content();
|
|
255
|
+
if (!root) return '';
|
|
256
|
+
var hs = root.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
257
|
+
var found = '';
|
|
258
|
+
for (var i = 0; i < hs.length; i++) {
|
|
259
|
+
if (hs[i].compareDocumentPosition(range.startContainer) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
260
|
+
found = hs[i].textContent.trim();
|
|
261
|
+
else break;
|
|
262
|
+
}
|
|
263
|
+
return found;
|
|
264
|
+
};
|
|
265
|
+
var anchorRect = function (range) {
|
|
266
|
+
var rects = range.getClientRects();
|
|
267
|
+
return rects.length ? rects[rects.length - 1] : range.getBoundingClientRect();
|
|
268
|
+
};
|
|
269
|
+
var place = function (el, rect) {
|
|
270
|
+
el.style.top = (rect.bottom + window.scrollY + 6) + 'px';
|
|
271
|
+
el.style.left = Math.max(8, Math.min(rect.left + window.scrollX, window.innerWidth - el.offsetWidth - 8)) + 'px';
|
|
272
|
+
};
|
|
273
|
+
var update = function () {
|
|
274
|
+
if (pop) return;
|
|
275
|
+
var s = selection();
|
|
276
|
+
if (!s) { btn.hidden = true; return; }
|
|
277
|
+
btn.hidden = false;
|
|
278
|
+
place(btn, anchorRect(s.range));
|
|
279
|
+
};
|
|
280
|
+
document.addEventListener('selectionchange', function () { setTimeout(update, 0); });
|
|
281
|
+
|
|
282
|
+
var pageFile = function () {
|
|
283
|
+
var file = docsifyVm && docsifyVm.route && docsifyVm.route.file;
|
|
284
|
+
if (typeof file === 'string' && file) {
|
|
285
|
+
try { file = decodeURIComponent(file); } catch (e) {}
|
|
286
|
+
file = file.replace(/^https?:\\/\\/[^/]+/, '').replace(/^\\/+/, '');
|
|
287
|
+
if (file) return file;
|
|
288
|
+
}
|
|
289
|
+
var p = location.hash.replace(/^#/, '').split('?')[0];
|
|
290
|
+
try { p = decodeURIComponent(p); } catch (e) {}
|
|
291
|
+
p = p.replace(/^\\/+/, '').replace(/\\/+$/, '');
|
|
292
|
+
if (!p) return (window.$docsify && window.$docsify.homepage) || 'README.md';
|
|
293
|
+
return /\\.md$/i.test(p) ? p : p + '.md';
|
|
294
|
+
};
|
|
295
|
+
var send = function (body, method) {
|
|
296
|
+
return fetch(ENDPOINT, {
|
|
297
|
+
method: method || 'POST',
|
|
298
|
+
headers: { 'Content-Type': 'application/json' },
|
|
299
|
+
body: JSON.stringify(body)
|
|
300
|
+
}).then(function (res) {
|
|
301
|
+
return res.json().catch(function () { return {}; }).then(function (data) {
|
|
302
|
+
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
|
|
303
|
+
return data;
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
var close = function () {
|
|
309
|
+
if (pop) { pop.remove(); pop = null; }
|
|
310
|
+
btn.hidden = true;
|
|
311
|
+
};
|
|
312
|
+
var open = function (data, rect) {
|
|
313
|
+
close();
|
|
314
|
+
pop = document.createElement('div');
|
|
315
|
+
pop.className = 'c4b-cpop';
|
|
316
|
+
var quote = document.createElement('blockquote');
|
|
317
|
+
quote.textContent = data.quote;
|
|
318
|
+
var ta = document.createElement('textarea');
|
|
319
|
+
ta.placeholder = 'Your question or comment (Ctrl+Enter to add, Esc to cancel)';
|
|
320
|
+
var row = document.createElement('div');
|
|
321
|
+
row.className = 'c4b-row';
|
|
322
|
+
var hint = document.createElement('small');
|
|
323
|
+
hint.textContent = 'To: ' + HEADING + (data.section ? ' / ' + data.section : '');
|
|
324
|
+
hint.title = hint.textContent;
|
|
325
|
+
var actions = document.createElement('span');
|
|
326
|
+
var cancel = document.createElement('button');
|
|
327
|
+
cancel.type = 'button';
|
|
328
|
+
cancel.textContent = 'Cancel';
|
|
329
|
+
var ok = document.createElement('button');
|
|
330
|
+
ok.type = 'button';
|
|
331
|
+
ok.className = 'c4b-primary';
|
|
332
|
+
ok.textContent = 'Add';
|
|
333
|
+
actions.appendChild(cancel);
|
|
334
|
+
actions.appendChild(document.createTextNode(' '));
|
|
335
|
+
actions.appendChild(ok);
|
|
336
|
+
row.appendChild(hint);
|
|
337
|
+
row.appendChild(actions);
|
|
338
|
+
pop.appendChild(quote);
|
|
339
|
+
pop.appendChild(ta);
|
|
340
|
+
pop.appendChild(row);
|
|
341
|
+
document.body.appendChild(pop);
|
|
342
|
+
place(pop, rect);
|
|
343
|
+
ta.focus();
|
|
344
|
+
var submit = function () {
|
|
345
|
+
var comment = ta.value.trim();
|
|
346
|
+
if (!comment) { ta.focus(); return; }
|
|
347
|
+
ok.disabled = true;
|
|
348
|
+
cancel.disabled = true;
|
|
349
|
+
send({ page: pageFile(), quote: data.quote, section: data.section, comment: comment }).then(function (r) {
|
|
350
|
+
var msg = 'Added to ' + r.file + (r.number ? ' as #' + r.number : '');
|
|
351
|
+
try { sessionStorage.setItem(TOAST_KEY, msg); } catch (e) {}
|
|
352
|
+
close();
|
|
353
|
+
toast(msg);
|
|
354
|
+
}, function (err) {
|
|
355
|
+
ok.disabled = false;
|
|
356
|
+
cancel.disabled = false;
|
|
357
|
+
toast('Comment failed: ' + (err && err.message ? err.message : err), true);
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
ok.addEventListener('click', submit);
|
|
361
|
+
cancel.addEventListener('click', close);
|
|
362
|
+
ta.addEventListener('keydown', function (e) {
|
|
363
|
+
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
364
|
+
else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); submit(); }
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
btn.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
|
369
|
+
btn.addEventListener('click', function () {
|
|
370
|
+
var s = selection();
|
|
371
|
+
if (s) open({ quote: s.text, section: headingBefore(s.range) }, anchorRect(s.range));
|
|
372
|
+
});
|
|
373
|
+
document.addEventListener('mousedown', function (e) {
|
|
374
|
+
if (pop && !pop.contains(e.target) && e.target !== btn) close();
|
|
375
|
+
});
|
|
376
|
+
window.addEventListener('hashchange', close);
|
|
377
|
+
})();
|
|
378
|
+
</script>`;
|
|
@@ -3,8 +3,11 @@ import path from 'node:path';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import express from 'express';
|
|
5
5
|
import open from 'open';
|
|
6
|
+
import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
|
|
6
7
|
import { packageJson as pkg } from "../../util/paths.js";
|
|
7
8
|
import { pendingUpdate } from "../update.js";
|
|
9
|
+
import { COMMENTS_PATH, commentsSnippet } from "./site.comments.js";
|
|
10
|
+
const COMMENT_MAX = 2000;
|
|
8
11
|
const LIVERELOAD_PATH = '/__livereload';
|
|
9
12
|
// Восстановление скролла после reload: docsify рендерит контент асинхронно после fetch md,
|
|
10
13
|
// высота страницы доступна не сразу — поллим до достижения нужной y или таймаута.
|
|
@@ -76,7 +79,9 @@ c4builder ${next} is available (current ${pkg.version}). Run: <code>c4builder up
|
|
|
76
79
|
};
|
|
77
80
|
})();
|
|
78
81
|
</script>`;
|
|
79
|
-
export default (currentConfiguration, program, reloadEmitter
|
|
82
|
+
export default (currentConfiguration, program, reloadEmitter,
|
|
83
|
+
// Карта «страница → исходники» текущей сборки — геттером, чтобы видеть результат ребилдов watch.
|
|
84
|
+
pages = () => new Map()) => {
|
|
80
85
|
if (!currentConfiguration.DIST_FOLDER)
|
|
81
86
|
return console.log(chalk.red('No destination folder configured'));
|
|
82
87
|
const app = express();
|
|
@@ -112,11 +117,103 @@ export default (currentConfiguration, program, reloadEmitter) => {
|
|
|
112
117
|
// запущенной этим же `site -w`, виден со следующей перезагрузки без рестарта сервера.
|
|
113
118
|
const inject = (html) => {
|
|
114
119
|
const next = pendingUpdate();
|
|
115
|
-
const snippet =
|
|
120
|
+
const snippet = LIVERELOAD_SNIPPET +
|
|
121
|
+
commentsSnippet(currentConfiguration.QUESTIONS_HEADING) +
|
|
122
|
+
(next ? updateBanner(next) : '');
|
|
116
123
|
if (html.includes('</body>'))
|
|
117
124
|
return html.replace('</body>', `${snippet}</body>`);
|
|
118
125
|
return html + snippet;
|
|
119
126
|
};
|
|
127
|
+
// Комментарий к выделенному тексту → раздел вопросов исходного файла страницы
|
|
128
|
+
// (change site-comments, design D3). Сервер слушает только 127.0.0.1, но страница
|
|
129
|
+
// любого сайта в браузере может слать POST на localhost — требуем Origin самого сайта.
|
|
130
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
131
|
+
const heading = currentConfiguration.QUESTIONS_HEADING;
|
|
132
|
+
// Общая часть POST/DELETE: проверка Origin и исходный файл страницы из тела запроса.
|
|
133
|
+
// Ошибка уже отправлена клиенту, когда возвращается undefined.
|
|
134
|
+
const resolveTarget = (req, res) => {
|
|
135
|
+
const origin = req.get('origin');
|
|
136
|
+
const host = req.get('host');
|
|
137
|
+
// Host — только локальный: при DNS rebinding чужой домен резолвится в 127.0.0.1 и
|
|
138
|
+
// присылает согласованные Host и Origin, сверка Origin с одним Host это пропускает.
|
|
139
|
+
const local = [`127.0.0.1:${req.socket.localPort}`, `localhost:${req.socket.localPort}`];
|
|
140
|
+
if (!origin || !host || !local.includes(host) || origin !== `http://${host}`) {
|
|
141
|
+
res.status(403).json({ error: 'comments are accepted only from the site itself' });
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const body = (req.body ?? {});
|
|
145
|
+
const page = path.posix.normalize(str(body.page).replace(/^\/+/, ''));
|
|
146
|
+
const sources = page.startsWith('..') ? undefined : pages().get(page);
|
|
147
|
+
if (!sources) {
|
|
148
|
+
res.status(404).json({ error: `unknown page: ${page}` });
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const cwd = process.cwd();
|
|
152
|
+
const file = pickSource(sources.filter((s) => s.startsWith(cwd + path.sep)), heading);
|
|
153
|
+
if (!file) {
|
|
154
|
+
res.status(404).json({ error: 'this page has no source file to write to' });
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
return { file, rel: path.relative(cwd, file).split(path.sep).join('/'), body };
|
|
158
|
+
};
|
|
159
|
+
app.post(COMMENTS_PATH, express.json({ limit: '64kb' }), (req, res) => {
|
|
160
|
+
const target = resolveTarget(req, res);
|
|
161
|
+
if (!target)
|
|
162
|
+
return;
|
|
163
|
+
const { file, rel, body } = target;
|
|
164
|
+
const comment = str(body.comment).trim();
|
|
165
|
+
const quote = str(body.quote);
|
|
166
|
+
const section = str(body.section);
|
|
167
|
+
if (!comment) {
|
|
168
|
+
res.status(400).json({ error: 'comment is empty' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (comment.length > COMMENT_MAX || quote.length > COMMENT_MAX) {
|
|
172
|
+
res.status(400).json({
|
|
173
|
+
error: `comment and quote must be at most ${COMMENT_MAX} characters`
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const number = writeQuestion(file, heading, formatQuestion({ comment, quote, section }));
|
|
179
|
+
console.log(chalk.gray(`comment added to ${rel}${number ? ` as #${number}` : ''}`));
|
|
180
|
+
res.json({ file: rel, number });
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
res.status(500).json({ error: `could not write the comment: ${e.message}` });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
// Удаление вопроса: по номеру (числовая таблица) и тексту, как он виден на странице.
|
|
187
|
+
app.delete(COMMENTS_PATH, express.json({ limit: '64kb' }), (req, res) => {
|
|
188
|
+
const target = resolveTarget(req, res);
|
|
189
|
+
if (!target)
|
|
190
|
+
return;
|
|
191
|
+
const { file, rel, body } = target;
|
|
192
|
+
const text = str(body.text).trim();
|
|
193
|
+
if (!text) {
|
|
194
|
+
res.status(400).json({ error: 'question text is empty' });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const number = typeof body.number === 'number' ? body.number : Number(str(body.number)) || null;
|
|
198
|
+
try {
|
|
199
|
+
const result = deleteQuestion(file, heading, { number, text });
|
|
200
|
+
if ('error' in result) {
|
|
201
|
+
const messages = {
|
|
202
|
+
'not-found': `question not found in ${rel} — the page may be stale, reload it`,
|
|
203
|
+
ambiguous: `several identical questions in ${rel} — remove it by hand`
|
|
204
|
+
};
|
|
205
|
+
res.status(result.error === 'not-found' ? 404 : 409).json({
|
|
206
|
+
error: messages[result.error]
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
console.log(chalk.gray(`comment removed from ${rel}`));
|
|
211
|
+
res.json({ file: rel });
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
res.status(500).json({ error: `could not remove the comment: ${e.message}` });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
120
217
|
app.use((req, res, next) => {
|
|
121
218
|
if (req.method !== 'GET')
|
|
122
219
|
return next();
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -17,7 +17,7 @@ import fs from 'node:fs';
|
|
|
17
17
|
import watch from 'node-watch';
|
|
18
18
|
import { EventEmitter } from 'node:events';
|
|
19
19
|
import { clearConsole } from "../util/utils.js";
|
|
20
|
-
import { acquireBuildLock, BuildLockHeldError } from "../util/lock.js";
|
|
20
|
+
import { acquireBuildLock, BuildLockHeldError, waitBuildLock } from "../util/lock.js";
|
|
21
21
|
// teardownD2 живёт здесь, а не в finally build(): в watch-режиме D2-воркер должен
|
|
22
22
|
// переживать ребилды (его пересоздание — секунды), гасим его только когда сборок
|
|
23
23
|
// больше не будет (одиночный запуск).
|
|
@@ -102,6 +102,7 @@ function getOptions(c) {
|
|
|
102
102
|
EXECUTE_SCRIPT: c.executeScript,
|
|
103
103
|
EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
|
|
104
104
|
USE_SYSTEM_FONTS: c.useSystemFonts,
|
|
105
|
+
QUESTIONS_HEADING: c.questionsHeading,
|
|
105
106
|
PLUGINS: c.plugins
|
|
106
107
|
};
|
|
107
108
|
}
|
|
@@ -256,6 +257,9 @@ export default async () => {
|
|
|
256
257
|
});
|
|
257
258
|
const reloadEmitter = new EventEmitter();
|
|
258
259
|
reloadEmitter.setMaxListeners(0);
|
|
260
|
+
// Карта «страница сайта → исходники» последней удачной сборки: dev-сервер читает её
|
|
261
|
+
// геттером, поэтому после ребилда в watch видит свежую без рестарта.
|
|
262
|
+
let pages = new Map();
|
|
259
263
|
if (opts.watch) {
|
|
260
264
|
// node-watch: CJS-рантайм при ESM-.d.ts (export default) — дефолт-импорт
|
|
261
265
|
// типизируется как namespace. Каст к реальной сигнатуре, рантайм не меняется.
|
|
@@ -286,13 +290,15 @@ export default async () => {
|
|
|
286
290
|
let buildOk = true;
|
|
287
291
|
try {
|
|
288
292
|
// Лок на всю серию ребилдов: параллельная ручная сборка в этом же
|
|
289
|
-
// каталоге не потопчет dist_bk и не потеряет чексуммы кеша.
|
|
290
|
-
|
|
293
|
+
// каталоге не потопчет dist_bk и не потеряет чексуммы кеша. Занят другим
|
|
294
|
+
// живым процессом (второй `site -w` на той же папке) — ждём до минуты и
|
|
295
|
+
// собираем сами: иначе ребилд пропадает и браузер не получает reload.
|
|
296
|
+
const release = await waitBuildLock(lockPath, 60_000, (e) => console.log(chalk.gray(`waiting for another build: ${e.message}`)));
|
|
291
297
|
try {
|
|
292
|
-
await build(options, cacheConf, plugins);
|
|
298
|
+
({ pages } = await build(options, cacheConf, plugins));
|
|
293
299
|
while (attemptedWatchBuild) {
|
|
294
300
|
attemptedWatchBuild = false;
|
|
295
|
-
await build(options, cacheConf, plugins);
|
|
301
|
+
({ pages } = await build(options, cacheConf, plugins));
|
|
296
302
|
}
|
|
297
303
|
}
|
|
298
304
|
finally {
|
|
@@ -329,7 +335,7 @@ export default async () => {
|
|
|
329
335
|
process.exit(1);
|
|
330
336
|
}
|
|
331
337
|
try {
|
|
332
|
-
await build(options, cacheConf, plugins);
|
|
338
|
+
({ pages } = await build(options, cacheConf, plugins));
|
|
333
339
|
}
|
|
334
340
|
finally {
|
|
335
341
|
release();
|
|
@@ -342,7 +348,7 @@ export default async () => {
|
|
|
342
348
|
}
|
|
343
349
|
isBuilding = false;
|
|
344
350
|
if (opts.site)
|
|
345
|
-
return await cmdSite(options, opts, reloadEmitter);
|
|
351
|
+
return await cmdSite(options, opts, reloadEmitter, () => pages);
|
|
346
352
|
if (options.GENERATE_WEBSITE && !opts.watch) {
|
|
347
353
|
console.log(chalk.gray('\nto view the generated website run'));
|
|
348
354
|
console.log(`> c4builder site`);
|
package/dist/config/defaults.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -57,6 +57,9 @@ export const configSchema = z.object({
|
|
|
57
57
|
d2Layout: str(defaultConfig.d2Layout),
|
|
58
58
|
charset: str(defaultConfig.charset),
|
|
59
59
|
useSystemFonts: bool(defaultConfig.useSystemFonts),
|
|
60
|
+
// Заголовок раздела, куда `site -w` дописывает комментарии к странице (в коде кириллицы
|
|
61
|
+
// быть не может — русский заголовок задаёт сам проект).
|
|
62
|
+
questionsHeading: str(defaultConfig.questionsHeading),
|
|
60
63
|
// Задаются явно (флаг/промпт) либо тонкой правкой файла — в defaultConfig не входят.
|
|
61
64
|
projectName: optStr(),
|
|
62
65
|
webFileName: optStr(),
|
package/dist/core/build.js
CHANGED
|
@@ -14,10 +14,9 @@ import { generateMD, generateWebMD, generateCompleteMD, generateLlmsTxt, generat
|
|
|
14
14
|
// Загружены заранее в cli/dispatch и приходят третьим аргументом (как cacheConf).
|
|
15
15
|
import { runAfterScan, runAfterBuild } from "./plugins/hooks.js";
|
|
16
16
|
import { injectPluginAssets } from "./plugins/assets.js";
|
|
17
|
-
// Оркестратор владеет lifecycle выходного каталога: dist бэкапится в dist_bk, откуда
|
|
18
|
-
// generateImages восстанавливает неизменённые по чексумме картинки; в конце бэкап сносится.
|
|
19
17
|
const build = async (options, cacheConf, plugins = []) => {
|
|
20
18
|
const start_date = new Date();
|
|
19
|
+
let pages = new Map();
|
|
21
20
|
const bkFolderName = options.DIST_FOLDER + DIST_BACKUP_FOLDER_SUFFIX;
|
|
22
21
|
// Generating local images, remove old backup image folder, rename current dist folder to new backup
|
|
23
22
|
if (options.GENERATE_LOCAL_IMAGES) {
|
|
@@ -72,7 +71,7 @@ const build = async (options, cacheConf, plugins = []) => {
|
|
|
72
71
|
}
|
|
73
72
|
if (options.GENERATE_WEBSITE) {
|
|
74
73
|
console.log(chalk.blue('generating docsify site'));
|
|
75
|
-
await generateWebMD(tree, options);
|
|
74
|
+
pages = await generateWebMD(tree, options);
|
|
76
75
|
await injectPluginAssets(plugins, options);
|
|
77
76
|
}
|
|
78
77
|
// llms.txt ссылается на .md страниц сайта — без website ссылаться не на что.
|
|
@@ -98,6 +97,7 @@ const build = async (options, cacheConf, plugins = []) => {
|
|
|
98
97
|
await runAfterBuild(options, plugins);
|
|
99
98
|
console.log(chalk.green(`built in ${(Date.now() - start_date.getTime()) / 1000} seconds`));
|
|
100
99
|
ok = true;
|
|
100
|
+
return { pages };
|
|
101
101
|
}
|
|
102
102
|
finally {
|
|
103
103
|
// Бэкап dist сносим ТОЛЬКО при успехе: при падении фазы dist_bk — единственная
|
|
@@ -315,6 +315,7 @@ export const generateMD = async (tree, options, onProgress) => {
|
|
|
315
315
|
export const generateWebMD = async (tree, options) => {
|
|
316
316
|
const filePromises = [];
|
|
317
317
|
let docsifySideBar = '';
|
|
318
|
+
const pages = new Map();
|
|
318
319
|
const getWebFileName = (originalFileName) => options.WEB_FILE_NAME || originalFileName;
|
|
319
320
|
for (const item of tree) {
|
|
320
321
|
if (inSidebar(item, options)) {
|
|
@@ -322,7 +323,9 @@ export const generateWebMD = async (tree, options) => {
|
|
|
322
323
|
}
|
|
323
324
|
const MD = await compilePage(item, options);
|
|
324
325
|
//write to disk
|
|
325
|
-
|
|
326
|
+
const pageRel = path.join(item.dir.replace(options.ROOT_FOLDER, ''), `${getWebFileName(item.name)}.md`);
|
|
327
|
+
pages.set(pageRel.split(path.sep).filter(Boolean).join('/'), item.sources);
|
|
328
|
+
filePromises.push(writeFile(path.join(options.DIST_FOLDER, pageRel), MD));
|
|
326
329
|
}
|
|
327
330
|
if (options.DOCSIFY_TEMPLATE && options.DOCSIFY_TEMPLATE !== '') {
|
|
328
331
|
// require ESM/TS-шаблона возвращает namespace { default: fn }, а не саму функцию —
|
|
@@ -375,4 +378,5 @@ export const generateWebMD = async (tree, options) => {
|
|
|
375
378
|
//sidebar
|
|
376
379
|
filePromises.push(writeFile(path.join(options.DIST_FOLDER, '_sidebar.md'), docsifySideBar));
|
|
377
380
|
await Promise.all(filePromises);
|
|
381
|
+
return pages;
|
|
378
382
|
};
|
|
@@ -52,7 +52,8 @@ export const addPage = (tree, options, page) => {
|
|
|
52
52
|
parent: parent.dir,
|
|
53
53
|
mdFiles: [],
|
|
54
54
|
diagrams: [],
|
|
55
|
-
descendants: []
|
|
55
|
+
descendants: [],
|
|
56
|
+
sources: []
|
|
56
57
|
};
|
|
57
58
|
VIRTUAL.add(item);
|
|
58
59
|
parent.descendants.push(seg);
|
|
@@ -66,6 +67,7 @@ export const addPage = (tree, options, page) => {
|
|
|
66
67
|
}
|
|
67
68
|
const md = page.markdown === undefined ? [] : Array.isArray(page.markdown) ? page.markdown : [page.markdown];
|
|
68
69
|
item.mdFiles.push(...md);
|
|
70
|
+
item.sources.push(...(page.sources ?? []).map((s) => path.resolve(s)));
|
|
69
71
|
for (const d of page.diagrams ?? []) {
|
|
70
72
|
const ext = path.extname(d.file).toLowerCase();
|
|
71
73
|
const engine = engineForExt(ext);
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Правка раздела вопросов исходного markdown по комментарию со страницы `site -w`
|
|
2
|
+
// (см. change site-comments, design D4): добавление и удаление. Чистые функции + запись
|
|
3
|
+
// файла; формат раздела (таблица / список / его отсутствие) определяется по содержимому,
|
|
4
|
+
// чтобы не навязывать проектам одну форму таблицы.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
const QUOTE_MAX = 200;
|
|
7
|
+
const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|
|
8
|
+
// docsify рендерит HTML из markdown как есть: без экранирования цитата `List<String>` теряет
|
|
9
|
+
// `<String>` на странице, а `<img onerror>` из комментария становится скриптом и в dist.
|
|
10
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
11
|
+
/** Текст вопроса: комментарий, цитата (до 200 символов) и раздел страницы, где сделано выделение. */
|
|
12
|
+
export const formatQuestion = ({ comment, quote, section }) => {
|
|
13
|
+
let q = collapse(quote ?? '');
|
|
14
|
+
if (q.length > QUOTE_MAX)
|
|
15
|
+
q = `${q.slice(0, QUOTE_MAX - 1).trimEnd()}…`;
|
|
16
|
+
const s = collapse(section ?? '');
|
|
17
|
+
return escapeHtml(`${collapse(comment)}${q ? ` — «${q}»` : ''}${s ? ` (§ ${s})` : ''}`);
|
|
18
|
+
};
|
|
19
|
+
// Заголовок раздела: любой уровень, без учёта регистра и лишних пробелов, с необязательными
|
|
20
|
+
// закрывающими `#`.
|
|
21
|
+
const headingLevel = (line, heading) => {
|
|
22
|
+
const m = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
23
|
+
if (!m)
|
|
24
|
+
return 0;
|
|
25
|
+
return collapse(m[2]).toLowerCase() === collapse(heading).toLowerCase() ? m[1].length : 0;
|
|
26
|
+
};
|
|
27
|
+
const isFence = (line) => /^\s*(```|~~~)/.test(line);
|
|
28
|
+
const isRow = (line) => line.trim().startsWith('|');
|
|
29
|
+
const isSeparator = (line) => /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line);
|
|
30
|
+
const isListItem = (line) => /^\s*([-*+]|\d+[.)])\s+/.test(line);
|
|
31
|
+
const splitRow = (line) => line
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/^\|/, '')
|
|
34
|
+
.replace(/\|$/, '')
|
|
35
|
+
.split(/(?<!\\)\|/)
|
|
36
|
+
.map((c) => c.trim());
|
|
37
|
+
const isPlaceholder = (cell) => /^(\.{3}|…|)$/.test(cell);
|
|
38
|
+
const escapeCell = (s) => s.replace(/\|/g, '\\|');
|
|
39
|
+
// Текст ячейки/пункта без inline-разметки — для сравнения с тем, что видно на странице
|
|
40
|
+
// (docsify отрисовал markdown, клиент прислал textContent). Применяется к обеим сторонам:
|
|
41
|
+
// сущности декодируются до срезания тегов, чтобы `<T>` из файла и `<T>` со страницы
|
|
42
|
+
// свелись к одному.
|
|
43
|
+
const plain = (s) => collapse(s
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
46
|
+
.replace(/&/g, '&')
|
|
47
|
+
.replace(/\\\|/g, '|')
|
|
48
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
49
|
+
.replace(/<[^>]+>/g, '')
|
|
50
|
+
.replace(/[`*_~]/g, ''));
|
|
51
|
+
/** Границы раздела: строка заголовка и индекс первой строки за разделом. */
|
|
52
|
+
const findSection = (lines, heading) => {
|
|
53
|
+
let start = -1;
|
|
54
|
+
let level = 0;
|
|
55
|
+
let fenced = false;
|
|
56
|
+
for (const [i, line] of lines.entries()) {
|
|
57
|
+
if (isFence(line))
|
|
58
|
+
fenced = !fenced;
|
|
59
|
+
if (fenced)
|
|
60
|
+
continue;
|
|
61
|
+
level = headingLevel(line, heading);
|
|
62
|
+
if (level) {
|
|
63
|
+
start = i;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (start < 0)
|
|
68
|
+
return undefined;
|
|
69
|
+
let end = lines.length;
|
|
70
|
+
fenced = false;
|
|
71
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
72
|
+
if (isFence(lines[i]))
|
|
73
|
+
fenced = !fenced;
|
|
74
|
+
if (fenced)
|
|
75
|
+
continue;
|
|
76
|
+
const m = lines[i].match(/^(#{1,6})\s/);
|
|
77
|
+
if (m && m[1].length <= level) {
|
|
78
|
+
end = i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { start, end };
|
|
83
|
+
};
|
|
84
|
+
const findTable = (lines, start, end) => {
|
|
85
|
+
for (let i = start + 1; i + 1 < end; i++) {
|
|
86
|
+
if (!isRow(lines[i]) || !isSeparator(lines[i + 1]))
|
|
87
|
+
continue;
|
|
88
|
+
let last = i + 1;
|
|
89
|
+
while (last + 1 < end && isRow(lines[last + 1]))
|
|
90
|
+
last++;
|
|
91
|
+
const cols = splitRow(lines[i]).length;
|
|
92
|
+
const rows = lines.slice(i + 2, last + 1).map(splitRow);
|
|
93
|
+
// Числовая первая колонка: по данным, а у пустой таблицы — по заголовку (`#`, `№`, `No`).
|
|
94
|
+
const numeric = rows.length
|
|
95
|
+
? rows.every((r) => /^\d+$/.test(r[0] ?? ''))
|
|
96
|
+
: /^(#|№|no\.?)$/i.test(splitRow(lines[i])[0] ?? '');
|
|
97
|
+
return { header: i, last, cols, numeric, textCol: numeric ? Math.min(1, cols - 1) : 0 };
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Дописывает вопрос в раздел `heading`: строкой таблицы (с номером, если первая колонка
|
|
103
|
+
* числовая; заглушка `1 | ... | ...` заменяется), иначе пунктом списка в конце раздела;
|
|
104
|
+
* нет раздела — создаёт его в конце файла. Перевод строки файла сохраняется.
|
|
105
|
+
*/
|
|
106
|
+
export const appendQuestion = (markdown, heading, text) => {
|
|
107
|
+
const eol = markdown.includes('\r\n') ? '\r\n' : '\n';
|
|
108
|
+
const lines = markdown.split(/\r?\n/);
|
|
109
|
+
const section = findSection(lines, heading);
|
|
110
|
+
if (!section) {
|
|
111
|
+
while (lines.length && lines[lines.length - 1].trim() === '')
|
|
112
|
+
lines.pop();
|
|
113
|
+
lines.push('', `## ${heading}`, '', `- ${text}`, '');
|
|
114
|
+
return { markdown: lines.join(eol), number: null };
|
|
115
|
+
}
|
|
116
|
+
const { start, end } = section;
|
|
117
|
+
const table = findTable(lines, start, end);
|
|
118
|
+
if (table) {
|
|
119
|
+
const { header, last, cols, numeric, textCol } = table;
|
|
120
|
+
const rows = lines.slice(header + 2, last + 1).map(splitRow);
|
|
121
|
+
// Единственная строка шаблона с `...` в ячейке вопроса — заглушка: заменяем её, сохраняя
|
|
122
|
+
// заполненные ячейки (например, «Кому: Аналитик»), остальные `...` очищаем.
|
|
123
|
+
const placeholder = rows.length === 1 && isPlaceholder(rows[0][textCol] ?? '');
|
|
124
|
+
const number = numeric
|
|
125
|
+
? placeholder
|
|
126
|
+
? 1
|
|
127
|
+
: rows.reduce((max, r) => Math.max(max, Number(r[0])), 0) + 1
|
|
128
|
+
: null;
|
|
129
|
+
const cells = placeholder
|
|
130
|
+
? Array.from({ length: cols }, (_, c) => isPlaceholder(rows[0][c] ?? '') ? '' : (rows[0][c] ?? ''))
|
|
131
|
+
: new Array(cols).fill('');
|
|
132
|
+
if (number !== null)
|
|
133
|
+
cells[0] = String(number);
|
|
134
|
+
cells[textCol] = escapeCell(text);
|
|
135
|
+
const row = `| ${cells.join(' | ')} |`;
|
|
136
|
+
if (placeholder)
|
|
137
|
+
lines.splice(last, 1, row);
|
|
138
|
+
else
|
|
139
|
+
lines.splice(last + 1, 0, row);
|
|
140
|
+
return { markdown: lines.join(eol), number };
|
|
141
|
+
}
|
|
142
|
+
let lastText = end - 1;
|
|
143
|
+
while (lastText > start && lines[lastText].trim() === '')
|
|
144
|
+
lastText--;
|
|
145
|
+
if (lastText === start) {
|
|
146
|
+
// Пустой раздел: пункт через пустую строку после заголовка; перед следующим
|
|
147
|
+
// заголовком — тоже пустая строка.
|
|
148
|
+
const insert = ['', `- ${text}`];
|
|
149
|
+
if (start + 1 < lines.length && lines[start + 1].trim() !== '')
|
|
150
|
+
insert.push('');
|
|
151
|
+
lines.splice(start + 1, 0, ...insert);
|
|
152
|
+
return { markdown: lines.join(eol), number: null };
|
|
153
|
+
}
|
|
154
|
+
// Нумерованный список продолжаем его же маркером: `-` после `2.` начал бы второй список.
|
|
155
|
+
const ordered = lines
|
|
156
|
+
.slice(start + 1, lastText + 1)
|
|
157
|
+
.reverse()
|
|
158
|
+
.find((l) => /^([-*+]|\d+[.)])\s/.test(l))
|
|
159
|
+
?.match(/^(\d+)([.)])\s/);
|
|
160
|
+
lines.splice(lastText + 1, 0, ordered ? `${Number(ordered[1]) + 1}${ordered[2]} ${text}` : `- ${text}`);
|
|
161
|
+
return { markdown: lines.join(eol), number: null };
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Удаляет вопрос из раздела `heading`: строку таблицы (по номеру и тексту; без совпадения
|
|
165
|
+
* номера — только по тексту) или пункт списка по тексту. Текст сравнивается без inline-разметки.
|
|
166
|
+
* Номера остальных строк не меняются — на них ссылаются другие документы.
|
|
167
|
+
*/
|
|
168
|
+
export const removeQuestion = (markdown, heading, ref) => {
|
|
169
|
+
const eol = markdown.includes('\r\n') ? '\r\n' : '\n';
|
|
170
|
+
const lines = markdown.split(/\r?\n/);
|
|
171
|
+
const section = findSection(lines, heading);
|
|
172
|
+
if (!section)
|
|
173
|
+
return { error: 'not-found' };
|
|
174
|
+
const { start, end } = section;
|
|
175
|
+
const want = plain(ref.text);
|
|
176
|
+
const table = findTable(lines, start, end);
|
|
177
|
+
let candidates = [];
|
|
178
|
+
if (table) {
|
|
179
|
+
const { header, last, numeric, textCol } = table;
|
|
180
|
+
const rows = [];
|
|
181
|
+
for (let i = header + 2; i <= last; i++)
|
|
182
|
+
rows.push({ line: i, cells: splitRow(lines[i]) });
|
|
183
|
+
const byText = rows.filter((r) => plain(r.cells[textCol] ?? '') === want);
|
|
184
|
+
const byNumber = numeric && ref.number != null ? byText.filter((r) => Number(r.cells[0]) === ref.number) : [];
|
|
185
|
+
candidates = (byNumber.length ? byNumber : byText).map((r) => r.line);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
for (let i = start + 1; i < end; i++)
|
|
189
|
+
if (isListItem(lines[i]) && plain(lines[i].replace(/^\s*([-*+]|\d+[.)])\s+/, '')) === want)
|
|
190
|
+
candidates.push(i);
|
|
191
|
+
}
|
|
192
|
+
if (!candidates.length)
|
|
193
|
+
return { error: 'not-found' };
|
|
194
|
+
if (candidates.length > 1)
|
|
195
|
+
return { error: 'ambiguous' };
|
|
196
|
+
lines.splice(candidates[0], 1);
|
|
197
|
+
return { markdown: lines.join(eol) };
|
|
198
|
+
};
|
|
199
|
+
const hasHeading = (file, heading) => {
|
|
200
|
+
try {
|
|
201
|
+
return fs
|
|
202
|
+
.readFileSync(file, 'utf8')
|
|
203
|
+
.split(/\r?\n/)
|
|
204
|
+
.some((l) => headingLevel(l, heading) > 0);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/** Файл страницы для записи: первый с разделом `heading`, иначе первый существующий. */
|
|
211
|
+
export const pickSource = (files, heading) => {
|
|
212
|
+
const existing = files.filter((f) => fs.existsSync(f));
|
|
213
|
+
return existing.find((f) => hasHeading(f, heading)) ?? existing[0];
|
|
214
|
+
};
|
|
215
|
+
/** Дописывает вопрос в файл и возвращает номер строки таблицы (null — список). */
|
|
216
|
+
export const writeQuestion = (file, heading, text) => {
|
|
217
|
+
const { markdown, number } = appendQuestion(fs.readFileSync(file, 'utf8'), heading, text);
|
|
218
|
+
fs.writeFileSync(file, markdown);
|
|
219
|
+
return number;
|
|
220
|
+
};
|
|
221
|
+
/** Удаляет вопрос из файла; ошибка поиска возвращается, не бросается. */
|
|
222
|
+
export const deleteQuestion = (file, heading, ref) => {
|
|
223
|
+
const result = removeQuestion(fs.readFileSync(file, 'utf8'), heading, ref);
|
|
224
|
+
if ('markdown' in result)
|
|
225
|
+
fs.writeFileSync(file, result.markdown);
|
|
226
|
+
return result;
|
|
227
|
+
};
|
package/dist/core/scan/tree.js
CHANGED
|
@@ -43,7 +43,8 @@ export const generateTree = async (dir, options) => {
|
|
|
43
43
|
parent: parent,
|
|
44
44
|
mdFiles: [],
|
|
45
45
|
diagrams: [],
|
|
46
|
-
descendants: []
|
|
46
|
+
descendants: [],
|
|
47
|
+
sources: []
|
|
47
48
|
};
|
|
48
49
|
tree.push(item);
|
|
49
50
|
}
|
|
@@ -84,6 +85,7 @@ export const generateTree = async (dir, options) => {
|
|
|
84
85
|
const mdPath = path.join(dir, mdFile);
|
|
85
86
|
const { markdown, diagrams } = fences.extract((await readFile(mdPath)).toString(), path.parse(mdFile).name, path.relative(process.cwd(), mdPath).split(path.sep).join('/'));
|
|
86
87
|
item.mdFiles.push(markdown);
|
|
88
|
+
item.sources.push(path.resolve(mdPath));
|
|
87
89
|
for (const d of diagrams)
|
|
88
90
|
item.diagrams.push({
|
|
89
91
|
dir: d.file,
|
|
@@ -16,7 +16,16 @@ const optionsSchema = z
|
|
|
16
16
|
artifacts: z.array(z.string()).default(['proposal', 'design', 'tasks']),
|
|
17
17
|
// Ключ `.openspec.yaml` со ссылкой на родительский change (например `x-parent`):
|
|
18
18
|
// вложенный change выводится страницей под родителем. Не задан — плоский список.
|
|
19
|
-
parentKey: z.string().min(1).optional()
|
|
19
|
+
parentKey: z.string().min(1).optional(),
|
|
20
|
+
// Ключ `.openspec.yaml` с группой change'а (например `x-team`): change'ы верхнего
|
|
21
|
+
// уровня раскладываются по папкам групп `Changes/<группа>/<id>`. Не задан — без групп.
|
|
22
|
+
groupKey: z.string().min(1).optional(),
|
|
23
|
+
// Ключ `.openspec.yaml` с человеческим именем change'а (например `x-name`): подпись и
|
|
24
|
+
// сегмент пути change'а верхнего уровня вместо id. Не задан — id.
|
|
25
|
+
nameKey: z.string().min(1).optional(),
|
|
26
|
+
// Дополнительные колонки таблицы Changes из `.openspec.yaml` (например исполнитель):
|
|
27
|
+
// `[{ key: "x-assignee", title: "Assignee" }]`. Не задана — прежняя таблица.
|
|
28
|
+
columns: z.array(z.object({ key: z.string().min(1), title: z.string().min(1) }).strict()).optional()
|
|
20
29
|
})
|
|
21
30
|
.strict();
|
|
22
31
|
export default definePlugin({
|
|
@@ -35,7 +44,12 @@ export default definePlugin({
|
|
|
35
44
|
// complete-markdown; excludeOtherFiles действует и здесь.
|
|
36
45
|
const addPages = (pages, srcRoot) => {
|
|
37
46
|
for (const page of pages) {
|
|
38
|
-
ctx.addPage({
|
|
47
|
+
ctx.addPage({
|
|
48
|
+
path: page.path,
|
|
49
|
+
markdown: page.markdown,
|
|
50
|
+
diagrams: page.diagrams,
|
|
51
|
+
sources: page.sources
|
|
52
|
+
});
|
|
39
53
|
if (options.EXCLUDE_OTHER_FILES)
|
|
40
54
|
continue;
|
|
41
55
|
for (const { from, to } of page.files) {
|
|
@@ -52,23 +66,36 @@ export default definePlugin({
|
|
|
52
66
|
}
|
|
53
67
|
};
|
|
54
68
|
// Родитель добавляется раньше вложенных: в sidebar под ним сначала его артефакты,
|
|
55
|
-
// затем вложенные change'ы.
|
|
56
|
-
|
|
69
|
+
// затем вложенные change'ы. Страница группы — перед первым её change'ем, со своей
|
|
70
|
+
// таблицей (без секций — она сама и есть секция).
|
|
71
|
+
const addChanges = (changes, layout, section, index) => {
|
|
72
|
+
const seen = new Set();
|
|
57
73
|
for (const change of pageOrder(changes, layout)) {
|
|
58
74
|
const at = layout.get(change);
|
|
59
|
-
if (at)
|
|
60
|
-
|
|
75
|
+
if (!at)
|
|
76
|
+
continue;
|
|
77
|
+
if (at.group && !seen.has(at.group)) {
|
|
78
|
+
seen.add(at.group);
|
|
79
|
+
const members = changes.filter((c) => layout.get(c)?.group === at.group);
|
|
80
|
+
ctx.addPage({ path: [mount, section, at.group], markdown: index(members) });
|
|
81
|
+
}
|
|
82
|
+
addPages(renderChange(change, at.segments, o.artifacts, options, layout), change.dir);
|
|
61
83
|
}
|
|
62
84
|
};
|
|
63
|
-
const { parentKey } = o;
|
|
64
|
-
const active = layoutChanges(store.changes, [mount, 'Changes'], { parentKey });
|
|
65
|
-
const archived = layoutChanges(store.archive, [mount, 'Archive'], {
|
|
66
|
-
|
|
85
|
+
const { parentKey, groupKey, nameKey } = o;
|
|
86
|
+
const active = layoutChanges(store.changes, [mount, 'Changes'], { parentKey, groupKey, nameKey });
|
|
87
|
+
const archived = layoutChanges(store.archive, [mount, 'Archive'], {
|
|
88
|
+
parentKey,
|
|
89
|
+
groupKey,
|
|
90
|
+
nameKey,
|
|
91
|
+
archived: true
|
|
92
|
+
});
|
|
93
|
+
ctx.addPage({ path: [mount], markdown: renderSummary(store, mount, options, active, o.columns) });
|
|
67
94
|
ctx.addPage({
|
|
68
95
|
path: [mount, 'Changes'],
|
|
69
|
-
markdown: renderChangesIndex(store.changes, mount, options, active)
|
|
96
|
+
markdown: renderChangesIndex(store.changes, mount, options, active, { columns: o.columns })
|
|
70
97
|
});
|
|
71
|
-
addChanges(store.changes, active);
|
|
98
|
+
addChanges(store.changes, active, 'Changes', (members) => renderChangesIndex(members, mount, options, active, { sections: false, columns: o.columns }));
|
|
72
99
|
// Specs: индекс, промежуточные папки, страницы спек — тем же рендером, что и
|
|
73
100
|
// дельты (fenced-диаграммы локально, ссылки на файлы рядом со спекой).
|
|
74
101
|
const specsDir = path.join(storeDir, 'specs');
|
|
@@ -82,6 +109,6 @@ export default definePlugin({
|
|
|
82
109
|
path: [mount, 'Archive'],
|
|
83
110
|
markdown: renderArchiveIndex(store.archive, mount, options, archived)
|
|
84
111
|
});
|
|
85
|
-
addChanges(store.archive, archived);
|
|
112
|
+
addChanges(store.archive, archived, 'Archive', (members) => renderArchiveIndex(members, mount, options, archived, { sections: false }));
|
|
86
113
|
}
|
|
87
114
|
});
|
|
@@ -8,8 +8,15 @@ const originalId = (c, archived) => archived ? c.id.replace(DATE_PREFIX, '') : c
|
|
|
8
8
|
* вложенный или не найден — change остаётся на верхнем уровне. Подпись вложенного — хвост
|
|
9
9
|
* id после `<id родителя>-`; если хвоста нет или он совпадает с подстраницей родителя
|
|
10
10
|
* (артефакт, `specs`) или с подписью соседа — полный id.
|
|
11
|
+
* С `groupKey` change верхнего уровня, у которого задан этот ключ, кладётся под папку
|
|
12
|
+
* группы: `<base>/<группа>/<id>`; вложенные идут за родителем. Значение со `/` или
|
|
13
|
+
* совпадающее с подписью change'а без группы (коллизия путей) — группой не считается.
|
|
14
|
+
* С `nameKey` подпись и сегмент пути change'а верхнего уровня — значение ключа (например
|
|
15
|
+
* `x-name: Оплата картой`): sidebar и таблицы читаются по-человечески, а адрес страницы
|
|
16
|
+
* стабилен, пока значение не меняют. Недопустимое значение (`/`, `\`, `#`, `?`, `.`, `..`) или
|
|
17
|
+
* занятое в той же папке — подпись остаётся id; для группы — то же, change остаётся без группы.
|
|
11
18
|
*/
|
|
12
|
-
export const layoutChanges = (changes, base, { parentKey, archived = false } = {}) => {
|
|
19
|
+
export const layoutChanges = (changes, base, { parentKey, groupKey, nameKey, archived = false } = {}) => {
|
|
13
20
|
const byId = new Map(changes.map((c) => [originalId(c, archived), c]));
|
|
14
21
|
const parentOf = (c) => {
|
|
15
22
|
const ref = parentKey ? c.meta[parentKey] : undefined;
|
|
@@ -21,8 +28,41 @@ export const layoutChanges = (changes, base, { parentKey, archived = false } = {
|
|
|
21
28
|
const p = parentOf(c);
|
|
22
29
|
return !p || parentOf(p);
|
|
23
30
|
});
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
// Значение ключа как сегмент пути: addPage отвергает `/`, `\`, `.`, `..` и пустые, а `#` и
|
|
32
|
+
// `?` encodeURI оставляет как есть — ссылка в sidebar оборвалась бы на якоре или запросе.
|
|
33
|
+
const segment = (key, c) => {
|
|
34
|
+
const v = key ? c.meta[key]?.trim() : undefined;
|
|
35
|
+
return v && !/[\\/#?]/.test(v) && v !== '.' && v !== '..' ? v : undefined;
|
|
36
|
+
};
|
|
37
|
+
// Имя, совпадающее с id другого change'а, дало бы тот же путь — остаётся id.
|
|
38
|
+
const ids = new Set(changes.map((c) => c.id));
|
|
39
|
+
const wanted = (c) => {
|
|
40
|
+
const name = segment(nameKey, c);
|
|
41
|
+
return name && !ids.has(name) ? name : c.id;
|
|
42
|
+
};
|
|
43
|
+
// Подписи change'ов без группы заняты на уровне base — группа с таким именем дала бы коллизию.
|
|
44
|
+
const flat = new Set(top.filter((c) => !segment(groupKey, c)).map(wanted));
|
|
45
|
+
const groups = new Set();
|
|
46
|
+
const used = new Map();
|
|
47
|
+
for (const c of top) {
|
|
48
|
+
const g = segment(groupKey, c);
|
|
49
|
+
const group = g && !flat.has(g) ? g : undefined;
|
|
50
|
+
if (group)
|
|
51
|
+
groups.add(group);
|
|
52
|
+
const folder = group ?? '';
|
|
53
|
+
const taken = used.get(folder) ?? new Set();
|
|
54
|
+
used.set(folder, taken);
|
|
55
|
+
// Повтор подписи в папке (или имя группы на уровне base) — откат на уникальный id.
|
|
56
|
+
const want = wanted(c);
|
|
57
|
+
const label = taken.has(want) || (!group && want !== c.id && groups.has(want)) ? c.id : want;
|
|
58
|
+
taken.add(label);
|
|
59
|
+
layout.set(c, {
|
|
60
|
+
segments: group ? [...base, group, label] : [...base, label],
|
|
61
|
+
label,
|
|
62
|
+
children: [],
|
|
63
|
+
group
|
|
64
|
+
});
|
|
65
|
+
}
|
|
26
66
|
for (const c of changes) {
|
|
27
67
|
if (layout.has(c))
|
|
28
68
|
continue;
|
|
@@ -37,19 +77,34 @@ export const layoutChanges = (changes, base, { parentKey, archived = false } = {
|
|
|
37
77
|
const prefix = `${originalId(parent, archived)}-`;
|
|
38
78
|
const tail = own.startsWith(prefix) ? own.slice(prefix.length) : '';
|
|
39
79
|
const label = tail && !taken.has(tail) ? tail : c.id;
|
|
40
|
-
layout.set(c, {
|
|
80
|
+
layout.set(c, {
|
|
81
|
+
segments: [...host.segments, label],
|
|
82
|
+
label,
|
|
83
|
+
parent,
|
|
84
|
+
children: [],
|
|
85
|
+
group: host.group
|
|
86
|
+
});
|
|
41
87
|
host.children.push(c);
|
|
42
88
|
}
|
|
43
89
|
return layout;
|
|
44
90
|
};
|
|
91
|
+
/** Группы раскладки по алфавиту; change'ы без группы в список не входят. */
|
|
92
|
+
export const groupNames = (changes, layout) => [...new Set(changes.map((c) => layout.get(c)?.group).filter((g) => !!g))].sort((a, b) => a.localeCompare(b));
|
|
45
93
|
/**
|
|
46
94
|
* Change'ы в порядке вывода: каждый верхнего уровня, сразу за ним — его вложенные. Порядок
|
|
47
|
-
* внутри обоих уровней — порядок `changes` (по имени для страниц, по дате для таблиц)
|
|
95
|
+
* внутри обоих уровней — порядок `changes` (по имени для страниц, по дате для таблиц); с
|
|
96
|
+
* группами — сначала группы по алфавиту, затем change'ы без группы.
|
|
48
97
|
*/
|
|
49
98
|
export const pageOrder = (changes, layout) => {
|
|
50
99
|
const pos = new Map(changes.map((c, i) => [c, i]));
|
|
100
|
+
const groups = groupNames(changes, layout);
|
|
101
|
+
const rank = (c) => {
|
|
102
|
+
const g = layout.get(c)?.group;
|
|
103
|
+
return g ? groups.indexOf(g) : groups.length;
|
|
104
|
+
};
|
|
51
105
|
return changes
|
|
52
106
|
.filter((c) => !layout.get(c)?.parent)
|
|
107
|
+
.sort((a, b) => rank(a) - rank(b) || (pos.get(a) ?? 0) - (pos.get(b) ?? 0))
|
|
53
108
|
.flatMap((c) => [
|
|
54
109
|
c,
|
|
55
110
|
...[...(layout.get(c)?.children ?? [])].sort((a, b) => (pos.get(a) ?? 0) - (pos.get(b) ?? 0))
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createFenceExtractor, mapOutsideFences } from "../../core/scan/fences.js";
|
|
5
5
|
import { encodeURIPath } from "../../util/utils.js";
|
|
6
|
-
import { pageOrder } from "./layout.js";
|
|
6
|
+
import { groupNames, pageOrder } from "./layout.js";
|
|
7
7
|
// Ссылка на страницу сайта от корня (без ведущего «/»): так же строится sidebar,
|
|
8
8
|
// а docsify без relativePath резолвит ссылки от корня сайта.
|
|
9
9
|
export const pageLink = (segments, options) => encodeURIPath(path.posix.join(...segments, options.WEB_FILE_NAME || segments[segments.length - 1]));
|
|
@@ -11,23 +11,46 @@ const fmtDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
|
11
11
|
// Прогресс задач: нативный <progress> (docsify рендерит HTML в markdown) + числа и процент.
|
|
12
12
|
const progressBar = (done, total) => `<progress value="${done}" max="${total}"></progress> ${done}/${total} (${Math.round((done / total) * 100)}%)`;
|
|
13
13
|
const fmtProgress = (c) => c.progress ? progressBar(c.progress.done, c.progress.total) : '—';
|
|
14
|
+
// Секции по группам (опция groupKey): `### <группа>` над своей частью таблицы или списка,
|
|
15
|
+
// change'ы без группы — последней секцией `### Ungrouped`. Групп нет или `sections` выключен
|
|
16
|
+
// (страница самой группы) — один блок без заголовка, как раньше.
|
|
17
|
+
const bySections = (changes, layout, sections, render) => {
|
|
18
|
+
const groups = layout && sections ? groupNames(changes, layout) : [];
|
|
19
|
+
if (!groups.length)
|
|
20
|
+
return render(changes);
|
|
21
|
+
const groupOf = (c) => layout?.get(c)?.group;
|
|
22
|
+
const parts = groups.map((g) => `### ${g}\n\n${render(changes.filter((c) => groupOf(c) === g))}`);
|
|
23
|
+
const rest = changes.filter((c) => !groupOf(c));
|
|
24
|
+
if (rest.length)
|
|
25
|
+
parts.push(`### Ungrouped\n\n${render(rest)}`);
|
|
26
|
+
return parts.join('\n\n');
|
|
27
|
+
};
|
|
28
|
+
// Значение ячейки markdown-таблицы: `|` разорвал бы строку на колонки.
|
|
29
|
+
const cell = (v) => (v ? v.replace(/\|/g, '\\|') : '—');
|
|
14
30
|
/**
|
|
15
31
|
* Таблица change'ов (сводка и индекс Changes): по дате изменения от новых к старым. С `layout`
|
|
16
|
-
* вложенные change'ы идут сразу под родителем (`↳` + подпись) и ведут на свои вложенные
|
|
32
|
+
* вложенные change'ы идут сразу под родителем (`↳` + подпись) и ведут на свои вложенные
|
|
33
|
+
* страницы, а при группах таблица делится на секции по группам. `columns` — колонки из
|
|
34
|
+
* `.openspec.yaml` после стандартных (например исполнитель); нет значения — `—`.
|
|
17
35
|
*/
|
|
18
|
-
export const renderChangesIndex = (changes, mount, options, layout) => {
|
|
36
|
+
export const renderChangesIndex = (changes, mount, options, layout, { sections = true, columns = [] } = {}) => {
|
|
19
37
|
if (!changes.length)
|
|
20
38
|
return '_No changes._';
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
const head = ['Change', 'Schema', 'Tasks', 'Updated', ...columns.map((c) => cell(c.title))];
|
|
40
|
+
const table = (part) => {
|
|
41
|
+
const byDate = [...part].sort((a, b) => b.mtime - a.mtime);
|
|
42
|
+
const rows = (layout ? pageOrder(byDate, layout) : byDate).map((c) => {
|
|
43
|
+
const at = layout?.get(c);
|
|
44
|
+
const link = `[${at?.label ?? c.id}](${pageLink(at?.segments ?? [mount, 'Changes', c.id], options)})`;
|
|
45
|
+
const extra = columns.map((col) => ` ${cell(c.meta[col.key])} |`).join('');
|
|
46
|
+
return `| ${at?.parent ? `↳ ${link}` : link} | ${c.schema ?? '—'} | ${fmtProgress(c)} | ${fmtDate(c.mtime)} |${extra}`;
|
|
47
|
+
});
|
|
48
|
+
return [`| ${head.join(' | ')} |`, `|${'---|'.repeat(head.length)}`, ...rows].join('\n');
|
|
49
|
+
};
|
|
50
|
+
return bySections(changes, layout, sections, table);
|
|
28
51
|
};
|
|
29
52
|
/** Сводка store (страница mount): счётчики, суммарный прогресс, таблица активных change'ов. */
|
|
30
|
-
export const renderSummary = (store, mount, options, layout) => {
|
|
53
|
+
export const renderSummary = (store, mount, options, layout, columns = []) => {
|
|
31
54
|
const withProgress = store.changes.filter((c) => c.progress);
|
|
32
55
|
const done = withProgress.reduce((s, c) => s + (c.progress?.done ?? 0), 0);
|
|
33
56
|
const total = withProgress.reduce((s, c) => s + (c.progress?.total ?? 0), 0);
|
|
@@ -40,18 +63,18 @@ export const renderSummary = (store, mount, options, layout) => {
|
|
|
40
63
|
'',
|
|
41
64
|
'## Active changes',
|
|
42
65
|
'',
|
|
43
|
-
renderChangesIndex(store.changes, mount, options, layout)
|
|
66
|
+
renderChangesIndex(store.changes, mount, options, layout, { columns })
|
|
44
67
|
].join('\n');
|
|
45
68
|
};
|
|
46
|
-
/** Список архива: от новых к старым; с `layout` вложенные — пунктами под
|
|
47
|
-
export const renderArchiveIndex = (archive, mount, options, layout) => archive.length
|
|
48
|
-
? (layout ? pageOrder(
|
|
69
|
+
/** Список архива: от новых к старым; с `layout` вложенные — пунктами под родителем, по секциям групп. */
|
|
70
|
+
export const renderArchiveIndex = (archive, mount, options, layout, { sections = true } = {}) => archive.length
|
|
71
|
+
? bySections(archive, layout, sections, (part) => (layout ? pageOrder(part, layout) : part)
|
|
49
72
|
.map((c) => {
|
|
50
73
|
const at = layout?.get(c);
|
|
51
74
|
const link = pageLink(at?.segments ?? [mount, 'Archive', c.id], options);
|
|
52
75
|
return `${at?.parent ? ' ' : ''}- [${at?.label ?? c.id}](${link})`;
|
|
53
76
|
})
|
|
54
|
-
.join('\n')
|
|
77
|
+
.join('\n'))
|
|
55
78
|
: '_Archive is empty._';
|
|
56
79
|
/** Индекс папки спек (Specs, промежуточные папки, дельты change'а): вложенный список под prefix. */
|
|
57
80
|
export const renderSpecIndex = (specs, prefix, base, options) => {
|
|
@@ -110,6 +133,7 @@ const FENCE_LANGS = ['plantuml', 'puml', 'd2'];
|
|
|
110
133
|
export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '', pageDir = fromDir, included = new Set(), target = () => undefined, source, options }) => {
|
|
111
134
|
const { markdown, diagrams } = createFenceExtractor(FENCE_LANGS).extract(content, base, source);
|
|
112
135
|
const files = [];
|
|
136
|
+
const sources = source ? [source] : [];
|
|
113
137
|
const rewrite = (t) => t.replace(LINK_RE, (whole, bang, text, href, title = '') => {
|
|
114
138
|
const inner = text.includes('![') ? rewrite(text) : text;
|
|
115
139
|
if (/^[a-z][a-z0-9+.-]*:|^[#/]/i.test(href))
|
|
@@ -145,6 +169,7 @@ export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '',
|
|
|
145
169
|
});
|
|
146
170
|
diagrams.push(...sub.diagrams);
|
|
147
171
|
files.push(...sub.files);
|
|
172
|
+
sources.push(...sub.sources);
|
|
148
173
|
return sub.markdown;
|
|
149
174
|
}
|
|
150
175
|
const sameDir = fromDir === pageDir;
|
|
@@ -154,7 +179,7 @@ export const renderMarkdown = (pagePath, base, content, { srcRoot, fromDir = '',
|
|
|
154
179
|
? `${bang}[${inner}](${sameDir ? href : encodeURIPath(to)}${title})`
|
|
155
180
|
: `[${inner}](${encodeURIPath(path.posix.join(...pagePath, to))}${ignoreTitle(title)})`;
|
|
156
181
|
});
|
|
157
|
-
return { path: pagePath, markdown: mapOutsideFences(markdown, rewrite).trim(), diagrams, files };
|
|
182
|
+
return { path: pagePath, markdown: mapOutsideFences(markdown, rewrite).trim(), diagrams, files, sources };
|
|
158
183
|
};
|
|
159
184
|
/**
|
|
160
185
|
* Страницы дерева спек под `base`: корень, промежуточные папки, спеки. Папка с собственной
|
|
@@ -170,7 +195,7 @@ export const renderSpecTree = (specs, base, options, render) => {
|
|
|
170
195
|
const spec = byKey.get(key);
|
|
171
196
|
const page = spec
|
|
172
197
|
? render(pagePath, spec)
|
|
173
|
-
: { path: pagePath, markdown: '', diagrams: [], files: [] };
|
|
198
|
+
: { path: pagePath, markdown: '', diagrams: [], files: [], sources: [] };
|
|
174
199
|
const nested = specs.some((s) => s.path.length > prefix.length && prefix.every((p, i) => s.path[i] === p));
|
|
175
200
|
if (nested || !spec)
|
|
176
201
|
page.markdown = [page.markdown, renderSpecIndex(specs, prefix, base, options)]
|
|
@@ -254,7 +279,8 @@ export const renderChange = (change, segments, order, options, layout) => {
|
|
|
254
279
|
path: segments,
|
|
255
280
|
markdown: [head, family, links.join(' · '), main?.markdown].filter(Boolean).join('\n\n'),
|
|
256
281
|
diagrams: main?.diagrams ?? [],
|
|
257
|
-
files: main?.files ?? []
|
|
282
|
+
files: main?.files ?? [],
|
|
283
|
+
sources: main?.sources ?? []
|
|
258
284
|
},
|
|
259
285
|
...subpages
|
|
260
286
|
];
|
|
@@ -41,8 +41,10 @@ const readMeta = (file) => {
|
|
|
41
41
|
const m = line.match(/^([A-Za-z0-9_.-]+):[ \t]*(.+?)\s*$/);
|
|
42
42
|
if (!m || m[2].startsWith('#'))
|
|
43
43
|
continue;
|
|
44
|
-
//
|
|
45
|
-
|
|
44
|
+
// Значение в кавычках — целиком (`x-name: "Задача #5"`); у остальных комментарий в
|
|
45
|
+
// конце строки (` # …`) — не часть значения.
|
|
46
|
+
const quoted = m[2].match(/^(["'])(.*?)\1(?:\s+#.*)?$/);
|
|
47
|
+
meta[m[1]] = quoted ? quoted[2] : m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, '');
|
|
46
48
|
}
|
|
47
49
|
return { schema: meta.schema, created: meta.created, meta };
|
|
48
50
|
};
|
package/dist/util/lock.js
CHANGED
|
@@ -56,3 +56,26 @@ export const acquireBuildLock = (lockPath) => {
|
|
|
56
56
|
}
|
|
57
57
|
throw new BuildLockHeldError(`failed to acquire the build lock: ${lockPath}`);
|
|
58
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Захват лока с ожиданием: пока сборку держит другой живой процесс (второй `site -w` в той же
|
|
61
|
+
* папке), пробуем каждые `stepMs`, не дольше `timeoutMs`; затем — исходная BuildLockHeldError.
|
|
62
|
+
* `onWait` зовётся один раз при первом отказе — для сообщения в терминал.
|
|
63
|
+
*/
|
|
64
|
+
export const waitBuildLock = async (lockPath, timeoutMs, onWait, stepMs = 500) => {
|
|
65
|
+
const deadline = Date.now() + timeoutMs;
|
|
66
|
+
let waited = false;
|
|
67
|
+
for (;;) {
|
|
68
|
+
try {
|
|
69
|
+
return acquireBuildLock(lockPath);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (!(e instanceof BuildLockHeldError) || Date.now() >= deadline)
|
|
73
|
+
throw e;
|
|
74
|
+
if (!waited) {
|
|
75
|
+
waited = true;
|
|
76
|
+
onWait?.(e);
|
|
77
|
+
}
|
|
78
|
+
await new Promise((r) => setTimeout(r, stepMs));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfacapital-tech/c4builder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-rc.1",
|
|
4
4
|
"description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|