archi-cli 0.1.0__py3-none-any.whl
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.
- archi_cli-0.1.0.dist-info/METADATA +214 -0
- archi_cli-0.1.0.dist-info/RECORD +16 -0
- archi_cli-0.1.0.dist-info/WHEEL +4 -0
- archi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- archi_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- archi_tool/__init__.py +1 -0
- archi_tool/cli.py +528 -0
- archi_tool/discovery.py +137 -0
- archi_tool/engine.py +266 -0
- archi_tool/model.py +280 -0
- archi_tool/normalize.py +91 -0
- archi_tool/render.py +207 -0
- archi_tool/render_html.py +645 -0
- archi_tool/render_slides.py +650 -0
- archi_tool/validate.py +150 -0
- archi_tool/views.py +215 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""Render slide decks from a .archimate model to standalone HTML.
|
|
2
|
+
|
|
3
|
+
A deck is a TOML file (decks/*.toml) telling one linear story: title,
|
|
4
|
+
sections, embedded views, prose and bullets, closing. View slides embed
|
|
5
|
+
the real diagram via render_html.diagram_canvas, so the architecture
|
|
6
|
+
content always comes from the model; the deck file only carries the
|
|
7
|
+
narrative. Every deck renders to a single self-contained HTML file:
|
|
8
|
+
inline CSS and vanilla JS, with the pinned NLDD CSS from the CDN as the
|
|
9
|
+
only external reference (the deck uses NLDD design tokens, not web
|
|
10
|
+
components, so the component bundle is not loaded). Output is
|
|
11
|
+
deterministic: no timestamps (the optional title date comes verbatim
|
|
12
|
+
from the deck file), stable ordering, marker comment first line.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
import tomllib
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .model import ModelError
|
|
21
|
+
from .render import MARKER, is_descendant, slugify, write_if_changed
|
|
22
|
+
from .render_html import (DIAGRAM_CSS, FAVICON, NLDD_CSS, absolute_boxes,
|
|
23
|
+
diagram_canvas, layer_css, legend_html)
|
|
24
|
+
|
|
25
|
+
FOCUS_MARGIN = 24 # canvas px around the focused subtree
|
|
26
|
+
FOCUS_MAX_SCALE = 2.2 # zoom cap when focusing; 1.4 for the full view
|
|
27
|
+
|
|
28
|
+
RIJKSBLAUW = "#154273"
|
|
29
|
+
GOUD = "#ffb612"
|
|
30
|
+
|
|
31
|
+
DECK_KEYS = {"title", "slug", "speaker", "affiliation", "date", "lead",
|
|
32
|
+
"slides"}
|
|
33
|
+
SLIDE_KEYS = {
|
|
34
|
+
"title": {"type", "title", "lead", "notes"},
|
|
35
|
+
"section": {"type", "title", "lead", "notes"},
|
|
36
|
+
"view": {"type", "view", "focus", "title", "intro", "notes"},
|
|
37
|
+
"text": {"type", "title", "lead", "body", "notes"},
|
|
38
|
+
"bullets": {"type", "title", "lead", "bullets", "gov", "notes"},
|
|
39
|
+
"closing": {"type", "title", "lead", "link", "notes"},
|
|
40
|
+
}
|
|
41
|
+
REQUIRED_SLIDE_KEYS = {
|
|
42
|
+
"section": ("title",),
|
|
43
|
+
"view": ("view",),
|
|
44
|
+
"text": ("body",),
|
|
45
|
+
"bullets": ("title", "bullets"),
|
|
46
|
+
"closing": ("title",),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
DECK_CSS = """\
|
|
50
|
+
html, body { margin: 0; height: 100%; overflow: hidden; }
|
|
51
|
+
body { background: #154273;
|
|
52
|
+
font-family: var(--primitives-font-family-sans-serif,
|
|
53
|
+
system-ui, sans-serif); color: #ffffff; }
|
|
54
|
+
.slide { position: fixed; inset: 0; box-sizing: border-box;
|
|
55
|
+
display: flex; flex-direction: column; justify-content: center;
|
|
56
|
+
padding: 3rem clamp(2.5rem, 9vw, 11rem); overflow: hidden;
|
|
57
|
+
opacity: 0; visibility: hidden; transition: opacity 0.35s ease;
|
|
58
|
+
background: #154273; color: #ffffff; }
|
|
59
|
+
.slide.active { opacity: 1; visibility: visible; }
|
|
60
|
+
.slide h1, .slide h2 {
|
|
61
|
+
font-family: var(--primitives-font-family-serif, Georgia, serif);
|
|
62
|
+
font-weight: 700; line-height: 1.08; margin: 0; }
|
|
63
|
+
.accent { width: 72px; height: 6px; background: #ffb612;
|
|
64
|
+
border-radius: 3px; margin-bottom: 1.6rem; flex: none; }
|
|
65
|
+
.lead { font-size: clamp(1.2rem, 2.2vw, 1.9rem); line-height: 1.35;
|
|
66
|
+
max-width: 60ch; margin: 1.2rem 0 0; opacity: 0.92; }
|
|
67
|
+
.slide-title h1 { font-size: clamp(2.6rem, 6.5vw, 6rem); }
|
|
68
|
+
.byline { margin-top: 3rem; display: flex; flex-wrap: wrap;
|
|
69
|
+
gap: 0.4rem 1.6rem; font-size: 1.05rem; opacity: 0.85; }
|
|
70
|
+
.byline .speaker { font-weight: 600; }
|
|
71
|
+
.slide-section h2 { font-size: clamp(2.4rem, 5.5vw, 5rem); }
|
|
72
|
+
.slide-bullets h2, .slide-text h2 {
|
|
73
|
+
font-size: clamp(2rem, 4vw, 3.4rem); }
|
|
74
|
+
ul.bullets { margin: 2rem 0 0; padding: 0; list-style: none;
|
|
75
|
+
display: grid; gap: 1.1rem; max-width: 62ch;
|
|
76
|
+
font-size: clamp(1.15rem, 2vw, 1.7rem); line-height: 1.4; }
|
|
77
|
+
ul.bullets li { padding-left: 1.6rem; position: relative; }
|
|
78
|
+
ul.bullets li::before { content: ""; position: absolute; left: 0;
|
|
79
|
+
top: 0.55em; width: 0.55rem; height: 0.55rem; border-radius: 50%;
|
|
80
|
+
background: #ffb612; }
|
|
81
|
+
.gov-pill { display: inline-flex; align-self: flex-start;
|
|
82
|
+
margin-top: 2.2rem; padding: 0.35rem 0.9rem; border-radius: 999px;
|
|
83
|
+
border: 1px solid #ffb612; color: #ffb612; font-size: 0.85rem;
|
|
84
|
+
font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
|
|
85
|
+
.gov-note { margin: 0.7rem 0 0; font-size: 1.05rem; opacity: 0.9;
|
|
86
|
+
max-width: 60ch; }
|
|
87
|
+
.prose { margin: 1.6rem 0 0; display: grid; gap: 0.9rem;
|
|
88
|
+
font-size: clamp(1.05rem, 1.8vw, 1.45rem); line-height: 1.5;
|
|
89
|
+
max-width: 65ch; }
|
|
90
|
+
.prose p { margin: 0; }
|
|
91
|
+
/* extra bottom padding keeps the footer clear of the fixed chrome */
|
|
92
|
+
.slide-view { padding: 2.2rem clamp(2rem, 5vw, 4.5rem) 3.2rem; }
|
|
93
|
+
.view-head { flex: none; display: flex; align-items: baseline;
|
|
94
|
+
gap: 0.3rem 1.6rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
95
|
+
.slide-view h2 { font-size: clamp(1.5rem, 2.6vw, 2.4rem); }
|
|
96
|
+
.slide-view .intro { margin: 0; font-size: 0.95rem; line-height: 1.4;
|
|
97
|
+
color: rgb(255 255 255 / 0.85); max-width: 100ch; flex: 1 1 30ch; }
|
|
98
|
+
/* the diagram sits on a light card inside the Rijksblauw slide, so a
|
|
99
|
+
view reads as part of the deck instead of a separate page */
|
|
100
|
+
.view-card { flex: 1; min-height: 0; display: flex;
|
|
101
|
+
flex-direction: column; background: #ffffff; border-radius: 14px;
|
|
102
|
+
padding: 10px 10px 0; box-shadow: 0 12px 40px rgb(0 0 0 / 0.35); }
|
|
103
|
+
/* the fit wrapper positions and scales the fixed-size diagram; the
|
|
104
|
+
transform itself is computed client-side (fitDiagrams) */
|
|
105
|
+
.view-fit { flex: 1; position: relative; overflow: hidden;
|
|
106
|
+
min-height: 0; border-radius: 8px; }
|
|
107
|
+
.view-fit .diagram { position: absolute; left: 0; top: 0;
|
|
108
|
+
transition: transform 1.1s cubic-bezier(0.22, 1, 0.36, 1); }
|
|
109
|
+
/* focus slides dim everything outside the focused subtree */
|
|
110
|
+
.box.dim { opacity: 0.22; }
|
|
111
|
+
.edge.dim { opacity: 0.12; }
|
|
112
|
+
.view-foot { flex: none; display: flex; justify-content: flex-end;
|
|
113
|
+
padding-top: 0.5rem; }
|
|
114
|
+
.view-open { font-size: 0.8rem; color: rgb(255 255 255 / 0.7);
|
|
115
|
+
white-space: nowrap; }
|
|
116
|
+
.slide-closing { align-items: center; text-align: center; }
|
|
117
|
+
.slide-closing .accent { margin-left: auto; margin-right: auto; }
|
|
118
|
+
.slide-closing h2 { font-size: clamp(2.6rem, 6vw, 5.5rem); }
|
|
119
|
+
.slide-closing .lead { margin-left: auto; margin-right: auto; }
|
|
120
|
+
.closing-link { margin-top: 2.5rem; font-size: 1.15rem; }
|
|
121
|
+
.closing-link a { color: #ffb612; }
|
|
122
|
+
.slide .notes { display: none; }
|
|
123
|
+
.hint { position: fixed; left: 1.1rem; bottom: 1rem; z-index: 10;
|
|
124
|
+
font-size: 0.75rem; color: #ffffff; opacity: 0.75;
|
|
125
|
+
background: rgb(21 66 115 / 0.85); padding: 0.25rem 0.7rem;
|
|
126
|
+
border-radius: 999px; }
|
|
127
|
+
.chrome { position: fixed; right: 1.1rem; bottom: 1rem; z-index: 10;
|
|
128
|
+
display: flex; align-items: center; gap: 0.6rem; }
|
|
129
|
+
.counter {
|
|
130
|
+
font-family: var(--primitives-font-family-monospace, monospace);
|
|
131
|
+
font-size: 0.8rem; letter-spacing: 0.06em; color: #ffffff;
|
|
132
|
+
background: rgb(21 66 115 / 0.85); padding: 0.25rem 0.7rem;
|
|
133
|
+
border-radius: 999px; }
|
|
134
|
+
.counter.autoplay::before { content: "\\25B6 "; color: #ffb612; }
|
|
135
|
+
.nav-btn { width: 2rem; height: 2rem; border-radius: 50%;
|
|
136
|
+
border: 1px solid rgb(255 255 255 / 0.4); cursor: pointer;
|
|
137
|
+
background: rgb(21 66 115 / 0.85); color: #ffffff;
|
|
138
|
+
font-size: 1rem; line-height: 1; }
|
|
139
|
+
.nav-btn:hover, .nav-btn:focus-visible { border-color: #ffb612; }
|
|
140
|
+
.notes-panel { position: fixed; right: 1.1rem; bottom: 3.6rem;
|
|
141
|
+
z-index: 12; width: min(420px, 80vw); max-height: 40vh;
|
|
142
|
+
overflow: auto; background: #ffffff; color: #202030;
|
|
143
|
+
border-radius: 12px; padding: 1rem 1.2rem;
|
|
144
|
+
box-shadow: 0 8px 30px rgb(0 0 0 / 0.35); font-size: 0.95rem; }
|
|
145
|
+
.notes-panel h3 { margin: 0 0 0.5rem; font-size: 0.8rem;
|
|
146
|
+
text-transform: uppercase; letter-spacing: 0.05em; color: #55555e; }
|
|
147
|
+
.progress { position: fixed; left: 0; right: 0; bottom: 0; height: 4px;
|
|
148
|
+
z-index: 11; background: rgb(255 255 255 / 0.15); }
|
|
149
|
+
.progress-fill { height: 100%; width: 0; background: #ffb612;
|
|
150
|
+
transition: width 0.3s ease; }
|
|
151
|
+
@page { size: A4 landscape; margin: 0; }
|
|
152
|
+
@media print {
|
|
153
|
+
html, body { overflow: visible; background: #ffffff; }
|
|
154
|
+
.slide { position: relative; inset: auto; height: 100vh;
|
|
155
|
+
opacity: 1; visibility: visible; transition: none;
|
|
156
|
+
break-after: page; page-break-after: always; }
|
|
157
|
+
.hint, .chrome, .notes-panel, .progress { display: none; }
|
|
158
|
+
}
|
|
159
|
+
@media (prefers-reduced-motion: reduce) {
|
|
160
|
+
.slide, .progress-fill, .view-fit .diagram { transition: none; }
|
|
161
|
+
}
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
DECK_JS = """\
|
|
165
|
+
(function () {
|
|
166
|
+
'use strict';
|
|
167
|
+
var slides = Array.prototype.slice.call(
|
|
168
|
+
document.querySelectorAll('.slide'));
|
|
169
|
+
var total = slides.length;
|
|
170
|
+
var counter = document.querySelector('.counter');
|
|
171
|
+
var fill = document.querySelector('.progress-fill');
|
|
172
|
+
var notesPanel = document.querySelector('.notes-panel');
|
|
173
|
+
var notesBody = notesPanel.querySelector('.notes-body');
|
|
174
|
+
var current = 0;
|
|
175
|
+
var autoplayTimer = null;
|
|
176
|
+
|
|
177
|
+
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
|
178
|
+
|
|
179
|
+
var reduceMotion = window.matchMedia(
|
|
180
|
+
'(prefers-reduced-motion: reduce)').matches;
|
|
181
|
+
|
|
182
|
+
function fitTransform(fit, x, y, w, h, cap) {
|
|
183
|
+
var fw = fit.clientWidth;
|
|
184
|
+
var fh = fit.clientHeight;
|
|
185
|
+
if (!fw || !fh || !w || !h) { return null; }
|
|
186
|
+
var s = Math.min(fw / w, fh / h, cap);
|
|
187
|
+
return 'translate(' + ((fw - w * s) / 2 - x * s).toFixed(1) + 'px, ' +
|
|
188
|
+
((fh - h * s) / 2 - y * s).toFixed(1) + 'px) scale(' +
|
|
189
|
+
s.toFixed(4) + ')';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function applyFit(slide, zoomIn) {
|
|
193
|
+
var fit = slide.querySelector('.view-fit');
|
|
194
|
+
if (!fit) { return; }
|
|
195
|
+
var diagram = fit.querySelector('.diagram');
|
|
196
|
+
var full = fitTransform(fit, 0, 0,
|
|
197
|
+
parseFloat(fit.getAttribute('data-w')),
|
|
198
|
+
parseFloat(fit.getAttribute('data-h')), 1.4);
|
|
199
|
+
if (!diagram || !full) { return; }
|
|
200
|
+
var focus = fit.hasAttribute('data-fx') ? fitTransform(fit,
|
|
201
|
+
parseFloat(fit.getAttribute('data-fx')),
|
|
202
|
+
parseFloat(fit.getAttribute('data-fy')),
|
|
203
|
+
parseFloat(fit.getAttribute('data-fw')),
|
|
204
|
+
parseFloat(fit.getAttribute('data-fh')), 2.2) : null;
|
|
205
|
+
diagram.style.transformOrigin = '0 0';
|
|
206
|
+
if (zoomIn && focus && !reduceMotion) {
|
|
207
|
+
// start at the full view, then glide into the focused area
|
|
208
|
+
diagram.style.transition = 'none';
|
|
209
|
+
diagram.style.transform = full;
|
|
210
|
+
void diagram.offsetWidth;
|
|
211
|
+
diagram.style.transition = '';
|
|
212
|
+
diagram.style.transform = focus;
|
|
213
|
+
} else {
|
|
214
|
+
diagram.style.transition = 'none';
|
|
215
|
+
diagram.style.transform = focus || full;
|
|
216
|
+
void diagram.offsetWidth;
|
|
217
|
+
diagram.style.transition = '';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function fitDiagrams() {
|
|
222
|
+
slides.forEach(function (slide) { applyFit(slide, false); });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function updateNotes() {
|
|
226
|
+
var notes = slides[current].querySelector('.notes');
|
|
227
|
+
notesBody.innerHTML = notes ? notes.innerHTML
|
|
228
|
+
: '<em>Geen notities bij deze slide.</em>';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function show(i) {
|
|
232
|
+
current = Math.max(0, Math.min(total - 1, i));
|
|
233
|
+
slides.forEach(function (slide, j) {
|
|
234
|
+
slide.classList.toggle('active', j === current);
|
|
235
|
+
});
|
|
236
|
+
applyFit(slides[current], true);
|
|
237
|
+
counter.textContent = pad(current + 1) + '/' + pad(total);
|
|
238
|
+
fill.style.width = ((current + 1) / total * 100) + '%';
|
|
239
|
+
if (('#' + (current + 1)) !== location.hash) {
|
|
240
|
+
history.replaceState(null, '', '#' + (current + 1));
|
|
241
|
+
}
|
|
242
|
+
updateNotes();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function step(delta) {
|
|
246
|
+
var next = current + delta;
|
|
247
|
+
if (next >= total) { stopAutoplay(); return; }
|
|
248
|
+
show(next);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function startAutoplay() {
|
|
252
|
+
autoplayTimer = setInterval(function () { step(1); }, 6000);
|
|
253
|
+
counter.classList.add('autoplay');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function stopAutoplay() {
|
|
257
|
+
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
|
|
258
|
+
counter.classList.remove('autoplay');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function toggleFullscreen() {
|
|
262
|
+
if (document.fullscreenElement) { document.exitFullscreen(); }
|
|
263
|
+
else { document.documentElement.requestFullscreen(); }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function fromHash() {
|
|
267
|
+
var n = parseInt(location.hash.slice(1), 10);
|
|
268
|
+
return isNaN(n) ? 0 : n - 1;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
document.addEventListener('keydown', function (event) {
|
|
272
|
+
if (event.altKey || event.ctrlKey || event.metaKey) { return; }
|
|
273
|
+
switch (event.key) {
|
|
274
|
+
case 'ArrowRight': case 'PageDown': case ' ':
|
|
275
|
+
event.preventDefault(); step(1); break;
|
|
276
|
+
case 'ArrowLeft': case 'PageUp':
|
|
277
|
+
event.preventDefault(); step(-1); break;
|
|
278
|
+
case 'Home': event.preventDefault(); show(0); break;
|
|
279
|
+
case 'End': event.preventDefault(); show(total - 1); break;
|
|
280
|
+
case 'f': toggleFullscreen(); break;
|
|
281
|
+
case 'n': notesPanel.hidden = !notesPanel.hidden; break;
|
|
282
|
+
case 'a': autoplayTimer ? stopAutoplay() : startAutoplay(); break;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
Array.prototype.forEach.call(
|
|
287
|
+
document.querySelectorAll('.nav-btn'), function (button) {
|
|
288
|
+
button.addEventListener('click', function () {
|
|
289
|
+
step(parseInt(button.getAttribute('data-nav'), 10));
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
window.addEventListener('hashchange', function () { show(fromHash()); });
|
|
294
|
+
window.addEventListener('resize', fitDiagrams);
|
|
295
|
+
window.addEventListener('beforeprint', fitDiagrams);
|
|
296
|
+
|
|
297
|
+
fitDiagrams();
|
|
298
|
+
show(fromHash());
|
|
299
|
+
})();
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def load_deck(path: Path, model) -> dict:
|
|
304
|
+
"""Parse and validate a deck TOML; errors name the file (in Dutch)."""
|
|
305
|
+
path = Path(path)
|
|
306
|
+
try:
|
|
307
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
308
|
+
except tomllib.TOMLDecodeError as exc:
|
|
309
|
+
raise ModelError(
|
|
310
|
+
f"Deck '{path.as_posix()}' is geen geldige TOML: {exc}") from exc
|
|
311
|
+
return validate_deck(data, model, source=path.as_posix(),
|
|
312
|
+
default_slug=slugify(path.stem))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _check_str(value, source: str, where: str, key: str) -> str:
|
|
316
|
+
if not isinstance(value, str) or not value.strip():
|
|
317
|
+
raise ModelError(f"Deck '{source}'{where}: '{key}' moet een "
|
|
318
|
+
f"niet-lege tekst zijn")
|
|
319
|
+
return value
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _resolve_view(model, ref, source: str, n: int):
|
|
323
|
+
diagrams = model.diagrams()
|
|
324
|
+
for diagram in diagrams:
|
|
325
|
+
if diagram.get("id") == ref:
|
|
326
|
+
return diagram
|
|
327
|
+
matches = [d for d in diagrams if (d.get("name") or "") == ref]
|
|
328
|
+
if len(matches) == 1:
|
|
329
|
+
return matches[0]
|
|
330
|
+
if len(matches) > 1:
|
|
331
|
+
raise ModelError(f"Deck '{source}', slide {n}: viewnaam '{ref}' is "
|
|
332
|
+
f"niet uniek in het model; gebruik het view-id")
|
|
333
|
+
names = ", ".join(sorted(d.get("name") or d.get("id")
|
|
334
|
+
for d in diagrams)) or "(geen)"
|
|
335
|
+
raise ModelError(f"Deck '{source}', slide {n}: view '{ref}' bestaat "
|
|
336
|
+
f"niet in het model. Beschikbare views: {names}")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def validate_deck(deck: dict, model, source: str, default_slug: str) -> dict:
|
|
340
|
+
"""Normalize a raw deck dict; raise ModelError (Dutch) on any problem."""
|
|
341
|
+
unknown = sorted(set(deck) - DECK_KEYS)
|
|
342
|
+
if unknown:
|
|
343
|
+
raise ModelError(f"Deck '{source}': onbekende sleutel "
|
|
344
|
+
f"'{unknown[0]}'. Toegestaan: "
|
|
345
|
+
+ ", ".join(sorted(DECK_KEYS)))
|
|
346
|
+
if "title" not in deck:
|
|
347
|
+
raise ModelError(f"Deck '{source}': verplicht veld 'title' ontbreekt")
|
|
348
|
+
title = _check_str(deck["title"], source, "", "title")
|
|
349
|
+
for key in ("slug", "speaker", "affiliation", "date", "lead"):
|
|
350
|
+
if key in deck:
|
|
351
|
+
_check_str(deck[key], source, "", key)
|
|
352
|
+
|
|
353
|
+
slug = slugify(deck.get("slug") or default_slug)
|
|
354
|
+
|
|
355
|
+
raw_slides = deck.get("slides")
|
|
356
|
+
if not isinstance(raw_slides, list) or not raw_slides:
|
|
357
|
+
raise ModelError(f"Deck '{source}': 'slides' moet een niet-lege "
|
|
358
|
+
f"lijst van [[slides]]-tabellen zijn")
|
|
359
|
+
|
|
360
|
+
slides = []
|
|
361
|
+
for n, raw in enumerate(raw_slides, start=1):
|
|
362
|
+
where = f", slide {n}"
|
|
363
|
+
if not isinstance(raw, dict):
|
|
364
|
+
raise ModelError(f"Deck '{source}'{where}: elke slide moet een "
|
|
365
|
+
f"[[slides]]-tabel zijn")
|
|
366
|
+
kind = raw.get("type")
|
|
367
|
+
if kind not in SLIDE_KEYS:
|
|
368
|
+
raise ModelError(f"Deck '{source}'{where}: onbekend type "
|
|
369
|
+
f"'{kind}'. Toegestaan: "
|
|
370
|
+
+ ", ".join(sorted(SLIDE_KEYS)))
|
|
371
|
+
unknown = sorted(set(raw) - SLIDE_KEYS[kind])
|
|
372
|
+
if unknown:
|
|
373
|
+
raise ModelError(f"Deck '{source}'{where}: onbekende sleutel "
|
|
374
|
+
f"'{unknown[0]}'. Toegestaan voor type "
|
|
375
|
+
f"'{kind}': "
|
|
376
|
+
+ ", ".join(sorted(SLIDE_KEYS[kind])))
|
|
377
|
+
for key in REQUIRED_SLIDE_KEYS.get(kind, ()):
|
|
378
|
+
if key not in raw:
|
|
379
|
+
raise ModelError(f"Deck '{source}'{where}: verplicht veld "
|
|
380
|
+
f"'{key}' ontbreekt voor type '{kind}'")
|
|
381
|
+
slide = dict(raw)
|
|
382
|
+
for key in ("title", "lead", "intro", "notes", "gov", "body",
|
|
383
|
+
"focus"):
|
|
384
|
+
if key in slide:
|
|
385
|
+
_check_str(slide[key], source, where, key)
|
|
386
|
+
if kind == "view":
|
|
387
|
+
ref = _check_str(slide["view"], source, where, "view")
|
|
388
|
+
slide["diagram"] = _resolve_view(model, ref, source, n)
|
|
389
|
+
if kind == "bullets":
|
|
390
|
+
bullets = slide["bullets"]
|
|
391
|
+
if (not isinstance(bullets, list) or not bullets
|
|
392
|
+
or not all(isinstance(b, str) and b.strip()
|
|
393
|
+
for b in bullets)):
|
|
394
|
+
raise ModelError(f"Deck '{source}'{where}: 'bullets' moet "
|
|
395
|
+
f"een niet-lege lijst van teksten zijn")
|
|
396
|
+
if kind == "closing" and "link" in slide:
|
|
397
|
+
link = slide["link"]
|
|
398
|
+
if isinstance(link, str):
|
|
399
|
+
slide["link"] = {"href": link, "label": link}
|
|
400
|
+
elif isinstance(link, dict) and isinstance(link.get("href"), str):
|
|
401
|
+
slide["link"] = {"href": link["href"],
|
|
402
|
+
"label": link.get("label") or link["href"]}
|
|
403
|
+
else:
|
|
404
|
+
raise ModelError(f"Deck '{source}'{where}: 'link' moet een "
|
|
405
|
+
f"tekst of een tabel met 'href' (en "
|
|
406
|
+
f"optioneel 'label') zijn")
|
|
407
|
+
slides.append(slide)
|
|
408
|
+
|
|
409
|
+
return {"title": title, "slug": slug, "source": source,
|
|
410
|
+
"speaker": deck.get("speaker"),
|
|
411
|
+
"affiliation": deck.get("affiliation"),
|
|
412
|
+
"date": deck.get("date"),
|
|
413
|
+
"lead": deck.get("lead"), "slides": slides}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _notes_html(slide: dict) -> str:
|
|
417
|
+
notes = slide.get("notes")
|
|
418
|
+
if not notes:
|
|
419
|
+
return ""
|
|
420
|
+
return f'<aside class="notes" hidden>{html.escape(notes)}</aside>'
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _slide_title(deck: dict, slide: dict) -> str:
|
|
424
|
+
title = slide.get("title") or deck["title"]
|
|
425
|
+
lead = slide.get("lead") or deck.get("lead")
|
|
426
|
+
parts = ['<div class="accent"></div>', f"<h1>{html.escape(title)}</h1>"]
|
|
427
|
+
if lead:
|
|
428
|
+
parts.append(f'<p class="lead">{html.escape(lead)}</p>')
|
|
429
|
+
byline = []
|
|
430
|
+
if deck.get("speaker"):
|
|
431
|
+
byline.append(f'<span class="speaker">'
|
|
432
|
+
f'{html.escape(deck["speaker"])}</span>')
|
|
433
|
+
if deck.get("affiliation"):
|
|
434
|
+
byline.append(f"<span>{html.escape(deck['affiliation'])}</span>")
|
|
435
|
+
if deck.get("date"):
|
|
436
|
+
byline.append(f"<span>{html.escape(deck['date'])}</span>")
|
|
437
|
+
if byline:
|
|
438
|
+
parts.append('<footer class="byline">' + "".join(byline)
|
|
439
|
+
+ "</footer>")
|
|
440
|
+
return "".join(parts)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _slide_section(slide: dict) -> str:
|
|
444
|
+
parts = ['<div class="accent"></div>',
|
|
445
|
+
f"<h2>{html.escape(slide['title'])}</h2>"]
|
|
446
|
+
if slide.get("lead"):
|
|
447
|
+
parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
|
|
448
|
+
return "".join(parts)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _resolve_focus(model, diagram, ref: str, source: str, n: int):
|
|
452
|
+
"""The focused element's box subtree in this view: (element, rect,
|
|
453
|
+
ids of everything outside the subtree)."""
|
|
454
|
+
boxes = absolute_boxes(diagram, model.id_index())
|
|
455
|
+
matches = [b for b in boxes if b["kind"] == "element"
|
|
456
|
+
and (b["element"].get("id") == ref
|
|
457
|
+
or (b["element"].get("name") or "") == ref)]
|
|
458
|
+
if len(matches) > 1:
|
|
459
|
+
raise ModelError(f"Deck '{source}', slide {n}: focus '{ref}' staat "
|
|
460
|
+
f"meer dan één keer in de view; gebruik het id")
|
|
461
|
+
if not matches:
|
|
462
|
+
containers = sorted((b["element"].get("name") or "?")
|
|
463
|
+
for b in boxes
|
|
464
|
+
if b["kind"] == "element" and b["container"])
|
|
465
|
+
hint = (f" Containers in deze view: {', '.join(containers)}"
|
|
466
|
+
if containers else "")
|
|
467
|
+
raise ModelError(f"Deck '{source}', slide {n}: focus '{ref}' niet "
|
|
468
|
+
f"gevonden in de view.{hint}")
|
|
469
|
+
focus = matches[0]
|
|
470
|
+
subtree = {b["id"] for b in boxes
|
|
471
|
+
if b is focus or is_descendant(b["node"], focus["node"])}
|
|
472
|
+
members = [b for b in boxes if b["id"] in subtree]
|
|
473
|
+
x = max(0, min(b["x"] for b in members) - FOCUS_MARGIN)
|
|
474
|
+
y = max(0, min(b["y"] for b in members) - FOCUS_MARGIN)
|
|
475
|
+
w = max(b["x"] + b["w"] for b in members) + FOCUS_MARGIN - x
|
|
476
|
+
h = max(b["y"] + b["h"] for b in members) + FOCUS_MARGIN - y
|
|
477
|
+
dim_ids = {b["id"] for b in boxes} - subtree
|
|
478
|
+
return focus["element"], (x, y, w, h), dim_ids
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _slide_view(model, slide: dict, n: int, source: str) -> str:
|
|
482
|
+
diagram = slide["diagram"]
|
|
483
|
+
name = diagram.get("name") or "(naamloze view)"
|
|
484
|
+
focus_el, rect, dim_ids = None, None, None
|
|
485
|
+
if slide.get("focus"):
|
|
486
|
+
focus_el, rect, dim_ids = _resolve_focus(
|
|
487
|
+
model, diagram, slide["focus"], source, n)
|
|
488
|
+
if focus_el is not None:
|
|
489
|
+
title = slide.get("title") or focus_el.get("name") or name
|
|
490
|
+
intro = (slide.get("intro")
|
|
491
|
+
or model.properties(focus_el).get("Omschrijving", "")
|
|
492
|
+
or model.documentation(focus_el))
|
|
493
|
+
else:
|
|
494
|
+
title = slide.get("title") or name
|
|
495
|
+
intro = slide.get("intro") or model.documentation(diagram)
|
|
496
|
+
canvas = diagram_canvas(model, diagram, marker_prefix=f"s{n}-",
|
|
497
|
+
ref_base="../", dim_ids=dim_ids)
|
|
498
|
+
intro_html = (f'<p class="intro">{html.escape(intro)}</p>'
|
|
499
|
+
if intro else "")
|
|
500
|
+
focus_attrs = (f' data-fx="{rect[0]}" data-fy="{rect[1]}"'
|
|
501
|
+
f' data-fw="{rect[2]}" data-fh="{rect[3]}"'
|
|
502
|
+
if rect else "")
|
|
503
|
+
return (
|
|
504
|
+
f'<header class="view-head"><h2>{html.escape(title)}</h2>'
|
|
505
|
+
f"{intro_html}</header>"
|
|
506
|
+
f'<div class="view-card">'
|
|
507
|
+
f'<div class="view-fit" data-w="{canvas["width"]}" '
|
|
508
|
+
f'data-h="{canvas["height"]}"{focus_attrs}>'
|
|
509
|
+
f'<div class="diagram" style="width:{canvas["width"]}px;'
|
|
510
|
+
f'height:{canvas["height"]}px">{canvas["svg"]}{canvas["divs"]}'
|
|
511
|
+
f"</div></div>"
|
|
512
|
+
f'{legend_html(canvas["boxes"], canvas["edges"])}'
|
|
513
|
+
f"</div>"
|
|
514
|
+
f'<footer class="view-foot">'
|
|
515
|
+
f'<a class="view-open" href="../{slugify(name)}.html">'
|
|
516
|
+
f"open als losse pagina</a></footer>")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _slide_bullets(slide: dict) -> str:
|
|
520
|
+
parts = ['<div class="accent"></div>',
|
|
521
|
+
f"<h2>{html.escape(slide['title'])}</h2>"]
|
|
522
|
+
if slide.get("lead"):
|
|
523
|
+
parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
|
|
524
|
+
items = "".join(f"<li>{html.escape(b)}</li>" for b in slide["bullets"])
|
|
525
|
+
parts.append(f'<ul class="bullets">{items}</ul>')
|
|
526
|
+
if slide.get("gov"):
|
|
527
|
+
parts.append('<span class="gov-pill">Specifiek voor de '
|
|
528
|
+
"overheid</span>")
|
|
529
|
+
parts.append(f'<p class="gov-note">{html.escape(slide["gov"])}</p>')
|
|
530
|
+
return "".join(parts)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _slide_text(slide: dict) -> str:
|
|
534
|
+
parts = ['<div class="accent"></div>']
|
|
535
|
+
if slide.get("title"):
|
|
536
|
+
parts.append(f"<h2>{html.escape(slide['title'])}</h2>")
|
|
537
|
+
if slide.get("lead"):
|
|
538
|
+
parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
|
|
539
|
+
paragraphs = "".join(
|
|
540
|
+
f"<p>{html.escape(p.strip())}</p>"
|
|
541
|
+
for p in slide["body"].split("\n\n") if p.strip())
|
|
542
|
+
parts.append(f'<div class="prose">{paragraphs}</div>')
|
|
543
|
+
return "".join(parts)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _slide_closing(slide: dict) -> str:
|
|
547
|
+
parts = ['<div class="accent"></div>',
|
|
548
|
+
f"<h2>{html.escape(slide['title'])}</h2>"]
|
|
549
|
+
if slide.get("lead"):
|
|
550
|
+
parts.append(f'<p class="lead">{html.escape(slide["lead"])}</p>')
|
|
551
|
+
link = slide.get("link")
|
|
552
|
+
if link:
|
|
553
|
+
parts.append(f'<p class="closing-link">'
|
|
554
|
+
f'<a href="{html.escape(link["href"])}">'
|
|
555
|
+
f'{html.escape(link["label"])}</a></p>')
|
|
556
|
+
return "".join(parts)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def render_slide_html(model, deck: dict, slide: dict, n: int) -> str:
|
|
560
|
+
kind = slide["type"]
|
|
561
|
+
if kind == "title":
|
|
562
|
+
inner = _slide_title(deck, slide)
|
|
563
|
+
elif kind == "section":
|
|
564
|
+
inner = _slide_section(slide)
|
|
565
|
+
elif kind == "view":
|
|
566
|
+
inner = _slide_view(model, slide, n, deck.get("source", ""))
|
|
567
|
+
elif kind == "text":
|
|
568
|
+
inner = _slide_text(slide)
|
|
569
|
+
elif kind == "bullets":
|
|
570
|
+
inner = _slide_bullets(slide)
|
|
571
|
+
else:
|
|
572
|
+
inner = _slide_closing(slide)
|
|
573
|
+
return (f'<section class="slide slide-{kind}" id="s{n}">'
|
|
574
|
+
f"{inner}{_notes_html(slide)}</section>")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def render_deck_html(model, deck: dict) -> str:
|
|
578
|
+
"""One self-contained HTML document for a validated deck."""
|
|
579
|
+
total = len(deck["slides"])
|
|
580
|
+
slides_html = "\n".join(
|
|
581
|
+
render_slide_html(model, deck, slide, n)
|
|
582
|
+
for n, slide in enumerate(deck["slides"], start=1))
|
|
583
|
+
return f"""{MARKER}
|
|
584
|
+
<!doctype html>
|
|
585
|
+
<html lang="nl">
|
|
586
|
+
<head>
|
|
587
|
+
<meta charset="utf-8">
|
|
588
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
589
|
+
<title>{html.escape(deck["title"])}</title>
|
|
590
|
+
<link rel="icon" href="{FAVICON}">
|
|
591
|
+
<link rel="stylesheet" href="{NLDD_CSS}">
|
|
592
|
+
<style>
|
|
593
|
+
{layer_css()}
|
|
594
|
+
{DIAGRAM_CSS}{DECK_CSS}</style>
|
|
595
|
+
</head>
|
|
596
|
+
<body>
|
|
597
|
+
<main class="deck">
|
|
598
|
+
{slides_html}
|
|
599
|
+
</main>
|
|
600
|
+
<div class="hint">← → navigeren · f volledig scherm ·
|
|
601
|
+
n notities · a autoplay</div>
|
|
602
|
+
<div class="chrome">
|
|
603
|
+
<button class="nav-btn" data-nav="-1" aria-label="Vorige slide">‹</button>
|
|
604
|
+
<button class="nav-btn" data-nav="1" aria-label="Volgende slide">›</button>
|
|
605
|
+
<div class="counter">01/{total:02d}</div>
|
|
606
|
+
</div>
|
|
607
|
+
<aside class="notes-panel" hidden><h3>Notities</h3>
|
|
608
|
+
<div class="notes-body"></div></aside>
|
|
609
|
+
<div class="progress"><div class="progress-fill"></div></div>
|
|
610
|
+
<script>
|
|
611
|
+
{DECK_JS}</script>
|
|
612
|
+
</body>
|
|
613
|
+
</html>
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def render_all_slides(model, decks_dir, out_dir) -> tuple[list, list]:
|
|
618
|
+
"""Render every decks/*.toml; returns (written, removed) path lists.
|
|
619
|
+
A missing or empty decks dir is not an error: nothing is rendered and
|
|
620
|
+
stale generated decks are cleaned up."""
|
|
621
|
+
out = Path(out_dir)
|
|
622
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
623
|
+
|
|
624
|
+
decks = [load_deck(path, model)
|
|
625
|
+
for path in sorted(Path(decks_dir).glob("*.toml"))]
|
|
626
|
+
|
|
627
|
+
slugs = set()
|
|
628
|
+
for deck in decks:
|
|
629
|
+
if deck["slug"] in slugs:
|
|
630
|
+
raise ModelError(f"Meerdere decks renderen naar "
|
|
631
|
+
f"'{deck['slug']}.html'; geef een deck een "
|
|
632
|
+
f"uniek 'slug'-veld")
|
|
633
|
+
slugs.add(deck["slug"])
|
|
634
|
+
|
|
635
|
+
written, produced = [], set()
|
|
636
|
+
for deck in decks:
|
|
637
|
+
path = out / f"{deck['slug']}.html"
|
|
638
|
+
if write_if_changed(path, render_deck_html(model, deck)):
|
|
639
|
+
written.append(path)
|
|
640
|
+
produced.add(path.name)
|
|
641
|
+
|
|
642
|
+
removed = []
|
|
643
|
+
for stale in out.glob("*.html"):
|
|
644
|
+
if stale.name in produced:
|
|
645
|
+
continue
|
|
646
|
+
first_line = stale.read_text(encoding="utf-8").split("\n", 1)[0]
|
|
647
|
+
if first_line.strip() == MARKER:
|
|
648
|
+
stale.unlink()
|
|
649
|
+
removed.append(stale)
|
|
650
|
+
return written, removed
|