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,431 @@
1
+ ---
2
+ import { useTranslations, type Lang } from '../i18n/ui';
3
+ import type { RenderedProject } from '../lib/project';
4
+ import ChevronDown from './ChevronDown.astro';
5
+ import ProjectFileList from './ProjectFileList.astro';
6
+ import ProjectRelatedTools, { type RelatedTool } from './ProjectRelatedTools.astro';
7
+
8
+ interface Props {
9
+ lang: Lang;
10
+ projects: RenderedProject[];
11
+ // Related tools per example folder; rendered inline below 2xl (in the rail at 2xl+).
12
+ related: Map<string, RelatedTool[]>;
13
+ // Embedded in an article body, where the page's TOC rail (≥1200px) already
14
+ // surfaces the files + related tools. Drop the inline sidebar at ≥1200px so it
15
+ // isn't shown twice; below that (no rail) the inline sidebar stays.
16
+ embedded?: boolean;
17
+ }
18
+
19
+ const { lang, projects, related, embedded = false } = Astro.props;
20
+ const t = useTranslations(lang);
21
+
22
+ // Split the rendered README into its leading <h1> inner content (used as a
23
+ // clickable header) and the body (collapsible under it).
24
+ function splitReadme(html?: string): { titleInner?: string; body?: string } {
25
+ if (!html) return {};
26
+ const m = html.match(/^\s*<h1[^>]*>([\s\S]*?)<\/h1>([\s\S]*)$/);
27
+ return m ? { titleInner: m[1], body: m[2] } : { body: html };
28
+ }
29
+
30
+ // Format a YYYY-MM-DD date in the locale's own style (ko: 2026년 6월 28일,
31
+ // en: June 28, 2026). Parsed in UTC so the day never shifts at build time.
32
+ function formatDate(iso: string): string {
33
+ const [y, m, d] = iso.split('-').map(Number);
34
+ if (!y || !m || !d) return iso;
35
+ return new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
36
+ year: 'numeric',
37
+ month: 'long',
38
+ day: 'numeric',
39
+ timeZone: 'UTC',
40
+ }).format(new Date(Date.UTC(y, m - 1, d)));
41
+ }
42
+ ---
43
+
44
+ <div data-projects>
45
+ {/* Example selector — shown even for a single project, so its name and date
46
+ are always visible at the top of the tab. */}
47
+ {
48
+ projects.length > 0 && (
49
+ <div class="aas-sample-picker flex items-center gap-2 text-sm text-[var(--aas-muted)]">
50
+ {t('project.example')}
51
+ <details class="aas-vdd" data-project-dd>
52
+ <summary class="flex cursor-pointer list-none items-center gap-2 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-[var(--aas-text)] select-none">
53
+ <span data-project-current>{projects[0].name}</span>
54
+ <ChevronDown class="text-[var(--aas-muted)]" />
55
+ </summary>
56
+ <ul class="absolute left-0 z-10 mt-1 min-w-[14rem] overflow-hidden rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] py-1 shadow-lg">
57
+ {projects.map((p, i) => (
58
+ <li>
59
+ <button
60
+ type="button"
61
+ data-project-option={i}
62
+ data-project-folder={p.folder}
63
+ data-project-name={p.name}
64
+ aria-current={i === 0 ? 'true' : 'false'}
65
+ class="group flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-[var(--aas-muted)] hover:bg-[var(--aas-tint)] aria-[current=true]:text-[var(--aas-text)]"
66
+ >
67
+ <span class="min-w-0">
68
+ <span class="block truncate group-aria-[current=true]:font-semibold">{p.name}</span>
69
+ <span class="block text-xs text-[var(--aas-muted)]">{formatDate(p.date)}</span>
70
+ </span>
71
+ <span data-check aria-hidden="true" class="shrink-0 opacity-0">
72
+
73
+ </span>
74
+ </button>
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ </details>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ {
84
+ projects.map((p, pi) => {
85
+ const readme = splitReadme(p.readmeHtml);
86
+ return (
87
+ <div data-project-panel={pi} data-project-folder={p.folder} class:list={[pi === 0 ? '' : 'hidden', 'mt-4']}>
88
+ <div class="flex flex-wrap items-start justify-between gap-3">
89
+ <p class="text-sm text-[var(--aas-muted)]">{t('project.intro')}</p>
90
+ <div class="flex flex-col items-end gap-2">
91
+ <a
92
+ href={p.folderUrl}
93
+ target="_blank"
94
+ rel="noopener noreferrer"
95
+ class="aas-lift inline-flex items-center gap-1.5 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-sm text-[var(--aas-text)] no-underline"
96
+ >
97
+ {t('project.viewOnGitHubPre')}
98
+ <code class="rounded bg-[var(--aas-tint)] px-1.5 py-0.5 text-xs">samples/{p.folder}</code>
99
+ {t('project.viewOnGitHubPost')}
100
+ <span class="text-[var(--aas-muted)]">&nearr;</span>
101
+ </a>
102
+ {/* Dev only: browse the checked-out folder through the dev server —
103
+ the GitHub link 404s until the branch is pushed. Absent in builds. */}
104
+ {import.meta.env.DEV && (
105
+ <a
106
+ href={`${import.meta.env.BASE_URL.replace(/\/$/, '')}/local-samples/${p.folder}/`}
107
+ target="_blank"
108
+ rel="noopener noreferrer"
109
+ class="aas-lift inline-flex items-center gap-1.5 rounded-lg border border-dashed border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-sm text-[var(--aas-text)] no-underline"
110
+ >
111
+ {t('project.viewLocallyPre')}
112
+ <code class="rounded bg-[var(--aas-tint)] px-1.5 py-0.5 text-xs">samples/{p.folder}</code>
113
+ {t('project.viewLocallyPost')}
114
+ <span class="text-[var(--aas-muted)]">&nearr;</span>
115
+ </a>
116
+ )}
117
+ </div>
118
+ </div>
119
+
120
+ {readme.titleInner && (
121
+ <div class="mt-5" data-readme>
122
+ <p class="text-sm text-[var(--aas-muted)]">{formatDate(p.date)}</p>
123
+ <span
124
+ class="mt-1 block text-2xl font-bold tracking-tight text-[var(--aas-text)]"
125
+ set:html={readme.titleInner}
126
+ />
127
+ <button
128
+ type="button"
129
+ data-readme-toggle
130
+ aria-expanded="true"
131
+ class="mt-2 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-sm text-[var(--aas-muted)] hover:bg-[var(--aas-tint)] hover:text-[var(--aas-text)]"
132
+ >
133
+ <ChevronDown class="aas-readme-chevron transition-transform" />
134
+ <span data-readme-label-expand>{t('project.readmeExpand')}</span>
135
+ <span data-readme-label-collapse>{t('project.readmeCollapse')}</span>
136
+ </button>
137
+ {readme.body && (
138
+ <Fragment>
139
+ <div class="prose mt-3 max-w-none" data-readme-body set:html={readme.body} />
140
+ {/* Second collapse control at the end of a long README. Hidden when
141
+ collapsed (the top button handles expanding), so there's never
142
+ two "expand" buttons. */}
143
+ <button
144
+ type="button"
145
+ data-readme-toggle
146
+ data-readme-bottom
147
+ aria-expanded="true"
148
+ class="mt-3 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-sm text-[var(--aas-muted)] hover:bg-[var(--aas-tint)] hover:text-[var(--aas-text)]"
149
+ >
150
+ <ChevronDown class="rotate-180" />
151
+ {t('project.readmeCollapse')}
152
+ </button>
153
+ </Fragment>
154
+ )}
155
+ </div>
156
+ )}
157
+
158
+ {p.files.length > 0 && (
159
+ <div
160
+ class:list={[
161
+ 'mt-6 lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:items-start lg:gap-4',
162
+ embedded ? 'aas-embed-proj' : '2xl:block',
163
+ ]}
164
+ >
165
+ {/* Sidebar — related tools + file tree, inline below 2xl; on 2xl+
166
+ both move to the right rail (rendered in StackDetail) since this
167
+ inline column is hidden here. */}
168
+ <div class:list={['space-y-5', embedded ? 'aas-embed-sidebar' : '2xl:hidden']}>
169
+ <ProjectRelatedTools lang={lang} tools={related.get(p.folder) ?? []} />
170
+ <ProjectFileList lang={lang} folder={p.folder} files={p.files} />
171
+ </div>
172
+
173
+ {/* Selected file content */}
174
+ <div class="mt-4 min-w-0 lg:mt-0">
175
+ {p.files.map((f, fi) => (
176
+ <div data-file-panel={fi} data-file-path={f.path} class:list={['scroll-mt-6', fi === 0 ? '' : 'hidden']}>
177
+ <figure class="overflow-hidden rounded-xl border border-[var(--aas-border)]">
178
+ <figcaption class="border-b border-[var(--aas-border)] bg-[var(--aas-panel)] px-3.5 py-2 font-mono text-xs text-[var(--aas-muted)]">
179
+ {f.path}
180
+ </figcaption>
181
+ <div class="aas-projfile overflow-x-auto" set:html={f.html} />
182
+ </figure>
183
+ </div>
184
+ ))}
185
+ </div>
186
+ </div>
187
+ )}
188
+ </div>
189
+ );
190
+ })
191
+ }
192
+
193
+ {/* Pre-paint: collapse the README before paint if the saved preference says so. */}
194
+ <script is:inline>
195
+ (function () {
196
+ try {
197
+ if (localStorage.getItem('aas:readme-collapsed') !== '1') return;
198
+ document.currentScript.parentElement.querySelectorAll('[data-readme]').forEach(function (r) {
199
+ r.querySelectorAll('[data-readme-toggle]').forEach(function (btn) {
200
+ btn.setAttribute('aria-expanded', 'false');
201
+ });
202
+ var body = r.querySelector('[data-readme-body]');
203
+ if (body) body.hidden = true;
204
+ });
205
+ } catch (e) {}
206
+ })();
207
+ </script>
208
+
209
+ {/* Pre-paint: restore the selected example/file from the URL with inline
210
+ display styles (applied before the stylesheet and the module script run),
211
+ so the chosen file shows immediately instead of flashing the first one.
212
+ The module script below clears these once it takes over. */}
213
+ <script is:inline>
214
+ (function () {
215
+ var root = document.currentScript.parentElement;
216
+ var params = new URLSearchParams(location.search);
217
+ var ex = params.get('ex');
218
+ var file = params.get('file');
219
+ if (!ex && !file) return;
220
+
221
+ var panels = Array.prototype.slice.call(root.querySelectorAll('[data-project-panel]'));
222
+ var active = null;
223
+ if (ex) {
224
+ panels.forEach(function (p) {
225
+ if (p.dataset.projectFolder === ex) active = p;
226
+ });
227
+ if (active && panels.length > 1) {
228
+ panels.forEach(function (p) {
229
+ p.style.display = p === active ? 'block' : 'none';
230
+ });
231
+ var current = root.querySelector('[data-project-current]');
232
+ root.querySelectorAll('[data-project-option]').forEach(function (o) {
233
+ var on = o.dataset.projectFolder === ex;
234
+ o.setAttribute('aria-current', String(on));
235
+ if (on && current) current.textContent = o.dataset.projectName || '';
236
+ });
237
+ }
238
+ }
239
+ if (!active) active = panels[0];
240
+ if (!file || !active) return;
241
+
242
+ var fpanels = Array.prototype.slice.call(active.querySelectorAll('[data-file-panel]'));
243
+ if (!fpanels.some((fp) => fp.dataset.filePath === file)) return;
244
+ fpanels.forEach(function (fp) {
245
+ fp.style.display = fp.dataset.filePath === file ? 'block' : 'none';
246
+ });
247
+ // Match every copy of this project's file buttons — the inline list AND
248
+ // the fixed right rail (≥2xl) — like the module script does, so the rail
249
+ // doesn't flash the default (first-file) highlight before it syncs.
250
+ document.querySelectorAll('[data-file][data-file-folder="' + active.dataset.projectFolder + '"]').forEach(function (b) {
251
+ b.setAttribute('aria-current', String(b.dataset.filePath === file));
252
+ });
253
+ })();
254
+ </script>
255
+ </div>
256
+
257
+ <style>
258
+ .aas-vdd {
259
+ position: relative;
260
+ }
261
+ .aas-vdd > summary::-webkit-details-marker {
262
+ display: none;
263
+ }
264
+ .aas-vdd [data-project-option][aria-current='true'] [data-check] {
265
+ opacity: 1;
266
+ }
267
+ .aas-projfile :global(pre.shiki) {
268
+ margin: 0;
269
+ padding: 1rem 1.1rem;
270
+ overflow-x: auto;
271
+ }
272
+ /* README toggle: rotate the chevron and swap the label by state. */
273
+ [data-readme-toggle][aria-expanded='false'] .aas-readme-chevron {
274
+ transform: rotate(-90deg);
275
+ }
276
+ [data-readme-toggle][aria-expanded='true'] [data-readme-label-expand],
277
+ [data-readme-toggle][aria-expanded='false'] [data-readme-label-collapse] {
278
+ display: none;
279
+ }
280
+ /* The bottom collapse button only exists while expanded, so a collapsed README
281
+ never shows two "expand" controls. */
282
+ [data-readme-bottom][aria-expanded='false'] {
283
+ display: none;
284
+ }
285
+ </style>
286
+
287
+ <script>
288
+ // The active tab lives in the URL #hash (see DetailTabs); the selected example
289
+ // and file live in query params so a refresh restores the exact view.
290
+ const PARAM_EX = 'ex'; // sample project folder, e.g. "langgraph_1"
291
+ const PARAM_FILE = 'file'; // file path within the project, e.g. "app.py"
292
+
293
+ document.querySelectorAll<HTMLElement>('[data-projects]').forEach((root) => {
294
+ const projDD = root.querySelector<HTMLDetailsElement>('[data-project-dd]');
295
+ const projCurrent = root.querySelector<HTMLElement>('[data-project-current]');
296
+ const projOptions = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-project-option]'));
297
+ const projPanels = Array.from(root.querySelectorAll<HTMLElement>('[data-project-panel]'));
298
+ const multi = projPanels.length > 1;
299
+
300
+ // Update the query params while leaving the path and #hash (tab) untouched.
301
+ function writeUrl(folder: string | null, file: string | null) {
302
+ const url = new URL(location.href);
303
+ if (multi && folder) url.searchParams.set(PARAM_EX, folder);
304
+ else url.searchParams.delete(PARAM_EX);
305
+ if (file) url.searchParams.set(PARAM_FILE, file);
306
+ else url.searchParams.delete(PARAM_FILE);
307
+ history.replaceState(null, '', url);
308
+ }
309
+
310
+ // File buttons exist in two places: inline next to the code (narrow screens)
311
+ // and in the fixed right rail (≥2xl). Both carry data-file-folder, so we
312
+ // match them document-wide by folder and keep their state in sync.
313
+ function fileButtonsFor(folder: string) {
314
+ return Array.from(
315
+ document.querySelectorAll<HTMLButtonElement>(`[data-file][data-file-folder="${folder}"]`),
316
+ );
317
+ }
318
+
319
+ function selectFile(panel: HTMLElement, file: string | null, write: boolean) {
320
+ const folder = panel.dataset.projectFolder ?? '';
321
+ const filePanels = Array.from(panel.querySelectorAll<HTMLElement>('[data-file-panel]'));
322
+ if (!filePanels.length) return;
323
+ const fileBtns = fileButtonsFor(folder);
324
+ const btn = fileBtns.find((b) => b.dataset.filePath === file);
325
+ const sel = btn?.dataset.filePath ?? filePanels[0].dataset.filePath ?? null;
326
+ filePanels.forEach((fp) => {
327
+ fp.style.removeProperty('display'); // clear the pre-paint inline style
328
+ fp.classList.toggle('hidden', fp.dataset.filePath !== sel);
329
+ });
330
+ fileBtns.forEach((b) => b.setAttribute('aria-current', String(b.dataset.filePath === sel)));
331
+ if (write) writeUrl(folder || null, sel);
332
+ }
333
+
334
+ function selectProject(folder: string | null, write: boolean) {
335
+ const panel = projPanels.find((p) => p.dataset.projectFolder === folder) ?? projPanels[0];
336
+ if (!panel) return;
337
+ const opt = projOptions.find((o) => o.dataset.projectFolder === panel.dataset.projectFolder);
338
+ projPanels.forEach((p) => {
339
+ p.style.removeProperty('display'); // clear the pre-paint inline style
340
+ p.classList.toggle('hidden', p !== panel);
341
+ });
342
+ projOptions.forEach((o) => o.setAttribute('aria-current', String(o === opt)));
343
+ if (projCurrent && opt) projCurrent.textContent = opt.dataset.projectName ?? '';
344
+ projDD?.removeAttribute('open');
345
+ // Let the rail swap its related-tools block to match the example.
346
+ document.dispatchEvent(
347
+ new CustomEvent('aas:project', { detail: { folder: panel.dataset.projectFolder } }),
348
+ );
349
+ // Switching examples resets to that example's first file.
350
+ selectFile(panel, null, write);
351
+ }
352
+
353
+ projOptions.forEach((opt) =>
354
+ opt.addEventListener('click', () => selectProject(opt.dataset.projectFolder ?? null, true)),
355
+ );
356
+
357
+ // Bring the selected file's code into view. The file list (especially the
358
+ // right rail) sits far above the code on a long README, so a click there
359
+ // would otherwise look like nothing happened.
360
+ function scrollToFile(panel: HTMLElement) {
361
+ const visible = panel.querySelector<HTMLElement>('[data-file-panel]:not(.hidden)');
362
+ visible?.scrollIntoView({ behavior: 'smooth', block: 'start' });
363
+ }
364
+
365
+ projPanels.forEach((panel) =>
366
+ fileButtonsFor(panel.dataset.projectFolder ?? '').forEach((btn) =>
367
+ btn.addEventListener('click', () => {
368
+ selectFile(panel, btn.dataset.filePath ?? null, true);
369
+ scrollToFile(panel);
370
+ }),
371
+ ),
372
+ );
373
+
374
+ // Restore the selection from the URL on load (without rewriting it).
375
+ const params = new URLSearchParams(location.search);
376
+ if (multi && params.get(PARAM_EX)) selectProject(params.get(PARAM_EX), false);
377
+ const active = projPanels.find((p) => !p.classList.contains('hidden')) ?? projPanels[0];
378
+ if (active && params.get(PARAM_FILE)) selectFile(active, params.get(PARAM_FILE), false);
379
+
380
+ // Reflect the current example/file in the URL whenever the impl tab becomes
381
+ // active, so even a default (un-clicked) impl view is shareable/restorable.
382
+ // DetailTabs broadcasts the initial tab on a microtask, so this also fires
383
+ // when impl is the landing tab.
384
+ function reflectSelection() {
385
+ const panel = projPanels.find((p) => !p.classList.contains('hidden')) ?? projPanels[0];
386
+ const folder = panel?.dataset.projectFolder ?? null;
387
+ const file =
388
+ fileButtonsFor(folder ?? '').find((b) => b.getAttribute('aria-current') === 'true')?.dataset
389
+ .filePath ?? null;
390
+ writeUrl(folder, file);
391
+ }
392
+ document.addEventListener('aas:tabchange', (e) => {
393
+ if ((e as CustomEvent<{ id: string }>).detail.id === 'impl') reflectSelection();
394
+ });
395
+ });
396
+ </script>
397
+
398
+ <script>
399
+ // Collapsible README, remembered in localStorage (one preference for all
400
+ // implementation samples). The inline pre-paint above applies it before paint;
401
+ // this binds the toggle and keeps every README in sync.
402
+ const README_KEY = 'aas:readme-collapsed';
403
+ const isReadmeCollapsed = () => {
404
+ try {
405
+ return localStorage.getItem(README_KEY) === '1';
406
+ } catch {
407
+ return false;
408
+ }
409
+ };
410
+ function applyReadme(collapsed: boolean) {
411
+ document.querySelectorAll<HTMLElement>('[data-readme]').forEach((r) => {
412
+ r.querySelectorAll<HTMLButtonElement>('[data-readme-toggle]').forEach((btn) =>
413
+ btn.setAttribute('aria-expanded', String(!collapsed)),
414
+ );
415
+ const body = r.querySelector<HTMLElement>('[data-readme-body]');
416
+ if (body) body.hidden = collapsed;
417
+ });
418
+ }
419
+ applyReadme(isReadmeCollapsed());
420
+ document.querySelectorAll<HTMLButtonElement>('[data-readme-toggle]').forEach((btn) =>
421
+ btn.addEventListener('click', () => {
422
+ const collapsed = !isReadmeCollapsed();
423
+ try {
424
+ localStorage.setItem(README_KEY, collapsed ? '1' : '0');
425
+ } catch {
426
+ /* private mode — best effort */
427
+ }
428
+ applyReadme(collapsed);
429
+ }),
430
+ );
431
+ </script>
@@ -0,0 +1,52 @@
1
+ ---
2
+ // "Related writing" card section, shared by stack / concept / article detail
3
+ // pages. Mirrors RelatedConcepts (image + title + date + description, in a
4
+ // collapsible grid) so articles and concepts read consistently.
5
+ import { getRelativeLocaleUrl } from 'astro:i18n';
6
+ import { Image } from 'astro:assets';
7
+ import { articleSlugOf, type ArticleEntry } from '../lib/articles';
8
+ import { useTranslations, type Lang } from '../i18n/ui';
9
+ import { formatDate } from '../lib/dates';
10
+ import { inlineMd } from '../lib/inline-md';
11
+ import CollapsibleGrid from './CollapsibleGrid.astro';
12
+
13
+ interface Props {
14
+ lang: Lang;
15
+ articles: ArticleEntry[];
16
+ }
17
+
18
+ const { lang, articles } = Astro.props;
19
+ const t = useTranslations(lang);
20
+ ---
21
+
22
+ {
23
+ articles.length > 0 && (
24
+ <section class="mt-8">
25
+ <h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
26
+ {t('detail.relatedWriting')}
27
+ </h2>
28
+ <CollapsibleGrid lang={lang} count={articles.length}>
29
+ {articles.map((a) => (
30
+ <a
31
+ href={getRelativeLocaleUrl(lang, `article/${articleSlugOf(a)}/`)}
32
+ class="aas-lift flex items-stretch overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
33
+ >
34
+ {a.data.image && (
35
+ <Image src={a.data.image} alt="" class="w-28 shrink-0 self-stretch object-cover" />
36
+ )}
37
+ <div class="min-w-0 p-4">
38
+ <span class="block font-semibold text-[var(--aas-text)]">{a.data.title}</span>
39
+ <span class="mt-0.5 block text-xs text-[var(--aas-muted)]">
40
+ {formatDate(a.data.date, lang)}
41
+ </span>
42
+ <span
43
+ class="aas-md mt-1.5 line-clamp-2 text-xs leading-relaxed text-[var(--aas-muted)] opacity-75"
44
+ set:html={inlineMd(a.data.description)}
45
+ />
46
+ </div>
47
+ </a>
48
+ ))}
49
+ </CollapsibleGrid>
50
+ </section>
51
+ )
52
+ }
@@ -0,0 +1,60 @@
1
+ ---
2
+ // "Related concepts" card section, shared by stack / concept / article detail
3
+ // pages so they all present concept links the same way (image + title + doc
4
+ // meta + description, in a collapsible grid).
5
+ import { getRelativeLocaleUrl } from 'astro:i18n';
6
+ import { Image } from 'astro:assets';
7
+ import { conceptSlugOf, type ConceptEntry } from '../lib/concepts';
8
+ import { useTranslations, type Lang } from '../i18n/ui';
9
+ import { formatDate } from '../lib/dates';
10
+ import { inlineMd } from '../lib/inline-md';
11
+ import CollapsibleGrid from './CollapsibleGrid.astro';
12
+
13
+ interface Props {
14
+ lang: Lang;
15
+ concepts: ConceptEntry[];
16
+ }
17
+
18
+ const { lang, concepts } = Astro.props;
19
+ const t = useTranslations(lang);
20
+ ---
21
+
22
+ {
23
+ concepts.length > 0 && (
24
+ <section class="mt-8">
25
+ <h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
26
+ {t('concept.relatedConcepts')}
27
+ </h2>
28
+ <CollapsibleGrid lang={lang} count={concepts.length}>
29
+ {concepts.map((c) => (
30
+ <a
31
+ href={getRelativeLocaleUrl(lang, `concept/${conceptSlugOf(c)}/`)}
32
+ class="aas-lift flex items-stretch overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
33
+ >
34
+ {c.data.image && (
35
+ <Image src={c.data.image} alt="" class="w-28 shrink-0 self-stretch object-cover" />
36
+ )}
37
+ <div class="min-w-0 p-4">
38
+ <span class="block font-semibold text-[var(--aas-text)]">{c.data.title}</span>
39
+ {(c.data.version || c.data.updated) && (
40
+ <span class="mt-0.5 block text-xs text-[var(--aas-muted)]">
41
+ {c.data.version && <span>{t('meta.docVersion')} {c.data.version}</span>}
42
+ {c.data.version && c.data.updated && <span> · </span>}
43
+ {c.data.updated && (
44
+ <span>
45
+ {t('concept.updated')} {formatDate(c.data.updated, lang)}
46
+ </span>
47
+ )}
48
+ </span>
49
+ )}
50
+ <span
51
+ class="aas-md mt-1.5 line-clamp-2 text-xs leading-relaxed text-[var(--aas-muted)] opacity-75"
52
+ set:html={inlineMd(c.data.description)}
53
+ />
54
+ </div>
55
+ </a>
56
+ ))}
57
+ </CollapsibleGrid>
58
+ </section>
59
+ )
60
+ }
@@ -0,0 +1,55 @@
1
+ ---
2
+ import { getRelativeLocaleUrl } from 'astro:i18n';
3
+ import { getStacks, slugOf } from '../lib/stacks';
4
+ import { categoryMap } from '@aas-data/categories';
5
+ import { useTranslations, type Lang } from '../i18n/ui';
6
+ import ToolAvatar from './ToolAvatar.astro';
7
+ import CollapsibleGrid from './CollapsibleGrid.astro';
8
+
9
+ interface Props {
10
+ lang: Lang;
11
+ slugs: string[];
12
+ }
13
+
14
+ const { lang, slugs } = Astro.props;
15
+ const t = useTranslations(lang);
16
+
17
+ const stacks = await getStacks(lang);
18
+ const bySlug = new Map(stacks.map((s) => [slugOf(s), s]));
19
+ const related = slugs.map((s) => bySlug.get(s)).filter(Boolean) as NonNullable<
20
+ ReturnType<typeof bySlug.get>
21
+ >[];
22
+ ---
23
+
24
+ {
25
+ related.length > 0 && (
26
+ <section class="mt-8">
27
+ <h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
28
+ {t('detail.relatedTools')}
29
+ </h2>
30
+ <CollapsibleGrid lang={lang} count={related.length}>
31
+ {related.map((e) => (
32
+ <a
33
+ href={getRelativeLocaleUrl(lang, `stack/${slugOf(e)}/`)}
34
+ class="aas-lift flex items-center gap-3 rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] p-3 no-underline"
35
+ >
36
+ <ToolAvatar name={e.data.name} logo={e.data.logo} logoDark={e.data.logoDark} category={e.data.category} size={36} />
37
+ <div class="min-w-0">
38
+ <div class="flex items-center gap-2">
39
+ <span class="truncate font-semibold text-[var(--aas-text)]">{e.data.name}</span>
40
+ {e.data.deprecated && (
41
+ <span class="aas-deprecated shrink-0 rounded px-1.5 py-0.5 text-[0.65rem]">
42
+ {t('meta.deprecated')}
43
+ </span>
44
+ )}
45
+ </div>
46
+ <div class="truncate text-xs text-[var(--aas-muted)]">
47
+ {categoryMap.get(e.data.category)?.label[lang]}
48
+ </div>
49
+ </div>
50
+ </a>
51
+ ))}
52
+ </CollapsibleGrid>
53
+ </section>
54
+ )
55
+ }