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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dev/local-samples-client.js +284 -0
- package/dev/local-samples.mjs +594 -0
- package/index.d.ts +12 -0
- package/index.mjs +124 -0
- package/markdown.mjs +394 -0
- package/package.json +52 -0
- package/src/components/ArrowUpRight.astro +24 -0
- package/src/components/ArticleCard.astro +56 -0
- package/src/components/ArticleDetail.astro +100 -0
- package/src/components/ArticleLink.astro +45 -0
- package/src/components/BackToTop.astro +47 -0
- package/src/components/BlogIndex.astro +137 -0
- package/src/components/Breadcrumb.astro +37 -0
- package/src/components/CategoryIndex.astro +115 -0
- package/src/components/ChevronDown.astro +23 -0
- package/src/components/CodeSamples.astro +141 -0
- package/src/components/CollapsibleGrid.astro +50 -0
- package/src/components/ConceptCard.astro +59 -0
- package/src/components/ConceptDetail.astro +102 -0
- package/src/components/ConceptIndex.astro +143 -0
- package/src/components/ConceptLink.astro +43 -0
- package/src/components/ConceptTools.astro +49 -0
- package/src/components/DeckView.astro +772 -0
- package/src/components/DetailTabs.astro +219 -0
- package/src/components/FilterControls.astro +407 -0
- package/src/components/Glossary.astro +264 -0
- package/src/components/Home.astro +90 -0
- package/src/components/LanguageSwitcher.astro +86 -0
- package/src/components/ListControls.astro +182 -0
- package/src/components/MermaidLoader.astro +96 -0
- package/src/components/PricingSection.astro +100 -0
- package/src/components/ProjectFileList.astro +68 -0
- package/src/components/ProjectFileTree.astro +95 -0
- package/src/components/ProjectRelatedTools.astro +70 -0
- package/src/components/ProjectViewer.astro +431 -0
- package/src/components/RelatedArticles.astro +52 -0
- package/src/components/RelatedConcepts.astro +60 -0
- package/src/components/RelatedTools.astro +55 -0
- package/src/components/SampleIndex.astro +464 -0
- package/src/components/SamplePage.astro +95 -0
- package/src/components/SampleProject.astro +56 -0
- package/src/components/Slide.astro +36 -0
- package/src/components/SlidesIndex.astro +60 -0
- package/src/components/StackCard.astro +183 -0
- package/src/components/StackDetail.astro +507 -0
- package/src/components/StarIcon.astro +21 -0
- package/src/components/TagIndex.astro +51 -0
- package/src/components/ThemeToggle.astro +106 -0
- package/src/components/Toc.astro +39 -0
- package/src/components/TocRail.astro +74 -0
- package/src/components/ToolAvatar.astro +133 -0
- package/src/components/ToolMeta.astro +95 -0
- package/src/components/VendorIndex.astro +51 -0
- package/src/content.ts +228 -0
- package/src/i18n/ui.ts +337 -0
- package/src/layouts/BaseLayout.astro +287 -0
- package/src/lib/articles.ts +27 -0
- package/src/lib/assets.ts +19 -0
- package/src/lib/category-tree.ts +64 -0
- package/src/lib/concepts.ts +35 -0
- package/src/lib/dates.ts +21 -0
- package/src/lib/facets.ts +57 -0
- package/src/lib/github.ts +186 -0
- package/src/lib/inline-md.ts +30 -0
- package/src/lib/pricing.ts +22 -0
- package/src/lib/project.ts +309 -0
- package/src/lib/samples.ts +88 -0
- package/src/lib/slides.ts +25 -0
- package/src/lib/stacks.ts +129 -0
- package/src/lib/toc-rail-client.ts +79 -0
- package/src/pages/article/[...id].astro +24 -0
- package/src/pages/article/category/[id].astro +17 -0
- package/src/pages/article/index.astro +12 -0
- package/src/pages/categories/[id].astro +25 -0
- package/src/pages/concept/[...id].astro +24 -0
- package/src/pages/concept/category/[id].astro +17 -0
- package/src/pages/concept/index.astro +12 -0
- package/src/pages/glossary.astro +12 -0
- package/src/pages/index.astro +9 -0
- package/src/pages/ko/article/[...id].astro +24 -0
- package/src/pages/ko/article/category/[id].astro +17 -0
- package/src/pages/ko/article/index.astro +12 -0
- package/src/pages/ko/categories/[id].astro +24 -0
- package/src/pages/ko/concept/[...id].astro +24 -0
- package/src/pages/ko/concept/category/[id].astro +17 -0
- package/src/pages/ko/concept/index.astro +12 -0
- package/src/pages/ko/glossary.astro +12 -0
- package/src/pages/ko/index.astro +9 -0
- package/src/pages/ko/sample/[folder].astro +16 -0
- package/src/pages/ko/sample/index.astro +12 -0
- package/src/pages/ko/slides/[deck].astro +14 -0
- package/src/pages/ko/slides/index.astro +12 -0
- package/src/pages/ko/stack/[...id].astro +51 -0
- package/src/pages/ko/tags/[tag].astro +18 -0
- package/src/pages/ko/vendors/[vendor].astro +18 -0
- package/src/pages/sample/[folder].astro +16 -0
- package/src/pages/sample/index.astro +12 -0
- package/src/pages/slides/[deck].astro +14 -0
- package/src/pages/slides/index.astro +12 -0
- package/src/pages/stack/[...id].astro +51 -0
- package/src/pages/tags/[tag].astro +18 -0
- package/src/pages/vendors/[vendor].astro +18 -0
- 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)]">→</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>
|