@arshdelight/practi 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,531 @@
1
+ // practi web 详情页(向导模式):一次只显示一个节点,从根出发,Next 逐步走。
2
+ // op 决定呈现:seq=顺序列表预告(可点直达);par=双列网格卡片;choice=可点选项卡(必须
3
+ // 择一才继续,选过的分支走完自动翻过整个 choice);set=目录链接自由跳转;
4
+ // loop=条件旁注。数据走 /doc/<hash>.json(文档树,children 递归内联)。
5
+ 'use strict';
6
+
7
+ var doc = null;
8
+ var path = []; // 当前节点 = 从根出发的 children 下标路径;[] = 根
9
+ var navStack = []; // 走过的路径栈,Prev 回退用(避开只读全局 window.history)
10
+ var nodeIndex = null; // 哈希→树内路径登记簿(数据窗 nodeIndex),inputs.from 反解用
11
+ var view = 'wizard'; // 内容区视图:wizard | sv | doc(侧栏顶部三 tab)
12
+ var svJson = null; // StandardView JSON 缓存(首次切到该 tab 才拉取)
13
+
14
+ // op 旁注:用自然语言说清组合语义(seq 不需要说明);loop 的旁注由 loopNote 按数据推导
15
+ var OP_NOTES = {
16
+ par: function () { return POP_I18N.t('opPar'); },
17
+ choice: function () { return POP_I18N.t('opChoice'); },
18
+ set: function () { return POP_I18N.t('opSet'); },
19
+ };
20
+
21
+ function escapeHtml(s) {
22
+ return String(s).replace(/[&<>"']/g, function (c) {
23
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
24
+ });
25
+ }
26
+
27
+ function walk(p) {
28
+ var n = doc;
29
+ for (var i = 0; i < p.length; i++) n = n.children[p[i]];
30
+ return n;
31
+ }
32
+
33
+
34
+ // 下一个节点:有孩子且非 choice → 首个孩子;否则向上找下一个兄弟。
35
+ // choice 的其他选项是「未选的分支」,不算下一步——选过的分支走完直接翻过整个 choice
36
+ function nextOf(p) {
37
+ var node = walk(p);
38
+ if (node.children && node.children.length && node.op !== 'choice') return p.concat([0]);
39
+ var q = p.slice();
40
+ while (q.length) {
41
+ var idx = q.pop();
42
+ var parent = walk(q);
43
+ if (idx + 1 < parent.children.length && parent.op !== 'choice') return q.concat([idx + 1]);
44
+ }
45
+ return null; // 走完了
46
+ }
47
+
48
+ function goTo(p) {
49
+ navStack.push(path.slice());
50
+ path = p;
51
+ render();
52
+ }
53
+
54
+ function shortHash(h) {
55
+ return String(h).length > 16 ? String(h).slice(0, 16) + '…' : String(h);
56
+ }
57
+
58
+ // ── 渲染 ──────────────────────────────────────────────
59
+
60
+ function render() {
61
+ var app = document.getElementById('app');
62
+ var node = walk(path);
63
+ // 三视图:向导正文 / StandardView JSON / document JSON——侧栏大纲始终是导航脊柱,
64
+ // 底部 Prev/Next 也常驻(在 JSON 视图里换节点=高亮跟着走)
65
+ app.innerHTML = (view === 'wizard' ? nodeHtml(node) : jsonSectionHtml()) + navHtml();
66
+ updateTabs();
67
+ markSide();
68
+ // 向导视图回顶(瞬移,既有行为);JSON 视图平滑滚到当前高亮块首行。
69
+ // 原生 smooth 滚动天然可中断:快速连点不同节点时,新目标的滚动指令
70
+ // 会取消进行中的动画,从当前位置转向新目标(CSSOM View 语义)
71
+ if (view === 'wizard') {
72
+ window.scrollTo(0, 0);
73
+ } else {
74
+ var hl = document.querySelector('.json-pre .ln.hl');
75
+ if (hl) hl.scrollIntoView({ behavior: 'smooth', block: 'start' });
76
+ else window.scrollTo({ top: 0, behavior: 'smooth' });
77
+ }
78
+ }
79
+
80
+ // ── JSON 视图:pretty 渲染 + 当前节点高亮 ──
81
+ // document 视图:树即嵌套对象,当前节点 = walk(path) 的对象引用,按引用相等高亮(双胞胎只亮所在那份)
82
+ // StandardView 视图:practice 容器不进 steps,按「子树 steps 区段」高亮(steps 顺序与树遍历一致);
83
+ // set 孩子的后代不递归聚合、天生不在视图里——老实不高亮
84
+
85
+ function jsonSectionHtml() {
86
+ if (view === 'doc') {
87
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('labelDocJson') + '</div>' +
88
+ '<pre class="json-pre">' + jsonHtml(doc, markDoc) + '</pre></div>';
89
+ }
90
+ if (svJson === null) {
91
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('labelSvJson') + '</div><p class="op-note">' + POP_I18N.t('loading') + '</p></div>';
92
+ }
93
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('labelSvJson') + '</div>' +
94
+ '<pre class="json-pre">' + jsonHtml(svJson, markSv) + '</pre></div>';
95
+ }
96
+
97
+ /** 按对象引用高亮:document 视图的目标就是当前节点对象本身 */
98
+ function markDoc(container, key, index, child) {
99
+ return child === walk(path);
100
+ }
101
+
102
+ /** StandardView 高亮:复算 steps 遍历顺序(action/set 直接孩子=一步;其余 practice 递归),
103
+ * 当前节点的子树 steps(含自身)全部高亮 */
104
+ function markSv(container, key, index, child) {
105
+ if (!svJson || container !== svJson.steps) return false;
106
+ var hits = svMarkedSteps();
107
+ for (var i = 0; i < hits.length; i++) if (svJson.steps[hits[i]] === child) return true;
108
+ return false;
109
+ }
110
+
111
+ function svMarkedSteps() {
112
+ // 根节点不高亮(与 document 视图对齐):全树都亮等于没有定位
113
+ if (!path.length) return [];
114
+ var marks = [];
115
+ var idx = 0;
116
+ (function w(n, p, parentIsSet) {
117
+ (n.children || []).forEach(function (c, i) {
118
+ var cp = p.concat([i]);
119
+ // 聚合口径:action 叶子成一步;set 的直接孩子无论类型都成条目;其余 practice 递归
120
+ var isStep = parentIsSet || c.type === 'action';
121
+ if (isStep) {
122
+ // 当前路径是该 step 路径的前缀(含相等)= 此 step 落在当前节点子树内
123
+ if (cp.length >= path.length && path.every(function (v, k) { return cp[k] === v; })) marks.push(idx);
124
+ idx++;
125
+ return;
126
+ }
127
+ w(c, cp, c.op === 'set');
128
+ });
129
+ })(doc, [], false);
130
+ return marks;
131
+ }
132
+
133
+ /** JSON → HTML:标准 pretty(2 空格缩进),markChild(container,key,index,child) 决定该孩子起是否高亮。
134
+ * 行级输出:每行一个 span.ln(display:block),命中子树(hl 继承给后代)的开行、内部行、
135
+ * 闭括号行各自整行铺底——编辑器选中行形态,不再按文本片段断续着色 */
136
+ function jsonHtml(value, markChild) {
137
+ function esc(s) { return escapeHtml(s); }
138
+ function w(v, depth, hl, key) {
139
+ var pad = new Array(depth + 1).join(' ');
140
+ var k = key === null ? '' : '<span class="jk">' + esc(JSON.stringify(key)) + '</span>: ';
141
+ if (v === null) return [{ t: pad + k + 'null', h: hl }];
142
+ if (Array.isArray(v)) {
143
+ if (!v.length) return [{ t: pad + k + '[]', h: hl }];
144
+ var lines = [{ t: pad + k + '[', h: hl }];
145
+ v.forEach(function (x, i) {
146
+ var block = w(x, depth + 1, hl || markChild(v, null, i, x), null);
147
+ if (i < v.length - 1) block[block.length - 1].t += ',';
148
+ lines = lines.concat(block);
149
+ });
150
+ lines.push({ t: pad + ']', h: hl });
151
+ return lines;
152
+ }
153
+ if (typeof v === 'object') {
154
+ var keys = Object.keys(v);
155
+ if (!keys.length) return [{ t: pad + k + '{}', h: hl }];
156
+ var lines2 = [{ t: pad + k + '{', h: hl }];
157
+ keys.forEach(function (kk, i) {
158
+ var block2 = w(v[kk], depth + 1, hl || markChild(v, kk, null, v[kk]), kk);
159
+ if (i < keys.length - 1) block2[block2.length - 1].t += ',';
160
+ lines2 = lines2.concat(block2);
161
+ });
162
+ lines2.push({ t: pad + '}', h: hl });
163
+ return lines2;
164
+ }
165
+ return [{ t: pad + k + (typeof v === 'string' ? esc(JSON.stringify(v)) : String(v)), h: hl }];
166
+ }
167
+ return w(value, 0, false, null).map(function (l) {
168
+ return '<span class="ln' + (l.h ? ' hl' : '') + '">' + l.t + '</span>';
169
+ }).join('');
170
+ }
171
+
172
+ // ── 左侧大纲(学 hub /pop 详情侧栏):根的直接孩子成节(1、2…)递归(1.1),
173
+ // 与 fromRef 的 #编号同一坐标系;点击 goTo 跳转,当前节点高亮 ──
174
+
175
+ function countActions(n) {
176
+ if (n.type === 'action') return 1;
177
+ return (n.children || []).reduce(function (s, c) { return s + countActions(c); }, 0);
178
+ }
179
+
180
+ /** 图标 tab(lucide list-todo/list-ordered/list-tree):图标即按钮,名字进 tooltip 与 aria-label */
181
+ function tabIconBtn(view, tipKey, inner) {
182
+ return '<button type="button" class="side-tab" data-view="' + view + '" data-tip="' + POP_I18N.t(tipKey) +
183
+ '" aria-label="' + POP_I18N.t(tipKey) + '">' +
184
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + inner + '</svg></button>';
185
+ }
186
+
187
+ function sideHtml() {
188
+ var total = countActions(doc);
189
+ // 视图三 tab 钉在侧栏最顶:向导正文 / StandardView JSON / document JSON(内容区随之换形态)
190
+ var html = '<div class="side-tabs">' +
191
+ tabIconBtn('wizard', 'tabWizard', '<path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/>') +
192
+ tabIconBtn('sv', 'tabSv', '<path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02"/>') +
193
+ tabIconBtn('doc', 'tabDoc', '<path d="M8 5h13"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="M3 10a2 2 0 0 0 2 2h3"/><path d="M3 5v12a2 2 0 0 0 2 2h3"/>') +
194
+ '</div>' +
195
+ '<p class="side-count">' + POP_I18N.t('stepCount', total) + '</p>' +
196
+ '<a href="#" class="side-item side-root" data-path="">' + escapeHtml(doc.name) + '</a>';
197
+ (function walkSide(n, prefix, depth, p) {
198
+ (n.children || []).forEach(function (c, i) {
199
+ var num = prefix + (i + 1);
200
+ var cp = p.concat([i]);
201
+ html += '<a href="#" class="side-item" data-path="' + cp.join(',') + '" style="padding-left:' + (14 + depth * 14) + 'px">' +
202
+ '<span class="side-num">' + num + '</span>' + escapeHtml(c.name) + '</a>';
203
+ if (c.children && c.children.length) walkSide(c, num + '.', depth + 1, cp);
204
+ });
205
+ })(doc, '', 0, []);
206
+
207
+ // 底部附加区:全文档 revisions(新→旧,点击跳到所属节点)+ refines 边
208
+ // (目标在文档内 → #编号跳转;悬空 → 短哈希);两段空则整段不出现。
209
+ // 包在 .side-foot(margin-top:auto)里钉在侧栏最底,不贴着大纲
210
+ var foot = '';
211
+ var revs = [];
212
+ var refs = [];
213
+ (function w(n, p) {
214
+ (n.revisions || []).forEach(function (r) { revs.push({ p: p, name: n.name, r: r }); });
215
+ if (n.type === 'practice' && n.refines) refs.push({ p: p, name: n.name, target: n.refines });
216
+ (n.children || []).forEach(function (c, i) { w(c, p.concat([i])); });
217
+ })(doc, []);
218
+ if (revs.length) {
219
+ revs.sort(function (a, b) { return a.r.when < b.r.when ? 1 : -1; });
220
+ foot += '<div class="side-sec"><p class="side-count">' + POP_I18N.t('revisions', revs.length) + '</p>' +
221
+ revs.map(function (v) {
222
+ return '<a href="#" class="side-note" data-path="' + v.p.join(',') + '" data-tip="' + escapeHtml(v.name) + '">' +
223
+ escapeHtml(String(v.r.when).slice(0, 10)) + ' — ' + escapeHtml(v.r.what) + '</a>';
224
+ }).join('') + '</div>';
225
+ }
226
+ if (refs.length) {
227
+ foot += '<div class="side-sec"><p class="side-count">' + POP_I18N.t('refines', refs.length) + '</p>' +
228
+ refs.map(function (v) {
229
+ var tp = nodeIndex && nodeIndex[v.target];
230
+ var tail = tp
231
+ ? '<a href="#" class="side-note mono" data-path="' + tp.join(',') + '">→ #' + tp.map(function (i) { return i + 1; }).join('.') + '</a>'
232
+ : '<span class="side-note mono plain">→ ' + escapeHtml(shortHash(v.target)) + '</span>';
233
+ return '<div class="side-note-row"><span class="side-note plain" data-tip="' + escapeHtml(v.name) + '">' +
234
+ escapeHtml(v.name) + '</span>' + tail + '</div>';
235
+ }).join('') + '</div>';
236
+ }
237
+ return html + (foot ? '<div class="side-foot">' + foot + '</div>' : '');
238
+ }
239
+
240
+ function updateTabs() {
241
+ var tabs = document.querySelectorAll('.side-tab');
242
+ for (var i = 0; i < tabs.length; i++) {
243
+ tabs[i].classList.toggle('on', tabs[i].getAttribute('data-view') === view);
244
+ }
245
+ }
246
+
247
+ function markSide() {
248
+ var cur = path.join(',');
249
+ var items = document.querySelectorAll('.side-item');
250
+ for (var i = 0; i < items.length; i++) {
251
+ // root 条目不参与 selected——点击前后观感一致,hover 仍可用
252
+ if (items[i].classList.contains('side-root')) continue;
253
+ items[i].classList.toggle('on', items[i].getAttribute('data-path') === cur);
254
+ }
255
+ }
256
+
257
+ // 顶部区已撤:返回职责归 header 左上角 brand-link(回目录),定位职责归左侧大纲
258
+
259
+ function nodeHtml(node) {
260
+ var html = '<h1 class="node-title">' + escapeHtml(node.name) + '</h1>';
261
+ if (node.description) html += '<p class="node-desc">' + escapeHtml(node.description) + '</p>';
262
+
263
+ if (node.type === 'practice') {
264
+ // loop 的 repeat 条件已升为循环体小节头,不再另发旁注
265
+ var note = OP_NOTES[node.op];
266
+ if (note) html += '<p class="op-note">' + escapeHtml(note()) + '</p>';
267
+ }
268
+
269
+ if (node.type === 'action') html += inputsHtml(node);
270
+ html += proseHtml(node);
271
+ if (node.type === 'action') html += outputsHtml(node) + attHtml(node);
272
+ html += childrenHtml(node) + choiceHtml(node) + setHtml(node);
273
+ // revisions 已挪到左侧大纲底部(全文档汇总);refines 同处
274
+ return html;
275
+ }
276
+
277
+ function loopNote(node) {
278
+ if (node.loop && node.loop.mode === 'count') return POP_I18N.t('repeatX', node.loop.count);
279
+ if (node.loop && node.loop.mode === 'until') return POP_I18N.t('repeatUntil', node.loop.until);
280
+ return POP_I18N.t('repeat');
281
+ }
282
+
283
+ // ── 正文(markdown-lite:围栏代码块 + 图片引用,其余按段落转义) ──
284
+
285
+ function proseHtml(node) {
286
+ var content = node.content || '';
287
+ if (!content.trim()) return '';
288
+ // marked 在位(detail.html 引入 /marked.min.js)走全语法渲染;DIY 覆盖前端缺库时回落 lite
289
+ if (typeof marked !== 'undefined' && marked.parse) {
290
+ return sectionWrap(renderMarkdown(content, node));
291
+ }
292
+ return sectionWrap(proseLite(content, node));
293
+ }
294
+
295
+ function sectionWrap(body) {
296
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('secContent') + '</div><div class="prose">' + body + '</div></div>';
297
+ }
298
+
299
+ /** content → HTML:对齐 hub MarkdownView(CommonMark+GFM,marked v15 vendored)。
300
+ * 安全口径同 react-markdown 默认:内联 HTML 一律按文本显示——渲染前预转义 &<>,
301
+ * marked 只做语法渲染;mailto 自动链接降级为文本(hub 同款规则) */
302
+ function renderMarkdown(content, node) {
303
+ var src = content.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, function (whole, alt, target) {
304
+ var url = mediaUrl(target, node);
305
+ // 未命中的图片引用整段按文本保留(转义 markdown 控制符)
306
+ if (url === null) return whole.replace(/([!\[\]()\\])/g, '\\$1');
307
+ return '![' + alt + '](' + url + ')';
308
+ });
309
+ // 只转义 < 与 &(足以挡内联 HTML 注入);> 保留——行首 "> " 是块引用语法
310
+ src = src.replace(/&/g, '&amp;').replace(/</g, '&lt;');
311
+ var html = marked.parse(src, { gfm: true, breaks: false, async: false });
312
+ return html.replace(/<a href="mailto:[^"]*">([^<]*)<\/a>/g, '$1');
313
+ }
314
+
315
+ function proseLite(content, node) {
316
+ var blocks = [];
317
+ var prose = content.replace(/```[\s\S]*?```/g, function (m) {
318
+ blocks.push(m);
319
+ return '\u0000B' + (blocks.length - 1) + '\u0000';
320
+ });
321
+ var parts = [];
322
+ var last = 0;
323
+ var re = /!\[([^\]]*)\]\(([^)]*)\)/g;
324
+ var m;
325
+ while ((m = re.exec(prose)) !== null) {
326
+ parts.push(paraHtml(prose.slice(last, m.index)));
327
+ var url = mediaUrl(m[2], node);
328
+ if (url === null) {
329
+ parts.push('<p>' + escapeHtml(m[0]) + '</p>');
330
+ } else {
331
+ parts.push('<figure><img src="' + escapeHtml(url) + '" alt="' + escapeHtml(m[1]) + '">' +
332
+ '<figcaption>' + escapeHtml(m[1]) + '</figcaption></figure>');
333
+ }
334
+ last = m.index + m[0].length;
335
+ }
336
+ parts.push(paraHtml(prose.slice(last)));
337
+ var out = parts.join('');
338
+ return out.replace(/\u0000B(\d+)\u0000/g, function (_, i) {
339
+ return '<pre>' + escapeHtml(blocks[Number(i)]) + '</pre>';
340
+ });
341
+ }
342
+
343
+ function paraHtml(text) {
344
+ var t = text.trim();
345
+ if (!t) return '';
346
+ return t.split(/\n{2,}/).map(function (p) {
347
+ return '<p>' + escapeHtml(p).replace(/\n/g, '<br>') + '</p>';
348
+ }).join('');
349
+ }
350
+
351
+ // §5.1 media:http(s) 外链原样;按名命中附件 → url ?? /blobs/hash;未命中留原文
352
+ function mediaUrl(target, node) {
353
+ if (/^https?:\/\//i.test(target)) return target;
354
+ if (!node || node.type !== 'action' || !node.attachments) return null;
355
+ for (var i = 0; i < node.attachments.length; i++) {
356
+ if (node.attachments[i].name === target) {
357
+ return node.attachments[i].url || ('/blobs/' + node.attachments[i].hash);
358
+ }
359
+ }
360
+ return null;
361
+ }
362
+
363
+ // ── action 字段:管道读序 inputs → 正文 → outputs,有序列表;空缺的块整个不出现 ──
364
+
365
+ // from 反解(同 hub 口径):命中登记簿 → #编号引用(可点跳转 data-path 委托、
366
+ // 悬停气泡 data-tip=来源节点名);未命中/无登记簿/指向根 → 短哈希(可验真)
367
+ function fromRef(from) {
368
+ if (!from) return '';
369
+ var p = nodeIndex && nodeIndex[from];
370
+ if (!p) return ' <span class="io-from">← ' + escapeHtml(shortHash(from)) + '</span>';
371
+ var num = p.map(function (i) { return i + 1; }).join('.');
372
+ return ' <a href="#" class="io-ref" data-path="' + p.join(',') + '" data-tip="' + escapeHtml(walk(p).name) + '">← #' + num + '</a>';
373
+ }
374
+
375
+ function ioItem(f) {
376
+ return '<li>' + escapeHtml(f.name) + fromRef(f.from) +
377
+ (f.spec ? '<span class="io-spec">' + escapeHtml(f.spec) + '</span>' : '') + '</li>';
378
+ }
379
+
380
+ function inputsHtml(node) {
381
+ var ins = node.inputs || [];
382
+ if (!ins.length) return '';
383
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('secInputs') + '</div><ol class="io-list">' + ins.map(ioItem).join('') + '</ol></div>';
384
+ }
385
+
386
+ function outputsHtml(node) {
387
+ var outs = node.outputs || [];
388
+ if (!outs.length) return '';
389
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('secOutputs') + '</div><ol class="io-list">' + outs.map(ioItem).join('') + '</ol></div>';
390
+ }
391
+
392
+ function attHtml(node) {
393
+ var atts = node.attachments || [];
394
+ if (!atts.length) return '';
395
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('secAttachments') + '</div><ul class="att-list">' + atts.map(function (a) {
396
+ var href = a.url || ('/blobs/' + a.hash);
397
+ var meta = [a.mime, a.hash ? shortHash(a.hash) : ''].filter(Boolean).join(' · ');
398
+ return '<li><a href="' + escapeHtml(href) + '">' + escapeHtml(a.name) + '</a>' +
399
+ (meta ? ' <span class="att-meta">' + escapeHtml(meta) + '</span>' : '') + '</li>';
400
+ }).join('') + '</ul></div>';
401
+ }
402
+
403
+ // ── practice 的孩子呈现(按 op 分形态) ──
404
+
405
+ function childrenHtml(node) {
406
+ var kids = node.children || [];
407
+ if (!kids.length || node.op === 'choice' || node.op === 'set') return '';
408
+ // 小节头:seq=steps(N)、par=tasks(N)、loop=repeat 条件本身当头(旁注不再重复)
409
+ var head = node.op === 'loop'
410
+ ? '<div class="section-sm"><div class="label">' + escapeHtml(loopNote(node)) + '</div></div>'
411
+ : '<div class="section-sm"><div class="label">' + POP_I18N.t(node.op === 'seq' ? 'secSteps' : 'secTasks', kids.length) + '</div></div>';
412
+ var items = kids.map(function (k, i) {
413
+ var body = (i + 1) + '. ' + escapeHtml(k.name) +
414
+ (k.description ? '<div class="child-desc">' + escapeHtml(k.description) + '</div>' : '');
415
+ // 序号卡一律=预告+跳转入口(seq/par/loop 全可点直达)
416
+ return '<button type="button" class="child-card" data-idx="' + i + '">' + body + '</button>';
417
+ }).join('');
418
+ return head + items;
419
+ }
420
+
421
+ function choiceHtml(node) {
422
+ if (node.op !== 'choice') return '';
423
+ var kids = node.children || [];
424
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('secOptions', kids.length) + '</div></div>' +
425
+ kids.map(function (k, i) {
426
+ return '<button type="button" class="choice-card" data-idx="' + i + '">' +
427
+ '<span class="choice-body">' + escapeHtml(k.name) +
428
+ (k.description ? '<span class="child-desc">' + escapeHtml(k.description) + '</span>' : '') + '</span></button>';
429
+ }).join('');
430
+ }
431
+
432
+ function setHtml(node) {
433
+ if (node.op !== 'set') return '';
434
+ var kids = node.children || [];
435
+ return '<div class="section-sm"><div class="label">' + POP_I18N.t('secItems', kids.length) + '</div></div>' +
436
+ kids.map(function (k, i) {
437
+ var body = (i + 1) + '. ' + escapeHtml(k.name) +
438
+ (k.description ? '<div class="child-desc">' + escapeHtml(k.description) + '</div>' : '');
439
+ return '<button type="button" class="child-card" data-idx="' + i + '">' + body + '</button>';
440
+ }).join('');
441
+ }
442
+
443
+ // ── 导航 ──
444
+
445
+ // 底部操作区:固定在视口底(悬浮于内容列上方,宽屏 left 让出侧栏),无分界线
446
+ function navHtml() {
447
+ var node = walk(path);
448
+ var next = nextOf(path);
449
+ var choiceGate = node.type === 'practice' && node.op === 'choice';
450
+ var html = '<div class="nav"><div class="nav-row">';
451
+ html += navStack.length ? '<button type="button" class="btn" data-act="prev">' + POP_I18N.t('wizardPrev') + '</button>' : '<span></span>';
452
+ if (choiceGate) {
453
+ html += '<span class="nav-hint">' + POP_I18N.t('chooseBranch') + '</span>';
454
+ } else if (next) {
455
+ html += '<button type="button" class="btn btn-primary" data-act="next">' + POP_I18N.t('next') + '</button>';
456
+ } else {
457
+ html += '<span class="finish">' + POP_I18N.t('endPractice') + '</span>';
458
+ }
459
+ html += '</div></div>';
460
+ return html;
461
+ }
462
+
463
+ // ── 事件(委托:innerHTML 重渲染不需要重复绑监听) ──
464
+
465
+ document.addEventListener('click', function (e) {
466
+ var tab = e.target.closest('[data-view]');
467
+ if (tab) {
468
+ view = tab.getAttribute('data-view');
469
+ if (view === 'sv' && svJson === null) {
470
+ // StandardView 首切拉取一次即缓存;期间先渲染 loading,到了再重绘
471
+ fetch('/pop/' + encodeURIComponent(HASH) + '.json')
472
+ .then(function (r) { return r.json(); })
473
+ .then(function (j) { svJson = j; if (view === 'sv') render(); });
474
+ }
475
+ render();
476
+ return;
477
+ }
478
+ var jump = e.target.closest('[data-idx]');
479
+ if (jump) {
480
+ goTo(path.concat([Number(jump.getAttribute('data-idx'))]));
481
+ return;
482
+ }
483
+ var jump = e.target.closest('[data-path]');
484
+ if (jump) {
485
+ e.preventDefault();
486
+ var raw = jump.getAttribute('data-path');
487
+ goTo(raw ? raw.split(',').map(Number) : []);
488
+ return;
489
+ }
490
+ var act = e.target.closest('[data-act]');
491
+ if (!act) return;
492
+ var a = act.getAttribute('data-act');
493
+ if (a === 'next') {
494
+ var n = nextOf(path);
495
+ if (n) goTo(n);
496
+ } else if (a === 'prev') {
497
+ if (navStack.length) {
498
+ path = navStack.pop();
499
+ render();
500
+ }
501
+ }
502
+ });
503
+
504
+ // ── 启动 ──
505
+
506
+ // 语言切换:侧栏大纲与内容区一并重绘
507
+ POP_I18N.onChange(function () {
508
+ if (doc) document.getElementById('side').innerHTML = sideHtml();
509
+ render();
510
+ });
511
+
512
+ var HASH = decodeURIComponent((location.pathname.split('/pop/')[1] || '').replace(/\.json$/, ''));
513
+
514
+ fetch('/doc/' + encodeURIComponent(HASH) + '.json')
515
+ .then(function (r) {
516
+ if (!r.ok) throw new Error('HTTP ' + r.status);
517
+ return r.json();
518
+ })
519
+ .then(function (d) {
520
+ // 数据窗=加法演进:树字段在顶层,nodeIndex 是后加的兄弟键(老响应没有它 → 哈希兜底)
521
+ doc = d;
522
+ nodeIndex = d.nodeIndex || null;
523
+ document.title = doc.name + ' — practi';
524
+ document.getElementById('side').innerHTML = sideHtml();
525
+ render();
526
+ })
527
+ .catch(function (err) {
528
+ document.getElementById('app').innerHTML =
529
+ '<div class="empty"><p class="lead">' + POP_I18N.t('docFail', escapeHtml(String(err.message || err))) + '</p>' +
530
+ '<p class="hint"><a href="/">' + POP_I18N.t('backMine') + '</a></p></div>';
531
+ });
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Practi</title>
7
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%2267.5%2075%20459%20459%22%20fill%3D%22none%22%20stroke%3D%22%23111827%22%20stroke-width%3D%2227%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%0A%20%20%20%20%3Cpath%20d%3D%22M%20301.0%20252.0%20C%20300.5%20246.2%20299.6%20228.7%20298.0%20217.2%20C%20296.4%20205.7%20294.8%20194.1%20291.4%20183.0%20C%20287.9%20171.8%20283.5%20160.2%20277.3%20150.3%20C%20271.1%20140.4%20263.1%20130.9%20254.1%20123.7%20C%20245.1%20116.5%20234.0%20110.2%20223.1%20107.1%20C%20212.2%20104.1%20199.5%20103.2%20188.5%20105.4%20C%20177.5%20107.5%20165.6%20112.8%20157.2%20119.9%20C%20148.8%20127.1%20142.8%20137.8%20138.0%20148.2%20C%20133.3%20158.7%20129.8%20171.1%20128.8%20182.7%20C%20127.7%20194.3%20127.4%20208.0%20131.8%20218.0%20C%20136.2%20228.0%20145.9%20236.1%20155.2%20242.8%20C%20164.4%20249.6%20176.1%20254.3%20187.3%20258.4%20C%20198.4%20262.5%20210.4%20264.9%20222.0%20267.2%20C%20233.7%20269.5%20256.5%20269.2%20257.2%20272.3%20C%20257.8%20275.5%20236.5%20281.6%20225.8%20286.2%20C%20215.0%20290.7%20203.3%20294.4%20192.6%20299.5%20C%20181.8%20304.7%20171.2%20310.6%20161.2%20317.1%20C%20151.2%20323.6%20140.8%20330.3%20132.6%20338.6%20C%20124.3%20346.9%20115.6%20356.3%20111.5%20366.7%20C%20107.4%20377.1%20103.6%20391.8%20107.9%20400.9%20C%20112.1%20410.0%20126.3%20418.0%20137.0%20421.5%20C%20147.7%20425.0%20160.5%20423.7%20172.0%20422.0%20C%20183.5%20420.2%20195.4%20416.3%20206.0%20411.3%20C%20216.6%20406.2%20226.8%20399.5%20235.6%20391.8%20C%20244.4%20384.1%20251.5%20374.1%20259.0%20365.0%20C%20266.5%20355.9%20274.3%20345.5%20280.7%20337.4%20C%20287.0%20329.2%20294.3%20319.6%20297.0%20316.0%22%20%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M%20381.0%20210.0%20C%20386.8%20209.4%20404.2%20205.8%20415.6%20206.3%20C%20426.9%20206.8%20438.9%20208.3%20449.1%20212.9%20C%20459.2%20217.4%20469.8%20224.7%20476.4%20233.4%20C%20483.1%20242.2%20487.7%20254.4%20489.1%20265.4%20C%20490.6%20276.5%20489.7%20289.4%20485.2%20299.6%20C%20480.7%20309.8%20471.5%20320.6%20462.1%20326.7%20C%20452.7%20332.7%20440.2%20335.1%20428.8%20336.0%20C%20417.4%20336.9%20404.9%20334.7%20393.6%20332.1%20C%20382.3%20329.4%20369.0%20323.4%20361.0%20320.1%20C%20353.1%20316.7%20348.5%20313.3%20346.0%20312.0%22%20%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M%20310.0%20303.0%20C%20311.1%20308.7%20315.4%20325.9%20316.8%20337.4%20C%20318.1%20349.0%20319.1%20360.6%20318.0%20372.1%20C%20316.8%20383.7%20313.7%20395.6%20309.9%20406.8%20C%20306.1%20418.1%20300.9%20429.2%20295.1%20439.5%20C%20289.3%20449.9%20283.2%20458.2%20275.0%20469.1%20C%20266.9%20480.1%20250.8%20499.0%20246.0%20505.0%22%20%2F%3E%0A%20%20%3C%2Fsvg%3E">
8
+ <link rel="stylesheet" href="/style.css">
9
+ </head>
10
+ <body>
11
+ <header>
12
+ <!-- 站点图标:与 hub 的 LogoMark 同一组贝塞尔路径 -->
13
+ <a class="brand-link" href="/" aria-label="practi — all practices">
14
+ <svg class="logo" viewBox="67.5 75 459 459" fill="none" stroke="currentColor" stroke-width="27" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
15
+ <path d="M 301.0 252.0 C 300.5 246.2 299.6 228.7 298.0 217.2 C 296.4 205.7 294.8 194.1 291.4 183.0 C 287.9 171.8 283.5 160.2 277.3 150.3 C 271.1 140.4 263.1 130.9 254.1 123.7 C 245.1 116.5 234.0 110.2 223.1 107.1 C 212.2 104.1 199.5 103.2 188.5 105.4 C 177.5 107.5 165.6 112.8 157.2 119.9 C 148.8 127.1 142.8 137.8 138.0 148.2 C 133.3 158.7 129.8 171.1 128.8 182.7 C 127.7 194.3 127.4 208.0 131.8 218.0 C 136.2 228.0 145.9 236.1 155.2 242.8 C 164.4 249.6 176.1 254.3 187.3 258.4 C 198.4 262.5 210.4 264.9 222.0 267.2 C 233.7 269.5 256.5 269.2 257.2 272.3 C 257.8 275.5 236.5 281.6 225.8 286.2 C 215.0 290.7 203.3 294.4 192.6 299.5 C 181.8 304.7 171.2 310.6 161.2 317.1 C 151.2 323.6 140.8 330.3 132.6 338.6 C 124.3 346.9 115.6 356.3 111.5 366.7 C 107.4 377.1 103.6 391.8 107.9 400.9 C 112.1 410.0 126.3 418.0 137.0 421.5 C 147.7 425.0 160.5 423.7 172.0 422.0 C 183.5 420.2 195.4 416.3 206.0 411.3 C 216.6 406.2 226.8 399.5 235.6 391.8 C 244.4 384.1 251.5 374.1 259.0 365.0 C 266.5 355.9 274.3 345.5 280.7 337.4 C 287.0 329.2 294.3 319.6 297.0 316.0" />
16
+ <path d="M 381.0 210.0 C 386.8 209.4 404.2 205.8 415.6 206.3 C 426.9 206.8 438.9 208.3 449.1 212.9 C 459.2 217.4 469.8 224.7 476.4 233.4 C 483.1 242.2 487.7 254.4 489.1 265.4 C 490.6 276.5 489.7 289.4 485.2 299.6 C 480.7 309.8 471.5 320.6 462.1 326.7 C 452.7 332.7 440.2 335.1 428.8 336.0 C 417.4 336.9 404.9 334.7 393.6 332.1 C 382.3 329.4 369.0 323.4 361.0 320.1 C 353.1 316.7 348.5 313.3 346.0 312.0" />
17
+ <path d="M 310.0 303.0 C 311.1 308.7 315.4 325.9 316.8 337.4 C 318.1 349.0 319.1 360.6 318.0 372.1 C 316.8 383.7 313.7 395.6 309.9 406.8 C 306.1 418.1 300.9 429.2 295.1 439.5 C 289.3 449.9 283.2 458.2 275.0 469.1 C 266.9 480.1 250.8 499.0 246.0 505.0" />
18
+ </svg>
19
+ <span class="brand">practi</span>
20
+ </a>
21
+ </header>
22
+ <main id="app">
23
+ <div class="empty"><p class="lead">…</p></div>
24
+ </main>
25
+ <script src="/lang.js" defer></script>
26
+ <script src="/app.js" defer></script>
27
+ </body>
28
+ </html>