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,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getRelativeLocaleUrl } from 'astro:i18n';
|
|
3
|
+
import { glossary } from '@aas-data/glossary.mjs';
|
|
4
|
+
import { getStacks, slugOf } from '../lib/stacks';
|
|
5
|
+
import { getConcepts, conceptSlugOf } from '../lib/concepts';
|
|
6
|
+
import { getArticles, articleSlugOf } from '../lib/articles';
|
|
7
|
+
import { pathOf as stackPath, rootCategories } from '@aas-data/categories';
|
|
8
|
+
import { conceptTree, conceptCatOf } from '@aas-data/concept-categories';
|
|
9
|
+
import { articleTree, articleCatOf } from '@aas-data/article-categories';
|
|
10
|
+
import ToolAvatar from './ToolAvatar.astro';
|
|
11
|
+
import { useTranslations, type Lang } from '../i18n/ui';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
lang: Lang;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Entry = {
|
|
18
|
+
label: string | { ko: string; en: string };
|
|
19
|
+
stack?: string;
|
|
20
|
+
concept?: string;
|
|
21
|
+
article?: string;
|
|
22
|
+
href?: string;
|
|
23
|
+
def?: string | { ko: string; en: string };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Term = {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
href?: string;
|
|
30
|
+
external: boolean;
|
|
31
|
+
subtitle: string;
|
|
32
|
+
icon: { name: string; logo?: string; logoDark?: string; category?: string; fallback?: string };
|
|
33
|
+
catId: string;
|
|
34
|
+
catLabel: string;
|
|
35
|
+
search: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type Group = { id: string; label: string; terms: Term[] };
|
|
39
|
+
|
|
40
|
+
const { lang } = Astro.props;
|
|
41
|
+
const t = useTranslations(lang);
|
|
42
|
+
|
|
43
|
+
const map = glossary as Record<string, Entry>;
|
|
44
|
+
const labelOf = (l: Entry['label']) => (typeof l === 'string' ? l : l[lang]);
|
|
45
|
+
const defOf = (d: Entry['def']) => (!d ? '' : typeof d === 'string' ? d : d[lang]);
|
|
46
|
+
|
|
47
|
+
const stacks = await getStacks(lang);
|
|
48
|
+
const stackBySlug = new Map(stacks.map((s) => [slugOf(s), s]));
|
|
49
|
+
const concepts = await getConcepts(lang);
|
|
50
|
+
const conceptBySlug = new Map(concepts.map((c) => [conceptSlugOf(c), c]));
|
|
51
|
+
const articles = await getArticles(lang);
|
|
52
|
+
const articleBySlug = new Map(articles.map((a) => [articleSlugOf(a), a]));
|
|
53
|
+
|
|
54
|
+
const tools: Term[] = [];
|
|
55
|
+
const conceptTerms: Term[] = [];
|
|
56
|
+
const articleTerms: Term[] = [];
|
|
57
|
+
const defTerms: Term[] = [];
|
|
58
|
+
const external: Term[] = [];
|
|
59
|
+
|
|
60
|
+
for (const [id, e] of Object.entries(map)) {
|
|
61
|
+
const label = labelOf(e.label);
|
|
62
|
+
const def = defOf(e.def);
|
|
63
|
+
if (e.stack) {
|
|
64
|
+
const s = stackBySlug.get(e.stack);
|
|
65
|
+
const path = stackPath(s?.data.category ?? '');
|
|
66
|
+
const leaf = path[path.length - 1];
|
|
67
|
+
const top = path[0];
|
|
68
|
+
tools.push({
|
|
69
|
+
id,
|
|
70
|
+
label,
|
|
71
|
+
href: getRelativeLocaleUrl(lang, `stack/${e.stack}/`),
|
|
72
|
+
subtitle: leaf?.label[lang] ?? '',
|
|
73
|
+
external: false,
|
|
74
|
+
icon: { name: s?.data.name ?? label, logo: s?.data.logo, logoDark: s?.data.logoDark, category: s?.data.category },
|
|
75
|
+
catId: top?.id ?? 'zzz',
|
|
76
|
+
catLabel: top?.label[lang] ?? '',
|
|
77
|
+
search: `${label} ${leaf?.label[lang] ?? ''} ${top?.label[lang] ?? ''}`.toLowerCase(),
|
|
78
|
+
});
|
|
79
|
+
} else if (e.concept) {
|
|
80
|
+
const c = conceptBySlug.get(e.concept);
|
|
81
|
+
const catId = conceptCatOf(c?.data.category);
|
|
82
|
+
const path = conceptTree.pathOf(catId);
|
|
83
|
+
const leaf = path[path.length - 1];
|
|
84
|
+
const top = path[0];
|
|
85
|
+
conceptTerms.push({
|
|
86
|
+
id,
|
|
87
|
+
label,
|
|
88
|
+
href: getRelativeLocaleUrl(lang, `concept/${e.concept}/`),
|
|
89
|
+
subtitle: leaf?.label[lang] ?? '',
|
|
90
|
+
external: false,
|
|
91
|
+
icon: { name: label, fallback: 'concept' },
|
|
92
|
+
catId: top?.id ?? 'zzz',
|
|
93
|
+
catLabel: top?.label[lang] ?? '',
|
|
94
|
+
search: `${label} ${leaf?.label[lang] ?? ''} ${top?.label[lang] ?? ''}`.toLowerCase(),
|
|
95
|
+
});
|
|
96
|
+
} else if (e.article) {
|
|
97
|
+
const a = articleBySlug.get(e.article);
|
|
98
|
+
const catId = articleCatOf(a?.data.category);
|
|
99
|
+
const path = articleTree.pathOf(catId);
|
|
100
|
+
const leaf = path[path.length - 1];
|
|
101
|
+
const top = path[0];
|
|
102
|
+
articleTerms.push({
|
|
103
|
+
id,
|
|
104
|
+
label,
|
|
105
|
+
href: getRelativeLocaleUrl(lang, `article/${e.article}/`),
|
|
106
|
+
subtitle: leaf?.label[lang] ?? '',
|
|
107
|
+
external: false,
|
|
108
|
+
icon: { name: label, fallback: 'article' },
|
|
109
|
+
catId: top?.id ?? 'zzz',
|
|
110
|
+
catLabel: top?.label[lang] ?? '',
|
|
111
|
+
search: `${label} ${leaf?.label[lang] ?? ''} ${top?.label[lang] ?? ''}`.toLowerCase(),
|
|
112
|
+
});
|
|
113
|
+
} else if (e.href) {
|
|
114
|
+
external.push({
|
|
115
|
+
id,
|
|
116
|
+
label,
|
|
117
|
+
href: e.href,
|
|
118
|
+
subtitle: def,
|
|
119
|
+
external: true,
|
|
120
|
+
icon: { name: label, fallback: 'external' },
|
|
121
|
+
catId: '',
|
|
122
|
+
catLabel: '',
|
|
123
|
+
search: `${label} ${def}`.toLowerCase(),
|
|
124
|
+
});
|
|
125
|
+
} else {
|
|
126
|
+
// Definition-only term: no link, its `[[Term]]` points here.
|
|
127
|
+
defTerms.push({
|
|
128
|
+
id,
|
|
129
|
+
label,
|
|
130
|
+
subtitle: def,
|
|
131
|
+
external: false,
|
|
132
|
+
icon: { name: label, fallback: 'term' },
|
|
133
|
+
catId: '',
|
|
134
|
+
catLabel: '',
|
|
135
|
+
search: `${label} ${def}`.toLowerCase(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Group a kind's terms by top-level category, ordered by the category tree, then
|
|
141
|
+
// each group's terms by label.
|
|
142
|
+
const groupBy = (terms: Term[], order: string[]): Group[] => {
|
|
143
|
+
const byId = new Map<string, Group>();
|
|
144
|
+
for (const term of terms) {
|
|
145
|
+
if (!byId.has(term.catId)) byId.set(term.catId, { id: term.catId, label: term.catLabel, terms: [] });
|
|
146
|
+
byId.get(term.catId)!.terms.push(term);
|
|
147
|
+
}
|
|
148
|
+
const groups = [...byId.values()];
|
|
149
|
+
const rank = (id: string) => {
|
|
150
|
+
const i = order.indexOf(id);
|
|
151
|
+
return i === -1 ? order.length : i;
|
|
152
|
+
};
|
|
153
|
+
groups.sort((a, b) => rank(a.id) - rank(b.id) || a.label.localeCompare(b.label, lang));
|
|
154
|
+
for (const g of groups) g.terms.sort((a, b) => a.label.localeCompare(b.label, lang));
|
|
155
|
+
return groups;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const flat = (terms: Term[]): Group[] =>
|
|
159
|
+
terms.length ? [{ id: '', label: '', terms: terms.sort((a, b) => a.label.localeCompare(b.label, lang)) }] : [];
|
|
160
|
+
|
|
161
|
+
const kinds = [
|
|
162
|
+
{ key: 'tool', title: t('glossary.tool'), groups: groupBy(tools, rootCategories.map((c) => c.id)) },
|
|
163
|
+
{ key: 'concept', title: t('glossary.concept'), groups: groupBy(conceptTerms, conceptTree.roots.map((c) => c.id)) },
|
|
164
|
+
{ key: 'article', title: t('glossary.article'), groups: groupBy(articleTerms, articleTree.roots.map((c) => c.id)) },
|
|
165
|
+
{ key: 'term', title: t('glossary.term'), groups: flat(defTerms) },
|
|
166
|
+
{ key: 'external', title: t('glossary.external'), groups: flat(external) },
|
|
167
|
+
].filter((k) => k.groups.length > 0);
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
<section class="py-6">
|
|
171
|
+
<h1 class="text-4xl font-bold tracking-tight">{t('glossary.title')}</h1>
|
|
172
|
+
<p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('glossary.tagline')}</p>
|
|
173
|
+
</section>
|
|
174
|
+
|
|
175
|
+
<div data-glossary>
|
|
176
|
+
<div class="relative max-w-md">
|
|
177
|
+
<svg
|
|
178
|
+
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-[var(--aas-muted)]"
|
|
179
|
+
width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
180
|
+
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
|
181
|
+
>
|
|
182
|
+
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
|
183
|
+
</svg>
|
|
184
|
+
<input
|
|
185
|
+
type="search"
|
|
186
|
+
data-glossary-search
|
|
187
|
+
placeholder={t('glossary.search')}
|
|
188
|
+
aria-label={t('glossary.search')}
|
|
189
|
+
class="w-full rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] py-2 pr-3 pl-9 text-sm text-[var(--aas-text)] placeholder:text-[var(--aas-muted)]"
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<p data-glossary-empty hidden class="mt-6 text-[var(--aas-muted)]">{t('glossary.noResults')}</p>
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
kinds.map((kind) => (
|
|
197
|
+
<section data-kind class="mt-8">
|
|
198
|
+
<h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">{kind.title}</h2>
|
|
199
|
+
{kind.groups.map((group) => (
|
|
200
|
+
<div data-group class="mt-3">
|
|
201
|
+
{group.label && (
|
|
202
|
+
<h3 class="mb-2 text-sm font-medium text-[var(--aas-text)]">{group.label}</h3>
|
|
203
|
+
)}
|
|
204
|
+
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
205
|
+
{group.terms.map((term) => {
|
|
206
|
+
// A term with a target is a link; a definition-only term is a
|
|
207
|
+
// plain card that its `[[Term]]` anchors to (via the id).
|
|
208
|
+
const Wrapper = term.href ? 'a' : 'div';
|
|
209
|
+
return (
|
|
210
|
+
<li data-term data-search={term.search} id={term.id} class="scroll-mt-24">
|
|
211
|
+
<Wrapper
|
|
212
|
+
href={term.href}
|
|
213
|
+
target={term.external ? '_blank' : undefined}
|
|
214
|
+
rel={term.external ? 'noopener noreferrer' : undefined}
|
|
215
|
+
class:list={[
|
|
216
|
+
'flex items-center gap-3 rounded-xl border border-[var(--aas-border)] bg-[var(--aas-panel)] p-3 no-underline',
|
|
217
|
+
term.href && 'aas-lift',
|
|
218
|
+
]}
|
|
219
|
+
>
|
|
220
|
+
<ToolAvatar name={term.icon.name} logo={term.icon.logo} logoDark={term.icon.logoDark} category={term.icon.category} fallback={term.icon.fallback} size={36} />
|
|
221
|
+
<span class="min-w-0">
|
|
222
|
+
<span class="block truncate font-semibold text-[var(--aas-text)]">{term.label}</span>
|
|
223
|
+
{term.subtitle && (
|
|
224
|
+
<span class="block line-clamp-2 text-xs text-[var(--aas-muted)]">{term.subtitle}</span>
|
|
225
|
+
)}
|
|
226
|
+
</span>
|
|
227
|
+
</Wrapper>
|
|
228
|
+
</li>
|
|
229
|
+
);
|
|
230
|
+
})}
|
|
231
|
+
</ul>
|
|
232
|
+
</div>
|
|
233
|
+
))}
|
|
234
|
+
</section>
|
|
235
|
+
))
|
|
236
|
+
}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<script>
|
|
240
|
+
// Client-side filter for the glossary: match each term's `data-search` text,
|
|
241
|
+
// then hide any category group or kind section left with no visible terms.
|
|
242
|
+
const root = document.querySelector('[data-glossary]');
|
|
243
|
+
if (root) {
|
|
244
|
+
const input = root.querySelector<HTMLInputElement>('[data-glossary-search]');
|
|
245
|
+
const empty = root.querySelector<HTMLElement>('[data-glossary-empty]');
|
|
246
|
+
const terms = Array.from(root.querySelectorAll<HTMLElement>('[data-term]'));
|
|
247
|
+
const groups = Array.from(root.querySelectorAll<HTMLElement>('[data-group]'));
|
|
248
|
+
const kinds = Array.from(root.querySelectorAll<HTMLElement>('[data-kind]'));
|
|
249
|
+
const hasVisibleTerm = (el: HTMLElement) => !!el.querySelector('[data-term]:not([hidden])');
|
|
250
|
+
const apply = () => {
|
|
251
|
+
const q = (input?.value ?? '').trim().toLowerCase();
|
|
252
|
+
let any = false;
|
|
253
|
+
for (const el of terms) {
|
|
254
|
+
const show = !q || (el.dataset.search ?? '').includes(q);
|
|
255
|
+
el.hidden = !show;
|
|
256
|
+
if (show) any = true;
|
|
257
|
+
}
|
|
258
|
+
for (const g of groups) g.hidden = !hasVisibleTerm(g);
|
|
259
|
+
for (const k of kinds) k.hidden = !hasVisibleTerm(k);
|
|
260
|
+
if (empty) empty.hidden = any;
|
|
261
|
+
};
|
|
262
|
+
input?.addEventListener('input', apply);
|
|
263
|
+
}
|
|
264
|
+
</script>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getRelativeLocaleUrl } from 'astro:i18n';
|
|
3
|
+
import StackCard from './StackCard.astro';
|
|
4
|
+
import FilterControls from './FilterControls.astro';
|
|
5
|
+
import ArrowUpRight from './ArrowUpRight.astro';
|
|
6
|
+
import { rootCategories, descendantIds } from '@aas-data/categories';
|
|
7
|
+
import { getStacks, slugOf } from '../lib/stacks';
|
|
8
|
+
import { site } from '@aas-data/site';
|
|
9
|
+
import { deriveFacets } from '../lib/facets';
|
|
10
|
+
import { useTranslations, type Lang } from '../i18n/ui';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
lang: Lang;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { lang } = Astro.props;
|
|
17
|
+
const t = useTranslations(lang);
|
|
18
|
+
const entries = await getStacks(lang);
|
|
19
|
+
|
|
20
|
+
// Default order is alphabetical (matches the client's default sort, so no flash).
|
|
21
|
+
// Group by top-level category, rolling up every subcategory in its subtree.
|
|
22
|
+
const byCategory = (categoryId: string) => {
|
|
23
|
+
const ids = new Set(descendantIds(categoryId));
|
|
24
|
+
return entries
|
|
25
|
+
.filter((e) => ids.has(e.data.category))
|
|
26
|
+
.sort((a, b) => a.data.name.localeCompare(b.data.name));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Filter facet options, derived from the data (shared with the category pages).
|
|
30
|
+
const facets = deriveFacets(entries, lang);
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<section class="py-6">
|
|
35
|
+
<h1 class="text-4xl font-bold tracking-tight">{site.name}</h1>
|
|
36
|
+
<p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('site.tagline')}</p>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
{/* Search · filter · sort controls (client-side, URL-synced) */}
|
|
40
|
+
<div class="mt-6">
|
|
41
|
+
<FilterControls lang={lang} facets={facets} anchorId="categories" />
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<section class="mt-8 space-y-12">
|
|
45
|
+
{
|
|
46
|
+
rootCategories.map((cat) => {
|
|
47
|
+
const items = byCategory(cat.id);
|
|
48
|
+
if (items.length === 0) return null;
|
|
49
|
+
return (
|
|
50
|
+
<div data-section>
|
|
51
|
+
<div class="pb-1">
|
|
52
|
+
<h2 class="text-2xl font-semibold">
|
|
53
|
+
<a
|
|
54
|
+
href={getRelativeLocaleUrl(lang, `categories/${cat.id}/`)}
|
|
55
|
+
data-keep-filters
|
|
56
|
+
class="text-[var(--aas-text)] no-underline hover:text-[var(--aas-accent)]"
|
|
57
|
+
>
|
|
58
|
+
{cat.label[lang]}
|
|
59
|
+
<ArrowUpRight size={20} class="ml-1 text-[var(--aas-muted)]" />
|
|
60
|
+
</a>
|
|
61
|
+
</h2>
|
|
62
|
+
<p class="mt-1 text-sm text-[var(--aas-muted)]">{cat.description[lang]}</p>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="mt-5 grid gap-4 sm:grid-cols-2" data-grid>
|
|
65
|
+
{items.map((e) => (
|
|
66
|
+
<StackCard
|
|
67
|
+
slug={slugOf(e)}
|
|
68
|
+
lang={lang}
|
|
69
|
+
name={e.data.name}
|
|
70
|
+
formerNames={e.data.formerNames}
|
|
71
|
+
vendor={e.data.vendor}
|
|
72
|
+
description={e.data.description}
|
|
73
|
+
tags={e.data.tags}
|
|
74
|
+
pricing={e.data.pricing}
|
|
75
|
+
license={e.data.license}
|
|
76
|
+
language={e.data.language}
|
|
77
|
+
category={e.data.category}
|
|
78
|
+
logo={e.data.logo}
|
|
79
|
+
logoDark={e.data.logoDark}
|
|
80
|
+
repo={e.data.repo}
|
|
81
|
+
version={e.data.version}
|
|
82
|
+
deprecated={e.data.deprecated}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
</section>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getRelativeLocaleUrl } from 'astro:i18n';
|
|
3
|
+
import ChevronDown from './ChevronDown.astro';
|
|
4
|
+
import { languages, type Lang } from '../i18n/ui';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
lang: Lang;
|
|
8
|
+
/** Locale-relative path of the current page, e.g. '' or 'stack/litellm/'. */
|
|
9
|
+
path: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { lang, path } = Astro.props;
|
|
13
|
+
const codes = Object.keys(languages) as Lang[];
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<details class="aas-lang">
|
|
17
|
+
<summary
|
|
18
|
+
class="flex cursor-pointer list-none items-center gap-1 rounded-lg border border-[var(--aas-border)] px-2.5 py-1 whitespace-nowrap text-[var(--aas-text)] select-none"
|
|
19
|
+
>
|
|
20
|
+
{/* Compact language code on mobile; full name from the `sm` breakpoint up. */}
|
|
21
|
+
<span class="sm:hidden">{lang.toUpperCase()}</span>
|
|
22
|
+
<span class="hidden sm:inline">{languages[lang]}</span>
|
|
23
|
+
<ChevronDown class="text-[var(--aas-muted)]" />
|
|
24
|
+
</summary>
|
|
25
|
+
<ul
|
|
26
|
+
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"
|
|
27
|
+
>
|
|
28
|
+
{
|
|
29
|
+
codes.map((code) => (
|
|
30
|
+
<li>
|
|
31
|
+
<a
|
|
32
|
+
data-lang-link
|
|
33
|
+
data-lang-code={code}
|
|
34
|
+
href={getRelativeLocaleUrl(code, path)}
|
|
35
|
+
class:list={[
|
|
36
|
+
'block px-3 py-1.5 no-underline hover:bg-[var(--aas-bg)]',
|
|
37
|
+
code === lang ? 'font-semibold text-[var(--aas-text)]' : 'text-[var(--aas-muted)]',
|
|
38
|
+
]}
|
|
39
|
+
aria-current={code === lang ? 'true' : undefined}
|
|
40
|
+
>
|
|
41
|
+
{languages[code]}
|
|
42
|
+
</a>
|
|
43
|
+
</li>
|
|
44
|
+
))
|
|
45
|
+
}
|
|
46
|
+
</ul>
|
|
47
|
+
</details>
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
/* Lightweight, JS-free dropdown built on <details>. */
|
|
51
|
+
.aas-lang {
|
|
52
|
+
position: relative;
|
|
53
|
+
}
|
|
54
|
+
.aas-lang > summary::-webkit-details-marker {
|
|
55
|
+
display: none;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
// Carry the current query + hash (selected tab/example/file/section) over to
|
|
61
|
+
// the other language, so switching keeps you on the same view. The links are
|
|
62
|
+
// server-rendered without that client-side state, so refresh it when the
|
|
63
|
+
// dropdown opens (also keeps real hrefs, so open-in-new-tab works too).
|
|
64
|
+
document.querySelectorAll<HTMLDetailsElement>('.aas-lang').forEach((dd) => {
|
|
65
|
+
const links = Array.from(dd.querySelectorAll<HTMLAnchorElement>('[data-lang-link]'));
|
|
66
|
+
const bases = links.map((a) => a.getAttribute('href') ?? '');
|
|
67
|
+
const sync = () => {
|
|
68
|
+
const extra = location.search + location.hash;
|
|
69
|
+
links.forEach((a, i) => a.setAttribute('href', bases[i] + extra));
|
|
70
|
+
};
|
|
71
|
+
sync();
|
|
72
|
+
dd.addEventListener('toggle', () => dd.open && sync());
|
|
73
|
+
|
|
74
|
+
// Remember an explicit language choice so the root-home auto-detect
|
|
75
|
+
// (BaseLayout) respects it instead of overriding on the next visit.
|
|
76
|
+
links.forEach((a) =>
|
|
77
|
+
a.addEventListener('click', () => {
|
|
78
|
+
try {
|
|
79
|
+
localStorage.setItem('aas:lang', a.dataset.langCode ?? '');
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
</script>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
---
|
|
2
|
+
import ChevronDown from './ChevronDown.astro';
|
|
3
|
+
import { useTranslations, type Lang } from '../i18n/ui';
|
|
4
|
+
|
|
5
|
+
// Sort + pagination control for the concept & article indexes and their
|
|
6
|
+
// category pages. Sorting re-orders every [data-sortgrid] on the page; if a
|
|
7
|
+
// single [data-paginate] grid is present (the flat list on a leaf category
|
|
8
|
+
// page) it is also paginated via ?page=, with the page links rendered into the
|
|
9
|
+
// [data-pager] element. Both pieces mirror their state into the URL the way the
|
|
10
|
+
// stacks catalog does, and degrade to "show everything" without JS.
|
|
11
|
+
//
|
|
12
|
+
// The default is `recent`, which matches the server render (both indexes
|
|
13
|
+
// pre-sort by date, ConceptIndex.astro:25-29 included).
|
|
14
|
+
interface Props {
|
|
15
|
+
lang: Lang;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { lang } = Astro.props;
|
|
19
|
+
const t = useTranslations(lang);
|
|
20
|
+
const options = [
|
|
21
|
+
{ key: 'recent', label: t('sort.recent') },
|
|
22
|
+
{ key: 'alpha', label: t('sort.alpha') },
|
|
23
|
+
];
|
|
24
|
+
const summaryClass =
|
|
25
|
+
'flex items-center gap-1.5 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-sm text-[var(--aas-text)]';
|
|
26
|
+
const menuClass =
|
|
27
|
+
'absolute right-0 z-10 mt-1 min-w-[12rem] overflow-y-auto rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] p-1 shadow-lg';
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<div class="flex justify-end" data-listcontrols data-default={options[0].key}>
|
|
31
|
+
<details class="aas-dd">
|
|
32
|
+
<summary class={summaryClass}>
|
|
33
|
+
<span class="text-[var(--aas-muted)]">{t('sort.label')}:</span>
|
|
34
|
+
<span data-sort-label>{options[0].label}</span>
|
|
35
|
+
<ChevronDown class="text-[var(--aas-muted)]" />
|
|
36
|
+
</summary>
|
|
37
|
+
<ul class={menuClass}>
|
|
38
|
+
{
|
|
39
|
+
options.map((o, i) => (
|
|
40
|
+
<li>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
data-sort={o.key}
|
|
44
|
+
aria-current={i === 0 ? 'true' : 'false'}
|
|
45
|
+
class="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)]"
|
|
46
|
+
>
|
|
47
|
+
{o.label}
|
|
48
|
+
<span data-check aria-hidden="true" class="opacity-0">
|
|
49
|
+
✓
|
|
50
|
+
</span>
|
|
51
|
+
</button>
|
|
52
|
+
</li>
|
|
53
|
+
))
|
|
54
|
+
}
|
|
55
|
+
</ul>
|
|
56
|
+
</details>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
const bar = document.querySelector<HTMLElement>('[data-listcontrols]');
|
|
61
|
+
if (bar) {
|
|
62
|
+
const fallback = bar.dataset.default ?? 'recent';
|
|
63
|
+
const buttons = Array.from(bar.querySelectorAll<HTMLButtonElement>('[data-sort]'));
|
|
64
|
+
const labelEl = bar.querySelector<HTMLElement>('[data-sort-label]');
|
|
65
|
+
const sortGrids = Array.from(document.querySelectorAll<HTMLElement>('[data-sortgrid]'));
|
|
66
|
+
// Stable snapshot of each grid's cards — the source list every sort starts from.
|
|
67
|
+
const original = new Map(sortGrids.map((g) => [g, Array.from(g.children) as HTMLElement[]]));
|
|
68
|
+
|
|
69
|
+
const pageGrid = document.querySelector<HTMLElement>('[data-paginate]');
|
|
70
|
+
const pager = document.querySelector<HTMLElement>('[data-pager]');
|
|
71
|
+
const pageSize = Number(pageGrid?.dataset.pageSize ?? '0') || 0;
|
|
72
|
+
|
|
73
|
+
let curSort = fallback;
|
|
74
|
+
let curPage = 1;
|
|
75
|
+
|
|
76
|
+
const byName = (a: HTMLElement, b: HTMLElement) =>
|
|
77
|
+
(a.dataset.name ?? '').localeCompare(b.dataset.name ?? '');
|
|
78
|
+
const comparator = (sort: string) =>
|
|
79
|
+
sort === 'alpha'
|
|
80
|
+
? byName
|
|
81
|
+
: (a: HTMLElement, b: HTMLElement) =>
|
|
82
|
+
(b.dataset.date ?? '').localeCompare(a.dataset.date ?? '') || byName(a, b);
|
|
83
|
+
|
|
84
|
+
function applySort(sort: string) {
|
|
85
|
+
curSort = sort;
|
|
86
|
+
for (const grid of sortGrids) {
|
|
87
|
+
const cards = original.get(grid) ?? [];
|
|
88
|
+
const ordered = [...cards].sort(comparator(sort));
|
|
89
|
+
ordered.forEach((c) => grid.appendChild(c));
|
|
90
|
+
}
|
|
91
|
+
for (const b of buttons) {
|
|
92
|
+
const on = b.dataset.sort === sort;
|
|
93
|
+
b.setAttribute('aria-current', String(on));
|
|
94
|
+
b.querySelector<HTMLElement>('[data-check]')?.classList.toggle('opacity-0', !on);
|
|
95
|
+
if (on && labelEl) labelEl.textContent = b.textContent?.replace('✓', '').trim() ?? '';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pageButton(label: string, page: number | null, opts: { current?: boolean; disabled?: boolean } = {}) {
|
|
100
|
+
const b = document.createElement('button');
|
|
101
|
+
b.type = 'button';
|
|
102
|
+
b.textContent = label;
|
|
103
|
+
if (page) b.dataset.page = String(page);
|
|
104
|
+
b.disabled = !!opts.disabled;
|
|
105
|
+
b.setAttribute('aria-current', opts.current ? 'page' : 'false');
|
|
106
|
+
b.className =
|
|
107
|
+
'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)]';
|
|
108
|
+
return b;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderPager(pages: number) {
|
|
112
|
+
if (!pager) return;
|
|
113
|
+
pager.replaceChildren();
|
|
114
|
+
if (pages <= 1) return;
|
|
115
|
+
// Page numbers shown in fixed groups of SIZE; the arrows jump a whole
|
|
116
|
+
// group (‹ → last page of the previous group, › → first of the next), so
|
|
117
|
+
// long lists are quick to traverse rather than clicking one page at a time.
|
|
118
|
+
const SIZE = 5;
|
|
119
|
+
const start = Math.floor((curPage - 1) / SIZE) * SIZE + 1;
|
|
120
|
+
const end = Math.min(pages, start + SIZE - 1);
|
|
121
|
+
pager.appendChild(pageButton('«', 1, { disabled: curPage === 1 }));
|
|
122
|
+
pager.appendChild(pageButton('‹', start - 1, { disabled: start === 1 }));
|
|
123
|
+
for (let i = start; i <= end; i++) pager.appendChild(pageButton(String(i), i, { current: i === curPage }));
|
|
124
|
+
// Pad a short last group to five slots with invisible placeholders so
|
|
125
|
+
// the arrows sit in the same spot on every page.
|
|
126
|
+
for (let i = end + 1; i < start + SIZE; i++) {
|
|
127
|
+
const ghost = pageButton('0', null, { disabled: true });
|
|
128
|
+
ghost.classList.add('invisible');
|
|
129
|
+
pager.appendChild(ghost);
|
|
130
|
+
}
|
|
131
|
+
pager.appendChild(pageButton('›', end + 1, { disabled: end >= pages }));
|
|
132
|
+
pager.appendChild(pageButton('»', pages, { disabled: curPage === pages }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function applyPage(page: number) {
|
|
136
|
+
if (!pageGrid || pageSize <= 0) return;
|
|
137
|
+
const cards = Array.from(pageGrid.children) as HTMLElement[];
|
|
138
|
+
const pages = Math.max(1, Math.ceil(cards.length / pageSize));
|
|
139
|
+
curPage = Math.min(Math.max(1, page), pages);
|
|
140
|
+
const start = (curPage - 1) * pageSize;
|
|
141
|
+
cards.forEach((c, i) => {
|
|
142
|
+
c.style.display = i >= start && i < start + pageSize ? '' : 'none';
|
|
143
|
+
});
|
|
144
|
+
renderPager(pages);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function sync() {
|
|
148
|
+
const url = new URL(location.href);
|
|
149
|
+
if (curSort === fallback) url.searchParams.delete('sort');
|
|
150
|
+
else url.searchParams.set('sort', curSort);
|
|
151
|
+
if (curPage === 1) url.searchParams.delete('page');
|
|
152
|
+
else url.searchParams.set('page', String(curPage));
|
|
153
|
+
history.replaceState(null, '', url);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const b of buttons) {
|
|
157
|
+
b.addEventListener('click', () => {
|
|
158
|
+
applySort(b.dataset.sort ?? fallback);
|
|
159
|
+
applyPage(1);
|
|
160
|
+
sync();
|
|
161
|
+
b.closest('details')?.removeAttribute('open');
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (pager) {
|
|
166
|
+
pager.addEventListener('click', (e) => {
|
|
167
|
+
const b = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-page]');
|
|
168
|
+
if (!b || b.disabled) return;
|
|
169
|
+
applyPage(Number(b.dataset.page));
|
|
170
|
+
sync();
|
|
171
|
+
window.scrollTo({ top: 0 });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const params = new URLSearchParams(location.search);
|
|
176
|
+
const known = new Set(buttons.map((b) => b.dataset.sort));
|
|
177
|
+
const wantSort = params.get('sort');
|
|
178
|
+
const wantPage = Number(params.get('page'));
|
|
179
|
+
applySort(wantSort && known.has(wantSort) ? wantSort : fallback);
|
|
180
|
+
applyPage(Number.isFinite(wantPage) && wantPage > 0 ? wantPage : 1);
|
|
181
|
+
}
|
|
182
|
+
</script>
|