stack-site-builder 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/dev/local-samples-client.js +284 -0
  4. package/dev/local-samples.mjs +594 -0
  5. package/index.d.ts +12 -0
  6. package/index.mjs +124 -0
  7. package/markdown.mjs +394 -0
  8. package/package.json +52 -0
  9. package/src/components/ArrowUpRight.astro +24 -0
  10. package/src/components/ArticleCard.astro +56 -0
  11. package/src/components/ArticleDetail.astro +100 -0
  12. package/src/components/ArticleLink.astro +45 -0
  13. package/src/components/BackToTop.astro +47 -0
  14. package/src/components/BlogIndex.astro +137 -0
  15. package/src/components/Breadcrumb.astro +37 -0
  16. package/src/components/CategoryIndex.astro +115 -0
  17. package/src/components/ChevronDown.astro +23 -0
  18. package/src/components/CodeSamples.astro +141 -0
  19. package/src/components/CollapsibleGrid.astro +50 -0
  20. package/src/components/ConceptCard.astro +59 -0
  21. package/src/components/ConceptDetail.astro +102 -0
  22. package/src/components/ConceptIndex.astro +143 -0
  23. package/src/components/ConceptLink.astro +43 -0
  24. package/src/components/ConceptTools.astro +49 -0
  25. package/src/components/DeckView.astro +772 -0
  26. package/src/components/DetailTabs.astro +219 -0
  27. package/src/components/FilterControls.astro +407 -0
  28. package/src/components/Glossary.astro +264 -0
  29. package/src/components/Home.astro +90 -0
  30. package/src/components/LanguageSwitcher.astro +86 -0
  31. package/src/components/ListControls.astro +182 -0
  32. package/src/components/MermaidLoader.astro +96 -0
  33. package/src/components/PricingSection.astro +100 -0
  34. package/src/components/ProjectFileList.astro +68 -0
  35. package/src/components/ProjectFileTree.astro +95 -0
  36. package/src/components/ProjectRelatedTools.astro +70 -0
  37. package/src/components/ProjectViewer.astro +431 -0
  38. package/src/components/RelatedArticles.astro +52 -0
  39. package/src/components/RelatedConcepts.astro +60 -0
  40. package/src/components/RelatedTools.astro +55 -0
  41. package/src/components/SampleIndex.astro +464 -0
  42. package/src/components/SamplePage.astro +95 -0
  43. package/src/components/SampleProject.astro +56 -0
  44. package/src/components/Slide.astro +36 -0
  45. package/src/components/SlidesIndex.astro +60 -0
  46. package/src/components/StackCard.astro +183 -0
  47. package/src/components/StackDetail.astro +507 -0
  48. package/src/components/StarIcon.astro +21 -0
  49. package/src/components/TagIndex.astro +51 -0
  50. package/src/components/ThemeToggle.astro +106 -0
  51. package/src/components/Toc.astro +39 -0
  52. package/src/components/TocRail.astro +74 -0
  53. package/src/components/ToolAvatar.astro +133 -0
  54. package/src/components/ToolMeta.astro +95 -0
  55. package/src/components/VendorIndex.astro +51 -0
  56. package/src/content.ts +228 -0
  57. package/src/i18n/ui.ts +337 -0
  58. package/src/layouts/BaseLayout.astro +287 -0
  59. package/src/lib/articles.ts +27 -0
  60. package/src/lib/assets.ts +19 -0
  61. package/src/lib/category-tree.ts +64 -0
  62. package/src/lib/concepts.ts +35 -0
  63. package/src/lib/dates.ts +21 -0
  64. package/src/lib/facets.ts +57 -0
  65. package/src/lib/github.ts +186 -0
  66. package/src/lib/inline-md.ts +30 -0
  67. package/src/lib/pricing.ts +22 -0
  68. package/src/lib/project.ts +309 -0
  69. package/src/lib/samples.ts +88 -0
  70. package/src/lib/slides.ts +25 -0
  71. package/src/lib/stacks.ts +129 -0
  72. package/src/lib/toc-rail-client.ts +79 -0
  73. package/src/pages/article/[...id].astro +24 -0
  74. package/src/pages/article/category/[id].astro +17 -0
  75. package/src/pages/article/index.astro +12 -0
  76. package/src/pages/categories/[id].astro +25 -0
  77. package/src/pages/concept/[...id].astro +24 -0
  78. package/src/pages/concept/category/[id].astro +17 -0
  79. package/src/pages/concept/index.astro +12 -0
  80. package/src/pages/glossary.astro +12 -0
  81. package/src/pages/index.astro +9 -0
  82. package/src/pages/ko/article/[...id].astro +24 -0
  83. package/src/pages/ko/article/category/[id].astro +17 -0
  84. package/src/pages/ko/article/index.astro +12 -0
  85. package/src/pages/ko/categories/[id].astro +24 -0
  86. package/src/pages/ko/concept/[...id].astro +24 -0
  87. package/src/pages/ko/concept/category/[id].astro +17 -0
  88. package/src/pages/ko/concept/index.astro +12 -0
  89. package/src/pages/ko/glossary.astro +12 -0
  90. package/src/pages/ko/index.astro +9 -0
  91. package/src/pages/ko/sample/[folder].astro +16 -0
  92. package/src/pages/ko/sample/index.astro +12 -0
  93. package/src/pages/ko/slides/[deck].astro +14 -0
  94. package/src/pages/ko/slides/index.astro +12 -0
  95. package/src/pages/ko/stack/[...id].astro +51 -0
  96. package/src/pages/ko/tags/[tag].astro +18 -0
  97. package/src/pages/ko/vendors/[vendor].astro +18 -0
  98. package/src/pages/sample/[folder].astro +16 -0
  99. package/src/pages/sample/index.astro +12 -0
  100. package/src/pages/slides/[deck].astro +14 -0
  101. package/src/pages/slides/index.astro +12 -0
  102. package/src/pages/stack/[...id].astro +51 -0
  103. package/src/pages/tags/[tag].astro +18 -0
  104. package/src/pages/vendors/[vendor].astro +18 -0
  105. package/src/styles/global.css +2350 -0
@@ -0,0 +1,772 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import '../styles/global.css';
4
+ import { getRelativeLocaleUrl } from 'astro:i18n';
5
+ import { render, type CollectionEntry } from 'astro:content';
6
+ import MermaidLoader from './MermaidLoader.astro';
7
+ import { useTranslations, type Lang } from '../i18n/ui';
8
+
9
+ // Renders a full-screen slide deck as a horizontal scroll-snap presentation.
10
+ // A page (src/pages/slides/[deck].astro and its ko/ sibling) picks the deck for
11
+ // its locale and hands it here; this component is the whole HTML document.
12
+ interface Props {
13
+ entry: CollectionEntry<'slides'>;
14
+ lang: Lang;
15
+ }
16
+
17
+ const { entry, lang } = Astro.props;
18
+ const t = useTranslations(lang);
19
+ const { Content } = await render(entry);
20
+ const hasMermaid = (entry.body ?? '').includes('```mermaid');
21
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
22
+ const backHref = getRelativeLocaleUrl(lang, 'slides/');
23
+ const themeClass = `aas-theme-${entry.data.theme}`;
24
+ ---
25
+
26
+ <!doctype html>
27
+ <html lang={lang}>
28
+ <head>
29
+ <meta charset="utf-8" />
30
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
31
+ <title>{entry.data.title} — {site.name}</title>
32
+ <meta name="description" content={entry.data.description} />
33
+ <link rel="icon" type="image/svg+xml" href={`${base}/favicon.svg`} />
34
+ {/* Follow the site's light/dark preference before first paint (same logic as
35
+ BaseLayout). The deck's visual theme is a separate CSS class, below. */}
36
+ <script is:inline>
37
+ (function () {
38
+ try {
39
+ var pref = localStorage.getItem('theme') || 'system';
40
+ var dark =
41
+ pref === 'dark' ||
42
+ (pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
43
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
44
+ } catch (e) {}
45
+ })();
46
+ </script>
47
+ </head>
48
+ <body class="aas-deck-body" data-aspect={entry.data.aspect}>
49
+ <div class="aas-deck-progress"><div class="aas-deck-progress-fill" data-progress></div></div>
50
+
51
+ {/* The slides: <Content/> renders the deck MDX, whose <Slide> blocks become
52
+ .aas-slide sections laid out as a horizontal scroll-snap track. */}
53
+ <main
54
+ class:list={['aas-deck', themeClass]}
55
+ data-deck
56
+ data-transition={entry.data.transition}
57
+ data-toc={String(entry.data.toc)}
58
+ data-toc-level={String(entry.data.toc_level)}
59
+ data-toc-open={String(entry.data.toc_open)}
60
+ aria-label={entry.data.title}
61
+ >
62
+ <Content />
63
+ </main>
64
+
65
+ {/* Back to the slides index (top-left). Plain SVG chevrons, no icon fonts. */}
66
+ <a class="aas-deck-back" href={backHref} aria-label={t('slides.title')}>
67
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
68
+ <span>{t('slides.title')}</span>
69
+ </a>
70
+
71
+ {/* Actions drawer: a slim tab on the left edge (vertically centered) that
72
+ slides a panel out. Kept out of the top-left so it doesn't draw the eye. */}
73
+ <div class="aas-deck-menu" data-menu-root>
74
+ <div class="aas-deck-menu-panel" data-menu-panel>
75
+ <button type="button" data-action="print">
76
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8" rx="1"/></svg>
77
+ {t('slides.print')}
78
+ </button>
79
+ <button type="button" data-action="fullscreen">
80
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
81
+ {t('slides.fullscreen')}
82
+ </button>
83
+ <div class="aas-deck-menu-sep" role="separator"></div>
84
+ <div class="aas-deck-menu-themes" role="group" aria-label={t('theme.label')}>
85
+ <button type="button" data-theme-option="system" aria-pressed="false" title={t('theme.system')} aria-label={t('theme.system')}>
86
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>
87
+ </button>
88
+ <button type="button" data-theme-option="light" aria-pressed="false" title={t('theme.light')} aria-label={t('theme.light')}>
89
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
90
+ </button>
91
+ <button type="button" data-theme-option="dark" aria-pressed="false" title={t('theme.dark')} aria-label={t('theme.dark')}>
92
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
93
+ </button>
94
+ </div>
95
+ </div>
96
+ <button type="button" class="aas-deck-menu-tab" data-menu-toggle aria-expanded="false" aria-label={t('slides.menu')}>
97
+ <svg class="aas-deck-menu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
98
+ </button>
99
+ </div>
100
+
101
+ {
102
+ (entry.data.presenter || entry.data.date) && (
103
+ <div class="aas-deck-footer">
104
+ {entry.data.presenter && <span>{entry.data.presenter}</span>}
105
+ {entry.data.presenter && entry.data.date && <span class="aas-deck-footer-sep">·</span>}
106
+ {entry.data.date && <span>{entry.data.date}</span>}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ {/* Table of contents (top-right). Populated + revealed by the script only
112
+ when the deck has TOC headings. */}
113
+ <div class="aas-deck-toc" data-toc-root hidden>
114
+ <button type="button" class="aas-deck-toc-btn" data-toc-toggle aria-expanded="false" aria-label={t('slides.contents')}>
115
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>
116
+ </button>
117
+ <nav class="aas-deck-toc-panel" data-toc-panel hidden aria-label={t('slides.contents')}></nav>
118
+ </div>
119
+
120
+ <div class="aas-deck-nav">
121
+ <button type="button" class="aas-deck-btn" data-prev aria-label={t('slides.prev')}>
122
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
123
+ </button>
124
+ <span class="aas-deck-count"><span data-current>1</span> / <span data-total>1</span></span>
125
+ <button type="button" class="aas-deck-btn" data-next aria-label={t('slides.next')}>
126
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
127
+ </button>
128
+ </div>
129
+
130
+ {/* Overview grid (press `o`). Thumbnails are built by the script. */}
131
+ <div class="aas-ov" data-ov hidden aria-label={t('slides.overview')}></div>
132
+
133
+ {/* Lightbox for enlarging a diagram or image. Filled + shown by the script. */}
134
+ <div class="aas-zoom" data-zoom hidden data-zoom-label={t('slides.zoom')}>
135
+ <button type="button" class="aas-zoom-close" data-zoom-close aria-label={t('slides.close')}>
136
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
137
+ </button>
138
+ <div class="aas-zoom-stage" data-zoom-stage></div>
139
+ </div>
140
+
141
+ {hasMermaid && <MermaidLoader />}
142
+
143
+ <script>
144
+ const deck = document.querySelector<HTMLElement>('[data-deck]');
145
+ if (deck) {
146
+ // Animate slide changes unless the deck opts out (data-transition="none")
147
+ // or the viewer prefers reduced motion.
148
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
149
+ const animate = deck.dataset.transition !== 'none' && !reduceMotion;
150
+ const slides = Array.from(deck.querySelectorAll<HTMLElement>('.aas-slide'));
151
+ const total = slides.length;
152
+ const elCur = document.querySelector<HTMLElement>('[data-current]');
153
+ const elTot = document.querySelector<HTMLElement>('[data-total]');
154
+ const elFill = document.querySelector<HTMLElement>('[data-progress]');
155
+ const btnPrev = document.querySelector<HTMLButtonElement>('[data-prev]');
156
+ const btnNext = document.querySelector<HTMLButtonElement>('[data-next]');
157
+ if (elTot) elTot.textContent = String(total);
158
+
159
+ let current = 0;
160
+ // Step reveal (fragments): each slide's `.aas-step` elements are revealed
161
+ // one at a time. `shown[i]` = how many are currently visible.
162
+ const slideSteps = slides.map((s) =>
163
+ Array.from(s.querySelectorAll<HTMLElement>('.aas-step')),
164
+ );
165
+ // The scroll container a walkthrough drives (the scrollable diagram in a
166
+ // `scroll` split, a `scroll-x` slide's diagram, or a generic scroll box
167
+ // such as the image walkthrough's `.aas-scrollbox`).
168
+ const stepScroller = slides.map(
169
+ (s) =>
170
+ s.querySelector<HTMLElement>(
171
+ '.aas-split.scroll > .aas-diagram, .aas-scrollbox, [data-scroll-container]',
172
+ ) || (s.classList.contains('scroll-x') ? s.querySelector<HTMLElement>('.aas-diagram') : null),
173
+ );
174
+ // Parse a line spec like "3-6,9" into a set of 1-based line numbers.
175
+ const parseLines = (spec: string) => {
176
+ const set = new Set<number>();
177
+ for (const part of spec.split(',')) {
178
+ const m = part.trim().match(/^(\d+)(?:-(\d+))?$/);
179
+ if (!m) continue;
180
+ const a = +m[1];
181
+ const bb = m[2] ? +m[2] : a;
182
+ for (let n = a; n <= bb; n++) set.add(n);
183
+ }
184
+ return set;
185
+ };
186
+ // Code walkthrough: highlight the active step's line range and scroll the
187
+ // code block so it's centred.
188
+ const syncStepLines = (i: number) => {
189
+ const code = slides[i].querySelector<HTMLElement>('pre.astro-code');
190
+ if (!code) return;
191
+ const lines = Array.from(code.querySelectorAll<HTMLElement>('.line'));
192
+ lines.forEach((l) => l.classList.remove('is-line-hl'));
193
+ code.classList.remove('has-hl');
194
+ const el = slideSteps[i][shown[i] - 1];
195
+ const spec = el && el.dataset.lines;
196
+ if (!spec) return;
197
+ const set = parseLines(spec);
198
+ let first: HTMLElement | null = null;
199
+ lines.forEach((l, idx) => {
200
+ if (set.has(idx + 1)) {
201
+ l.classList.add('is-line-hl');
202
+ if (!first) first = l;
203
+ }
204
+ });
205
+ if (!first) return;
206
+ code.classList.add('has-hl');
207
+ const cr = code.getBoundingClientRect();
208
+ const lr = (first as HTMLElement).getBoundingClientRect();
209
+ const delta = lr.top - cr.top - (code.clientHeight / 2 - lr.height / 2);
210
+ code.scrollTo({ top: code.scrollTop + delta, behavior: animate ? 'smooth' : 'auto' });
211
+ };
212
+ const shown = slides.map(() => 0);
213
+ // If the active step carries `data-scroll`, scroll the slide's container
214
+ // to that spot (`N` = N% of the scrollable length, or `Npx`). The axis is
215
+ // whichever one actually overflows.
216
+ const syncStepScroll = (i: number) => {
217
+ const el = slideSteps[i][shown[i] - 1];
218
+ const scroller = stepScroller[i];
219
+ if (!el || !scroller || el.dataset.scroll == null) return;
220
+ const raw = el.dataset.scroll;
221
+ const maxX = scroller.scrollWidth - scroller.clientWidth;
222
+ const maxY = scroller.scrollHeight - scroller.clientHeight;
223
+ const behavior: ScrollBehavior = animate ? 'smooth' : 'auto';
224
+ if (maxX > maxY) {
225
+ const left = /px$/.test(raw) ? parseFloat(raw) : (parseFloat(raw) / 100) * maxX;
226
+ scroller.scrollTo({ left, behavior });
227
+ } else {
228
+ const top = /px$/.test(raw) ? parseFloat(raw) : (parseFloat(raw) / 100) * maxY;
229
+ scroller.scrollTo({ top, behavior });
230
+ }
231
+ };
232
+ const applySteps = (i: number) => {
233
+ slideSteps[i].forEach((el, k) => {
234
+ el.classList.toggle('is-step-hidden', k >= shown[i]);
235
+ el.classList.toggle('is-step-active', k === shown[i] - 1);
236
+ });
237
+ syncStepScroll(i);
238
+ syncStepLines(i);
239
+ };
240
+ slides.forEach((_, i) => applySteps(i));
241
+ const revealAllSteps = (i: number) => {
242
+ shown[i] = slideSteps[i].length;
243
+ applySteps(i);
244
+ };
245
+ // Programmatic navigation (buttons/keys/TOC) is the source of truth: while
246
+ // a smooth scroll is animating toward `target`, the IntersectionObserver
247
+ // must NOT rewrite `current` to slides it passes — otherwise fast presses
248
+ // compute the next step from an intermediate slide and jump backward.
249
+ let target = 0;
250
+ let navLock = false;
251
+ let navTimer: ReturnType<typeof setTimeout> | undefined;
252
+ // Filled in by the TOC builder below; paint() highlights the active entry.
253
+ const tocLinks: { el: HTMLElement; slide: number }[] = [];
254
+
255
+ const paint = () => {
256
+ if (elCur) elCur.textContent = String(current + 1);
257
+ if (elFill) elFill.style.width = total > 1 ? `${(current / (total - 1)) * 100}%` : '100%';
258
+ // Prev/Next stay enabled while there are steps to hide/reveal on the edge slides.
259
+ if (btnPrev) btnPrev.disabled = current === 0 && shown[current] === 0;
260
+ if (btnNext)
261
+ btnNext.disabled = current === total - 1 && shown[current] === slideSteps[current].length;
262
+ for (const l of tocLinks) l.el.classList.toggle('active', l.slide === current);
263
+ };
264
+
265
+ // Slide-change animation length (ms). Native smooth scroll can't be timed;
266
+ // this rAF tween runs for a fixed, tunable duration.
267
+ const DURATION = 450;
268
+ const easeOutCubic = (t: number) => 1 - (1 - t) ** 3;
269
+ let rafId: number | undefined;
270
+
271
+ const go = (i: number, smooth = true, revealAll = true) => {
272
+ target = Math.max(0, Math.min(total - 1, i));
273
+ current = target;
274
+ // Landing on a slide: reveal all its steps (jumps/back) or none of them
275
+ // (forward, so they can be stepped through).
276
+ shown[current] = revealAll ? slideSteps[current].length : 0;
277
+ applySteps(current);
278
+ navLock = true;
279
+ clearTimeout(navTimer);
280
+ // Fallback: release the lock even if the observer never reports arrival.
281
+ navTimer = setTimeout(() => {
282
+ navLock = false;
283
+ }, 1200);
284
+ const el = slides[current];
285
+ if (el) {
286
+ const x = el.offsetLeft;
287
+ if (rafId) cancelAnimationFrame(rafId);
288
+ if (smooth && animate && Math.abs(deck.scrollLeft - x) > 1) {
289
+ // Custom tween instead of scrollIntoView({behavior:'smooth'}): fixed
290
+ // short duration, and scroll-snap turned off while animating so it
291
+ // doesn't stutter against the motion. Ends exactly on the snap point.
292
+ const startX = deck.scrollLeft;
293
+ const dist = x - startX;
294
+ const t0 = performance.now();
295
+ deck.style.scrollSnapType = 'none';
296
+ const stepFrame = (now: number) => {
297
+ const t = Math.min(1, (now - t0) / DURATION);
298
+ deck.scrollLeft = startX + dist * easeOutCubic(t);
299
+ if (t < 1) {
300
+ rafId = requestAnimationFrame(stepFrame);
301
+ } else {
302
+ deck.style.scrollSnapType = '';
303
+ rafId = undefined;
304
+ }
305
+ };
306
+ rafId = requestAnimationFrame(stepFrame);
307
+ } else {
308
+ deck.scrollLeft = x; // instant (transition:none / reduced motion / deep-link)
309
+ }
310
+ }
311
+ history.replaceState(null, '', `#${current + 1}`);
312
+ paint();
313
+ };
314
+
315
+ // Next/Prev reveal or hide a step first, and only move slides once the
316
+ // current slide's steps are exhausted.
317
+ const goNext = () => {
318
+ if (shown[current] < slideSteps[current].length) {
319
+ shown[current]++;
320
+ applySteps(current);
321
+ paint();
322
+ } else {
323
+ go(current + 1, true, false);
324
+ }
325
+ };
326
+ const goPrev = () => {
327
+ if (shown[current] > 0) {
328
+ shown[current]--;
329
+ applySteps(current);
330
+ paint();
331
+ } else {
332
+ go(current - 1, true, true);
333
+ }
334
+ };
335
+
336
+ // Mermaid sizes a diagram to its container at render time; a diagram in
337
+ // an off-screen slide can occasionally come out mis-sized. When a slide
338
+ // becomes current, re-run the site's Mermaid renderer if its diagram
339
+ // didn't lay out (missing or zero-height SVG). Idempotent + cheap.
340
+ const ensureMermaid = (i: number) => {
341
+ const pre = slides[i]?.querySelector('pre.mermaid');
342
+ if (!pre) return;
343
+ const svg = pre.querySelector('svg');
344
+ if (!svg || svg.getBoundingClientRect().height < 2) {
345
+ (window as unknown as { __aasRenderMermaid?: () => void }).__aasRenderMermaid?.();
346
+ }
347
+ };
348
+
349
+ // --- Table of contents ------------------------------------------------
350
+ // Collect headings h2..h{toc_level} from slides not marked data-no-toc,
351
+ // each linking to the slide it lives on. Reveal the top-right control
352
+ // only if there's at least one entry.
353
+ const tocRoot = document.querySelector<HTMLElement>('[data-toc-root]');
354
+ const tocPanel = document.querySelector<HTMLElement>('[data-toc-panel]');
355
+ const tocToggle = document.querySelector<HTMLButtonElement>('[data-toc-toggle]');
356
+ const closeToc = () => {
357
+ if (tocPanel) tocPanel.hidden = true;
358
+ tocToggle?.setAttribute('aria-expanded', 'false');
359
+ };
360
+ if (deck.dataset.toc !== 'false' && tocRoot && tocPanel && tocToggle) {
361
+ const level = Math.min(4, Math.max(2, parseInt(deck.dataset.tocLevel || '2', 10)));
362
+ const sel = ['h2', 'h3', 'h4'].slice(0, level - 1).join(', ');
363
+ const ul = document.createElement('ul');
364
+ slides.forEach((slide, i) => {
365
+ if (slide.hasAttribute('data-no-toc')) return;
366
+ // Only the slide's leading heading (its title) goes in the TOC — not
367
+ // sub-headings within it, e.g. the column titles inside a :::cols slide.
368
+ const h = slide.querySelector<HTMLElement>(sel);
369
+ if (!h) return;
370
+ const li = document.createElement('li');
371
+ li.className = 'aas-toc-l' + h.tagName[1];
372
+ const a = document.createElement('button');
373
+ a.type = 'button';
374
+ a.textContent = (h.textContent || '').trim();
375
+ // Jump to the slide but keep the panel open — it's a persistent
376
+ // agenda, not a menu that dismisses on use.
377
+ a.addEventListener('click', () => go(i));
378
+ li.appendChild(a);
379
+ ul.appendChild(li);
380
+ tocLinks.push({ el: a, slide: i });
381
+ });
382
+ if (tocLinks.length) {
383
+ tocPanel.appendChild(ul);
384
+ tocRoot.hidden = false;
385
+ // Start expanded unless the deck opts out (data-toc-open="false").
386
+ if (deck.dataset.tocOpen !== 'false') {
387
+ tocPanel.hidden = false;
388
+ tocToggle.setAttribute('aria-expanded', 'true');
389
+ }
390
+ tocToggle.addEventListener('click', () => {
391
+ const open = tocPanel.hidden;
392
+ tocPanel.hidden = !open;
393
+ tocToggle.setAttribute('aria-expanded', String(open));
394
+ });
395
+ }
396
+ }
397
+
398
+ // Actions menu (top-left): print/PDF and fullscreen.
399
+ const menuRoot = document.querySelector<HTMLElement>('[data-menu-root]');
400
+ const menuPanel = document.querySelector<HTMLElement>('[data-menu-panel]');
401
+ const menuToggle = document.querySelector<HTMLButtonElement>('[data-menu-toggle]');
402
+ if (menuRoot && menuPanel && menuToggle) {
403
+ const closeMenu = () => {
404
+ menuRoot.classList.remove('is-open');
405
+ menuToggle.setAttribute('aria-expanded', 'false');
406
+ };
407
+ menuToggle.addEventListener('click', (e) => {
408
+ e.stopPropagation();
409
+ const open = !menuRoot.classList.contains('is-open');
410
+ menuRoot.classList.toggle('is-open', open);
411
+ menuToggle.setAttribute('aria-expanded', String(open));
412
+ });
413
+ // The drawer stays open until the tab is toggled again — an outside
414
+ // click must not close it (same as the TOC panel).
415
+ menuPanel.querySelector('[data-action="print"]')?.addEventListener('click', () => {
416
+ closeMenu();
417
+ // Reveal every step so nothing is hidden in the PDF.
418
+ slides.forEach((_, i) => revealAllSteps(i));
419
+ window.print();
420
+ });
421
+ menuPanel.querySelector('[data-action="fullscreen"]')?.addEventListener('click', () => {
422
+ closeMenu();
423
+ if (document.fullscreenElement) document.exitFullscreen();
424
+ else document.documentElement.requestFullscreen?.();
425
+ });
426
+
427
+ // Light / dark / system — same storage + resolution as the site's
428
+ // ThemeToggle. MermaidLoader watches data-theme and re-renders, so the
429
+ // diagrams follow along automatically.
430
+ const root = document.documentElement;
431
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
432
+ const themeOptions = Array.from(
433
+ menuPanel.querySelectorAll<HTMLButtonElement>('[data-theme-option]')
434
+ );
435
+ const getPref = () => {
436
+ try {
437
+ return localStorage.getItem('theme') || 'system';
438
+ } catch (e) {
439
+ return 'system';
440
+ }
441
+ };
442
+ const applyTheme = (pref: string) => {
443
+ root.dataset.theme = pref === 'dark' || (pref === 'system' && mq.matches) ? 'dark' : 'light';
444
+ themeOptions.forEach((el) =>
445
+ el.setAttribute('aria-pressed', el.dataset.themeOption === pref ? 'true' : 'false')
446
+ );
447
+ };
448
+ themeOptions.forEach((el) => {
449
+ el.addEventListener('click', () => {
450
+ const pref = el.dataset.themeOption || 'system';
451
+ try {
452
+ localStorage.setItem('theme', pref);
453
+ } catch (e) {}
454
+ applyTheme(pref);
455
+ });
456
+ });
457
+ mq.addEventListener('change', () => {
458
+ if (getPref() === 'system') applyTheme('system');
459
+ });
460
+ applyTheme(getPref());
461
+ }
462
+
463
+ // Keep `current` in sync when the user swipes/scrolls the track directly.
464
+ // During programmatic navigation (navLock), ignore the slides we scroll
465
+ // past and only clear the lock once the target slide has arrived.
466
+ const io = new IntersectionObserver(
467
+ (entries) => {
468
+ for (const e of entries) {
469
+ if (!e.isIntersecting) continue;
470
+ const idx = slides.indexOf(e.target as HTMLElement);
471
+ ensureMermaid(idx);
472
+ if (navLock) {
473
+ if (idx === target) navLock = false;
474
+ } else if (idx >= 0 && idx !== current) {
475
+ current = idx;
476
+ history.replaceState(null, '', `#${current + 1}`);
477
+ paint();
478
+ }
479
+ }
480
+ },
481
+ { root: deck, threshold: 0.6 },
482
+ );
483
+ slides.forEach((s) => io.observe(s));
484
+
485
+ btnPrev?.addEventListener('click', () => goPrev());
486
+ btnNext?.addEventListener('click', () => goNext());
487
+
488
+ window.addEventListener('keydown', (e) => {
489
+ // While the overview grid is open, it owns the keys (handled below).
490
+ if (deck.dataset.overview === 'open') return;
491
+ if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
492
+ e.preventDefault();
493
+ goNext();
494
+ } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
495
+ e.preventDefault();
496
+ goPrev();
497
+ } else if (e.key === 'Home') {
498
+ e.preventDefault();
499
+ go(0);
500
+ } else if (e.key === 'End') {
501
+ e.preventDefault();
502
+ go(total - 1);
503
+ } else if (e.key === 'Escape') {
504
+ closeToc();
505
+ }
506
+ });
507
+
508
+ // Zoom / lightbox: enlarge a diagram or image in an overlay. Diagrams get
509
+ // a button inside their (.aas-diagram) box; images are wrapped so the
510
+ // button can sit in the corner. Clicking the media works too.
511
+ const zoom = document.querySelector<HTMLElement>('[data-zoom]');
512
+ const zoomStage = document.querySelector<HTMLElement>('[data-zoom-stage]');
513
+ if (zoom && zoomStage) {
514
+ const zoomLabel = zoom.dataset.zoomLabel || 'Enlarge';
515
+ let zoomOpen = false;
516
+ const openZoom = (media: Element | null) => {
517
+ if (!media) return;
518
+ // For a diagram, enlarge the rendered svg itself (not the bordered
519
+ // box); for an image, the <img>.
520
+ const source = media.classList.contains('aas-diagram')
521
+ ? media.querySelector('svg') || media.querySelector('.mermaid') || media
522
+ : media;
523
+ const clone = source.cloneNode(true) as HTMLElement;
524
+ clone.querySelectorAll?.('.aas-zoom-btn').forEach((b) => b.remove());
525
+ let content: HTMLElement = clone;
526
+ if (clone.tagName?.toLowerCase() === 'svg') {
527
+ // The pastel node styling is scoped to `.mermaid …`; tag the clone
528
+ // so it keeps that look outside the slide.
529
+ clone.classList.add('mermaid');
530
+ // Mermaid measured its labels with the slide's font (25px+ inside
531
+ // .aas-slide-inner); outside that context the labels drop to the
532
+ // 16px root size and drift to the top-left of their boxes. Copy the
533
+ // source label's computed font onto the clone so text lays out at
534
+ // the size the boxes were measured for.
535
+ // Query the <p> first: the .nodeLabel span around it inherits the
536
+ // svg's 16px, while the p is what the slide's font rule actually
537
+ // sizes (and what Mermaid measured against).
538
+ const srcLabel =
539
+ source.querySelector('.nodeLabel p') ||
540
+ source.querySelector('.nodeLabel, .edgeLabel, text');
541
+ if (srcLabel) {
542
+ const lcs = getComputedStyle(srcLabel);
543
+ clone.style.fontSize = lcs.fontSize;
544
+ clone.style.lineHeight = lcs.lineHeight;
545
+ }
546
+ // Size for a readable enlargement: small diagrams scale up to fill
547
+ // the stage; a diagram bigger than the stage is shown at its natural
548
+ // size and the stage scrolls (pan a complex flowchart).
549
+ const vb = (clone.getAttribute('viewBox') || '').split(/[\s,]+/).map(Number);
550
+ const nW = vb[2] || 800;
551
+ const nH = vb[3] || 450;
552
+ const stageW = window.innerWidth * 0.92;
553
+ const stageH = window.innerHeight * 0.86;
554
+ const fit = Math.min(stageW / nW, stageH / nH);
555
+ const scale = fit >= 1 ? Math.min(fit, 2.4) : 1;
556
+ // Enlarge with a CSS transform on the natural-size svg (not by
557
+ // resizing width/height) so the node shapes and their labels scale
558
+ // together — resizing the svg shifts Mermaid's labels off-centre.
559
+ clone.style.width = `${nW}px`;
560
+ clone.style.height = `${nH}px`;
561
+ clone.style.maxWidth = 'none';
562
+ clone.style.maxHeight = 'none';
563
+ clone.style.transform = `scale(${scale})`;
564
+ clone.style.transformOrigin = 'top left';
565
+ // A box the size of the scaled diagram, so the stage scrolls/centres it.
566
+ const box = document.createElement('div');
567
+ box.className = 'aas-zoom-scalebox';
568
+ box.style.width = `${Math.round(nW * scale)}px`;
569
+ box.style.height = `${Math.round(nH * scale)}px`;
570
+ box.appendChild(clone);
571
+ content = box;
572
+ }
573
+ // A centering wrapper: it's at least the stage size (so a small
574
+ // diagram/image is centred both ways) and grows with the content (so
575
+ // a big diagram scrolls). Reliable, unlike flex `margin:auto`.
576
+ const fitWrap = document.createElement('div');
577
+ fitWrap.className = 'aas-zoom-fit';
578
+ fitWrap.appendChild(content);
579
+ zoomStage.replaceChildren(fitWrap);
580
+ zoom.hidden = false;
581
+ requestAnimationFrame(() => {
582
+ zoom.classList.add('is-open');
583
+ // Start centred horizontally, at the top vertically (read top-down).
584
+ zoomStage.scrollLeft = (zoomStage.scrollWidth - zoomStage.clientWidth) / 2;
585
+ zoomStage.scrollTop = 0;
586
+ });
587
+ zoomOpen = true;
588
+ };
589
+ const closeZoom = () => {
590
+ zoom.classList.remove('is-open');
591
+ zoomOpen = false;
592
+ const done = () => {
593
+ if (!zoomOpen) {
594
+ zoom.hidden = true;
595
+ zoomStage.replaceChildren();
596
+ }
597
+ zoom.removeEventListener('transitionend', done);
598
+ };
599
+ zoom.addEventListener('transitionend', done);
600
+ };
601
+ zoom.addEventListener('click', (e) => {
602
+ if (e.target === zoom || (e.target as HTMLElement).closest('[data-zoom-close]')) closeZoom();
603
+ });
604
+ // Capture phase so Escape / arrows don't reach the slide navigation while
605
+ // the lightbox is open.
606
+ window.addEventListener(
607
+ 'keydown',
608
+ (e) => {
609
+ if (!zoomOpen) return;
610
+ if (e.key === 'Escape') closeZoom();
611
+ e.stopImmediatePropagation();
612
+ e.preventDefault();
613
+ },
614
+ true,
615
+ );
616
+
617
+ const zoomBtn = (media: Element) => {
618
+ const btn = document.createElement('button');
619
+ btn.type = 'button';
620
+ btn.className = 'aas-zoom-btn';
621
+ btn.setAttribute('aria-label', zoomLabel);
622
+ btn.title = zoomLabel;
623
+ btn.innerHTML =
624
+ '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6"/><path d="M8 11h6"/></svg>';
625
+ btn.addEventListener('click', (e) => {
626
+ e.stopPropagation();
627
+ openZoom(media);
628
+ });
629
+ return btn;
630
+ };
631
+
632
+ // Diagrams: button inside the box; the whole box is clickable too.
633
+ deck.querySelectorAll<HTMLElement>('.aas-slide-inner .aas-diagram').forEach((d) => {
634
+ d.classList.add('aas-zoomable');
635
+ d.appendChild(zoomBtn(d));
636
+ d.addEventListener('click', (e) => {
637
+ if (!(e.target as HTMLElement).closest('.aas-zoom-btn')) openZoom(d);
638
+ });
639
+ });
640
+ // Images: wrap so the button can overlay the picture's corner.
641
+ deck.querySelectorAll<HTMLImageElement>('.aas-slide-inner img').forEach((img) => {
642
+ const wrap = document.createElement('span');
643
+ wrap.className = 'aas-zoomable aas-zoomable-img';
644
+ img.parentNode?.insertBefore(wrap, img);
645
+ wrap.appendChild(img);
646
+ wrap.appendChild(zoomBtn(img));
647
+ wrap.addEventListener('click', (e) => {
648
+ if (!(e.target as HTMLElement).closest('.aas-zoom-btn')) openZoom(img);
649
+ });
650
+ });
651
+ }
652
+
653
+ // Auto-fit: if a slide's content is taller than the slide, scale it down
654
+ // so it fits instead of scrolling. (Filled media slides already measure
655
+ // exactly 100%, so they're never scaled.)
656
+ const fitSlide = (s: HTMLElement) => {
657
+ const inner = s.querySelector<HTMLElement>('.aas-slide-inner');
658
+ if (!inner) return;
659
+ inner.style.transform = '';
660
+ inner.style.removeProperty('--aas-fit');
661
+ s.style.removeProperty('overflow-y');
662
+ const cs = getComputedStyle(s);
663
+ const avail = s.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
664
+ const need = inner.scrollHeight;
665
+ if (need > avail + 2) {
666
+ const scale = Math.max(0.55, avail / need);
667
+ inner.style.transform = `scale(${scale})`;
668
+ inner.style.transformOrigin = 'top center';
669
+ s.style.overflowY = 'hidden';
670
+ }
671
+ };
672
+ const fitAll = () => slides.forEach(fitSlide);
673
+ fitAll();
674
+ // Re-fit after late layout shifts (fonts, images, Mermaid).
675
+ setTimeout(fitAll, 300);
676
+ setTimeout(fitAll, 900);
677
+ window.addEventListener('load', fitAll);
678
+
679
+ // Overview grid (press `o`): scaled thumbnails of every slide; click one
680
+ // to jump there. Built lazily and rebuilt when the viewport changes.
681
+ const ov = document.querySelector<HTMLElement>('[data-ov]');
682
+ let ovOpen = false;
683
+ const buildOv = () => {
684
+ if (!ov) return;
685
+ ov.replaceChildren();
686
+ const scale = 260 / window.innerWidth;
687
+ const thumbH = window.innerHeight * scale;
688
+ slides.forEach((s, i) => {
689
+ const cell = document.createElement('button');
690
+ cell.type = 'button';
691
+ cell.className = 'aas-ov-cell';
692
+ const thumb = document.createElement('div');
693
+ thumb.className = 'aas-ov-thumb';
694
+ thumb.style.height = `${thumbH}px`;
695
+ const clone = s.cloneNode(true) as HTMLElement;
696
+ clone.removeAttribute('style');
697
+ clone.style.width = `${window.innerWidth}px`;
698
+ clone.style.height = `${window.innerHeight}px`;
699
+ clone.style.transform = `scale(${scale})`;
700
+ clone.style.transformOrigin = 'top left';
701
+ if (s.classList.contains('has-bg'))
702
+ clone.style.backgroundImage = s.style.backgroundImage;
703
+ clone.querySelectorAll('.aas-zoom-btn, .aas-slide-source').forEach((n) => n.remove());
704
+ thumb.appendChild(clone);
705
+ const num = document.createElement('span');
706
+ num.className = 'aas-ov-num';
707
+ num.textContent = String(i + 1);
708
+ cell.append(thumb, num);
709
+ cell.addEventListener('click', () => {
710
+ closeOv();
711
+ go(i);
712
+ });
713
+ ov.appendChild(cell);
714
+ });
715
+ };
716
+ const openOv = () => {
717
+ if (!ov) return;
718
+ buildOv();
719
+ ov.hidden = false;
720
+ requestAnimationFrame(() => ov.classList.add('is-open'));
721
+ deck.dataset.overview = 'open';
722
+ ovOpen = true;
723
+ const active = ov.children[current] as HTMLElement | undefined;
724
+ active?.classList.add('is-current');
725
+ active?.scrollIntoView({ block: 'center' });
726
+ };
727
+ const closeOv = () => {
728
+ if (!ov) return;
729
+ ov.classList.remove('is-open');
730
+ delete deck.dataset.overview;
731
+ ovOpen = false;
732
+ const done = () => {
733
+ if (!ovOpen) {
734
+ ov.hidden = true;
735
+ ov.replaceChildren();
736
+ }
737
+ ov.removeEventListener('transitionend', done);
738
+ };
739
+ ov.addEventListener('transitionend', done);
740
+ };
741
+ window.addEventListener('keydown', (e) => {
742
+ if (e.key === 'o' || e.key === 'O') {
743
+ if (
744
+ (e.target as HTMLElement)?.tagName === 'INPUT' ||
745
+ (e.target as HTMLElement)?.tagName === 'TEXTAREA'
746
+ )
747
+ return;
748
+ e.preventDefault();
749
+ ovOpen ? closeOv() : openOv();
750
+ } else if (ovOpen && e.key === 'Escape') {
751
+ e.preventDefault();
752
+ closeOv();
753
+ }
754
+ });
755
+
756
+ let resizeT: ReturnType<typeof setTimeout> | undefined;
757
+ window.addEventListener('resize', () => {
758
+ clearTimeout(resizeT);
759
+ resizeT = setTimeout(() => {
760
+ fitAll();
761
+ if (ovOpen) buildOv();
762
+ }, 150);
763
+ });
764
+
765
+ // Deep-link: open at #N if present, else slide 1. Jump without animation.
766
+ const fromHash = parseInt((location.hash || '').slice(1), 10);
767
+ go(Number.isFinite(fromHash) && fromHash >= 1 ? fromHash - 1 : 0, false);
768
+ paint();
769
+ }
770
+ </script>
771
+ </body>
772
+ </html>