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,464 @@
1
+ ---
2
+ // The samples hub: one card per samples/<folder>/ mini-project, titled and
3
+ // excerpted from the locale-appropriate README. A single-column list with a
4
+ // client-side control bar — free-text search, a "source tools" multi-select,
5
+ // and a sort toggle (default newest first) — paginated 5 at a time. All state
6
+ // mirrors into the URL (?q, ?tools, ?sort, ?page) and it degrades to
7
+ // "show everything" without JS. Cards link to the per-sample page.
8
+ import { getRelativeLocaleUrl } from 'astro:i18n';
9
+ import ChevronDown from './ChevronDown.astro';
10
+ import { listProjects } from '../lib/project';
11
+ import { getStacks, slugOf } from '../lib/stacks';
12
+ import { getArticles } from '../lib/articles';
13
+ import { useTranslations, type Lang } from '../i18n/ui';
14
+
15
+ interface Props {
16
+ lang: Lang;
17
+ }
18
+
19
+ const { lang } = Astro.props;
20
+ const t = useTranslations(lang);
21
+
22
+ // Which catalog tools each sample is built with — the same frontmatter the
23
+ // sample page derives its backlinks from, inverted folder → tools: the tool of
24
+ // every stack that ships the folder (via `projects`), plus the `related` tools
25
+ // declared on that stack's or an article's project entry.
26
+ const stacks = await getStacks(lang);
27
+ const articles = await getArticles(lang);
28
+ const nameBySlug = new Map(stacks.map((s) => [slugOf(s), s.data.name]));
29
+
30
+ const toolsByFolder = new Map<string, Set<string>>();
31
+ const addTool = (folder: string, slug: string) => {
32
+ const set = toolsByFolder.get(folder) ?? new Set<string>();
33
+ set.add(slug);
34
+ toolsByFolder.set(folder, set);
35
+ };
36
+ for (const s of stacks) {
37
+ for (const p of s.data.projects) {
38
+ const folder = typeof p === 'string' ? p : p.folder;
39
+ addTool(folder, slugOf(s));
40
+ if (typeof p !== 'string') p.related.forEach((r) => addTool(folder, r));
41
+ }
42
+ }
43
+ for (const a of articles) {
44
+ const p = a.data.project;
45
+ if (!p || typeof p === 'string') continue;
46
+ p.related.forEach((r) => addTool(p.folder, r));
47
+ }
48
+
49
+ // Newest first, then by name — matches the client's default 'recent' sort, so
50
+ // the server render and the initial client sort agree (no reorder flash). Only
51
+ // tools that resolve to a real catalog entry are kept.
52
+ const projects = listProjects(lang)
53
+ .map((p) => ({
54
+ ...p,
55
+ tools: [...(toolsByFolder.get(p.folder) ?? [])].filter((slug) => nameBySlug.has(slug)),
56
+ }))
57
+ .sort((a, b) => b.date.localeCompare(a.date) || a.name.localeCompare(b.name, lang));
58
+
59
+ // The union of tools actually used across samples, as the filter's options.
60
+ const toolFacet = [...new Set(projects.flatMap((p) => p.tools))]
61
+ .map((slug) => ({ slug, name: nameBySlug.get(slug) ?? slug }))
62
+ .sort((a, b) => a.name.localeCompare(b.name, lang));
63
+
64
+ const sortOptions = [
65
+ { key: 'recent', label: t('sort.recent') },
66
+ { key: 'alpha', label: t('sort.alpha') },
67
+ ];
68
+ // What the free-text search matches: the title (name + folder), the body
69
+ // (excerpt), or both. `all` is the default.
70
+ const scopeOptions = [
71
+ { key: 'all', label: t('sample.searchAll') },
72
+ { key: 'title', label: t('sample.searchTitle') },
73
+ { key: 'body', label: t('sample.searchBody') },
74
+ ];
75
+
76
+ function formatDate(iso: string): string {
77
+ const [y, m, d] = iso.split('-').map(Number);
78
+ if (!y || !m || !d) return iso;
79
+ return new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
80
+ year: 'numeric',
81
+ month: 'long',
82
+ day: 'numeric',
83
+ timeZone: 'UTC',
84
+ }).format(new Date(Date.UTC(y, m - 1, d)));
85
+ }
86
+
87
+ const summaryClass =
88
+ 'flex cursor-pointer 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)]';
89
+ const menuClass =
90
+ 'absolute z-10 mt-1 max-h-72 min-w-[12rem] overflow-y-auto rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] p-1 shadow-lg';
91
+ const optClass =
92
+ 'flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-[var(--aas-tint)] aria-[current=true]:font-semibold aria-[current=true]:text-[var(--aas-accent)] aria-[pressed=true]:font-semibold aria-[pressed=true]:text-[var(--aas-accent)]';
93
+ ---
94
+
95
+ <section class="py-6">
96
+ <h1 class="text-4xl font-bold tracking-tight">{t('sample.title')}</h1>
97
+ <p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('sample.tagline')}</p>
98
+ </section>
99
+
100
+ {
101
+ projects.length > 0 && (
102
+ <div class="mt-2 flex flex-wrap items-center gap-2" data-sample-controls>
103
+ <details class="aas-dd">
104
+ <summary class={summaryClass}>
105
+ <span data-scope-label>{scopeOptions[0].label}</span>
106
+ <ChevronDown class="text-[var(--aas-muted)]" />
107
+ </summary>
108
+ <ul class={`${menuClass} left-0`}>
109
+ {scopeOptions.map((o, i) => (
110
+ <li>
111
+ <button
112
+ type="button"
113
+ data-scope={o.key}
114
+ aria-current={i === 0 ? 'true' : 'false'}
115
+ class={optClass}
116
+ >
117
+ {o.label}
118
+ <span data-check aria-hidden="true" class="opacity-0">
119
+
120
+ </span>
121
+ </button>
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </details>
126
+
127
+ <input
128
+ type="search"
129
+ data-sample-q
130
+ placeholder={t('sample.search')}
131
+ aria-label={t('sample.search')}
132
+ class="min-w-[10rem] flex-1 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-sm text-[var(--aas-text)] placeholder:text-[var(--aas-muted)]"
133
+ />
134
+
135
+ {toolFacet.length > 0 && (
136
+ <details class="aas-dd">
137
+ <summary class={summaryClass}>
138
+ <span class="text-[var(--aas-muted)]">{t('sample.tools')}</span>
139
+ <span
140
+ data-tools-count
141
+ hidden
142
+ class="rounded bg-[var(--aas-accent)] px-1.5 text-xs font-semibold text-white"
143
+ />
144
+ <ChevronDown class="text-[var(--aas-muted)]" />
145
+ </summary>
146
+ <ul class={`${menuClass} left-0`}>
147
+ {toolFacet.map((tf) => (
148
+ <li>
149
+ <button type="button" data-tool-opt={tf.slug} aria-pressed="false" class={optClass}>
150
+ {tf.name}
151
+ <span data-check aria-hidden="true" class="opacity-0">
152
+
153
+ </span>
154
+ </button>
155
+ </li>
156
+ ))}
157
+ </ul>
158
+ </details>
159
+ )}
160
+
161
+ <button
162
+ type="button"
163
+ data-sample-clear
164
+ hidden
165
+ class="rounded-lg px-2 py-1.5 text-sm text-[var(--aas-muted)] hover:text-[var(--aas-text)]"
166
+ >
167
+ {t('filter.clear')}
168
+ </button>
169
+
170
+ <details class="aas-dd ml-auto">
171
+ <summary class={summaryClass}>
172
+ <span class="text-[var(--aas-muted)]">{t('sort.label')}:</span>
173
+ <span data-sort-label>{sortOptions[0].label}</span>
174
+ <ChevronDown class="text-[var(--aas-muted)]" />
175
+ </summary>
176
+ <ul class={`${menuClass} right-0`}>
177
+ {sortOptions.map((o, i) => (
178
+ <li>
179
+ <button
180
+ type="button"
181
+ data-sort={o.key}
182
+ aria-current={i === 0 ? 'true' : 'false'}
183
+ class={optClass}
184
+ >
185
+ {o.label}
186
+ <span data-check aria-hidden="true" class="opacity-0">
187
+
188
+ </span>
189
+ </button>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ </details>
194
+ </div>
195
+ )
196
+ }
197
+
198
+ {/* min-h reserves five card rows (5 × h-20 + 4 gaps) so a short last page
199
+ doesn't pull the pager up; the script drops it when nothing matches. */}
200
+ <ul class="mt-4 grid min-h-[27.5rem] grid-cols-[minmax(0,1fr)] content-start gap-2.5" data-sample-grid>
201
+ {
202
+ projects.map((p) => (
203
+ <li
204
+ class="min-w-0"
205
+ data-sample-card
206
+ data-name={p.name}
207
+ data-date={p.date}
208
+ data-title={`${p.name} ${p.folder}`.toLowerCase()}
209
+ data-body={p.excerpt.toLowerCase()}
210
+ data-tools={p.tools.join(' ')}
211
+ >
212
+ <a
213
+ href={getRelativeLocaleUrl(lang, `sample/${p.folder}/`)}
214
+ class="aas-lift flex h-20 flex-col justify-center gap-1 overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] px-4 no-underline"
215
+ >
216
+ <span class="flex items-center gap-2">
217
+ <span class="min-w-0 truncate font-semibold text-[var(--aas-text)]">{p.name}</span>
218
+ <span class="ml-auto shrink-0 text-xs text-[var(--aas-muted)]">{formatDate(p.date)}</span>
219
+ </span>
220
+ <span class="flex items-center gap-2 text-xs">
221
+ <code class="shrink-0 rounded bg-[var(--aas-tint)] px-1.5 py-0.5 text-[var(--aas-muted)]">
222
+ samples/{p.folder}
223
+ </code>
224
+ {p.excerpt && (
225
+ <span class="min-w-0 truncate text-[var(--aas-muted)] opacity-60">{p.excerpt}</span>
226
+ )}
227
+ </span>
228
+ </a>
229
+ </li>
230
+ ))
231
+ }
232
+ </ul>
233
+
234
+ {/* Swapped in for the list when nothing matches — same five-row height, so
235
+ the pager and footer stay put. */}
236
+ <p data-sample-empty hidden class="mt-4 flex min-h-[27.5rem] items-center justify-center text-sm text-[var(--aas-muted)]">
237
+ {t('sample.noResults')}
238
+ </p>
239
+
240
+ <nav data-pager aria-label="Pagination" class="mt-8 flex flex-wrap items-center justify-center gap-1.5"></nav>
241
+
242
+ <script>
243
+ const bar = document.querySelector<HTMLElement>('[data-sample-controls]');
244
+ const grid = document.querySelector<HTMLElement>('[data-sample-grid]');
245
+ if (bar && grid) {
246
+ const PAGE = 5;
247
+ const DEFAULT_SORT = 'recent';
248
+ const cards = Array.from(grid.querySelectorAll<HTMLElement>('[data-sample-card]'));
249
+ const qInput = bar.querySelector<HTMLInputElement>('[data-sample-q]');
250
+ const scopeBtns = Array.from(bar.querySelectorAll<HTMLButtonElement>('[data-scope]'));
251
+ const scopeLabel = bar.querySelector<HTMLElement>('[data-scope-label]');
252
+ const toolBtns = Array.from(bar.querySelectorAll<HTMLButtonElement>('[data-tool-opt]'));
253
+ const toolsCount = bar.querySelector<HTMLElement>('[data-tools-count]');
254
+ const clearBtn = bar.querySelector<HTMLButtonElement>('[data-sample-clear]');
255
+ const sortBtns = Array.from(bar.querySelectorAll<HTMLButtonElement>('[data-sort]'));
256
+ const sortLabel = bar.querySelector<HTMLElement>('[data-sort-label]');
257
+ const pager = document.querySelector<HTMLElement>('[data-pager]');
258
+ const emptyEl = document.querySelector<HTMLElement>('[data-sample-empty]');
259
+
260
+ let q = '';
261
+ let scope = 'all';
262
+ const tools = new Set<string>();
263
+ let sort = DEFAULT_SORT;
264
+ let page = 1;
265
+
266
+ const byName = (a: HTMLElement, b: HTMLElement) =>
267
+ (a.dataset.name ?? '').localeCompare(b.dataset.name ?? '');
268
+ const comparator = () =>
269
+ sort === 'alpha'
270
+ ? byName
271
+ : (a: HTMLElement, b: HTMLElement) =>
272
+ (b.dataset.date ?? '').localeCompare(a.dataset.date ?? '') || byName(a, b);
273
+
274
+ function matches(card: HTMLElement) {
275
+ if (q) {
276
+ const title = card.dataset.title ?? '';
277
+ const body = card.dataset.body ?? '';
278
+ const hit =
279
+ scope === 'title' ? title.includes(q) : scope === 'body' ? body.includes(q) : title.includes(q) || body.includes(q);
280
+ if (!hit) return false;
281
+ }
282
+ if (tools.size) {
283
+ const ts = (card.dataset.tools ?? '').split(' ').filter(Boolean);
284
+ // A sample matches only if it uses ALL of the selected tools.
285
+ if (![...tools].every((t) => ts.includes(t))) return false;
286
+ }
287
+ return true;
288
+ }
289
+
290
+ function pageButton(label: string, target: number, opts: { current?: boolean; disabled?: boolean } = {}) {
291
+ const b = document.createElement('button');
292
+ b.type = 'button';
293
+ b.textContent = label;
294
+ b.dataset.page = String(target);
295
+ b.disabled = !!opts.disabled;
296
+ b.setAttribute('aria-current', opts.current ? 'page' : 'false');
297
+ b.className =
298
+ 'min-w-9 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-sm text-[var(--aas-text)] hover:bg-[var(--aas-tint)] disabled:pointer-events-none disabled:opacity-40 aria-[current=page]:border-[var(--aas-accent)] aria-[current=page]:font-semibold aria-[current=page]:text-[var(--aas-accent)]';
299
+ return b;
300
+ }
301
+
302
+ function renderPager(pages: number) {
303
+ if (!pager) return;
304
+ pager.replaceChildren();
305
+ // Page numbers in fixed groups of PAGE; the arrows jump a whole group
306
+ // (‹ → last page of the previous group, › → first of the next). The
307
+ // pager renders even for a single page (just "1", arrows disabled) so
308
+ // the layout below the list never jumps. A short group is padded to
309
+ // five slots with invisible placeholders, split evenly so the numbers
310
+ // stay centered while the arrows keep their exact position.
311
+ const start = Math.floor((page - 1) / PAGE) * PAGE + 1;
312
+ const end = Math.min(pages, start + PAGE - 1);
313
+ const missing = PAGE - (end - start + 1);
314
+ const padLeft = Math.floor(missing / 2);
315
+ const ghost = () => {
316
+ const b = pageButton('0', 0, { disabled: true });
317
+ b.classList.add('invisible');
318
+ return b;
319
+ };
320
+ pager.appendChild(pageButton('«', 1, { disabled: page === 1 }));
321
+ pager.appendChild(pageButton('‹', start - 1, { disabled: start === 1 }));
322
+ for (let i = 0; i < padLeft; i++) pager.appendChild(ghost());
323
+ for (let i = start; i <= end; i++) pager.appendChild(pageButton(String(i), i, { current: i === page }));
324
+ for (let i = 0; i < missing - padLeft; i++) pager.appendChild(ghost());
325
+ pager.appendChild(pageButton('›', end + 1, { disabled: end >= pages }));
326
+ pager.appendChild(pageButton('»', pages, { disabled: page === pages }));
327
+ }
328
+
329
+ function render() {
330
+ const visible = cards.filter(matches).sort(comparator());
331
+ visible.forEach((c) => grid!.appendChild(c)); // reorder to sorted order
332
+ const pages = Math.max(1, Math.ceil(visible.length / PAGE));
333
+ if (page > pages) page = pages;
334
+ const start = (page - 1) * PAGE;
335
+ cards.forEach((c) => {
336
+ c.style.display = 'none';
337
+ });
338
+ visible.forEach((c, i) => {
339
+ c.style.display = i >= start && i < start + PAGE ? '' : 'none';
340
+ });
341
+ // Zero matches: swap the (height-reserving) list for the equally tall
342
+ // empty-state message so nothing below moves.
343
+ grid!.style.display = visible.length === 0 ? 'none' : '';
344
+ if (emptyEl) emptyEl.hidden = visible.length !== 0;
345
+ renderPager(pages);
346
+ }
347
+
348
+ function updateToolUI() {
349
+ for (const b of toolBtns) {
350
+ const on = tools.has(b.dataset.toolOpt ?? '');
351
+ b.setAttribute('aria-pressed', String(on));
352
+ b.querySelector<HTMLElement>('[data-check]')?.classList.toggle('opacity-0', !on);
353
+ }
354
+ if (toolsCount) {
355
+ toolsCount.textContent = String(tools.size);
356
+ toolsCount.hidden = tools.size === 0;
357
+ }
358
+ if (clearBtn) clearBtn.hidden = q.length === 0 && tools.size === 0;
359
+ }
360
+
361
+ function updateSortUI() {
362
+ for (const b of sortBtns) {
363
+ const on = (b.dataset.sort ?? DEFAULT_SORT) === sort;
364
+ b.setAttribute('aria-current', String(on));
365
+ b.querySelector<HTMLElement>('[data-check]')?.classList.toggle('opacity-0', !on);
366
+ if (on && sortLabel) sortLabel.textContent = b.textContent?.replace('✓', '').trim() ?? '';
367
+ }
368
+ }
369
+
370
+ function updateScopeUI() {
371
+ for (const b of scopeBtns) {
372
+ const on = (b.dataset.scope ?? 'all') === scope;
373
+ b.setAttribute('aria-current', String(on));
374
+ b.querySelector<HTMLElement>('[data-check]')?.classList.toggle('opacity-0', !on);
375
+ if (on && scopeLabel) scopeLabel.textContent = b.textContent?.replace('✓', '').trim() ?? '';
376
+ }
377
+ }
378
+
379
+ function syncUrl() {
380
+ const url = new URL(location.href);
381
+ const set = (k: string, v: string) =>
382
+ v ? url.searchParams.set(k, v) : url.searchParams.delete(k);
383
+ set('q', qInput?.value.trim() ?? '');
384
+ set('scope', scope === 'all' ? '' : scope);
385
+ set('tools', [...tools].join(','));
386
+ set('sort', sort === DEFAULT_SORT ? '' : sort);
387
+ set('page', page === 1 ? '' : String(page));
388
+ history.replaceState(null, '', url);
389
+ }
390
+
391
+ qInput?.addEventListener('input', () => {
392
+ q = qInput.value.trim().toLowerCase();
393
+ page = 1;
394
+ updateToolUI();
395
+ render();
396
+ syncUrl();
397
+ });
398
+ for (const b of toolBtns) {
399
+ b.addEventListener('click', () => {
400
+ const slug = b.dataset.toolOpt ?? '';
401
+ if (tools.has(slug)) tools.delete(slug);
402
+ else tools.add(slug);
403
+ page = 1;
404
+ updateToolUI();
405
+ render();
406
+ syncUrl();
407
+ });
408
+ }
409
+ for (const b of scopeBtns) {
410
+ b.addEventListener('click', () => {
411
+ scope = b.dataset.scope ?? 'all';
412
+ page = 1;
413
+ updateScopeUI();
414
+ render();
415
+ syncUrl();
416
+ b.closest('details')?.removeAttribute('open');
417
+ });
418
+ }
419
+ for (const b of sortBtns) {
420
+ b.addEventListener('click', () => {
421
+ sort = b.dataset.sort ?? DEFAULT_SORT;
422
+ page = 1;
423
+ updateSortUI();
424
+ render();
425
+ syncUrl();
426
+ b.closest('details')?.removeAttribute('open');
427
+ });
428
+ }
429
+ clearBtn?.addEventListener('click', () => {
430
+ if (qInput) qInput.value = '';
431
+ q = '';
432
+ tools.clear();
433
+ page = 1;
434
+ updateToolUI();
435
+ render();
436
+ syncUrl();
437
+ });
438
+ pager?.addEventListener('click', (e) => {
439
+ const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-page]');
440
+ if (!b || b.disabled) return;
441
+ page = Number(b.dataset.page);
442
+ render();
443
+ syncUrl();
444
+ window.scrollTo({ top: 0 });
445
+ });
446
+
447
+ // Restore state from the URL.
448
+ const params = new URLSearchParams(location.search);
449
+ const rawQ = params.get('q') ?? '';
450
+ if (qInput) qInput.value = rawQ;
451
+ q = rawQ.trim().toLowerCase();
452
+ const wantScope = params.get('scope');
453
+ if (wantScope && scopeBtns.some((b) => b.dataset.scope === wantScope)) scope = wantScope;
454
+ (params.get('tools') ?? '').split(',').filter(Boolean).forEach((slug) => tools.add(slug));
455
+ const wantSort = params.get('sort');
456
+ if (wantSort && sortBtns.some((b) => b.dataset.sort === wantSort)) sort = wantSort;
457
+ const wantPage = Number(params.get('page'));
458
+ if (Number.isFinite(wantPage) && wantPage > 0) page = wantPage;
459
+ updateToolUI();
460
+ updateSortUI();
461
+ updateScopeUI();
462
+ render();
463
+ }
464
+ </script>
@@ -0,0 +1,95 @@
1
+ ---
2
+ // A sample's own page at sample/<folder>/ — the same viewer the stack impl tab
3
+ // and article embeds use (locale-aware README, file tree, highlighted files),
4
+ // plus "used by" backlinks to every stack and article that ships this folder.
5
+ import { getRelativeLocaleUrl } from 'astro:i18n';
6
+ import Breadcrumb from './Breadcrumb.astro';
7
+ import MermaidLoader from './MermaidLoader.astro';
8
+ import ProjectViewer from './ProjectViewer.astro';
9
+ import TocRail from './TocRail.astro';
10
+ import { renderProjects } from '../lib/project';
11
+ import { getStacks, slugOf, type ToolRef } from '../lib/stacks';
12
+ import { getArticles, articleSlugOf } from '../lib/articles';
13
+ import { useTranslations, type Lang } from '../i18n/ui';
14
+
15
+ interface Props {
16
+ lang: Lang;
17
+ folder: string;
18
+ }
19
+
20
+ const { lang, folder } = Astro.props;
21
+ const t = useTranslations(lang);
22
+
23
+ const projects = await renderProjects([folder], lang);
24
+
25
+ // "Used by" backlinks, derived from frontmatter that references this folder:
26
+ // a stack's `projects` list or an article's `project` field. Related-tool
27
+ // cards are NOT shown here — that association lives in the referencing page's
28
+ // frontmatter, not in the sample itself, so it belongs on those pages.
29
+ const stacks = await getStacks(lang);
30
+ const articles = await getArticles(lang);
31
+
32
+ const usedByStacks = stacks.filter((s) =>
33
+ s.data.projects.some((p) => (typeof p === 'string' ? p : p.folder) === folder),
34
+ );
35
+ const usedByArticles = articles.filter((a) => {
36
+ const p = a.data.project;
37
+ return p !== undefined && (typeof p === 'string' ? p : p.folder) === folder;
38
+ });
39
+
40
+ const related = new Map<string, ToolRef[]>();
41
+
42
+ // The fixed right rail: the README's h2/h3 as the TOC + the clickable file list.
43
+ const project = projects[0];
44
+ const tocItems = project?.headings ?? [];
45
+ const projectFiles = project ? { folder: project.folder, files: project.files } : undefined;
46
+
47
+ const crumbs = [
48
+ { label: t('sample.title'), href: getRelativeLocaleUrl(lang, 'sample/') },
49
+ { label: `samples/${folder}` },
50
+ ];
51
+ const usedBy = [
52
+ ...usedByStacks.map((s) => ({
53
+ label: s.data.name,
54
+ href: getRelativeLocaleUrl(lang, `stack/${slugOf(s)}/`),
55
+ })),
56
+ ...usedByArticles.map((a) => ({
57
+ label: a.data.title,
58
+ href: getRelativeLocaleUrl(lang, `article/${articleSlugOf(a)}/`),
59
+ })),
60
+ ];
61
+ ---
62
+
63
+ <Breadcrumb segments={crumbs} />
64
+
65
+ {/* Content + the same fixed right rail an article page has (README TOC,
66
+ related tools, file list). `embedded` drops the viewer's inline sidebar at
67
+ rail widths so the files aren't listed twice; aas-reading gives the README
68
+ body the same serif reading typography the articles and stack tabs use. */}
69
+ <div class="mt-6 flex items-start gap-8">
70
+ <div class="aas-reading min-w-0 flex-1">
71
+ <ProjectViewer lang={lang} projects={projects} related={related} embedded />
72
+ </div>
73
+ <TocRail lang={lang} items={tocItems} projectFiles={projectFiles} />
74
+ </div>
75
+
76
+ {
77
+ usedBy.length > 0 && (
78
+ <aside class="mt-10 border-t border-[var(--aas-border)] pt-6">
79
+ <h2 class="text-sm font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
80
+ {t('sample.usedBy')}
81
+ </h2>
82
+ <ul class="mt-3 space-y-2">
83
+ {usedBy.map((u) => (
84
+ <li>
85
+ <a href={u.href} class="text-[var(--aas-accent)] no-underline hover:underline">
86
+ {u.label}
87
+ </a>
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ </aside>
92
+ )
93
+ }
94
+
95
+ <MermaidLoader />
@@ -0,0 +1,56 @@
1
+ ---
2
+ // Inline, full-width callout linking to a sample's own page, for use inside an
3
+ // article's MDX body, e.g.
4
+ // <SampleProject folder="tavily_1" />
5
+ // Styled like <ArticleLink/> (accent left-bar callout with a label + date):
6
+ // the full viewer — locale-aware README, file tree, highlighted files — lives
7
+ // on the linked sample page instead of being embedded in the article.
8
+ import { getRelativeLocaleUrl } from 'astro:i18n';
9
+ import { listProjects } from '../lib/project';
10
+ import { useTranslations, type Lang } from '../i18n/ui';
11
+
12
+ interface Props {
13
+ folder: string;
14
+ }
15
+
16
+ const { folder } = Astro.props;
17
+ const lang: Lang = Astro.currentLocale === 'ko' ? 'ko' : 'en';
18
+ const t = useTranslations(lang);
19
+ const target = listProjects(lang).find((p) => p.folder === folder);
20
+
21
+ function formatDate(iso: string): string {
22
+ const [y, m, d] = iso.split('-').map(Number);
23
+ if (!y || !m || !d) return iso;
24
+ return new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
25
+ year: 'numeric',
26
+ month: 'long',
27
+ day: 'numeric',
28
+ timeZone: 'UTC',
29
+ }).format(new Date(Date.UTC(y, m - 1, d)));
30
+ }
31
+ ---
32
+
33
+ {
34
+ target && (
35
+ <a
36
+ href={getRelativeLocaleUrl(lang, `sample/${target.folder}/`)}
37
+ class="aas-lift mt-3 flex items-start gap-4 rounded-xl border border-l-4 p-4 no-underline"
38
+ style="border-color: var(--aas-border); border-left-color: var(--aas-accent); background: var(--aas-panel);"
39
+ >
40
+ <div class="min-w-0 flex-1">
41
+ <span class="block text-xs font-semibold tracking-wide text-[var(--aas-accent)] uppercase">
42
+ {t('sample.related')}
43
+ </span>
44
+ <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">{target.name}</span>
45
+ {target.excerpt && (
46
+ <span class="mt-1 block text-sm text-[var(--aas-muted)]">{target.excerpt}</span>
47
+ )}
48
+ <span class="mt-1.5 flex flex-wrap items-center gap-2 text-xs text-[var(--aas-muted)]">
49
+ <code class="rounded bg-[var(--aas-tint)] px-1.5 py-0.5">samples/{target.folder}</code>
50
+ <span>{formatDate(target.date)}</span>
51
+ </span>
52
+ </div>
53
+ <span aria-hidden="true" class="shrink-0 text-xl text-[var(--aas-accent)]">&rarr;</span>
54
+ </a>
55
+ )
56
+ }
@@ -0,0 +1,36 @@
1
+ ---
2
+ import type { ImageMetadata } from 'astro';
3
+
4
+ // One slide in a deck. Authored in a deck's MDX as <Slide>…</Slide>; the deck
5
+ // page (src/components/DeckView.astro) lays these out as a horizontal
6
+ // scroll-snap presentation.
7
+ //
8
+ // <Slide> a normal slide (marked `default`; title pinned to top)
9
+ // <Slide class="cover"> title slide (centered, large)
10
+ // <Slide class="center"> short slide, vertically centered
11
+ // <Slide toc={false}> keep this slide's headings out of the deck TOC
12
+ // <Slide bg={img}> full-bleed background image (import it first); a
13
+ // scrim + light text keep the content legible
14
+ // <Slide source="…"> small citation in the bottom-right corner (≤2 lines)
15
+ interface Props {
16
+ class?: string;
17
+ /** Include this slide's headings in the deck's table of contents. */
18
+ toc?: boolean;
19
+ /** Full-bleed background image (an imported ImageMetadata). */
20
+ bg?: ImageMetadata;
21
+ /** A short source / citation, shown small in the slide's bottom-right corner. */
22
+ source?: string;
23
+ }
24
+ const { class: cls = 'default', toc = true, bg, source } = Astro.props;
25
+ ---
26
+
27
+ <section
28
+ class:list={['aas-slide', cls, bg && 'has-bg']}
29
+ data-no-toc={toc ? undefined : ''}
30
+ style={bg ? `background-image: url(${bg.src})` : undefined}
31
+ >
32
+ <div class="aas-slide-inner">
33
+ <slot />
34
+ </div>
35
+ {source && <p class="aas-slide-source">{source}</p>}
36
+ </section>