@hybridlabor-api/aos 4.13.2 → 4.14.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/.agents/AGENTS.md +8 -0
- package/.agents/nodes.json +5 -2
- package/.claude/hooks/conventional-commits.mjs +14 -15
- package/.claude/hooks/env-file-protection.mjs +14 -15
- package/.claude/hooks/go-gate.mjs +152 -10
- package/.claude/hooks/go-token.mjs +55 -0
- package/.claude/hooks/memb-inject.mjs +75 -62
- package/.claude/hooks/trail-autostart.mjs +27 -0
- package/.claude/hooks/trail-relay.mjs +1 -0
- package/.claude/settings.json +22 -4
- package/.claude/workflows/startcycle-dispatch.mjs +11 -4
- package/.opencode/plugins/bdb-aos.js +98 -121
- package/.opencode/plugins/lib/trail-autostart.js +38 -0
- package/CLAUDE.md +1 -1
- package/README.de.md +6 -6
- package/README.md +6 -6
- package/README.pt.md +6 -6
- package/THIRD_PARTY_NOTICES.md +19 -3
- package/assets/header-v5.png +0 -0
- package/bin/aos-acp.mjs +211 -0
- package/bin/aos-doctor.mjs +1 -1
- package/bin/aos-uninstall.mjs +2 -2
- package/docs/master-session-acp.md +51 -0
- package/installer.js +314 -42
- package/mcps/mcsc/packages/mcp/server.js +6 -7
- package/package.json +4 -3
- package/scripts/validate-skills.mjs +76 -0
- package/skills/basic/bdbmediastorm/SKILL.md +1 -1
- package/skills/basic/godmode-shipping/SKILL.md +3 -0
- package/skills/basic/master-session/SKILL.md +89 -0
- package/skills/basic/startcycle/SKILL.md +1 -1
- package/skills/basic/startcycle-graph/SKILL.md +2 -2
- package/skills/basic/startcycle-graph-user/SKILL.md +1 -1
- package/skills/basic/teamwork-preview/SKILL.md +1 -1
- package/skills/bdbrainstorm/SKILL.md +7 -1
- package/skills/global_config/agentic-harness-patterns/SKILL.md +257 -0
- package/skills/global_config/agentic-harness-patterns/metadata.json +10 -0
- package/skills/global_config/agentic-harness-patterns/references/agent-orchestration-pattern.md +97 -0
- package/skills/global_config/agentic-harness-patterns/references/bootstrap-sequence-pattern.md +106 -0
- package/skills/global_config/agentic-harness-patterns/references/context-engineering/compress-pattern.md +78 -0
- package/skills/global_config/agentic-harness-patterns/references/context-engineering/isolate-pattern.md +82 -0
- package/skills/global_config/agentic-harness-patterns/references/context-engineering/select-pattern.md +86 -0
- package/skills/global_config/agentic-harness-patterns/references/context-engineering-pattern.md +29 -0
- package/skills/global_config/agentic-harness-patterns/references/hook-lifecycle-pattern.md +111 -0
- package/skills/global_config/agentic-harness-patterns/references/memory-persistence-pattern.md +109 -0
- package/skills/global_config/agentic-harness-patterns/references/permission-gate-pattern.md +111 -0
- package/skills/global_config/agentic-harness-patterns/references/skill-runtime-pattern.md +104 -0
- package/skills/global_config/agentic-harness-patterns/references/task-decomposition-pattern.md +92 -0
- package/skills/global_config/agentic-harness-patterns/references/tool-registry-pattern.md +101 -0
- package/skills/global_config/agenttrail/SKILL.md +8 -0
- package/skills/global_config/agenttrail/bin/agenttrail.mjs +14 -0
- package/skills/global_config/agenttrail/bin/ensure.mjs +118 -0
- package/skills/global_config/aos-setup/scripts/aos-doctor.mjs +1 -1
- package/skills/global_config/bdb-visual-edit/SKILL.md +51 -0
- package/skills/global_config/bdb-visual-edit/references/vite-react-source-attr.md +59 -0
- package/skills/global_config/bdb-visual-edit/scripts/pick-snippet.js +27 -0
- package/skills/global_config/bdb-visual-edit/scripts/sanitize-element.mjs +123 -0
- package/skills/global_config/factory-collect/SKILL.md +74 -0
- package/skills/global_config/factory-human-digest/SKILL.md +92 -0
- package/skills/global_config/factory-lookback/SKILL.md +95 -0
- package/skills/global_config/factory-review-prs/SKILL.md +63 -0
- package/skills/global_config/git-pr-review/SKILL.md +3 -0
- package/skills/global_config/grilling/SKILL.md +2 -0
- package/skills/global_config/mcsc/SKILL.md +1 -1
- package/skills/global_config/plan-arbiter/SKILL.md +125 -0
- package/skills/global_config/plan-canvas/SKILL.md +62 -5
- package/skills/global_config/plan-canvas/scripts/lib/loopback-guard.js +19 -3
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/README.md +285 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/agent-trail.js +129 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/board-client.js +124 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/demo-plan/canvas.mdx +19 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/demo-plan/plan.mdx +18 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/recap-demo/plan.mdx +72 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/showcase/README.md +29 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/showcase/architecture.json +30 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/showcase/builder/00_architecture.html +14950 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/showcase/builder/canvas.mdx +511 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/showcase/builder/plan.mdx +208 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/showcase/recap/plan.mdx +102 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/showcase/standard/plan.md +136 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/signup-storyboard/canvas.mdx +124 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/examples/signup-storyboard/plan.mdx +37 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/index.js +188 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/kit.js +123 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/mdx.js +411 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/render.js +1291 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/architecture/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/architecture/plan.mdx +195 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/architecture/standard.md +95 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/bugfix/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/bugfix/plan.mdx +105 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/bugfix/standard.md +76 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/feature/canvas.mdx +81 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/feature/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/feature/plan.mdx +145 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/feature/standard.md +76 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/migration/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/migration/plan.mdx +172 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/migration/standard.md +100 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap/plan.mdx +67 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap/standard.md +49 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap-board/canvas.mdx +63 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap-board/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap-board/plan.mdx +49 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap-board/standard.md +39 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap-review/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap-review/plan.mdx +118 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/recap-review/standard.md +57 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/release/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/release/plan.mdx +173 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/release/standard.md +96 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/research/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/research/plan.mdx +91 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/research/standard.md +54 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/show-control/canvas.mdx +53 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/show-control/meta.json +1 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/show-control/plan.mdx +225 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/templates/show-control/standard.md +111 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/theme.css +472 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/trail.js +216 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/markdown.js +1 -1
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/server.js +37 -4
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/ui.js +125 -29
- package/skills/global_config/plan-canvas/scripts/plan-canvas.js +196 -8
- package/skills/global_config/pr-recap/SKILL.md +47 -0
- package/skills/global_config/pr-recap/scripts/pr-recap.mjs +200 -0
- package/skills/global_config/quick-recap/SKILL.md +55 -0
- package/skills/global_config/stay-within-limits/SKILL.md +85 -0
- package/skills/global_config/triage/SKILL.md +3 -0
- package/skills/global_config/visual-edit/README.md +96 -0
- package/skills/global_config/visual-edit/SKILL.md +615 -0
- package/skills/global_config/visual-plan/README.md +93 -0
- package/skills/global_config/visual-plan/SKILL.md +544 -0
- package/skills/global_config/visual-plan/references/canvas.md +139 -0
- package/skills/global_config/visual-plan/references/connection.md +51 -0
- package/skills/global_config/visual-plan/references/document-quality.md +186 -0
- package/skills/global_config/visual-plan/references/exemplar.md +62 -0
- package/skills/global_config/visual-plan/references/local-files.md +99 -0
- package/skills/global_config/visual-plan/references/wireframe.md +319 -0
- package/skills/global_config/visual-recap/README.md +103 -0
- package/skills/global_config/visual-recap/SKILL.md +560 -0
- package/skills/global_config/visual-recap/references/connection.md +51 -0
- package/skills/global_config/visual-recap/references/local-files.md +99 -0
- package/skills/global_config/visual-recap/references/wireframe.md +319 -0
- package/skills/playbooks/pb-ci-fix/SKILL.md +49 -0
- package/skills/playbooks/pb-event-tracker/SKILL.md +45 -0
- package/skills/playbooks/pb-meeting-actions/SKILL.md +42 -0
- package/skills/playbooks/pb-project-new/SKILL.md +48 -0
- package/skills/playbooks/pb-week-plan/SKILL.md +45 -0
- package/assets/header-v4.jpg +0 -0
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Block renderers for the BDB Plan Builder.
|
|
5
|
+
*
|
|
6
|
+
* Security model: plan content is untrusted. Every string that reaches the page
|
|
7
|
+
* goes through escapeHtml (or renderMarkdown, which escapes first). The only
|
|
8
|
+
* raw HTML surfaces are the two sandboxed iframes — `custom-html` and the
|
|
9
|
+
* `html` payload of wireframe/diagram blocks — and both are `sandbox` WITHOUT
|
|
10
|
+
* `allow-scripts`, so a plan can draw but never run.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { escapeHtml, renderMarkdown, renderInline, slugify } = require('../plan-canvas/markdown');
|
|
17
|
+
// Same pinned Mermaid ESM build (and AOS_PLAN_CANVAS_MERMAID_URL override) as
|
|
18
|
+
// lib/plan-canvas/ui.js — one CDN, one pin, no second URL to invent.
|
|
19
|
+
const { mermaidUrl } = require('../plan-canvas/ui');
|
|
20
|
+
const { renderKit } = require('./kit');
|
|
21
|
+
|
|
22
|
+
function mermaidLoaderScript(url) {
|
|
23
|
+
return `<script type="module">
|
|
24
|
+
try {
|
|
25
|
+
const mermaid = (await import(${JSON.stringify(url)})).default;
|
|
26
|
+
mermaid.initialize({
|
|
27
|
+
startOnLoad: false,
|
|
28
|
+
securityLevel: 'strict',
|
|
29
|
+
theme: 'dark',
|
|
30
|
+
fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
|
|
31
|
+
flowchart: { useMaxWidth: true },
|
|
32
|
+
sequence: { useMaxWidth: true, mirrorActors: false },
|
|
33
|
+
gantt: { useMaxWidth: true, barHeight: 24, barGap: 6, fontSize: 12, topPadding: 56, leftPadding: 96 },
|
|
34
|
+
themeVariables: {
|
|
35
|
+
primaryColor: '#161616', primaryBorderColor: '#9b30c4', primaryTextColor: '#ffffff',
|
|
36
|
+
lineColor: '#7a7a7a', secondaryColor: '#1c1c1c', tertiaryColor: '#121212',
|
|
37
|
+
background: '#0a0a0a', mainBkg: '#161616', clusterBkg: '#121212',
|
|
38
|
+
textColor: '#b8b8b8', edgeLabelBackground: '#161616', labelBackground: '#161616',
|
|
39
|
+
actorBkg: '#161616', actorBorder: '#9b30c4', actorTextColor: '#ffffff', actorLineColor: '#3a3a3a',
|
|
40
|
+
signalColor: '#7a7a7a', signalTextColor: '#b8b8b8', labelBoxBkgColor: '#161616',
|
|
41
|
+
labelBoxBorderColor: '#3a3a3a', labelTextColor: '#b8b8b8', loopTextColor: '#b8b8b8',
|
|
42
|
+
noteBkgColor: '#1c1c1c', noteBorderColor: '#3a3a3a', noteTextColor: '#ffffff',
|
|
43
|
+
activationBkgColor: '#1c1c1c', activationBorderColor: '#9b30c4', sequenceNumberColor: '#ffffff',
|
|
44
|
+
gridColor: '#1e1e1e', sectionBkgColor: '#121212', altSectionBkgColor: '#0e0e0e', sectionBkgColor2: '#121212',
|
|
45
|
+
taskBkgColor: '#3d1a4d', taskBorderColor: '#9b30c4', taskTextColor: '#ffffff',
|
|
46
|
+
taskTextLightColor: '#ffffff', taskTextDarkColor: '#ffffff', taskTextOutsideColor: '#b8b8b8',
|
|
47
|
+
taskTextClickableColor: '#c05ee6', activeTaskBkgColor: '#9b30c4', activeTaskBorderColor: '#c05ee6',
|
|
48
|
+
doneTaskBkgColor: '#2a2a2a', doneTaskBorderColor: '#4a4a4a',
|
|
49
|
+
critBkgColor: '#6a2285', critBorderColor: '#c05ee6', todayLineColor: '#c05ee6', excludeBkgColor: '#101010'
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
await mermaid.run({ querySelector: '.mermaid' });
|
|
53
|
+
document.querySelectorAll('.mermaid svg').forEach(svg => {
|
|
54
|
+
const vb = svg.viewBox.baseVal;
|
|
55
|
+
if (vb && vb.width && svg.getBoundingClientRect().width / vb.width < 0.6) { svg.style.maxWidth = 'none'; svg.style.width = Math.round(vb.width * 0.6) + 'px'; }
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
document.querySelectorAll('.mermaid').forEach(el => el.classList.add('mermaid-unrendered'));
|
|
59
|
+
console.warn('Mermaid render skipped:', err && err.message);
|
|
60
|
+
}
|
|
61
|
+
</script>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// "Diagram source shown — renderer unavailable" is the offline fallback the
|
|
65
|
+
// canvas already uses: the source stays readable, the block is never blank.
|
|
66
|
+
|
|
67
|
+
const esc = escapeHtml;
|
|
68
|
+
|
|
69
|
+
function text(value, fallback = '') {
|
|
70
|
+
if (value === undefined || value === null || value === true) return fallback;
|
|
71
|
+
return String(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Attribute values may arrive as JSON (numbers, objects) or as plain strings.
|
|
75
|
+
function asText(value) {
|
|
76
|
+
if (value === undefined || value === null || value === true) return '';
|
|
77
|
+
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
|
78
|
+
return String(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asArray(value) {
|
|
82
|
+
if (Array.isArray(value)) return value;
|
|
83
|
+
if (typeof value === 'string' && value.trim()) return value.split('\n');
|
|
84
|
+
if (value && typeof value === 'object') return Object.values(value);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function codeBlock(source, cls = '') {
|
|
89
|
+
const body = text(source).replace(/\n+$/, '');
|
|
90
|
+
if (!body) return '<pre><code' + (cls ? ' class="language-' + esc(cls) + '"' : '') + '></code></pre>';
|
|
91
|
+
return '<pre><code' + (cls ? ' class="language-' + esc(cls) + '"' : '') + '>' + esc(body) + '</code></pre>';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The --wf-* tokens visual-plan wireframes are authored against, defined in the
|
|
95
|
+
// frame so plan-authored markup renders instead of showing undefined colors.
|
|
96
|
+
const WF_TOKENS = `:root{
|
|
97
|
+
--wf-paper:#0d0d0d; --wf-card:#161616; --wf-ink:#ffffff; --wf-muted:#7a7a7a;
|
|
98
|
+
--wf-line:#262626; --wf-accent:#9b30c4; --wf-accent-fg:#ffffff; --wf-accent-soft:rgba(155,48,196,.16);
|
|
99
|
+
--wf-warn:#9b30c4; --wf-ok:#9b30c4; --wf-radius:6px;
|
|
100
|
+
}
|
|
101
|
+
.wf-card,.wf-box{border:1px solid var(--wf-line);border-radius:var(--wf-radius);padding:10px 12px;background:var(--wf-card)}
|
|
102
|
+
.wf-muted,.wf-pill-secondary{color:var(--wf-muted)}
|
|
103
|
+
.wf-pill,.wf-accent{color:var(--wf-accent-fg);background:var(--wf-accent);border-radius:999px;padding:2px 8px}
|
|
104
|
+
button.primary{background:var(--wf-accent);color:var(--wf-accent-fg);border:0;border-radius:var(--wf-radius);padding:6px 12px}`;
|
|
105
|
+
|
|
106
|
+
function sandboxFrame(html, label, { height = 420, css = '', bare = false } = {}) {
|
|
107
|
+
const doc = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
|
108
|
+
html,body{margin:0;padding:16px;background:var(--wf-paper);color:var(--wf-ink);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.5;box-sizing:border-box}
|
|
109
|
+
*{box-sizing:border-box}
|
|
110
|
+
img{max-width:100%}
|
|
111
|
+
${WF_TOKENS}
|
|
112
|
+
${css}
|
|
113
|
+
</style></head><body>${String(html == null ? '' : html)}</body></html>`;
|
|
114
|
+
const iframe = `<iframe class="frame" sandbox loading="lazy" title="${esc(label)}" height="${Number(height) || 420}" srcdoc="${esc(doc)}"></iframe>`;
|
|
115
|
+
if (bare) return iframe;
|
|
116
|
+
return `<div class="frame-wrap"><div class="label">${esc(label)}</div>${iframe}</div>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function card(label, inner, extraClass = '') {
|
|
120
|
+
return `<div class="card${extraClass ? ' ' + extraClass : ''}">` +
|
|
121
|
+
(label ? `<div class="label">${esc(label)}</div>` : '') +
|
|
122
|
+
inner + '</div>';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function noteList(annotations, label) {
|
|
126
|
+
const items = asArray(annotations);
|
|
127
|
+
if (!items.length) return '';
|
|
128
|
+
const rows = items.map((a) => {
|
|
129
|
+
if (a && typeof a === 'object') {
|
|
130
|
+
const lines = a.lines != null ? `L${text(a.lines)}` : '';
|
|
131
|
+
return `<li><span class="at">${esc(lines || label)}</span><span class="note">${esc(asText(a.note != null ? a.note : a.text))}</span></li>`;
|
|
132
|
+
}
|
|
133
|
+
return `<li><span class="note">${esc(asText(a))}</span></li>`;
|
|
134
|
+
});
|
|
135
|
+
return `<ul class="notes">${rows.join('')}</ul>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function table(headers, rows) {
|
|
139
|
+
const head = headers.filter(Boolean).map((h) => `<th>${esc(h)}</th>`).join('');
|
|
140
|
+
const body = rows.map((row) => '<tr>' + row.map((cell) => `<td>${cell}</td>`).join('') + '</tr>').join('');
|
|
141
|
+
return `<table class="kv"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- blocks -------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
function renderDiagram(block, ctx) {
|
|
147
|
+
const data = block.props.data && typeof block.props.data === 'object' ? block.props.data : {};
|
|
148
|
+
const html = asText(data.html != null ? data.html : block.props.html);
|
|
149
|
+
if (html) {
|
|
150
|
+
return card(text(block.props.label, 'diagram'), sandboxFrame(html, text(block.props.label, 'diagram'), { height: block.props.height, css: asText(data.css) }));
|
|
151
|
+
}
|
|
152
|
+
const source = asText(data.source != null ? data.source : block.props.source);
|
|
153
|
+
if (source) {
|
|
154
|
+
ctx.hasMermaid = true;
|
|
155
|
+
return card(text(block.props.label, 'mermaid'), '<pre class="mermaid">' + esc(source) + '</pre>');
|
|
156
|
+
}
|
|
157
|
+
const nodes = asArray(data.nodes);
|
|
158
|
+
const edges = asArray(data.edges);
|
|
159
|
+
if (!nodes.length && !edges.length) {
|
|
160
|
+
return card(text(block.props.label, 'diagram'), '<div class="note">diagram block carried no html, source, nodes or edges</div>');
|
|
161
|
+
}
|
|
162
|
+
const rows = [
|
|
163
|
+
...nodes.map((n) => (typeof n === 'object' ? [esc(asText(n.label || n.id || n.text)), esc(asText(n.description))] : ['node', esc(asText(n))])),
|
|
164
|
+
...edges.map((e) => (typeof e === 'object'
|
|
165
|
+
? [esc(asText(`${e.from || e.source || '?'} → ${e.to || e.target || '?'}`)), esc(asText(e.label))]
|
|
166
|
+
: ['edge', esc(asText(e))]))
|
|
167
|
+
];
|
|
168
|
+
return card(text(block.props.label, 'diagram'), table(['element', 'detail'], rows));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderMermaid(block, ctx) {
|
|
172
|
+
const source = asText(block.props.source != null ? block.props.source : block.props.code);
|
|
173
|
+
ctx.hasMermaid = true;
|
|
174
|
+
return card(text(block.props.label, 'mermaid'), '<pre class="mermaid">' + esc(source) + '</pre>');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function renderFileTree(block) {
|
|
178
|
+
const entries = asArray(block.props.entries || block.props.files || block.props.items);
|
|
179
|
+
if (!entries.length) return card('file tree', '<div class="note">no entries</div>');
|
|
180
|
+
const items = entries.map((entry) => {
|
|
181
|
+
if (typeof entry === 'string') return `<li><span class="path">${esc(entry)}</span></li>`;
|
|
182
|
+
const change = text(entry.change);
|
|
183
|
+
const pathText = text(entry.path || entry.name);
|
|
184
|
+
const depth = Number(entry.depth || 0) || 0;
|
|
185
|
+
return `<li style="padding-left:${depth * 14}px">` +
|
|
186
|
+
(change ? `<span class="badge ${esc(change)}">${esc(change)}</span>` : '') +
|
|
187
|
+
`<span class="path">${esc(pathText)}</span>` +
|
|
188
|
+
(entry.snippet ? codeBlock(entry.snippet) : '') +
|
|
189
|
+
(entry.note ? ` <span class="note">${esc(asText(entry.note))}</span>` : '') +
|
|
190
|
+
'</li>';
|
|
191
|
+
});
|
|
192
|
+
return card(text(block.props.label, 'file tree'), `<ul class="tree">${items.join('')}</ul>`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function renderDiff(block) {
|
|
196
|
+
const before = text(block.props.before);
|
|
197
|
+
const after = text(block.props.after);
|
|
198
|
+
const filename = text(block.props.filename);
|
|
199
|
+
const summary = text(block.props.summary);
|
|
200
|
+
const mode = text(block.props.mode, 'split');
|
|
201
|
+
const lines = (src, cls) => String(src)
|
|
202
|
+
.split('\n')
|
|
203
|
+
.map((line) => `<span class="line ${cls}">${esc(line)}</span>`)
|
|
204
|
+
.join('');
|
|
205
|
+
const body = mode === 'unified'
|
|
206
|
+
? `<div class="side"><div class="label">${esc(filename || 'diff')}</div><pre>${lines(after, 'add')}</pre></div>`
|
|
207
|
+
: `<div class="side"><div class="label">before</div><pre>${lines(before, 'del')}</pre></div>` +
|
|
208
|
+
`<div class="side"><div class="label">after</div><pre>${lines(after, 'add')}</pre></div>`;
|
|
209
|
+
const head = [
|
|
210
|
+
filename ? `<div class="filename">${esc(filename)}${block.props.language ? ' · ' + esc(block.props.language) : ''}</div>` : '',
|
|
211
|
+
summary ? `<div class="prose" style="padding:10px 14px 0"><p>${esc(summary)}</p></div>` : ''
|
|
212
|
+
].join('');
|
|
213
|
+
const notes = noteList(block.props.annotations, filename || 'diff');
|
|
214
|
+
return `<div class="card flush">${head}<div class="diff ${esc(mode)}">${body}</div>${notes ? `<div style="padding:0 14px 14px">${notes}</div>` : ''}</div>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function renderCode(block) {
|
|
218
|
+
const filename = text(block.props.filename || block.props.file);
|
|
219
|
+
return `<div class="card flush">` +
|
|
220
|
+
(filename ? `<div class="filename">${esc(filename)}${block.props.language ? ' · ' + esc(block.props.language) : ''}</div>` : '') +
|
|
221
|
+
codeBlock(block.props.code != null ? block.props.code : block.props.body, text(block.props.language)) +
|
|
222
|
+
(noteList(block.props.annotations, filename) ? `<div style="padding:0 14px 14px">${noteList(block.props.annotations, filename)}</div>` : '') +
|
|
223
|
+
'</div>';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function renderEndpoint(block) {
|
|
227
|
+
const method = text(block.props.method, 'GET').toUpperCase();
|
|
228
|
+
const endpointPath = text(block.props.path || block.props.url);
|
|
229
|
+
const description = childText(block);
|
|
230
|
+
const params = asArray(block.props.params);
|
|
231
|
+
const rows = [];
|
|
232
|
+
rows.push([`<span class="method">${esc(method)}</span>`, '']);
|
|
233
|
+
for (const p of params) {
|
|
234
|
+
if (p && typeof p === 'object') {
|
|
235
|
+
rows.push([
|
|
236
|
+
esc(asText(p.name)),
|
|
237
|
+
`<span class="plain">${esc(asText(p.type || 'any'))}${p.required ? ' · required' : ''}${p.in ? ' · in: ' + esc(asText(p.in)) : ''}${p.description ? '<br>' + esc(asText(p.description)) : ''}</span>`
|
|
238
|
+
]);
|
|
239
|
+
} else {
|
|
240
|
+
rows.push(['param', `<span class="plain">${esc(asText(p))}</span>`]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const examples = asArray(block.props.examples || block.props.responses).map((ex) => {
|
|
244
|
+
const label = ex && typeof ex === 'object' ? text(ex.label || ex.status || 'response') : 'response';
|
|
245
|
+
const body = ex && typeof ex === 'object' ? text(ex.body != null ? ex.body : ex.example) : asText(ex);
|
|
246
|
+
return `<div class="filename">${esc(label)}</div>${codeBlock(body, 'json')}`;
|
|
247
|
+
}).join('');
|
|
248
|
+
const head = `<div class="endpoint-path"><span class="method">${esc(method)}</span>${esc(endpointPath)}</div>`;
|
|
249
|
+
const desc = description ? `<div class="prose" style="padding-bottom:8px">${renderMarkdown(description)}</div>` : '';
|
|
250
|
+
const paramTable = params.length ? table(['param', 'detail'], rows.slice(1)) : '';
|
|
251
|
+
return card(text(block.props.label, 'api endpoint'), head + desc + paramTable +
|
|
252
|
+
(examples ? `<div style="margin-top:12px">${examples}</div>` : ''));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderDataModel(block) {
|
|
256
|
+
const entities = asArray(block.props.entities);
|
|
257
|
+
if (!entities.length) return card(text(block.props.label, 'data model'), '<div class="note">no entities</div>');
|
|
258
|
+
const cards = entities.map((entity) => {
|
|
259
|
+
if (!entity || typeof entity !== 'object') return card(null, `<pre><code>${esc(asText(entity))}</code></pre>`);
|
|
260
|
+
const fields = asArray(entity.fields);
|
|
261
|
+
const rows = fields.map((f) => {
|
|
262
|
+
if (f && typeof f === 'object') {
|
|
263
|
+
return [
|
|
264
|
+
`<span class="badge ${esc(text(f.change, ''))}">${esc(text(f.change))}</span>${esc(asText(f.name))}`,
|
|
265
|
+
`<span class="plain">${esc(asText(f.type))}${f.was ? ' (was ' + esc(asText(f.was)) + ')' : ''}${f.note ? '<br>' + esc(asText(f.note)) : ''}</span>`
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
return [esc(asText(f)), '<span class="plain">—</span>'];
|
|
269
|
+
});
|
|
270
|
+
return card(text(entity.name || entity.entity, 'entity'), table(['field', 'type'], rows));
|
|
271
|
+
}).join('');
|
|
272
|
+
return card(text(block.props.label, 'data model'), `<div class="flow">${cards}</div>`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderQuestionForm(block) {
|
|
276
|
+
const questions = asArray(block.props.questions);
|
|
277
|
+
if (!questions.length) return card(text(block.props.title, 'open questions'), '<div class="note">no questions</div>');
|
|
278
|
+
const items = questions.map((q) => {
|
|
279
|
+
if (!q || typeof q !== 'object') return `<div class="question"><div class="q">${esc(asText(q))}</div></div>`;
|
|
280
|
+
const options = asArray(q.options).map((opt) => {
|
|
281
|
+
const o = opt && typeof opt === 'object' ? opt : { label: asText(opt) };
|
|
282
|
+
const rec = o.recommended ? ' rec' : '';
|
|
283
|
+
return `<li class="${rec}">${esc(asText(o.label))}${o.detail ? ` <span class="detail">${esc(asText(o.detail))}</span>` : ''}${o.recommended ? ' <span class="badge">recommended</span>' : ''}</li>`;
|
|
284
|
+
}).join('');
|
|
285
|
+
return `<div class="question"><div class="q">${esc(asText(q.title || q.label || q.id))}<span class="mode">${esc(text(q.mode, 'open'))}</span></div>` +
|
|
286
|
+
(q.description ? `<div class="prose"><p>${esc(asText(q.description))}</p></div>` : '') +
|
|
287
|
+
(options ? `<ul>${options}</ul>` : '') + '</div>';
|
|
288
|
+
}).join('');
|
|
289
|
+
return card(text(block.props.title || block.props.label, 'open questions'), items);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function renderColumns(block, ctx) {
|
|
293
|
+
const columns = asArray(block.props.columns);
|
|
294
|
+
const wrap = `<div class="columns cols-${Math.min(columns.length || 1, 2)}">`;
|
|
295
|
+
const inner = columns.map((col, idx) => {
|
|
296
|
+
const label = col && typeof col === 'object' ? text(col.label || col.title, `column ${idx + 1}`) : `column ${idx + 1}`;
|
|
297
|
+
const payload = col && typeof col === 'object' ? (col.blocks || col.children || col.content) : col;
|
|
298
|
+
return `<div><div class="group"><div class="group-label">${esc(label)}</div><div class="group-body">` +
|
|
299
|
+
renderPayload(payload, ctx) + '</div></div></div>';
|
|
300
|
+
}).join('');
|
|
301
|
+
return wrap + inner + '</div>';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function renderTabs(block, ctx) {
|
|
305
|
+
const tabs = asArray(block.props.tabs);
|
|
306
|
+
if (!tabs.length) return card(text(block.props.label, 'tabs'), '<div class="note">no tabs</div>');
|
|
307
|
+
// Rendered stacked: an annotation layer owns clicks, so no tab switching JS.
|
|
308
|
+
const inner = tabs.map((tab, idx) => {
|
|
309
|
+
const label = tab && typeof tab === 'object' ? text(tab.label || tab.title, `tab ${idx + 1}`) : `tab ${idx + 1}`;
|
|
310
|
+
const payload = tab && typeof tab === 'object' ? (tab.blocks || tab.children || tab.content) : tab;
|
|
311
|
+
return `<div class="group"><div class="group-label">${esc(label)}</div><div class="group-body">${renderPayload(payload, ctx)}</div></div>`;
|
|
312
|
+
}).join('');
|
|
313
|
+
return card(text(block.props.label, ''), inner);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderCustomHtml(block) {
|
|
317
|
+
const html = block.props.html != null ? block.props.html : childRaw(block);
|
|
318
|
+
const label = text(block.props.label || block.props.title, 'custom html');
|
|
319
|
+
return card(label, sandboxFrame(asText(html), label, { height: block.props.height, css: asText(block.props.css) }));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const ARCHIFY_MAX_BYTES = 5 * 1024 * 1024;
|
|
323
|
+
|
|
324
|
+
// Returns { file } or { reason } for an <Archify src>; src must stay inside the plan folder.
|
|
325
|
+
function resolveArchifySrc(dir, src) {
|
|
326
|
+
if (!src) return { reason: 'src is required' };
|
|
327
|
+
if (!dir) return { reason: 'needs a plan folder on disk to resolve src' };
|
|
328
|
+
if (src.includes('\0') || path.isAbsolute(src) || /^[a-z][a-z0-9+.-]*:|^[\\/]/i.test(src)) return { reason: 'src must be a relative path inside the plan folder' };
|
|
329
|
+
if (src.split(/[\\/]/).includes('..')) return { reason: 'src must not contain ".."' };
|
|
330
|
+
if (!/\.html?$/i.test(src)) return { reason: 'src must be an .html file' };
|
|
331
|
+
let root;
|
|
332
|
+
let real;
|
|
333
|
+
try {
|
|
334
|
+
root = fs.realpathSync(dir);
|
|
335
|
+
real = fs.realpathSync(path.resolve(root, src));
|
|
336
|
+
} catch {
|
|
337
|
+
return { missing: true };
|
|
338
|
+
}
|
|
339
|
+
const rel = path.relative(root, real);
|
|
340
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { reason: 'src resolves outside the plan folder' };
|
|
341
|
+
let stat;
|
|
342
|
+
try { stat = fs.statSync(real); } catch { return { missing: true }; }
|
|
343
|
+
if (!stat.isFile()) return { missing: true };
|
|
344
|
+
if (stat.size > ARCHIFY_MAX_BYTES) return { reason: `file is ${stat.size} bytes; the limit is ${ARCHIFY_MAX_BYTES}` };
|
|
345
|
+
return { file: real };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// allow-scripts only, never allow-same-origin: the diagram runs, but cannot reach the page or its origin.
|
|
349
|
+
// Archify's "Present" is an in-page stage, so inside this frame it stays in the frame.
|
|
350
|
+
// The bridge mirrors that stage onto real browser fullscreen (the iframe has allow="fullscreen").
|
|
351
|
+
const FULLSCREEN_BRIDGE = `<script>(function(){var b=document.getElementById('btn-present');if(!b||!document.documentElement.requestFullscreen)return;
|
|
352
|
+
function presenting(){var l=document.getElementById('present-label');return /^exit/i.test((l?l.textContent:'')||b.getAttribute('aria-label')||'');}
|
|
353
|
+
function sync(){var on=presenting();
|
|
354
|
+
if(on&&!document.fullscreenElement){document.documentElement.requestFullscreen().catch(function(){});}
|
|
355
|
+
else if(!on&&document.fullscreenElement){document.exitFullscreen().catch(function(){});}}
|
|
356
|
+
new MutationObserver(sync).observe(b,{attributes:true,childList:true,characterData:true,subtree:true});})();</script>`;
|
|
357
|
+
|
|
358
|
+
function withFullscreenBridge(html) {
|
|
359
|
+
const at = html.lastIndexOf('</body>');
|
|
360
|
+
return at === -1 ? html + FULLSCREEN_BRIDGE : html.slice(0, at) + FULLSCREEN_BRIDGE + html.slice(at);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderArchify(block, ctx) {
|
|
364
|
+
const src = text(block.props.src);
|
|
365
|
+
const label = text(block.props.label || block.props.title, 'architecture diagram');
|
|
366
|
+
const found = resolveArchifySrc(ctx.dir, src);
|
|
367
|
+
if (found.missing || found.reason) {
|
|
368
|
+
const message = found.missing ? 'file not found' : found.reason;
|
|
369
|
+
ctx.warnings.push(`<Archify src="${src}"> ${message}`);
|
|
370
|
+
return errorCard(`Archify diagram: ${message}`, src);
|
|
371
|
+
}
|
|
372
|
+
let html;
|
|
373
|
+
try {
|
|
374
|
+
html = fs.readFileSync(found.file, 'utf8');
|
|
375
|
+
} catch (error) {
|
|
376
|
+
ctx.warnings.push(`<Archify src="${src}"> could not read file: ${error.message}`);
|
|
377
|
+
return errorCard('Archify diagram: could not read file', src);
|
|
378
|
+
}
|
|
379
|
+
// No card and no fixed inner box: the diagram sits on the page background and
|
|
380
|
+
// takes the viewport height by default so its toolbar and legend stay reachable.
|
|
381
|
+
html = withFullscreenBridge(html);
|
|
382
|
+
const explicit = numProp(block.props.height);
|
|
383
|
+
const style = explicit ? ` style="height:${within(explicit, 200, 2400)}px"` : '';
|
|
384
|
+
const href = src.split(/[\\/]/).map(encodeURIComponent).join('/');
|
|
385
|
+
return `<figure class="archify-block"><figcaption class="label">${esc(label)}</figcaption>` +
|
|
386
|
+
`<iframe class="archify-frame" sandbox="allow-scripts" allow="fullscreen" loading="lazy" title="${esc(label)}"${style} srcdoc="${esc(html)}"></iframe>` +
|
|
387
|
+
`<div class="links"><a href="${esc(href)}" target="_blank" rel="noopener noreferrer">open standalone</a> <span class="path">${esc(src)}</span></div></figure>`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const PROTOTYPE_SURFACES = new Set(['web', 'desktop']);
|
|
391
|
+
|
|
392
|
+
function screenNames(blocks, found = []) {
|
|
393
|
+
for (const block of blocks || []) {
|
|
394
|
+
if (block.type !== 'tag') continue;
|
|
395
|
+
const isBoard = block.name === 'Artboard';
|
|
396
|
+
if (isBoard || isScreenTag(block)) {
|
|
397
|
+
if (PROTOTYPE_SURFACES.has(surfaceOf(block).toLowerCase())) {
|
|
398
|
+
const name = text(block.props.label || block.props.title || block.props.id, 'screen');
|
|
399
|
+
if (!found.includes(name)) found.push(name);
|
|
400
|
+
}
|
|
401
|
+
if (isBoard) continue;
|
|
402
|
+
}
|
|
403
|
+
screenNames(block.children, found);
|
|
404
|
+
}
|
|
405
|
+
return found;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// A hint only: plain escaped text, nothing is started.
|
|
409
|
+
function prototypeHint(blocks, frontmatter = {}) {
|
|
410
|
+
const mode = text(frontmatter.prototype).toLowerCase();
|
|
411
|
+
if (mode === 'skip') return '';
|
|
412
|
+
const names = screenNames(blocks);
|
|
413
|
+
if (!names.length && mode !== 'suggest') return '';
|
|
414
|
+
const which = names.length ? `Screens with a web or desktop surface: ${names.join(', ')}. ` : '';
|
|
415
|
+
return '<div class="card prototype-hint"><div class="label">Suggested next step</div>' +
|
|
416
|
+
`<div class="prose"><p>${esc(which)}A throwaway prototype can be built with the <code>prototype</code> skill. Nothing is started automatically.</p></div></div>`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const SURFACE_SIZE = { mobile: [320, 580], popover: [360, 420], panel: [360, 480], tablet: [600, 700], browser: [720, 520], desktop: [960, 640] };
|
|
420
|
+
|
|
421
|
+
function numProp(value) {
|
|
422
|
+
if (value === undefined || value === null || value === true || value === '') return null;
|
|
423
|
+
const n = Number(value);
|
|
424
|
+
return Number.isFinite(n) ? n : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function within(value, min, max) {
|
|
428
|
+
return Math.min(max, Math.max(min, value));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isScreenTag(child) {
|
|
432
|
+
return child.type === 'tag' && /^(screen|wireframeblock)$/i.test(child.name);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function renderScreen(block, ctx) {
|
|
436
|
+
const label = text(block.props.label || block.props.title, text(block.props.surface, 'screen'));
|
|
437
|
+
const ab = ctx.ab || {};
|
|
438
|
+
const height = block.props.height != null ? block.props.height : ab.height;
|
|
439
|
+
const caption = text(block.props.caption);
|
|
440
|
+
const captionHtml = caption ? `<div class="screen-caption">${esc(caption)}</div>` : '';
|
|
441
|
+
const kids = block.children || [];
|
|
442
|
+
|
|
443
|
+
if (block.props.html != null) {
|
|
444
|
+
return sandboxFrame(asText(block.props.html), label, { height, css: asText(block.props.css), bare: ab.abs }) + captionHtml;
|
|
445
|
+
}
|
|
446
|
+
// A WireframeBlock body is normally a single <Screen>.
|
|
447
|
+
if (kids.some(isScreenTag)) return renderPayload(kids, ctx);
|
|
448
|
+
if (kids.length && !kids.some((c) => c.type === 'tag')) {
|
|
449
|
+
return sandboxFrame(childRaw(block), label, { height, css: asText(block.props.css), bare: ab.abs }) + captionHtml;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const surface = Object.hasOwn(WIDTHS, text(block.props.surface).toLowerCase()) ? text(block.props.surface).toLowerCase() : '';
|
|
453
|
+
const minHeight = ab.abs ? '' : ` style="min-height:${within(numProp(height) || 300, 120, 2400)}px"`;
|
|
454
|
+
const screen = `<div class="kit-screen${surface ? ' s-' + surface : ''}"${minHeight}>${renderKit(kids, ctx)}</div>`;
|
|
455
|
+
if (ab.abs || ctx.ab) return screen + captionHtml;
|
|
456
|
+
return `<div class="frame-wrap"><div class="label">${esc(label)}</div>${screen}</div>${captionHtml}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function withArtboard(block, ctx, abs, fn) {
|
|
460
|
+
const previous = ctx.ab;
|
|
461
|
+
ctx.ab = { abs, height: numProp(block.props.height), surface: surfaceOf(block) };
|
|
462
|
+
try {
|
|
463
|
+
return fn();
|
|
464
|
+
} finally {
|
|
465
|
+
ctx.ab = previous;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function renderRichText(block) {
|
|
470
|
+
const title = text(block.props.title);
|
|
471
|
+
return `<div class="prose">${title ? `<h3 class="rt-title">${esc(title)}</h3>` : ''}${renderMarkdown(childText(block))}</div>`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderCallout(block) {
|
|
475
|
+
const tone = text(block.props.tone, 'note');
|
|
476
|
+
const title = text(block.props.title);
|
|
477
|
+
return card(`callout · ${tone}`, (title ? `<div class="callout-title">${esc(title)}</div>` : '') +
|
|
478
|
+
`<div class="prose">${renderMarkdown(childText(block))}</div>`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function annotationHtml(block) {
|
|
482
|
+
const title = text(block.props.title);
|
|
483
|
+
const body = childText(block) || text(block.props.text) || text(block.props.body);
|
|
484
|
+
return '<div class="annot">' +
|
|
485
|
+
(title ? `<div class="annot-title"><span class="glyph" aria-hidden="true">↗</span>${esc(title)}</div>` : '') +
|
|
486
|
+
`<div class="annot-body${title ? '' : ' glyphed'}">${renderMarkdown(body)}</div></div>`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// --- checklist, table, code tabs, decision, html, implementation map, compare
|
|
490
|
+
|
|
491
|
+
const CHECK_LINE_RE = /^\s*(?:[-*]\s+)?(?:\[([ xX])\]\s+)?(.+?)\s*$/;
|
|
492
|
+
|
|
493
|
+
function checklistItems(block) {
|
|
494
|
+
const items = [];
|
|
495
|
+
for (const raw of asArray(block.props.items)) {
|
|
496
|
+
if (raw && typeof raw === 'object') {
|
|
497
|
+
items.push({ label: asText(raw.label ?? raw.text ?? raw.title), checked: Boolean(raw.checked ?? raw.done), note: asText(raw.note ?? raw.detail) });
|
|
498
|
+
} else if (asText(raw).trim()) items.push({ label: asText(raw).trim(), checked: false, note: '' });
|
|
499
|
+
}
|
|
500
|
+
for (const child of block.children || []) {
|
|
501
|
+
if (child.type === 'tag') {
|
|
502
|
+
const label = asText(child.props.label ?? child.props.text) || childRaw(child);
|
|
503
|
+
items.push({ label, checked: Boolean(child.props.checked ?? child.props.done), note: '' });
|
|
504
|
+
} else if (child.type === 'prose') {
|
|
505
|
+
for (const line of child.text.split('\n')) {
|
|
506
|
+
const m = CHECK_LINE_RE.exec(line);
|
|
507
|
+
if (m && m[2]) items.push({ label: m[2], checked: m[1] === 'x' || m[1] === 'X', note: '' });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return items;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function renderChecklist(block) {
|
|
515
|
+
const items = checklistItems(block);
|
|
516
|
+
const label = text(block.props.title || block.props.label, 'checklist');
|
|
517
|
+
if (!items.length) return card(label, '<div class="note">no items</div>');
|
|
518
|
+
const rows = items.map((item) => `<li class="${item.checked ? 'done' : ''}"><span class="cbox" aria-hidden="true"></span>` +
|
|
519
|
+
`<span class="ctext">${renderInline(item.label)}${item.note ? ` <span class="detail">${esc(item.note)}</span>` : ''}</span>` +
|
|
520
|
+
`<span class="sr-only">${item.checked ? 'done' : 'open'}</span></li>`).join('');
|
|
521
|
+
return card(label, `<ul class="checklist">${rows}</ul>`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderDataTable(block) {
|
|
525
|
+
const label = text(block.props.title || block.props.label, 'table');
|
|
526
|
+
const columns = asArray(block.props.columns).map((c) => (c && typeof c === 'object'
|
|
527
|
+
? { key: asText(c.key ?? c.id ?? c.label), label: asText(c.label ?? c.title ?? c.key) }
|
|
528
|
+
: { key: asText(c), label: asText(c) }));
|
|
529
|
+
let rows = asArray(block.props.rows);
|
|
530
|
+
if (!columns.length && rows.length && rows[0] && typeof rows[0] === 'object' && !Array.isArray(rows[0])) {
|
|
531
|
+
for (const key of Object.keys(rows[0])) columns.push({ key, label: key });
|
|
532
|
+
}
|
|
533
|
+
if (!rows.length) {
|
|
534
|
+
const md = childText(block);
|
|
535
|
+
return card(label, md ? `<div class="prose">${renderMarkdown(md)}</div>` : '<div class="note">no rows</div>');
|
|
536
|
+
}
|
|
537
|
+
rows = rows.map((row) => {
|
|
538
|
+
if (Array.isArray(row)) return row;
|
|
539
|
+
if (row && typeof row === 'object') {
|
|
540
|
+
return columns.length ? columns.map((c) => row[c.key] ?? row[c.label] ?? '') : Object.values(row);
|
|
541
|
+
}
|
|
542
|
+
return [row];
|
|
543
|
+
});
|
|
544
|
+
const head = columns.length ? `<thead><tr>${columns.map((c) => `<th>${esc(c.label)}</th>`).join('')}</tr></thead>` : '';
|
|
545
|
+
const body = rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(asText(cell))}</td>`).join('')}</tr>`).join('');
|
|
546
|
+
return card(label, `<div class="table-wrap"><table class="data">${head}<tbody>${body}</tbody></table></div>`);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function renderCodeTabs(block) {
|
|
550
|
+
const tabs = asArray(block.props.tabs);
|
|
551
|
+
if (!tabs.length) return card(text(block.props.title || block.props.label, 'code tabs'), '<div class="note">no tabs</div>');
|
|
552
|
+
const panes = tabs.map((tab, idx) => {
|
|
553
|
+
const t = tab && typeof tab === 'object' ? tab : { code: asText(tab) };
|
|
554
|
+
const label = text(t.label || t.title || t.filename, `tab ${idx + 1}`);
|
|
555
|
+
return `<div class="tab-pane"><div class="filename">${esc(label)}${t.language ? ' · ' + esc(text(t.language)) : ''}</div>` +
|
|
556
|
+
codeBlock(t.code != null ? t.code : t.body, text(t.language)) + '</div>';
|
|
557
|
+
}).join('');
|
|
558
|
+
return `<div class="card flush code-tabs">${panes}</div>`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isRecommended(option, index, props) {
|
|
562
|
+
if (option.recommended) return true;
|
|
563
|
+
const mark = props.recommended ?? props.chosen ?? props.selected;
|
|
564
|
+
if (mark === undefined || mark === true) return false;
|
|
565
|
+
return String(mark) === String(option.id) || String(mark) === String(option.label) || (typeof mark === 'number' && mark === index);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function renderDecision(block) {
|
|
569
|
+
const title = text(block.props.title);
|
|
570
|
+
const question = text(block.props.question || block.props.title);
|
|
571
|
+
const options = asArray(block.props.options).map((raw) => (raw && typeof raw === 'object' ? raw : { label: asText(raw) }));
|
|
572
|
+
const rows = options.map((o, i) => {
|
|
573
|
+
const rec = isRecommended(o, i, block.props);
|
|
574
|
+
return `<li class="opt${rec ? ' rec' : ''}"><div class="opt-label">${esc(asText(o.label ?? o.title ?? o.id))}` +
|
|
575
|
+
(rec ? ' <span class="badge">recommended</span>' : '') + '</div>' +
|
|
576
|
+
(o.detail || o.description ? `<div class="detail">${esc(asText(o.detail ?? o.description))}</div>` : '') + '</li>';
|
|
577
|
+
}).join('');
|
|
578
|
+
const rationale = childText(block) || text(block.props.rationale);
|
|
579
|
+
return card(title && title !== question ? title : 'decision',
|
|
580
|
+
(question ? `<div class="q">${esc(question)}</div>` : '') +
|
|
581
|
+
(rows ? `<ul class="options">${rows}</ul>` : '<div class="note">no options</div>') +
|
|
582
|
+
(rationale ? `<div class="rationale"><div class="label">rationale</div><div class="prose">${renderMarkdown(rationale)}</div></div>` : ''));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function renderImplementationMap(block, ctx) {
|
|
586
|
+
const label = text(block.props.title || block.props.label, 'implementation map');
|
|
587
|
+
const raw = block.props.files;
|
|
588
|
+
if ((block.props._unparsed || []).includes('files')) {
|
|
589
|
+
ctx.warnings.push('<ImplementationMap> files could not be parsed; raw text shown');
|
|
590
|
+
return card(label, '<div class="note">The files list could not be parsed. The raw text is shown so nothing is lost.</div>' +
|
|
591
|
+
`<pre><code>${esc(asText(raw))}</code></pre>`, 'unsupported');
|
|
592
|
+
}
|
|
593
|
+
let entries;
|
|
594
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) entries = Object.entries(raw).map(([k, v]) => (v && typeof v === 'object' ? { path: k, ...v } : { path: k, note: v }));
|
|
595
|
+
else entries = asArray(raw);
|
|
596
|
+
if (!entries.length) {
|
|
597
|
+
entries = (block.children || []).filter((c) => c.type === 'prose')
|
|
598
|
+
.flatMap((c) => c.text.split('\n')).map((l) => l.replace(/^\s*[-*]\s+/, '').trim()).filter(Boolean);
|
|
599
|
+
}
|
|
600
|
+
if (!entries.length) return card(label, '<div class="note">no files</div>');
|
|
601
|
+
const items = entries.map((entry) => {
|
|
602
|
+
if (!entry || typeof entry !== 'object') return `<li><span class="path">${esc(asText(entry))}</span></li>`;
|
|
603
|
+
const change = text(entry.change ?? entry.status ?? entry.kind);
|
|
604
|
+
return '<li>' + (change ? `<span class="badge ${esc(change)}">${esc(change)}</span>` : '') +
|
|
605
|
+
`<span class="path">${esc(asText(entry.path ?? entry.file ?? entry.name))}</span>` +
|
|
606
|
+
(entry.title ? `<div class="impl-title">${esc(asText(entry.title))}</div>` : '') +
|
|
607
|
+
(entry.note ?? entry.description ? `<div class="impl-note">${esc(asText(entry.note ?? entry.description))}</div>` : '') +
|
|
608
|
+
(entry.snippet ? codeBlock(entry.snippet) : '') + '</li>';
|
|
609
|
+
});
|
|
610
|
+
return card(label, `<ul class="tree impl">${items.join('')}</ul>`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function compareSide(block, name, ctx) {
|
|
614
|
+
const child = (block.children || []).find((c) => c.type === 'tag' && c.name.toLowerCase() === name);
|
|
615
|
+
if (child) return child.props.html != null ? renderPayload({ html: child.props.html }, ctx) : renderBlocks(child.children, ctx);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function renderCompare(block, ctx) {
|
|
620
|
+
let before = compareSide(block, 'before', ctx);
|
|
621
|
+
let after = compareSide(block, 'after', ctx);
|
|
622
|
+
if (before === null && after === null) {
|
|
623
|
+
const paired = (block.children || [])
|
|
624
|
+
.flatMap((c) => (c.type === 'prose' ? c.text.split(/\n\s*\n/).map((t) => ({ type: 'prose', text: t })) : [c]))
|
|
625
|
+
.filter((c) => c.type === 'tag' || c.type === 'prose');
|
|
626
|
+
if (paired.length === 2) {
|
|
627
|
+
before = renderBlocks([paired[0]], ctx);
|
|
628
|
+
after = renderBlocks([paired[1]], ctx);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (before === null) before = block.props.before !== undefined ? renderPayload(block.props.before, ctx) : '<div class="note">empty</div>';
|
|
632
|
+
if (after === null) after = block.props.after !== undefined ? renderPayload(block.props.after, ctx) : '<div class="note">empty</div>';
|
|
633
|
+
const side = (cls, title, body) => `<div class="cmp-side ${cls}"><div class="cmp-label">${esc(title)}</div><div class="cmp-body">${body}</div></div>`;
|
|
634
|
+
return '<div class="compare">' +
|
|
635
|
+
side('before', text(block.props.beforeLabel, 'Before'), before) +
|
|
636
|
+
side('after', text(block.props.afterLabel, 'After'), after) + '</div>';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function renderUnknown(block, ctx) {
|
|
640
|
+
const warning = `unsupported block <${block.name}>`;
|
|
641
|
+
ctx.warnings.push(warning);
|
|
642
|
+
return `<div class="card unsupported"><div class="label">unsupported block · <${esc(block.name)}></div>` +
|
|
643
|
+
'<div class="note">This plan builder does not render this tag. The source is shown below so nothing is lost.</div>' +
|
|
644
|
+
'<pre><code>' + esc(block.raw || '') + '</code></pre></div>';
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Canonical tag names from the visual-plan / visual-recap block reference.
|
|
648
|
+
// Hyphenated conceptual names are accepted as aliases so a hand-written plan
|
|
649
|
+
// using either spelling renders.
|
|
650
|
+
const HANDLERS = {
|
|
651
|
+
Diagram: renderDiagram,
|
|
652
|
+
Mermaid: renderMermaid,
|
|
653
|
+
FileTree: renderFileTree,
|
|
654
|
+
WireframeBlock: renderScreen,
|
|
655
|
+
Screen: renderScreen,
|
|
656
|
+
Diff: renderDiff,
|
|
657
|
+
Code: renderCode,
|
|
658
|
+
AnnotatedCode: renderCode,
|
|
659
|
+
Endpoint: renderEndpoint,
|
|
660
|
+
ApiEndpoint: renderEndpoint,
|
|
661
|
+
DataModel: renderDataModel,
|
|
662
|
+
QuestionForm: renderQuestionForm,
|
|
663
|
+
Columns: renderColumns,
|
|
664
|
+
TabsBlock: renderTabs,
|
|
665
|
+
Tabs: renderTabs,
|
|
666
|
+
CustomHtml: renderCustomHtml,
|
|
667
|
+
Archify: renderArchify,
|
|
668
|
+
AgentTrail: (block, ctx) => require('./agent-trail').renderAgentTrail(block, ctx),
|
|
669
|
+
RichText: renderRichText,
|
|
670
|
+
Callout: renderCallout,
|
|
671
|
+
Json: (block) => card('json', codeBlock(block.props.code != null ? block.props.code : block.props.data, 'json')),
|
|
672
|
+
OpenApiSpec: (block) => card('openapi spec', codeBlock(block.props.code != null ? block.props.code : block.props.spec, 'yaml')),
|
|
673
|
+
Checklist: renderChecklist,
|
|
674
|
+
Table: renderDataTable,
|
|
675
|
+
CodeTabs: renderCodeTabs,
|
|
676
|
+
Decision: renderDecision,
|
|
677
|
+
HtmlBlock: renderCustomHtml,
|
|
678
|
+
ImplementationMap: renderImplementationMap,
|
|
679
|
+
Compare: renderCompare,
|
|
680
|
+
// canvas.mdx containers: pass-through so artboards and their Screen bodies
|
|
681
|
+
// still render instead of collapsing into one unsupported card.
|
|
682
|
+
DesignBoard: (block, ctx) => renderPayload(block.children, ctx),
|
|
683
|
+
Section: (block, ctx) => renderPayload(block.children, ctx),
|
|
684
|
+
Artboard: (block, ctx) => card(text(block.props.title || block.props.label, 'artboard'), withArtboard(block, ctx, false, () => renderPayload(block.children, ctx))),
|
|
685
|
+
Annotation: annotationHtml,
|
|
686
|
+
Connector: (block) => card('connector', `<div class="note">${esc(asText(block.props.label || block.props.text || ''))}</div>`)
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const ALIASES = {
|
|
690
|
+
diagram: 'Diagram',
|
|
691
|
+
mermaid: 'Mermaid',
|
|
692
|
+
'file-tree': 'FileTree',
|
|
693
|
+
wireframe: 'WireframeBlock',
|
|
694
|
+
screen: 'Screen',
|
|
695
|
+
diff: 'Diff',
|
|
696
|
+
code: 'Code',
|
|
697
|
+
'annotated-code': 'AnnotatedCode',
|
|
698
|
+
endpoint: 'Endpoint',
|
|
699
|
+
'api-endpoint': 'ApiEndpoint',
|
|
700
|
+
'data-model': 'DataModel',
|
|
701
|
+
'question-form': 'QuestionForm',
|
|
702
|
+
columns: 'Columns',
|
|
703
|
+
tabs: 'TabsBlock',
|
|
704
|
+
'custom-html': 'CustomHtml',
|
|
705
|
+
archify: 'Archify',
|
|
706
|
+
'agent-trail': 'AgentTrail',
|
|
707
|
+
'rich-text': 'RichText',
|
|
708
|
+
callout: 'Callout',
|
|
709
|
+
json: 'Json',
|
|
710
|
+
checklist: 'Checklist',
|
|
711
|
+
table: 'Table',
|
|
712
|
+
'code-tabs': 'CodeTabs',
|
|
713
|
+
decision: 'Decision',
|
|
714
|
+
'html-block': 'HtmlBlock',
|
|
715
|
+
'implementation-map': 'ImplementationMap',
|
|
716
|
+
compare: 'Compare',
|
|
717
|
+
openapi: 'OpenApiSpec'
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
function childRaw(block) {
|
|
721
|
+
return (block.childrenRaw || '').trim();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function childText(block) {
|
|
725
|
+
if (typeof block.childrenRaw === 'string' && block.childrenRaw.trim()) return block.childrenRaw.trim();
|
|
726
|
+
return '';
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Nested block payloads (columns/tabs/artboard children) arrive either as real
|
|
730
|
+
// blocks, a nested block object, a raw HTML string, or plain text.
|
|
731
|
+
function renderPayload(payload, ctx) {
|
|
732
|
+
if (payload === undefined || payload === null || payload === '') return '<div class="note">empty</div>';
|
|
733
|
+
if (Array.isArray(payload)) return renderBlocks(payload, ctx);
|
|
734
|
+
if (typeof payload === 'object' && payload.type === 'tag') return renderTag(payload, ctx);
|
|
735
|
+
if (payload.html !== undefined) return sandboxFrame(asText(payload.html), text(payload.label, 'html'), { height: payload.height, css: asText(payload.css) });
|
|
736
|
+
if (typeof payload === 'string' && /^\s*</.test(payload)) {
|
|
737
|
+
ctx.warnings.push('raw html inside a nested payload was sandboxed rather than injected');
|
|
738
|
+
return sandboxFrame(payload, 'html');
|
|
739
|
+
}
|
|
740
|
+
return `<div class="prose">${renderMarkdown(asText(payload))}</div>`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function renderTag(block, ctx) {
|
|
744
|
+
const key = HANDLERS[block.name] ? block.name : ALIASES[block.name.toLowerCase()];
|
|
745
|
+
const handler = key && HANDLERS[key];
|
|
746
|
+
if (!handler) return renderUnknown(block, ctx);
|
|
747
|
+
return handler(block, ctx);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Source anchor: lets an annotation selector (`#src-plan.mdx-L42`) point back
|
|
751
|
+
// at the MDX line. The file name comes from the renderer, never from document
|
|
752
|
+
// content. An element that already has an id keeps it and only gets data-src.
|
|
753
|
+
function srcAttr(block, ctx, withId = true) {
|
|
754
|
+
if (!ctx || !ctx.src || !Number.isInteger(block.line) || block.line < 1) return '';
|
|
755
|
+
const ref = `${ctx.src}:${block.line}`;
|
|
756
|
+
const id = `src-${ctx.src}-L${block.line}`;
|
|
757
|
+
ctx.srcIds = ctx.srcIds || new Set();
|
|
758
|
+
if (!withId || ctx.srcIds.has(id)) return ` data-src="${ref}"`;
|
|
759
|
+
ctx.srcIds.add(id);
|
|
760
|
+
return ` id="${id}" data-src="${ref}"`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function withSrc(html, block, ctx) {
|
|
764
|
+
const m = /^(\s*<[a-zA-Z][a-zA-Z0-9]*)([^>]*)/.exec(String(html));
|
|
765
|
+
if (!m) return html;
|
|
766
|
+
const attr = srcAttr(block, ctx, !/\sid\s*=/.test(m[2]));
|
|
767
|
+
return attr ? m[1] + attr + String(html).slice(m[1].length) : html;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function renderBlocks(blocks, ctx) {
|
|
771
|
+
const out = [];
|
|
772
|
+
for (const block of blocks || []) {
|
|
773
|
+
try {
|
|
774
|
+
if (block.type === 'heading') out.push({ html: withSrc(renderHeading(block, ctx), block, ctx) });
|
|
775
|
+
else if (block.type === 'prose') {
|
|
776
|
+
const html = renderMarkdown(block.text);
|
|
777
|
+
collectProseHeadings(html, ctx);
|
|
778
|
+
out.push({ html: `<div class="prose"${srcAttr(block, ctx)}>${html}</div>` });
|
|
779
|
+
}
|
|
780
|
+
else if (block.type === 'tag') out.push({ html: withSrc(renderTag(block, ctx), block, ctx) });
|
|
781
|
+
else if (block.type === 'malformed') {
|
|
782
|
+
ctx.warnings.push(block.message || 'malformed block');
|
|
783
|
+
out.push({ html: errorCard(block.message || 'malformed block', block.raw || '') });
|
|
784
|
+
} else out.push({ html: `<div class="prose">${renderMarkdown(asText(block))}</div>` });
|
|
785
|
+
} catch (error) {
|
|
786
|
+
// One bad block must not lose the rest of the document.
|
|
787
|
+
ctx.warnings.push(`render failed for ${block && block.name ? '<' + block.name + '>' : 'a block'}: ${error.message}`);
|
|
788
|
+
out.push({ html: errorCard('render failed', error.message) });
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return out.map((entry) => entry.html).join('\n');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function errorCard(message, detail) {
|
|
795
|
+
return `<div class="card error-card"><div class="label">could not render</div>` +
|
|
796
|
+
`<div>${esc(message)}</div>` +
|
|
797
|
+
(detail ? `<pre><code>${esc(detail)}</code></pre>` : '') + '</div>';
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const ID_TAG_RE = /\s*\{#[A-Za-z0-9-]+\}\s*$/;
|
|
801
|
+
|
|
802
|
+
function renderHeading(block, ctx) {
|
|
803
|
+
const level = Math.min(Math.max(block.level, 2), 4);
|
|
804
|
+
const shown = block.text.replace(ID_TAG_RE, '') || block.text;
|
|
805
|
+
const id = uniqueId(slugify(shown) || 'section', ctx);
|
|
806
|
+
ctx.headings.push({ id, text: shown, level });
|
|
807
|
+
return `<h${level} class="sec" id="${esc(id)}">${esc(shown)}</h${level}>`;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Markdown headings inside a prose chunk already carry an id from
|
|
811
|
+
// lib/plan-canvas/markdown.js. Register them for the nav, and re-slug on a
|
|
812
|
+
// collision with a heading the block parser already took.
|
|
813
|
+
const PROSE_HEADING_RE = /<h([1-6]) id="([^"]*)">([\s\S]*?)<\/h\1>/g;
|
|
814
|
+
|
|
815
|
+
function collectProseHeadings(html, ctx) {
|
|
816
|
+
return html.replace(PROSE_HEADING_RE, (match, level, id, inner) => {
|
|
817
|
+
const next = uniqueId(id || 'section', ctx);
|
|
818
|
+
const plain = inner.replace(/<[^>]*>/g, '').trim();
|
|
819
|
+
ctx.headings.push({ id: next, text: plain || id, level: Math.max(Number(level), 2) });
|
|
820
|
+
return match.replace(`id="${id}"`, `id="${next}"`);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function uniqueId(base, ctx) {
|
|
825
|
+
const id = base || 'section';
|
|
826
|
+
const count = ctx.ids.get(id) || 0;
|
|
827
|
+
ctx.ids.set(id, count + 1);
|
|
828
|
+
return count ? id + '-' + count : id;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function navHtml(headings) {
|
|
832
|
+
if (!headings.length) return '<div class="empty">no headings</div>';
|
|
833
|
+
const items = headings.map((h) => `<li><a class="lvl${h.level}" href="#${esc(h.id)}">${esc(h.text)}</a></li>`).join('');
|
|
834
|
+
return `<h2>on this page</h2><ol>${items}</ol>`;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
// --- board ---------------------------------------------------------------
|
|
839
|
+
// Visual blocks laid out as numbered rows of cards. Arrows come only from
|
|
840
|
+
// relations the plan states (transitions / Connector / data.edges); nothing is
|
|
841
|
+
// inferred. Positions are measured in the browser (board-client.js).
|
|
842
|
+
|
|
843
|
+
const BOARD_HEADING_RE = /\s*\{#board\}\s*$/;
|
|
844
|
+
const WIDTHS = { mobile: 'mobile', popover: 'narrow', panel: 'narrow', tablet: 'tablet', browser: 'wide', desktop: 'wide' };
|
|
845
|
+
const WIDE_TAGS = new Set(['Mermaid', 'Diagram', 'Diff', 'Code', 'AnnotatedCode', 'FileTree', 'DataModel', 'Endpoint']);
|
|
846
|
+
|
|
847
|
+
function surfaceOf(block) {
|
|
848
|
+
if (block.props && block.props.surface) return String(block.props.surface);
|
|
849
|
+
for (const child of block.children || []) {
|
|
850
|
+
const found = child && child.type === 'tag' ? surfaceOf(child) : '';
|
|
851
|
+
if (found) return found;
|
|
852
|
+
}
|
|
853
|
+
return '';
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function widthClass(block) {
|
|
857
|
+
const surface = WIDTHS[surfaceOf(block).toLowerCase()];
|
|
858
|
+
if (surface) return 'w-' + surface;
|
|
859
|
+
return WIDE_TAGS.has(block.name) ? 'w-wide' : '';
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function relationOf(raw) {
|
|
863
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
864
|
+
const from = raw.from ?? raw.fromId ?? raw.source ?? raw.sourceId;
|
|
865
|
+
const to = raw.to ?? raw.toId ?? raw.target ?? raw.targetId;
|
|
866
|
+
if (from === undefined || to === undefined) return null;
|
|
867
|
+
return { from: String(from), to: String(to), label: text(raw.label ?? raw.text) };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function isNodeDiagram(block) {
|
|
871
|
+
if (block.name !== 'Diagram') return false;
|
|
872
|
+
const data = block.props.data && typeof block.props.data === 'object' ? block.props.data : {};
|
|
873
|
+
return !(data.html || block.props.html || data.source || block.props.source) && asArray(data.nodes).length > 0;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function resolveEdges(relations, known, ctx) {
|
|
877
|
+
const edges = [];
|
|
878
|
+
for (const rel of relations) {
|
|
879
|
+
const from = known.get(rel.from);
|
|
880
|
+
const to = known.get(rel.to);
|
|
881
|
+
if (!from || !to) {
|
|
882
|
+
ctx.warnings.push(`board relation ${rel.from} -> ${rel.to} names an unknown card; no arrow drawn`);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
edges.push({ from, to, label: rel.label, names: `${rel.from} to ${rel.to}` });
|
|
886
|
+
}
|
|
887
|
+
return edges;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function edgeSvg(edges, index) {
|
|
891
|
+
if (!edges.length) return '';
|
|
892
|
+
return `<svg class="board-edges" aria-hidden="true" focusable="false"><defs>` +
|
|
893
|
+
`<marker id="bm-arrow-${index}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="edge-head" d="M0 0L10 5L0 10z"/></marker></defs>` +
|
|
894
|
+
edges.map((e) => `<g class="edge" data-from="${e.from}" data-to="${e.to}"><path class="edge-line" d="" marker-end="url(#bm-arrow-${index})"/>` +
|
|
895
|
+
`<text class="edge-label" text-anchor="middle">${esc(e.label)}</text></g>`).join('') +
|
|
896
|
+
'</svg>' +
|
|
897
|
+
`<ul class="sr-only">${edges.map((e) => `<li>${esc(e.names)}${e.label ? ': ' + esc(e.label) : ''}</li>`).join('')}</ul>`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function boardShell(index, canvasInner, { abs = false, canvasStyle = '' } = {}) {
|
|
901
|
+
return `<section class="board${abs ? ' board-abs' : ''}" aria-label="Visual board ${index}">` +
|
|
902
|
+
`<div class="board-viewport"><div class="board-stage"><div class="board-canvas${abs ? ' abs' : ''}"${canvasStyle ? ` style="${canvasStyle}"` : ''}>${canvasInner}</div></div></div>` +
|
|
903
|
+
'<div class="board-zoom" role="group" aria-label="Board zoom">' +
|
|
904
|
+
'<button type="button" data-zoom="out" aria-label="Zoom out">−</button>' +
|
|
905
|
+
'<output class="zoom-readout" aria-live="polite">100%</output>' +
|
|
906
|
+
'<button type="button" data-zoom="in" aria-label="Zoom in">+</button>' +
|
|
907
|
+
'<button type="button" data-zoom="fit" aria-label="Fit board to view">fit</button>' +
|
|
908
|
+
'<button type="button" data-zoom="reset" aria-label="Reset zoom to 100 percent">1:1</button>' +
|
|
909
|
+
'</div></section>';
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// --- absolute layout ------------------------------------------------------
|
|
913
|
+
// Artboards that carry x/y are placed on a canvas sized to their bounding box.
|
|
914
|
+
// Only numbers computed here reach a style attribute.
|
|
915
|
+
|
|
916
|
+
const ABS_MARGIN = 72;
|
|
917
|
+
|
|
918
|
+
function isAbsoluteArtboard(block) {
|
|
919
|
+
return block.type === 'tag' && block.name === 'Artboard' && numProp(block.props.x) !== null && numProp(block.props.y) !== null;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function hasAbsolute(blocks) {
|
|
923
|
+
return (blocks || []).some((b) => isAbsoluteArtboard(b) || (b.type === 'tag' && (b.name === 'DesignBoard' || b.name === 'Section') && hasAbsolute(b.children)));
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function artboardBox(block) {
|
|
927
|
+
const [dw, dh] = SURFACE_SIZE[surfaceOf(block).toLowerCase()] || [420, 520];
|
|
928
|
+
return {
|
|
929
|
+
x: within(numProp(block.props.x), -20000, 20000),
|
|
930
|
+
y: within(numProp(block.props.y), -20000, 20000),
|
|
931
|
+
width: within(numProp(block.props.width) ?? dw, 40, 6000),
|
|
932
|
+
height: within(numProp(block.props.height) ?? dh, 40, 6000)
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function hasCaption(block) {
|
|
937
|
+
return (block.children || []).some((c) => c.type === 'tag' && text(c.props.caption));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function estimateNoteHeight(block, width) {
|
|
941
|
+
const body = childText(block) || text(block.props.text) || text(block.props.body);
|
|
942
|
+
const lines = body.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / Math.max(12, width / 6.6))), 0);
|
|
943
|
+
return (text(block.props.title) ? 22 : 0) + lines * 19 + 8;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function renderAbsoluteBoard(blocks, ctx, index) {
|
|
947
|
+
const relations = [];
|
|
948
|
+
const known = new Map();
|
|
949
|
+
const sections = [];
|
|
950
|
+
const artboards = [];
|
|
951
|
+
const notes = [];
|
|
952
|
+
const tray = [];
|
|
953
|
+
let section = null;
|
|
954
|
+
let count = 0;
|
|
955
|
+
const nextId = () => `bc-${index}-${++count}`;
|
|
956
|
+
const collect = (list) => {
|
|
957
|
+
for (const raw of asArray(list)) {
|
|
958
|
+
const rel = relationOf(raw);
|
|
959
|
+
if (rel) relations.push(rel);
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
const register = (id, block, extra = []) => {
|
|
963
|
+
for (const key of [block.props && block.props.id, block.props && block.props.blockId, ...extra]) if (key) known.set(String(key), id);
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const walk = (list) => {
|
|
967
|
+
for (const block of list || []) {
|
|
968
|
+
try {
|
|
969
|
+
if (block.type === 'heading') tray.push(`<h3 class="btitle">${esc(block.text)}</h3>`);
|
|
970
|
+
else if (block.type === 'prose') tray.push(`<div class="bcard w-wide">${card('', `<div class="prose">${renderMarkdown(block.text)}</div>`)}</div>`);
|
|
971
|
+
else if (block.type === 'tag') walkTag(block);
|
|
972
|
+
else tray.push(`<div class="bcard">${renderBlocks([block], ctx)}</div>`);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
ctx.warnings.push(`render failed for ${block && block.name ? '<' + block.name + '>' : 'a block'}: ${error.message}`);
|
|
975
|
+
tray.push(`<div class="bcard">${errorCard('render failed', error.message)}</div>`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
const walkTag = (block) => {
|
|
981
|
+
collect(block.props.transitions);
|
|
982
|
+
if (block.name === 'DesignBoard') return walk(block.children);
|
|
983
|
+
if (block.name === 'Section') {
|
|
984
|
+
const previous = section;
|
|
985
|
+
section = { title: text(block.props.title || block.props.label), subtitle: text(block.props.subtitle), boxes: [] };
|
|
986
|
+
sections.push(section);
|
|
987
|
+
walk(block.children);
|
|
988
|
+
section = previous;
|
|
989
|
+
return undefined;
|
|
990
|
+
}
|
|
991
|
+
if (block.name === 'Connector') {
|
|
992
|
+
const rel = relationOf(block.props);
|
|
993
|
+
if (rel) relations.push(rel);
|
|
994
|
+
return undefined;
|
|
995
|
+
}
|
|
996
|
+
if (block.name === 'Annotation') {
|
|
997
|
+
notes.push(block);
|
|
998
|
+
return undefined;
|
|
999
|
+
}
|
|
1000
|
+
if (isAbsoluteArtboard(block)) {
|
|
1001
|
+
const box = artboardBox(block);
|
|
1002
|
+
const item = { block, box, id: nextId(), order: numProp(block.props.order), seq: artboards.length, caption: hasCaption(block) };
|
|
1003
|
+
artboards.push(item);
|
|
1004
|
+
if (section) section.boxes.push(box);
|
|
1005
|
+
register(item.id, block, [slugify(text(block.props.label || block.props.title))]);
|
|
1006
|
+
return undefined;
|
|
1007
|
+
}
|
|
1008
|
+
const id = nextId();
|
|
1009
|
+
register(id, block, [slugify(text(block.props.title || block.props.label))]);
|
|
1010
|
+
tray.push(`<div class="bcard ${widthClass(block)}" id="${id}">${renderTag(block, ctx)}</div>`);
|
|
1011
|
+
return undefined;
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
walk(blocks);
|
|
1015
|
+
artboards.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.seq - b.seq);
|
|
1016
|
+
|
|
1017
|
+
const byKey = new Map();
|
|
1018
|
+
for (const item of artboards) {
|
|
1019
|
+
for (const key of [item.block.props.id, item.block.props.blockId, slugify(text(item.block.props.label || item.block.props.title))]) {
|
|
1020
|
+
if (key && !byKey.has(String(key))) byKey.set(String(key), item);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const rects = artboards.map((a) => ({ x: a.box.x, y: a.box.y - 28, w: a.box.width, h: a.box.height + 28 + (a.caption ? 30 : 0) }));
|
|
1025
|
+
|
|
1026
|
+
const sectionLabels = [];
|
|
1027
|
+
let number = 0;
|
|
1028
|
+
for (const s of sections.filter((sec) => sec.title && sec.boxes.length)) {
|
|
1029
|
+
const x = Math.min(...s.boxes.map((b) => b.x));
|
|
1030
|
+
const y = Math.min(...s.boxes.map((b) => b.y));
|
|
1031
|
+
const right = Math.max(...s.boxes.map((b) => b.x + b.width));
|
|
1032
|
+
const width = within(right - x, 300, 640);
|
|
1033
|
+
const height = 22 + (s.subtitle ? Math.ceil(s.subtitle.length / (width / 6.4)) * 17 + 4 : 0);
|
|
1034
|
+
const top = y - 46;
|
|
1035
|
+
rects.push({ x, y: top - height, w: width, h: height });
|
|
1036
|
+
sectionLabels.push({ html: (n) => `<div class="sec-label pos" style="left:${n.x(x)}px;top:${n.y(top)}px;width:${width}px">` +
|
|
1037
|
+
`<div class="sec-title">${esc(`${++number} · ${s.title}`)}</div>` +
|
|
1038
|
+
(s.subtitle ? `<div class="sec-sub">${esc(s.subtitle)}</div>` : '') + '</div>' });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const stacks = new Map();
|
|
1042
|
+
const placedNotes = [];
|
|
1043
|
+
for (const note of notes) {
|
|
1044
|
+
const target = byKey.get(text(note.props.targetId));
|
|
1045
|
+
const nx = numProp(note.props.x);
|
|
1046
|
+
const ny = numProp(note.props.y);
|
|
1047
|
+
let place = null;
|
|
1048
|
+
if (target) {
|
|
1049
|
+
const placement = ['top', 'right', 'bottom', 'left'].includes(text(note.props.placement)) ? text(note.props.placement) : 'bottom';
|
|
1050
|
+
const t = target.box;
|
|
1051
|
+
const width = within(numProp(note.props.width) ?? (placement === 'top' || placement === 'bottom' ? Math.min(t.width, 320) : 240), 120, 640);
|
|
1052
|
+
const height = estimateNoteHeight(note, width);
|
|
1053
|
+
const key = `${target.id}:${placement}`;
|
|
1054
|
+
const used = stacks.get(key) || 0;
|
|
1055
|
+
stacks.set(key, used + height + 14);
|
|
1056
|
+
if (placement === 'bottom') place = { x: t.x, y: t.y + t.height + 16 + (target.caption ? 30 : 0) + used, width, height };
|
|
1057
|
+
else if (placement === 'top') place = { x: t.x, y: t.y - 40 - used - height, width, height };
|
|
1058
|
+
else if (placement === 'right') place = { x: t.x + t.width + 32, y: t.y + used, width, height };
|
|
1059
|
+
else place = { x: t.x - 32 - width, y: t.y + used, width, height };
|
|
1060
|
+
} else if (nx !== null && ny !== null) {
|
|
1061
|
+
const width = within(numProp(note.props.width) ?? 260, 120, 640);
|
|
1062
|
+
place = { x: within(nx, -20000, 20000), y: within(ny, -20000, 20000), width, height: estimateNoteHeight(note, width) };
|
|
1063
|
+
}
|
|
1064
|
+
if (place) {
|
|
1065
|
+
rects.push({ x: place.x, y: place.y, w: place.width, h: place.height });
|
|
1066
|
+
placedNotes.push({ note, place, id: nextId() });
|
|
1067
|
+
} else {
|
|
1068
|
+
tray.push(`<div class="bcard bnote" id="${nextId()}" style="width:260px">${annotationHtml(note)}</div>`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const minX = rects.length ? Math.min(...rects.map((r) => r.x)) : 0;
|
|
1073
|
+
const minY = rects.length ? Math.min(...rects.map((r) => r.y)) : 0;
|
|
1074
|
+
const maxX = rects.length ? Math.max(...rects.map((r) => r.x + r.w)) : 0;
|
|
1075
|
+
const maxY = rects.length ? Math.max(...rects.map((r) => r.y + r.h)) : 0;
|
|
1076
|
+
const off = { x: (v) => Math.round(v + ABS_MARGIN - minX), y: (v) => Math.round(v + ABS_MARGIN - minY) };
|
|
1077
|
+
const width = Math.round(maxX - minX + ABS_MARGIN * 2);
|
|
1078
|
+
const height = Math.round(maxY - minY + ABS_MARGIN * 2);
|
|
1079
|
+
|
|
1080
|
+
const abHtml = artboards.map((a) => {
|
|
1081
|
+
const label = text(a.block.props.label || a.block.props.title || a.block.props.id, 'artboard');
|
|
1082
|
+
const inner = withArtboard(a.block, ctx, true, () => renderPayload(a.block.children, ctx));
|
|
1083
|
+
return `<div class="bcard ab pos" id="${a.id}" style="left:${off.x(a.box.x)}px;top:${off.y(a.box.y)}px;width:${a.box.width}px;height:${a.box.height}px">` +
|
|
1084
|
+
`<div class="ab-label">${a.order !== null ? `<span class="ab-n">${esc(String(a.order))}</span>` : ''}${esc(label)}</div>` +
|
|
1085
|
+
`<div class="ab-frame">${inner}</div></div>`;
|
|
1086
|
+
}).join('');
|
|
1087
|
+
|
|
1088
|
+
const noteHtml = placedNotes.map(({ note, place, id }) => `<div class="bcard bnote pos" id="${id}" style="left:${off.x(place.x)}px;top:${off.y(place.y)}px;width:${place.width}px">${annotationHtml(note)}</div>`).join('');
|
|
1089
|
+
|
|
1090
|
+
const trayHtml = tray.length ? `<div class="abs-tray" style="padding-top:${height - ABS_MARGIN + 40}px">${tray.join('')}</div>` : '';
|
|
1091
|
+
const edges = resolveEdges(relations, known, ctx);
|
|
1092
|
+
const inner = edgeSvg(edges, index) + sectionLabels.map((s) => s.html(off)).join('') + abHtml + noteHtml + trayHtml;
|
|
1093
|
+
return boardShell(index, inner, { abs: true, canvasStyle: `width:${width}px;min-height:${height}px` });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function renderBoard(blocks, ctx) {
|
|
1097
|
+
const index = ctx.boards = (ctx.boards || 0) + 1;
|
|
1098
|
+
if (hasAbsolute(blocks)) return renderAbsoluteBoard(blocks, ctx, index);
|
|
1099
|
+
const rows = [];
|
|
1100
|
+
const relations = [];
|
|
1101
|
+
const known = new Map();
|
|
1102
|
+
let cardCount = 0;
|
|
1103
|
+
let row = null;
|
|
1104
|
+
|
|
1105
|
+
const startRow = (title) => { row = { title, cards: [] }; rows.push(row); };
|
|
1106
|
+
const addCard = (html, cls, keys, block) => {
|
|
1107
|
+
if (!row) startRow('');
|
|
1108
|
+
cardCount += 1;
|
|
1109
|
+
const id = `bc-${index}-${cardCount}`;
|
|
1110
|
+
for (const key of keys) if (key) known.set(String(key), id);
|
|
1111
|
+
row.cards.push(`<div class="bcard${cls ? ' ' + cls : ''}" id="${id}"${block ? srcAttr(block, ctx, false) : ''}>${block ? withSrc(html, block, ctx) : html}</div>`);
|
|
1112
|
+
};
|
|
1113
|
+
const collect = (list) => {
|
|
1114
|
+
for (const raw of asArray(list)) {
|
|
1115
|
+
const rel = relationOf(raw);
|
|
1116
|
+
if (rel) relations.push(rel);
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
const walk = (list) => {
|
|
1121
|
+
for (const block of list || []) {
|
|
1122
|
+
try {
|
|
1123
|
+
if (block.type === 'heading') startRow(block.text);
|
|
1124
|
+
else if (block.type === 'prose') addCard(card('', `<div class="prose">${renderMarkdown(block.text)}</div>`), 'w-wide', [], block);
|
|
1125
|
+
else if (block.type === 'tag') walkTag(block);
|
|
1126
|
+
else addCard(renderBlocks([block], ctx), '', []);
|
|
1127
|
+
} catch (error) {
|
|
1128
|
+
ctx.warnings.push(`render failed for ${block && block.name ? '<' + block.name + '>' : 'a block'}: ${error.message}`);
|
|
1129
|
+
addCard(errorCard('render failed', error.message), '', []);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const walkTag = (block) => {
|
|
1135
|
+
collect(block.props.transitions);
|
|
1136
|
+
if (block.name === 'DesignBoard') return walk(block.children);
|
|
1137
|
+
if (block.name === 'Section') {
|
|
1138
|
+
startRow(text(block.props.title || block.props.label));
|
|
1139
|
+
return walk(block.children);
|
|
1140
|
+
}
|
|
1141
|
+
if (block.name === 'Connector') {
|
|
1142
|
+
const rel = relationOf(block.props);
|
|
1143
|
+
if (rel) relations.push(rel);
|
|
1144
|
+
return undefined;
|
|
1145
|
+
}
|
|
1146
|
+
if (isNodeDiagram(block)) {
|
|
1147
|
+
const data = block.props.data;
|
|
1148
|
+
asArray(data.nodes).forEach((node, i) => {
|
|
1149
|
+
const obj = node && typeof node === 'object' ? node : { label: asText(node) };
|
|
1150
|
+
const id = text(obj.id, String(i));
|
|
1151
|
+
const label = text(obj.label || obj.text || obj.id, 'node');
|
|
1152
|
+
addCard(card(label, obj.description ? `<div class="note">${esc(asText(obj.description))}</div>` : ''), 'bnode', [id]);
|
|
1153
|
+
});
|
|
1154
|
+
collect(data.edges);
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
const keys = [block.props.id, block.props.blockId, slugify(text(block.props.title || block.props.label))];
|
|
1158
|
+
addCard(renderTag(block, ctx), widthClass(block), keys, block);
|
|
1159
|
+
return undefined;
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
walk(blocks);
|
|
1163
|
+
|
|
1164
|
+
const edges = resolveEdges(relations, known, ctx);
|
|
1165
|
+
|
|
1166
|
+
let number = 0;
|
|
1167
|
+
const rowHtml = rows.filter((r) => r.cards.length).map((r) => {
|
|
1168
|
+
const title = r.title ? `<h3 class="btitle">${esc(`${++number} · ${r.title}`)}</h3>` : '';
|
|
1169
|
+
return `<div class="brow">${title}<div class="bcards">${r.cards.join('')}</div></div>`;
|
|
1170
|
+
}).join('');
|
|
1171
|
+
|
|
1172
|
+
return boardShell(index, edgeSvg(edges, index) + rowHtml);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Document blocks with board islands: a heading tagged {#board} hands its
|
|
1176
|
+
// section (up to the next heading of the same or a higher level) to the board,
|
|
1177
|
+
// and a run of blocks carrying a `board` prop becomes one board.
|
|
1178
|
+
function renderDocument(blocks, ctx) {
|
|
1179
|
+
const out = [];
|
|
1180
|
+
let run = [];
|
|
1181
|
+
const flush = () => {
|
|
1182
|
+
if (run.length) ctx.docBlocks = (ctx.docBlocks || 0) + run.filter((b) => b.type !== 'heading').length;
|
|
1183
|
+
if (run.length) out.push(renderBlocks(run, ctx));
|
|
1184
|
+
run = [];
|
|
1185
|
+
};
|
|
1186
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
1187
|
+
const block = blocks[i];
|
|
1188
|
+
if (block.type === 'heading' && BOARD_HEADING_RE.test(block.text)) {
|
|
1189
|
+
flush();
|
|
1190
|
+
out.push(renderBlocks([{ ...block, text: block.text.replace(BOARD_HEADING_RE, '') }], ctx));
|
|
1191
|
+
const group = [];
|
|
1192
|
+
while (i + 1 < blocks.length && !(blocks[i + 1].type === 'heading' && blocks[i + 1].level <= block.level)) group.push(blocks[++i]);
|
|
1193
|
+
out.push(renderBoard(group, ctx));
|
|
1194
|
+
} else if (block.type === 'tag' && block.props.board) {
|
|
1195
|
+
flush();
|
|
1196
|
+
const group = [block];
|
|
1197
|
+
while (i + 1 < blocks.length && blocks[i + 1].type === 'tag' && blocks[i + 1].props.board) group.push(blocks[++i]);
|
|
1198
|
+
out.push(renderBoard(group, ctx));
|
|
1199
|
+
} else run.push(block);
|
|
1200
|
+
}
|
|
1201
|
+
flush();
|
|
1202
|
+
return out.join('\n');
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function themeCss() {
|
|
1206
|
+
return fs.readFileSync(path.join(__dirname, 'theme.css'), 'utf8');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function boardScript() {
|
|
1210
|
+
return `<script>\n${fs.readFileSync(path.join(__dirname, 'board-client.js'), 'utf8')}</script>`;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const NAV_TOGGLE_SCRIPT = `<script>
|
|
1214
|
+
(function () {
|
|
1215
|
+
var K = 'bdb-plan-builder:nav-hidden', b = document.querySelector('.nav-toggle');
|
|
1216
|
+
function set(h) {
|
|
1217
|
+
document.body.classList.toggle('nav-collapsed', h);
|
|
1218
|
+
b.setAttribute('aria-pressed', String(h));
|
|
1219
|
+
b.textContent = h ? 'Show sections' : 'Hide sections';
|
|
1220
|
+
}
|
|
1221
|
+
var h = false;
|
|
1222
|
+
try { h = localStorage.getItem(K) === '1'; } catch (e) {}
|
|
1223
|
+
set(h);
|
|
1224
|
+
b.addEventListener('click', function () {
|
|
1225
|
+
h = !h; set(h);
|
|
1226
|
+
try { localStorage.setItem(K, h ? '1' : '0'); } catch (e) {}
|
|
1227
|
+
});
|
|
1228
|
+
})();
|
|
1229
|
+
</script>`;
|
|
1230
|
+
|
|
1231
|
+
const RECAP_CHIPS = [
|
|
1232
|
+
['pr', 'PR', (v) => v],
|
|
1233
|
+
['branch', 'branch', (v, fm) => (fm.base ? `${v} \u2192 ${fm.base}` : v)],
|
|
1234
|
+
['commit', 'commit', (v) => v],
|
|
1235
|
+
['files', 'files', (v) => `${v} file${Number(v) === 1 ? '' : 's'}`],
|
|
1236
|
+
['additions', '', (v) => `+${v}`, 'add'],
|
|
1237
|
+
['deletions', '', (v) => `\u2212${v}`, 'del'],
|
|
1238
|
+
['author', 'by', (v) => v],
|
|
1239
|
+
['date', '', (v) => v]
|
|
1240
|
+
];
|
|
1241
|
+
|
|
1242
|
+
function recapHeader(title, frontmatter) {
|
|
1243
|
+
const chips = RECAP_CHIPS
|
|
1244
|
+
.filter(([key]) => frontmatter[key] !== undefined && frontmatter[key] !== '' && frontmatter[key] !== true)
|
|
1245
|
+
.map(([key, label, format, tone]) => `<li class="${tone || ''}">${label ? `<span class="k">${esc(label)}</span>` : ''}${esc(format(String(frontmatter[key]), frontmatter))}</li>`);
|
|
1246
|
+
const subtitle = text(frontmatter.subtitle || frontmatter.summary);
|
|
1247
|
+
return '<header class="recap-head"><div class="recap-eyebrow">VISUAL RECAP</div>' +
|
|
1248
|
+
`<h1 class="doc-title recap-title">${esc(title)}</h1>` +
|
|
1249
|
+
(subtitle ? `<p class="recap-sub">${esc(subtitle)}</p>` : '') +
|
|
1250
|
+
(chips.length ? `<ul class="recap-chips">${chips.join('')}</ul>` : '') + '</header>';
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function page({ title, status, meta, headings, body, warnings, hasMermaid, hasBoard, boardOnly, recap }) {
|
|
1254
|
+
const warningBlock = warnings.length
|
|
1255
|
+
? `<div class="warnings"><span class="label">${warnings.length} rendering warning${warnings.length === 1 ? '' : 's'}</span><ul>${warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul></div>`
|
|
1256
|
+
: '';
|
|
1257
|
+
return `<!DOCTYPE html>
|
|
1258
|
+
<html lang="en">
|
|
1259
|
+
<head>
|
|
1260
|
+
<meta charset="utf-8">
|
|
1261
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1262
|
+
<title>${esc(title)}</title>
|
|
1263
|
+
<style>
|
|
1264
|
+
${themeCss()}
|
|
1265
|
+
</style>
|
|
1266
|
+
</head>
|
|
1267
|
+
<body${boardOnly ? ' class="board-only"' : ''}>
|
|
1268
|
+
<header class="topbar">
|
|
1269
|
+
<span class="mark"></span>
|
|
1270
|
+
<span class="title">${esc(title)}</span>
|
|
1271
|
+
${status ? `<span class="status">${esc(status)}</span>` : ''}
|
|
1272
|
+
${meta ? `<span class="meta">${esc(meta)}</span>` : ''}
|
|
1273
|
+
<button class="nav-toggle" type="button" aria-pressed="false" title="Show or hide the section navigation">Hide sections</button>
|
|
1274
|
+
</header>
|
|
1275
|
+
<div class="shell">
|
|
1276
|
+
<nav class="sidenav" aria-label="Sections">${navHtml(headings)}</nav>
|
|
1277
|
+
<main>
|
|
1278
|
+
${warningBlock}
|
|
1279
|
+
${recap ? recapHeader(title, recap) : `<h1 class="doc-title">${esc(title)}</h1>`}
|
|
1280
|
+
<div class="flow">${body}</div>
|
|
1281
|
+
<footer class="footer">Built by BDB Plan Builder from the plan folder beside this file. Edit the MDX and re-run the build.<span class="vp-mark">VISUAL PLAN</span></footer>
|
|
1282
|
+
</main>
|
|
1283
|
+
</div>
|
|
1284
|
+
${hasMermaid ? mermaidLoaderScript(mermaidUrl()) : ''}
|
|
1285
|
+
${hasBoard ? boardScript() : ''}
|
|
1286
|
+
${NAV_TOGGLE_SCRIPT}
|
|
1287
|
+
</body>
|
|
1288
|
+
</html>`;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
module.exports = { checklistItems, prototypeHint, renderBlocks, renderBoard, renderDocument, renderTag, page, mermaidUrl, escapeHtml };
|