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,507 @@
1
+ ---
2
+ import { render } from 'astro:content';
3
+ import { Image } from 'astro:assets';
4
+ import { getRelativeLocaleUrl } from 'astro:i18n';
5
+ import { pathOf } from '@aas-data/categories';
6
+ import { pricingLabels, useTranslations, type Lang } from '../i18n/ui';
7
+ import { pricingTags, COMPLETELY_FREE } from '../lib/pricing';
8
+ import StarIcon from './StarIcon.astro';
9
+ import { slugOf, slugifyName, toolResolver, type StackEntry } from '../lib/stacks';
10
+ import { isImageRef, resolveImageUrl } from '../lib/assets';
11
+ import { inlineMd } from '../lib/inline-md';
12
+ import { getArticlesForTool, articleSlugOf } from '../lib/articles';
13
+ import { getConceptsForTool, conceptSlugOf } from '../lib/concepts';
14
+ import ToolAvatar from './ToolAvatar.astro';
15
+ import ToolMeta from './ToolMeta.astro';
16
+ import Breadcrumb from './Breadcrumb.astro';
17
+ import PricingSection from './PricingSection.astro';
18
+ import DetailTabs from './DetailTabs.astro';
19
+ import CodeSamples from './CodeSamples.astro';
20
+ import MermaidLoader from './MermaidLoader.astro';
21
+ import RelatedTools from './RelatedTools.astro';
22
+ import CollapsibleGrid from './CollapsibleGrid.astro';
23
+ import ProjectViewer from './ProjectViewer.astro';
24
+ import ProjectRelatedTools from './ProjectRelatedTools.astro';
25
+ import ProjectFileList from './ProjectFileList.astro';
26
+ import Toc from './Toc.astro';
27
+ import { getRepoStats, stalenessOf, formatStars } from '../lib/github';
28
+ import { formatDate, isoDate } from '../lib/dates';
29
+ import { renderSamples } from '../lib/samples';
30
+ import { renderProjects } from '../lib/project';
31
+
32
+ interface Props {
33
+ entry: StackEntry;
34
+ lang: Lang;
35
+ }
36
+
37
+ const { entry, lang } = Astro.props;
38
+ const t = useTranslations(lang);
39
+ const { Content, headings } = await render(entry);
40
+ const data = entry.data;
41
+ // The overview body can contain a Mermaid diagram even with no samples/projects
42
+ // (and thus no tabs), so the loader must not be gated on the tabs alone.
43
+ const overviewHasMermaid = (entry.body ?? '').includes('```mermaid');
44
+ // Breadcrumb: All tools › category › subcategory… (each segment linked).
45
+ const crumbs = [
46
+ { label: t('detail.allTools'), href: getRelativeLocaleUrl(lang, '') },
47
+ ...pathOf(data.category).map((c) => ({
48
+ label: c.label[lang],
49
+ href: getRelativeLocaleUrl(lang, `categories/${c.id}/`),
50
+ })),
51
+ ];
52
+
53
+ // Right-rail table of contents: body headings, h2–h3 only.
54
+ const tocItems = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
55
+
56
+ // Backlinks: writing that references this tool, and concepts that use it.
57
+ const relatedArticles = await getArticlesForTool(lang, slugOf(entry));
58
+ const relatedConcepts = await getConceptsForTool(lang, slugOf(entry));
59
+
60
+ // Live GitHub stats (build-time), with the manual `version` as fallback.
61
+ const stats = await getRepoStats(data.repo);
62
+ const displayVersion = stats?.version ?? data.version;
63
+
64
+ // Resolved logo URL for the hero card's blurred ambient backdrop (image logos
65
+ // only; monograms/emoji have nothing to blur).
66
+ const logoUrl = isImageRef(data.logo) ? resolveImageUrl(data.logo) : undefined;
67
+ // Vendor link slug; empty (e.g. a non-Latin vendor name) → plain text, no link.
68
+ const vendorSlug = data.vendor ? slugifyName(data.vendor) : '';
69
+ // Warn when an open-source project's latest release is getting old (6m / 1y).
70
+ const staleness = data.pricing.includes('open-source') ? stalenessOf(stats?.releasedAt) : 'fresh';
71
+
72
+ const links = [
73
+ data.website && { label: t('detail.website'), href: data.website },
74
+ data.repo && { label: t('detail.repository'), href: data.repo },
75
+ data.docs && { label: t('detail.docs'), href: data.docs },
76
+ ].filter(Boolean) as { label: string; href: string }[];
77
+
78
+ const hasSamples = data.samples.length > 0;
79
+ const renderedSamples = hasSamples ? await renderSamples(data.samples) : [];
80
+
81
+ // Real runnable mini-projects (samples/<folder>/) listed in frontmatter. Each
82
+ // entry is a bare folder name or { folder, related: [slugs] }; normalize both.
83
+ const projectSpecs = data.projects.map((p) =>
84
+ typeof p === 'string' ? { folder: p, related: [] as string[] } : { folder: p.folder, related: p.related },
85
+ );
86
+ const projects = projectSpecs.length
87
+ ? await renderProjects(projectSpecs.map((p) => p.folder), lang)
88
+ : [];
89
+ const hasProject = projects.length > 0;
90
+ const hasPricing = Boolean(data.pricingNote || data.pricingTiers.length);
91
+ const hasTabs = hasSamples || hasProject || hasPricing;
92
+
93
+ // Resolve each project's related tool slugs against the stacks collection. A
94
+ // known slug links to its page; an unknown one shows as "not in our catalog".
95
+ const relatedByFolder = new Map(projectSpecs.map((p) => [p.folder, p.related]));
96
+ const resolveTool = await toolResolver(lang);
97
+ // The right rail's impl block carries, per example: its related tools plus its
98
+ // file list (the file list lives here on ≥2xl screens, inline otherwise).
99
+ const projectRail = projects.map((p) => ({
100
+ folder: p.folder,
101
+ files: p.files,
102
+ headings: p.headings,
103
+ tools: (relatedByFolder.get(p.folder) ?? []).map(resolveTool),
104
+ }));
105
+ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools]));
106
+ ---
107
+
108
+ <Breadcrumb segments={crumbs} />
109
+
110
+ <header class="mt-4">
111
+ <div class="relative isolate overflow-hidden rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-panel)] p-6 sm:p-8">
112
+ {/* Ambient blurred-logo backdrop, echoing the listing cards' tint. */}
113
+ {logoUrl && (
114
+ <div class="aas-hero-bg" aria-hidden="true" style={`background-image:url("${logoUrl}")`} />
115
+ )}
116
+
117
+ <div class="flex flex-col gap-5 min-[900px]:flex-row min-[900px]:items-start">
118
+ {/* Left: identity, description, links, facts, tags. */}
119
+ <div class="min-w-0 flex-1">
120
+ <div class="flex items-center gap-4">
121
+ <ToolAvatar name={data.name} logo={data.logo} logoDark={data.logoDark} category={data.category} size={56} />
122
+ <div class="min-w-0">
123
+ <h1 class="text-3xl font-bold tracking-tight">{data.name}</h1>
124
+ {data.vendor && (
125
+ <p class="mt-0.5 text-sm text-[var(--aas-muted)]">
126
+ {t('vendor.heading')}{' '}
127
+ {vendorSlug ? (
128
+ <a
129
+ href={getRelativeLocaleUrl(lang, `vendors/${vendorSlug}/`)}
130
+ class="text-[var(--aas-accent)] no-underline hover:underline"
131
+ >
132
+ {data.vendor}
133
+ </a>
134
+ ) : (
135
+ <span>{data.vendor}</span>
136
+ )}
137
+ </p>
138
+ )}
139
+ {data.formerNames.length > 0 && (
140
+ <p class="mt-0.5 text-sm text-[var(--aas-muted)]">
141
+ {t('meta.formerly')} {data.formerNames.join(' · ')}
142
+ </p>
143
+ )}
144
+ </div>
145
+ </div>
146
+
147
+ <p class="mt-3 aas-md max-w-2xl text-lg text-[var(--aas-muted)]" set:html={inlineMd(data.description)} />
148
+
149
+ <div class="mt-4 flex flex-wrap gap-2 text-sm">
150
+ {
151
+ links.map((l) => (
152
+ <a
153
+ href={l.href}
154
+ target="_blank"
155
+ rel="noopener noreferrer"
156
+ class="aas-lift rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-[var(--aas-text)] no-underline"
157
+ >
158
+ {l.label} <span class="text-[var(--aas-muted)]">&nearr;</span>
159
+ </a>
160
+ ))
161
+ }
162
+ </div>
163
+
164
+ <ToolMeta
165
+ lang={lang}
166
+ pricing={[]}
167
+ license={data.license}
168
+ language={data.language}
169
+ version={displayVersion}
170
+ released={stats?.releasedAt?.slice(0, 10)}
171
+ deprecated={data.deprecated}
172
+ staleness={staleness}
173
+ class="mt-5"
174
+ />
175
+
176
+ {
177
+ data.tags.length > 0 && (
178
+ <div class="mt-4 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--aas-muted)]">
179
+ {data.tags.map((tag) => (
180
+ <a
181
+ href={getRelativeLocaleUrl(lang, `tags/${tag}/`)}
182
+ class="no-underline opacity-80 hover:text-[var(--aas-accent)] hover:opacity-100"
183
+ >
184
+ #{tag}
185
+ </a>
186
+ ))}
187
+ </div>
188
+ )
189
+ }
190
+ </div>
191
+
192
+ {/* Right: key facts — stars + coloured pricing chips, as on the cards. */}
193
+ <div class="flex shrink-0 flex-wrap gap-1.5 text-xs min-[900px]:flex-col min-[900px]:items-end">
194
+ {
195
+ typeof stats?.stars === 'number' && (
196
+ <span class="aas-chip inline-flex items-center gap-1 rounded-lg px-2.5 py-1">
197
+ <StarIcon class="text-[var(--aas-text)]" />
198
+ {formatStars(stats.stars)}
199
+ </span>
200
+ )
201
+ }
202
+ {
203
+ pricingTags(data.pricing).map((p) => (
204
+ <span
205
+ data-pricing-tag={p}
206
+ title={pricingLabels[lang][p] ?? p}
207
+ class:list={[p === COMPLETELY_FREE ? 'aas-free' : 'aas-pricing', 'inline-flex items-center gap-1 rounded-lg px-2.5 py-1']}
208
+ >
209
+ {p === 'open-source' && (
210
+ <svg
211
+ class="aas-oss-icon"
212
+ width="15"
213
+ height="15"
214
+ viewBox="0 0 24 24"
215
+ fill="none"
216
+ stroke="currentColor"
217
+ stroke-width="2"
218
+ stroke-linecap="round"
219
+ stroke-linejoin="round"
220
+ aria-hidden="true"
221
+ >
222
+ <polyline points="16 18 22 12 16 6" />
223
+ <polyline points="8 6 2 12 8 18" />
224
+ </svg>
225
+ )}
226
+ <span>{pricingLabels[lang][p] ?? p}</span>
227
+ </span>
228
+ ))
229
+ }
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+ {/* Editorial meta sits outside the card, just below it. */}
235
+ {
236
+ (data.docVersion || data.updated) && (
237
+ <p class="mt-2 px-1 text-xs text-[var(--aas-muted)]">
238
+ {data.docVersion && <span>{t('meta.docVersion')} {data.docVersion}</span>}
239
+ {data.docVersion && data.updated && <span> · </span>}
240
+ {data.updated && (
241
+ <span>
242
+ {t('meta.updated')}{' '}
243
+ <time datetime={isoDate(data.updated)}>{formatDate(data.updated, lang)}</time>
244
+ </span>
245
+ )}
246
+ </p>
247
+ )
248
+ }
249
+ </header>
250
+
251
+ {/* Concepts that use this tool (computed backlink), shown above related tools. */}
252
+ {
253
+ relatedConcepts.length > 0 && (
254
+ <section class="mt-8">
255
+ <h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
256
+ {t('concept.relatedConcepts')}
257
+ </h2>
258
+ <CollapsibleGrid lang={lang} count={relatedConcepts.length}>
259
+ {relatedConcepts.map((c) => (
260
+ <a
261
+ href={getRelativeLocaleUrl(lang, `concept/${conceptSlugOf(c)}/`)}
262
+ class="aas-lift flex items-stretch overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
263
+ >
264
+ {c.data.image && (
265
+ <Image src={c.data.image} alt="" class="w-28 shrink-0 self-stretch object-cover" />
266
+ )}
267
+ <div class="min-w-0 p-4">
268
+ <span class="block font-semibold text-[var(--aas-text)]">{c.data.title}</span>
269
+ {(c.data.version || c.data.updated) && (
270
+ <span class="mt-0.5 block text-xs text-[var(--aas-muted)]">
271
+ {c.data.version && <span>{t('meta.docVersion')} {c.data.version}</span>}
272
+ {c.data.version && c.data.updated && <span> · </span>}
273
+ {c.data.updated && (
274
+ <span>
275
+ {t('concept.updated')} {formatDate(c.data.updated, lang)}
276
+ </span>
277
+ )}
278
+ </span>
279
+ )}
280
+ <span
281
+ class="aas-md mt-1.5 line-clamp-2 text-xs leading-relaxed text-[var(--aas-muted)] opacity-75"
282
+ set:html={inlineMd(c.data.description)}
283
+ />
284
+ </div>
285
+ </a>
286
+ ))}
287
+ </CollapsibleGrid>
288
+ </section>
289
+ )
290
+ }
291
+
292
+ <RelatedTools lang={lang} slugs={data.related} />
293
+
294
+ {/* Content + a sticky right-rail TOC, side by side in one centered column. */}
295
+ <div class="mt-8 flex items-start gap-8">
296
+ <div class="min-w-0 flex-1 aas-reading">
297
+ {
298
+ hasTabs ? (
299
+ <DetailTabs
300
+ overviewLabel={t('tab.overview')}
301
+ pricingLabel={hasPricing ? t('tab.pricing') : undefined}
302
+ codeLabel={hasSamples ? t('tab.code') : undefined}
303
+ implLabel={hasProject ? t('tab.impl') : undefined}
304
+ >
305
+ <article slot="overview" class="prose max-w-none">
306
+ <Content />
307
+ </article>
308
+ {hasPricing && (
309
+ <article slot="pricing" class="prose max-w-none">
310
+ <PricingSection
311
+ lang={lang}
312
+ note={data.pricingNote}
313
+ tiers={data.pricingTiers}
314
+ source={data.pricingSource}
315
+ checkedAt={data.pricingCheckedAt}
316
+ />
317
+ </article>
318
+ )}
319
+ {hasSamples && (
320
+ <div slot="code">
321
+ <CodeSamples samples={renderedSamples} lang={lang} />
322
+ </div>
323
+ )}
324
+ {hasProject && (
325
+ <div slot="impl">
326
+ <ProjectViewer lang={lang} projects={projects} related={relatedToolsByFolder} />
327
+ </div>
328
+ )}
329
+ </DetailTabs>
330
+ ) : (
331
+ <article class="prose max-w-none">
332
+ <Content />
333
+ </article>
334
+ )
335
+ }
336
+ </div>
337
+
338
+ {/* Fixed right-rail TOC: follows scroll, swaps with the active tab, and hides
339
+ near the footer. */}
340
+ <aside class="aas-toc-rail" data-toc-rail>
341
+ <div class="aas-toc-scroll">
342
+ <div data-toc="overview">
343
+ <Toc items={tocItems} title={t('toc.onThisPage')} />
344
+ </div>
345
+ {
346
+ hasSamples && (
347
+ <div data-toc="code" class="hidden">
348
+ {renderedSamples.map((s, i) => (
349
+ <div data-sample-toc={i} class:list={[i === 0 ? '' : 'hidden']}>
350
+ <Toc items={s.headings} title={t('toc.onThisPage')} />
351
+ </div>
352
+ ))}
353
+ </div>
354
+ )
355
+ }
356
+ {
357
+ hasProject && (
358
+ <div data-toc="impl" class="hidden">
359
+ {projectRail.map((pr, i) => (
360
+ <div data-project-related={pr.folder} class:list={[i === 0 ? '' : 'hidden', 'space-y-5']}>
361
+ <Toc items={pr.headings} title={t('toc.onThisPage')} />
362
+ <ProjectRelatedTools lang={lang} tools={pr.tools} />
363
+ <ProjectFileList lang={lang} folder={pr.folder} files={pr.files} />
364
+ </div>
365
+ ))}
366
+ </div>
367
+ )
368
+ }
369
+ </div>
370
+ <div class="aas-toc-foot">
371
+ <button type="button" class="aas-toc-top" data-back-to-top>↑ {t('top.label')}</button>
372
+ <button
373
+ type="button"
374
+ class="aas-toc-allbtn"
375
+ data-toggle-all
376
+ data-collapse={t('toc.collapseAll')}
377
+ data-expand={t('toc.expandAll')}
378
+ hidden
379
+ >
380
+ {t('toc.collapseAll')}
381
+ </button>
382
+ </div>
383
+ </aside>
384
+ </div>
385
+
386
+ {(hasSamples || hasProject || overviewHasMermaid) && <MermaidLoader />}
387
+
388
+ {/* Restore collapsed file-tree folders from localStorage before first paint
389
+ (placed after every tree in the DOM, so no expand→collapse flash). */}
390
+ {hasProject && (
391
+ <script is:inline>
392
+ (function () {
393
+ try {
394
+ var collapsed = new Set(JSON.parse(localStorage.getItem('aas:tree-collapsed') || '[]'));
395
+ document.querySelectorAll('details.aas-tree-dir').forEach(function (d) {
396
+ if (collapsed.has(d.dataset.treeFolder + '::' + d.dataset.treeDir)) d.removeAttribute('open');
397
+ });
398
+ } catch (e) {}
399
+ })();
400
+ </script>
401
+ )}
402
+
403
+ <script>
404
+ import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
405
+
406
+ const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
407
+ // Scroll-spy + back-to-top + prose-<details> persistence, shared with
408
+ // TocRail.astro. The tab/version/example handlers below re-run the spy after
409
+ // changing which headings are visible.
410
+ const spy = rail ? startScrollSpy(rail) : () => {};
411
+ if (rail) {
412
+ initBackToTop(rail);
413
+ initReveals(rail);
414
+ }
415
+
416
+ // Swap the rail's TOC with the active tab.
417
+ const syncToc = (id: string) =>
418
+ rail
419
+ ?.querySelectorAll<HTMLElement>('[data-toc]')
420
+ .forEach((el) => el.classList.toggle('hidden', el.dataset.toc !== id));
421
+
422
+ document.addEventListener('aas:tabchange', (e) => {
423
+ syncToc((e as CustomEvent<{ id: string }>).detail.id);
424
+ spy();
425
+ });
426
+
427
+ const active = document.querySelector<HTMLElement>('[data-tab][aria-selected="true"]');
428
+ if (active?.dataset.tab) syncToc(active.dataset.tab);
429
+
430
+ // Impl tab: the rail's related-tools block depends on the selected example.
431
+ const syncProjectRelated = (folder: string) =>
432
+ rail
433
+ ?.querySelectorAll<HTMLElement>('[data-project-related]')
434
+ .forEach((b) => b.classList.toggle('hidden', b.dataset.projectRelated !== folder));
435
+
436
+ document.addEventListener('aas:project', (e) => {
437
+ syncProjectRelated((e as CustomEvent<{ folder: string }>).detail.folder);
438
+ spy();
439
+ });
440
+
441
+ // Initial example (ProjectViewer may have restored one from the URL already).
442
+ const activeProj = document.querySelector<HTMLElement>('[data-project-panel]:not(.hidden)');
443
+ if (activeProj?.dataset.projectFolder) syncProjectRelated(activeProj.dataset.projectFolder);
444
+
445
+ // Code tab: show the selected version's internal headings (CodeSamples emits
446
+ // `aas:sample` when the version changes).
447
+ const tocBlocks = Array.from(document.querySelectorAll<HTMLElement>('[data-sample-toc]'));
448
+ document.addEventListener('aas:sample', (e) => {
449
+ const idx = String((e as CustomEvent<{ index: number }>).detail.index);
450
+ tocBlocks.forEach((b) => b.classList.toggle('hidden', b.dataset.sampleToc !== idx));
451
+ spy();
452
+ });
453
+
454
+ // Re-run once after the initial tab/example sync above settled visibility.
455
+ spy();
456
+
457
+ // Persist file-tree folder collapse to localStorage. The same folder shows
458
+ // twice (inline list + rail), so toggling one mirrors the other. The initial
459
+ // closed state is applied flash-free by the inline script above.
460
+ const TREE_KEY = 'aas:tree-collapsed';
461
+ const treeDirs = Array.from(document.querySelectorAll<HTMLDetailsElement>('details.aas-tree-dir'));
462
+ const treeKey = (d: HTMLDetailsElement) => `${d.dataset.treeFolder}::${d.dataset.treeDir}`;
463
+ const loadCollapsed = (): Set<string> => {
464
+ try {
465
+ return new Set(JSON.parse(localStorage.getItem(TREE_KEY) || '[]'));
466
+ } catch {
467
+ return new Set();
468
+ }
469
+ };
470
+ treeDirs.forEach((d) =>
471
+ d.addEventListener('toggle', () => {
472
+ const key = treeKey(d);
473
+ const set = loadCollapsed();
474
+ d.open ? set.delete(key) : set.add(key);
475
+ try {
476
+ localStorage.setItem(TREE_KEY, JSON.stringify([...set]));
477
+ } catch {}
478
+ // Mirror the other copy of this folder (guard prevents a toggle loop).
479
+ treeDirs.forEach((o) => {
480
+ if (o !== d && treeKey(o) === key && o.open !== d.open) o.open = d.open;
481
+ });
482
+ }),
483
+ );
484
+ </script>
485
+
486
+ {
487
+ relatedArticles.length > 0 && (
488
+ <aside class="mt-10 border-t border-[var(--aas-border)] pt-6">
489
+ <h2 class="text-sm font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
490
+ {t('detail.relatedWriting')}
491
+ </h2>
492
+ <ul class="mt-3 space-y-2">
493
+ {relatedArticles.map((a) => (
494
+ <li>
495
+ <a
496
+ href={getRelativeLocaleUrl(lang, `article/${articleSlugOf(a)}/`)}
497
+ class="text-[var(--aas-accent)] no-underline hover:underline"
498
+ >
499
+ {a.data.title}
500
+ </a>
501
+ </li>
502
+ ))}
503
+ </ul>
504
+ </aside>
505
+ )
506
+ }
507
+
@@ -0,0 +1,21 @@
1
+ ---
2
+ interface Props {
3
+ size?: number;
4
+ class?: string;
5
+ }
6
+
7
+ const { size = 12, class: className } = Astro.props;
8
+ ---
9
+
10
+ <svg
11
+ width={size}
12
+ height={size}
13
+ viewBox="0 0 24 24"
14
+ fill="currentColor"
15
+ aria-hidden="true"
16
+ class:list={['inline-block', className]}
17
+ >
18
+ <path
19
+ d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.123 2.123 0 0 0-.611-1.878L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.595-1.16z"
20
+ ></path>
21
+ </svg>
@@ -0,0 +1,51 @@
1
+ ---
2
+ import { getRelativeLocaleUrl } from 'astro:i18n';
3
+ import StackCard from './StackCard.astro';
4
+ import { getStacksByTag, slugOf } from '../lib/stacks';
5
+ import { useTranslations, type Lang } from '../i18n/ui';
6
+
7
+ interface Props {
8
+ lang: Lang;
9
+ tag: string;
10
+ }
11
+
12
+ const { lang, tag } = Astro.props;
13
+ const t = useTranslations(lang);
14
+ const entries = (await getStacksByTag(lang, tag)).sort((a, b) =>
15
+ a.data.name.localeCompare(b.data.name),
16
+ );
17
+ ---
18
+
19
+ <section class="py-6">
20
+ <a href={getRelativeLocaleUrl(lang, '')} class="text-sm text-[var(--aas-accent)] no-underline"
21
+ >&larr; {t('tag.allTools')}</a
22
+ >
23
+ <h1 class="mt-3 text-3xl font-bold tracking-tight">
24
+ <span class="text-[var(--aas-muted)]">{t('tag.heading')}</span> #{tag}
25
+ </h1>
26
+ </section>
27
+
28
+ <div class="mt-4 grid gap-4 sm:grid-cols-2">
29
+ {
30
+ entries.map((e) => (
31
+ <StackCard
32
+ slug={slugOf(e)}
33
+ lang={lang}
34
+ name={e.data.name}
35
+ formerNames={e.data.formerNames}
36
+ vendor={e.data.vendor}
37
+ description={e.data.description}
38
+ tags={e.data.tags}
39
+ pricing={e.data.pricing}
40
+ license={e.data.license}
41
+ language={e.data.language}
42
+ category={e.data.category}
43
+ logo={e.data.logo}
44
+ logoDark={e.data.logoDark}
45
+ repo={e.data.repo}
46
+ version={e.data.version}
47
+ deprecated={e.data.deprecated}
48
+ />
49
+ ))
50
+ }
51
+ </div>
@@ -0,0 +1,106 @@
1
+ ---
2
+ import { useTranslations, type Lang } from '../i18n/ui';
3
+ import ChevronDown from './ChevronDown.astro';
4
+
5
+ interface Props {
6
+ lang: Lang;
7
+ }
8
+
9
+ const { lang } = Astro.props;
10
+ const t = useTranslations(lang);
11
+
12
+ const options = [
13
+ { value: 'system', label: t('theme.system') },
14
+ { value: 'light', label: t('theme.light') },
15
+ { value: 'dark', label: t('theme.dark') },
16
+ ];
17
+ ---
18
+
19
+ <details class="aas-theme">
20
+ <summary
21
+ class="flex cursor-pointer list-none items-center gap-1 rounded-lg border border-[var(--aas-border)] px-2.5 py-1 text-[var(--aas-text)] select-none"
22
+ aria-label={t('theme.label')}
23
+ title={t('theme.label')}
24
+ >
25
+ <span data-theme-icon aria-hidden="true">☀</span>
26
+ <ChevronDown class="text-[var(--aas-muted)]" />
27
+ </summary>
28
+ <ul
29
+ class="absolute right-0 z-10 mt-1 min-w-[8rem] overflow-hidden rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] py-1 shadow-lg"
30
+ >
31
+ {
32
+ options.map((o) => (
33
+ <li>
34
+ <button
35
+ type="button"
36
+ data-theme-option={o.value}
37
+ aria-pressed="false"
38
+ class="flex w-full items-center justify-between px-3 py-1.5 text-left text-[var(--aas-muted)] hover:bg-[var(--aas-bg)] aria-pressed:font-semibold aria-pressed:text-[var(--aas-text)]"
39
+ >
40
+ {o.label}
41
+ <span data-check aria-hidden="true" class="opacity-0">
42
+
43
+ </span>
44
+ </button>
45
+ </li>
46
+ ))
47
+ }
48
+ </ul>
49
+ </details>
50
+
51
+ <style>
52
+ .aas-theme {
53
+ position: relative;
54
+ }
55
+ .aas-theme > summary::-webkit-details-marker {
56
+ display: none;
57
+ }
58
+ .aas-theme [data-theme-option][aria-pressed='true'] [data-check] {
59
+ opacity: 1;
60
+ }
61
+ </style>
62
+
63
+ <script is:inline>
64
+ (function () {
65
+ var root = document.documentElement;
66
+ var KEY = 'theme';
67
+ var mq = window.matchMedia('(prefers-color-scheme: dark)');
68
+
69
+ function getPref() {
70
+ try {
71
+ return localStorage.getItem(KEY) || 'system';
72
+ } catch (e) {
73
+ return 'system';
74
+ }
75
+ }
76
+ function resolve(pref) {
77
+ return pref === 'dark' || (pref === 'system' && mq.matches) ? 'dark' : 'light';
78
+ }
79
+ function apply(pref) {
80
+ root.dataset.theme = resolve(pref);
81
+ document.querySelectorAll('[data-theme-option]').forEach(function (el) {
82
+ el.setAttribute('aria-pressed', el.dataset.themeOption === pref ? 'true' : 'false');
83
+ });
84
+ document.querySelectorAll('[data-theme-icon]').forEach(function (el) {
85
+ el.textContent = root.dataset.theme === 'dark' ? '☾' : '☀';
86
+ });
87
+ }
88
+
89
+ document.querySelectorAll('[data-theme-option]').forEach(function (el) {
90
+ el.addEventListener('click', function () {
91
+ var pref = el.dataset.themeOption;
92
+ try {
93
+ localStorage.setItem(KEY, pref);
94
+ } catch (e) {}
95
+ apply(pref);
96
+ var d = el.closest('details');
97
+ if (d) d.open = false;
98
+ });
99
+ });
100
+ mq.addEventListener('change', function () {
101
+ if (getPref() === 'system') apply('system');
102
+ });
103
+
104
+ apply(getPref());
105
+ })();
106
+ </script>