stack-site-builder 1.10.0 → 1.12.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/CHANGELOG.md +112 -0
- package/README.md +20 -1
- package/index.mjs +43 -29
- package/markdown.mjs +24 -12
- package/package.json +9 -3
- package/src/components/ArticleLink.astro +2 -2
- package/src/components/ConceptDetail.astro +2 -2
- package/src/components/ConceptLink.astro +2 -2
- package/src/components/ConceptTools.astro +2 -2
- package/src/components/LanguageSwitcher.astro +7 -7
- package/src/components/PageDetail.astro +52 -0
- package/src/components/ProjectViewer.astro +2 -2
- package/src/components/SampleIndex.astro +2 -2
- package/src/components/SampleProject.astro +3 -3
- package/src/components/StackCard.astro +3 -3
- package/src/components/StackDetail.astro +3 -3
- package/src/components/ThemeToggle.astro +1 -3
- package/src/components/ToolMeta.astro +2 -2
- package/src/content.ts +29 -1
- package/src/i18n/ui.ts +70 -13
- package/src/layouts/BaseLayout.astro +118 -57
- package/src/lib/dates.ts +2 -2
- package/src/lib/facets.ts +2 -2
- package/src/lib/locales.ts +16 -0
- package/src/lib/pages.ts +24 -0
- package/src/pages/[...lang]/[page].astro +30 -0
- package/src/pages/{ko → [...lang]}/article/[...id].astro +11 -5
- package/src/pages/[...lang]/article/category/[id].astro +25 -0
- package/src/pages/[...lang]/article/index.astro +18 -0
- package/src/pages/[...lang]/categories/[id].astro +28 -0
- package/src/pages/{ko → [...lang]}/concept/[...id].astro +11 -5
- package/src/pages/[...lang]/concept/category/[id].astro +25 -0
- package/src/pages/[...lang]/concept/index.astro +18 -0
- package/src/pages/[...lang]/glossary.astro +18 -0
- package/src/pages/[...lang]/index.astro +16 -0
- package/src/pages/[...lang]/sample/[folder].astro +25 -0
- package/src/pages/[...lang]/sample/index.astro +18 -0
- package/src/pages/[...lang]/slides/[deck].astro +22 -0
- package/src/pages/[...lang]/slides/index.astro +18 -0
- package/src/pages/{ko → [...lang]}/stack/[...id].astro +20 -13
- package/src/pages/[...lang]/tags/[tag].astro +24 -0
- package/src/pages/[...lang]/vendors/[vendor].astro +24 -0
- package/src/styles/global.css +7 -0
- package/src/pages/article/[...id].astro +0 -24
- package/src/pages/article/category/[id].astro +0 -17
- package/src/pages/article/index.astro +0 -12
- package/src/pages/categories/[id].astro +0 -25
- package/src/pages/concept/[...id].astro +0 -24
- package/src/pages/concept/category/[id].astro +0 -17
- package/src/pages/concept/index.astro +0 -12
- package/src/pages/glossary.astro +0 -12
- package/src/pages/index.astro +0 -9
- package/src/pages/ko/article/category/[id].astro +0 -17
- package/src/pages/ko/article/index.astro +0 -12
- package/src/pages/ko/categories/[id].astro +0 -24
- package/src/pages/ko/concept/category/[id].astro +0 -17
- package/src/pages/ko/concept/index.astro +0 -12
- package/src/pages/ko/glossary.astro +0 -12
- package/src/pages/ko/index.astro +0 -9
- package/src/pages/ko/sample/[folder].astro +0 -16
- package/src/pages/ko/sample/index.astro +0 -12
- package/src/pages/ko/slides/[deck].astro +0 -14
- package/src/pages/ko/slides/index.astro +0 -12
- package/src/pages/ko/tags/[tag].astro +0 -18
- package/src/pages/ko/vendors/[vendor].astro +0 -18
- package/src/pages/sample/[folder].astro +0 -16
- package/src/pages/sample/index.astro +0 -12
- package/src/pages/slides/[deck].astro +0 -14
- package/src/pages/slides/index.astro +0 -12
- package/src/pages/stack/[...id].astro +0 -51
- package/src/pages/tags/[tag].astro +0 -18
- package/src/pages/vendors/[vendor].astro +0 -18
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { useTranslations, type Lang } from '../i18n/ui';
|
|
3
|
-
import ChevronDown from './ChevronDown.astro';
|
|
4
3
|
|
|
5
4
|
interface Props {
|
|
6
5
|
lang: Lang;
|
|
@@ -18,12 +17,11 @@ const options = [
|
|
|
18
17
|
|
|
19
18
|
<details class="aas-theme">
|
|
20
19
|
<summary
|
|
21
|
-
class="flex cursor-pointer list-none items-center
|
|
20
|
+
class="flex cursor-pointer list-none items-center rounded-lg border border-[var(--aas-border)] px-2.5 py-1 text-[var(--aas-text)] select-none"
|
|
22
21
|
aria-label={t('theme.label')}
|
|
23
22
|
title={t('theme.label')}
|
|
24
23
|
>
|
|
25
24
|
<span data-theme-icon aria-hidden="true">☀</span>
|
|
26
|
-
<ChevronDown class="text-[var(--aas-muted)]" />
|
|
27
25
|
</summary>
|
|
28
26
|
<ul
|
|
29
27
|
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"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import {
|
|
2
|
+
import { pricingLabel, licenseLabel, useTranslations, type Lang } from '../i18n/ui';
|
|
3
3
|
import { formatStars, type Staleness } from '../lib/github';
|
|
4
4
|
import { pricingTags, COMPLETELY_FREE } from '../lib/pricing';
|
|
5
5
|
import StarIcon from './StarIcon.astro';
|
|
@@ -81,7 +81,7 @@ const chip = 'aas-chip rounded-lg px-2.5 py-1';
|
|
|
81
81
|
{language && <span class={chip}>{language}</span>}
|
|
82
82
|
{pricingTags(pricing).map((p) => (
|
|
83
83
|
<span class:list={[p === COMPLETELY_FREE ? 'aas-free' : 'aas-pricing', 'rounded-lg px-2.5 py-1']}>
|
|
84
|
-
{
|
|
84
|
+
{pricingLabel(lang, p)}
|
|
85
85
|
</span>
|
|
86
86
|
))}
|
|
87
87
|
{license && (
|
package/src/content.ts
CHANGED
|
@@ -224,5 +224,33 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
|
|
|
224
224
|
}),
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
/**
|
|
228
|
+
* The `pages` collection holds standalone top-level pages — an About/소개, a
|
|
229
|
+
* contact page, terms, etc. Unlike the other collections there's no index or
|
|
230
|
+
* taxonomy: each entry renders on its own at the site root (`/<slug>/`), and
|
|
231
|
+
* (when `nav` is set) shows up as its own item in the header navigation. This
|
|
232
|
+
* is how a site adds a first-class section that is just one page.
|
|
233
|
+
*
|
|
234
|
+
* Locale-partitioned like the rest: `pages/en/about.mdx`, `pages/ko/about.mdx`.
|
|
235
|
+
*/
|
|
236
|
+
const pages = defineCollection({
|
|
237
|
+
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/pages' }),
|
|
238
|
+
schema: ({ image }) =>
|
|
239
|
+
z.object({
|
|
240
|
+
title: z.string(),
|
|
241
|
+
description: z.string().optional(),
|
|
242
|
+
image: image().optional(), // hero image (optimized; relative to the file)
|
|
243
|
+
imageAlt: z.string().optional(),
|
|
244
|
+
// Header-nav placement. `nav` toggles the link; `navLabel` overrides the
|
|
245
|
+
// link's accessible label (defaults to `title`); `order` sorts the page
|
|
246
|
+
// links among themselves (lower first). A page with `nav: false` still
|
|
247
|
+
// renders at its URL but isn't linked from the header.
|
|
248
|
+
nav: z.boolean().default(true),
|
|
249
|
+
navLabel: z.string().optional(),
|
|
250
|
+
order: z.number().default(0),
|
|
251
|
+
draft: z.boolean().default(false),
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return { stacks, articles, concepts, slides, pages };
|
|
228
256
|
}
|
package/src/i18n/ui.ts
CHANGED
|
@@ -8,14 +8,48 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { site } from '@aas-data/site';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/** One locale the site ships. `code` matches the astro.config `i18n` locale and
|
|
12
|
+
* the content folder (`stacks/<code>/…`); `label` names it in the switcher;
|
|
13
|
+
* `dateLocale` is the BCP-47 tag for date formatting (defaults to `code`). */
|
|
14
|
+
export interface LocaleDef {
|
|
15
|
+
code: string;
|
|
16
|
+
label: string;
|
|
17
|
+
dateLocale?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The theme ships English + Korean. A consuming site overrides this list via
|
|
21
|
+
// `site.locales` to rename them, reorder them, or add its own (e.g. `ja`). The
|
|
22
|
+
// list is authoritative and ordered: it drives the language switcher, and its
|
|
23
|
+
// FIRST entry is the default locale (must match astro.config `i18n.defaultLocale`).
|
|
24
|
+
const themeLocales: LocaleDef[] = [
|
|
25
|
+
{ code: 'en', label: 'English', dateLocale: 'en-US' },
|
|
26
|
+
{ code: 'ko', label: '한국어', dateLocale: 'ko-KR' },
|
|
27
|
+
];
|
|
28
|
+
// Read defensively: a site that hasn't opted into custom locales simply has no
|
|
29
|
+
// `locales` field, and should keep the theme's en/ko default (not a type error).
|
|
30
|
+
const siteLocales = (site as { locales?: LocaleDef[] }).locales;
|
|
31
|
+
const localeList: LocaleDef[] =
|
|
32
|
+
Array.isArray(siteLocales) && siteLocales.length ? siteLocales : themeLocales;
|
|
33
|
+
|
|
34
|
+
/** Display names keyed by code — the language switcher iterates this. */
|
|
35
|
+
export const languages: Record<string, string> = Object.fromEntries(
|
|
36
|
+
localeList.map((l) => [l.code, l.label]),
|
|
37
|
+
);
|
|
15
38
|
|
|
16
|
-
|
|
39
|
+
// Locales are site-configurable, so `Lang` can't be a fixed union of codes.
|
|
40
|
+
export type Lang = string;
|
|
17
41
|
|
|
18
|
-
export const defaultLang: Lang =
|
|
42
|
+
export const defaultLang: Lang = localeList[0].code;
|
|
43
|
+
|
|
44
|
+
/** BCP-47 tag for `Intl` date/number formatting in `lang` (falls back to the
|
|
45
|
+
* bare code, then the default locale). */
|
|
46
|
+
export function dateLocaleOf(lang: Lang): string {
|
|
47
|
+
return (
|
|
48
|
+
localeList.find((l) => l.code === lang)?.dateLocale ??
|
|
49
|
+
localeList.find((l) => l.code === defaultLang)?.dateLocale ??
|
|
50
|
+
lang
|
|
51
|
+
);
|
|
52
|
+
}
|
|
19
53
|
|
|
20
54
|
/** UI chrome strings, keyed by a dotted id. */
|
|
21
55
|
export const ui = {
|
|
@@ -29,6 +63,10 @@ export const ui = {
|
|
|
29
63
|
'nav.slides': 'Slides',
|
|
30
64
|
'nav.backToTop': 'Back to top',
|
|
31
65
|
'nav.glossary': 'Glossary',
|
|
66
|
+
'nav.language': 'Language',
|
|
67
|
+
'nav.menu': 'Menu',
|
|
68
|
+
'code.copy': 'Copy code',
|
|
69
|
+
'code.copied': 'Copied',
|
|
32
70
|
'slides.title': 'Slides',
|
|
33
71
|
'slides.tagline': 'Concept decks — the same ideas as the concept pages, in slide form.',
|
|
34
72
|
'slides.deck': 'Deck',
|
|
@@ -167,6 +205,10 @@ export const ui = {
|
|
|
167
205
|
'nav.slides': '슬라이드',
|
|
168
206
|
'nav.backToTop': '맨 위로',
|
|
169
207
|
'nav.glossary': '용어집',
|
|
208
|
+
'nav.language': '언어',
|
|
209
|
+
'nav.menu': '메뉴',
|
|
210
|
+
'code.copy': '코드 복사',
|
|
211
|
+
'code.copied': '복사됨',
|
|
170
212
|
'slides.title': '슬라이드',
|
|
171
213
|
'slides.tagline': '개념 덱 — 개념 페이지와 같은 내용을 슬라이드로 옮겼습니다.',
|
|
172
214
|
'slides.deck': '덱',
|
|
@@ -299,18 +341,33 @@ export const ui = {
|
|
|
299
341
|
export type UIKey = keyof (typeof ui)['en'];
|
|
300
342
|
|
|
301
343
|
/**
|
|
302
|
-
* Returns a `t(key)` translator bound to the given language.
|
|
303
|
-
* override
|
|
304
|
-
*
|
|
344
|
+
* Returns a `t(key)` translator bound to the given language. Lookup order:
|
|
345
|
+
* the site's override for this locale, the theme string for this locale, then
|
|
346
|
+
* the same two for the default locale, then the key itself. The default-locale
|
|
347
|
+
* fallback is what lets a site add a locale (e.g. `ja`) and supply only some
|
|
348
|
+
* strings via `site.ui.ja` — the rest degrade to the default locale instead of
|
|
349
|
+
* throwing on a locale the theme doesn't ship.
|
|
305
350
|
*/
|
|
306
351
|
export function useTranslations(lang: Lang) {
|
|
307
352
|
return function t(key: UIKey): string {
|
|
308
|
-
return
|
|
353
|
+
return (
|
|
354
|
+
site.ui?.[lang]?.[key] ??
|
|
355
|
+
(ui as Record<string, Partial<Record<UIKey, string>>>)[lang]?.[key] ??
|
|
356
|
+
site.ui?.[defaultLang]?.[key] ??
|
|
357
|
+
(ui as Record<string, Partial<Record<UIKey, string>>>)[defaultLang]?.[key] ??
|
|
358
|
+
key
|
|
359
|
+
);
|
|
309
360
|
};
|
|
310
361
|
}
|
|
311
362
|
|
|
363
|
+
/** Localized label for a `pricing` enum value, falling back to the default
|
|
364
|
+
* locale then the raw value (so a site-added locale never crashes). */
|
|
365
|
+
export function pricingLabel(lang: Lang, value: string): string {
|
|
366
|
+
return pricingLabels[lang]?.[value] ?? pricingLabels[defaultLang]?.[value] ?? value;
|
|
367
|
+
}
|
|
368
|
+
|
|
312
369
|
/** Human labels for the `pricing` frontmatter enum, per locale. */
|
|
313
|
-
export const pricingLabels: Record<
|
|
370
|
+
export const pricingLabels: Record<string, Record<string, string>> = {
|
|
314
371
|
en: {
|
|
315
372
|
'completely-free': 'Completely free',
|
|
316
373
|
'open-source': 'Open source',
|
|
@@ -328,10 +385,10 @@ export const pricingLabels: Record<Lang, Record<string, string>> = {
|
|
|
328
385
|
};
|
|
329
386
|
|
|
330
387
|
/** Descriptive (non-name) licenses get localized; real license names pass through. */
|
|
331
|
-
const licenseLabels: Record<
|
|
388
|
+
const licenseLabels: Record<string, Record<string, string>> = {
|
|
332
389
|
en: { proprietary: 'Proprietary' },
|
|
333
390
|
ko: { proprietary: '독점' },
|
|
334
391
|
};
|
|
335
392
|
export function licenseLabel(lang: Lang, value: string): string {
|
|
336
|
-
return licenseLabels[lang][value] ?? value;
|
|
393
|
+
return licenseLabels[lang]?.[value] ?? licenseLabels[defaultLang]?.[value] ?? value;
|
|
337
394
|
}
|
|
@@ -6,6 +6,7 @@ import { useTranslations, languages, type Lang } from '../i18n/ui';
|
|
|
6
6
|
import LanguageSwitcher from '../components/LanguageSwitcher.astro';
|
|
7
7
|
import ThemeToggle from '../components/ThemeToggle.astro';
|
|
8
8
|
import BackToTop from '../components/BackToTop.astro';
|
|
9
|
+
import { getNavPages, pageSlugOf } from '../lib/pages';
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
11
12
|
title: string;
|
|
@@ -32,10 +33,70 @@ const localeHomes = Object.fromEntries(
|
|
|
32
33
|
(Object.keys(languages) as Lang[]).map((c) => [c, getRelativeLocaleUrl(c, '')]),
|
|
33
34
|
);
|
|
34
35
|
const isDefaultHome = path === '' && lang === 'en';
|
|
36
|
+
|
|
37
|
+
// Standalone pages (the `pages` collection) that opt into a header-nav link,
|
|
38
|
+
// e.g. an About/소개 page. Rendered as their own nav items alongside the
|
|
39
|
+
// built-in sections; the accessible label comes from the page's own frontmatter.
|
|
40
|
+
const navPages = await getNavPages(lang);
|
|
41
|
+
|
|
42
|
+
// The header sections, as data — rendered twice from one source so the two
|
|
43
|
+
// layouts never drift: an icon-only row (≥sm) and a labelled dropdown menu
|
|
44
|
+
// (<sm, so a phone header stays to a few controls instead of a long icon run).
|
|
45
|
+
// `svg` is the icon's inner markup (drawn into a shared <svg> shell below).
|
|
46
|
+
const navItems: {
|
|
47
|
+
href: string;
|
|
48
|
+
label: string;
|
|
49
|
+
svg: string;
|
|
50
|
+
external?: boolean;
|
|
51
|
+
keepFilters?: boolean;
|
|
52
|
+
}[] = [
|
|
53
|
+
{
|
|
54
|
+
href: `${home}#categories`,
|
|
55
|
+
label: t('nav.browse'),
|
|
56
|
+
keepFilters: true,
|
|
57
|
+
svg: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
href: getRelativeLocaleUrl(lang, 'concept/'),
|
|
61
|
+
label: t('nav.concepts'),
|
|
62
|
+
svg: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
href: getRelativeLocaleUrl(lang, 'article/'),
|
|
66
|
+
label: t('nav.blog'),
|
|
67
|
+
svg: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
href: getRelativeLocaleUrl(lang, 'sample/'),
|
|
71
|
+
label: t('nav.samples'),
|
|
72
|
+
svg: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
href: getRelativeLocaleUrl(lang, 'slides/'),
|
|
76
|
+
label: t('nav.slides'),
|
|
77
|
+
svg: '<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/><path d="M12 12v9"/>',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
href: getRelativeLocaleUrl(lang, 'glossary/'),
|
|
81
|
+
label: t('nav.glossary'),
|
|
82
|
+
svg: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
|
|
83
|
+
},
|
|
84
|
+
...navPages.map((p) => ({
|
|
85
|
+
href: getRelativeLocaleUrl(lang, `${pageSlugOf(p)}/`),
|
|
86
|
+
label: p.data.navLabel ?? p.data.title,
|
|
87
|
+
svg: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
88
|
+
})),
|
|
89
|
+
{
|
|
90
|
+
href: site.repoUrl,
|
|
91
|
+
label: 'GitHub',
|
|
92
|
+
external: true,
|
|
93
|
+
svg: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C4 2 3 2 3 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 2 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
|
|
94
|
+
},
|
|
95
|
+
];
|
|
35
96
|
---
|
|
36
97
|
|
|
37
98
|
<!doctype html>
|
|
38
|
-
<html lang={lang}>
|
|
99
|
+
<html lang={lang} data-copy-label={t('code.copy')} data-copied-label={t('code.copied')}>
|
|
39
100
|
<head>
|
|
40
101
|
<meta charset="utf-8" />
|
|
41
102
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -107,66 +168,67 @@ const isDefaultHome = path === '' && lang === 'en';
|
|
|
107
168
|
{/* Below 600px only the bolt remains, so the narrow header never truncates the wordmark. */}
|
|
108
169
|
<span class="hidden truncate min-[600px]:inline">{site.name}</span>
|
|
109
170
|
</a>
|
|
110
|
-
{/*
|
|
171
|
+
{/* Section links are icon-only (labels live in aria-label). On phones
|
|
172
|
+
(<sm) they collapse into the labelled dropdown menu below, so the
|
|
173
|
+
header stays to a few controls; from sm up the full icon row shows. */}
|
|
111
174
|
<nav class="flex shrink-0 items-center gap-3 text-sm text-[var(--aas-muted)] sm:gap-4">
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
</
|
|
127
|
-
<a
|
|
128
|
-
href={getRelativeLocaleUrl(lang, 'article/')}
|
|
129
|
-
class="flex items-center no-underline hover:text-[var(--aas-text)]"
|
|
130
|
-
aria-label={t('nav.blog')}
|
|
131
|
-
>
|
|
132
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>
|
|
133
|
-
</a>
|
|
134
|
-
<a
|
|
135
|
-
href={getRelativeLocaleUrl(lang, 'sample/')}
|
|
136
|
-
class="flex items-center no-underline hover:text-[var(--aas-text)]"
|
|
137
|
-
aria-label={t('nav.samples')}
|
|
138
|
-
>
|
|
139
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/></svg>
|
|
140
|
-
</a>
|
|
141
|
-
<a
|
|
142
|
-
href={getRelativeLocaleUrl(lang, 'slides/')}
|
|
143
|
-
class="flex items-center no-underline hover:text-[var(--aas-text)]"
|
|
144
|
-
aria-label={t('nav.slides')}
|
|
145
|
-
>
|
|
146
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/><path d="M12 12v9"/></svg>
|
|
147
|
-
</a>
|
|
148
|
-
<a
|
|
149
|
-
href={getRelativeLocaleUrl(lang, 'glossary/')}
|
|
150
|
-
class="flex items-center no-underline hover:text-[var(--aas-text)]"
|
|
151
|
-
aria-label={t('nav.glossary')}
|
|
152
|
-
>
|
|
153
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>
|
|
154
|
-
</a>
|
|
155
|
-
<a
|
|
156
|
-
href={site.repoUrl}
|
|
157
|
-
target="_blank"
|
|
158
|
-
rel="noopener noreferrer"
|
|
159
|
-
class="flex items-center no-underline hover:text-[var(--aas-text)]"
|
|
160
|
-
aria-label="GitHub"
|
|
161
|
-
>
|
|
162
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C4 2 3 2 3 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 2 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>
|
|
163
|
-
</a>
|
|
175
|
+
<div class="hidden items-center gap-3 sm:flex sm:gap-4">
|
|
176
|
+
{
|
|
177
|
+
navItems.map((item) => (
|
|
178
|
+
<a
|
|
179
|
+
href={item.href}
|
|
180
|
+
class="flex items-center no-underline hover:text-[var(--aas-text)]"
|
|
181
|
+
aria-label={item.label}
|
|
182
|
+
{...(item.keepFilters ? { 'data-keep-filters': true } : {})}
|
|
183
|
+
{...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
184
|
+
>
|
|
185
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" set:html={item.svg} />
|
|
186
|
+
</a>
|
|
187
|
+
))
|
|
188
|
+
}
|
|
189
|
+
</div>
|
|
164
190
|
<ThemeToggle lang={lang} />
|
|
165
191
|
<LanguageSwitcher lang={lang} path={path} />
|
|
192
|
+
<details class="aas-menu relative sm:hidden">
|
|
193
|
+
<summary
|
|
194
|
+
class="flex cursor-pointer list-none items-center rounded-lg border border-[var(--aas-border)] px-2.5 py-1 text-[var(--aas-text)] select-none"
|
|
195
|
+
aria-label={t('nav.menu')}
|
|
196
|
+
title={t('nav.menu')}
|
|
197
|
+
>
|
|
198
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/></svg>
|
|
199
|
+
</summary>
|
|
200
|
+
<ul
|
|
201
|
+
class="absolute right-0 z-20 mt-1 min-w-[11rem] overflow-hidden rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] py-1 shadow-lg"
|
|
202
|
+
>
|
|
203
|
+
{
|
|
204
|
+
navItems.map((item) => (
|
|
205
|
+
<li>
|
|
206
|
+
<a
|
|
207
|
+
href={item.href}
|
|
208
|
+
class="flex items-center gap-2.5 px-3 py-2 whitespace-nowrap text-[var(--aas-text)] no-underline hover:bg-[var(--aas-bg)]"
|
|
209
|
+
{...(item.keepFilters ? { 'data-keep-filters': true } : {})}
|
|
210
|
+
{...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
211
|
+
>
|
|
212
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="shrink-0 text-[var(--aas-muted)]" set:html={item.svg} />
|
|
213
|
+
<span>{item.label}</span>
|
|
214
|
+
</a>
|
|
215
|
+
</li>
|
|
216
|
+
))
|
|
217
|
+
}
|
|
218
|
+
</ul>
|
|
219
|
+
</details>
|
|
166
220
|
</nav>
|
|
167
221
|
</div>
|
|
168
222
|
</header>
|
|
169
223
|
|
|
224
|
+
<style>
|
|
225
|
+
/* Hide the default <details> disclosure triangle on the mobile menu
|
|
226
|
+
(Safari needs the -webkit rule even with list-style: none). */
|
|
227
|
+
.aas-menu > summary::-webkit-details-marker {
|
|
228
|
+
display: none;
|
|
229
|
+
}
|
|
230
|
+
</style>
|
|
231
|
+
|
|
170
232
|
<main class:list={['mx-auto px-5 py-10', container]}>
|
|
171
233
|
<slot />
|
|
172
234
|
</main>
|
|
@@ -209,9 +271,8 @@ const isDefaultHome = path === '' && lang === 'en';
|
|
|
209
271
|
// (.aas-code). The button is pinned to a non-scrolling host so it stays in
|
|
210
272
|
// place on long, horizontally-scrolling code.
|
|
211
273
|
(function mountCopyButtons() {
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
const DONE = koLang ? '복사됨' : 'Copied';
|
|
274
|
+
const LABEL = document.documentElement.dataset.copyLabel || 'Copy code';
|
|
275
|
+
const DONE = document.documentElement.dataset.copiedLabel || 'Copied';
|
|
215
276
|
const COPY_ICON =
|
|
216
277
|
'<svg class="aas-ico-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="13" height="13" x="9" y="9" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
217
278
|
const CHECK_ICON =
|
package/src/lib/dates.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { dateLocaleOf, type Lang } from '../i18n/ui';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Human-facing date in the reader's locale (e.g. "June 28, 2026" / "2026년 6월
|
|
@@ -7,7 +7,7 @@ import type { Lang } from '../i18n/ui';
|
|
|
7
7
|
* attribute on the surrounding `<time>` for machines.
|
|
8
8
|
*/
|
|
9
9
|
export function formatDate(d: Date, lang: Lang): string {
|
|
10
|
-
return new Intl.DateTimeFormat(lang
|
|
10
|
+
return new Intl.DateTimeFormat(dateLocaleOf(lang), {
|
|
11
11
|
year: 'numeric',
|
|
12
12
|
month: 'long',
|
|
13
13
|
day: 'numeric',
|
package/src/lib/facets.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pricingTags } from './pricing';
|
|
2
|
-
import {
|
|
2
|
+
import { pricingLabel, licenseLabel, useTranslations, type Lang } from '../i18n/ui';
|
|
3
3
|
import type { StackEntry } from './stacks';
|
|
4
4
|
|
|
5
5
|
export interface FacetOption {
|
|
@@ -39,7 +39,7 @@ export function deriveFacets(entries: StackEntry[], lang: Lang): Facet[] {
|
|
|
39
39
|
label: t('detail.pricing'),
|
|
40
40
|
options: pricingOrder
|
|
41
41
|
.filter((p) => usedPricing.has(p))
|
|
42
|
-
.map((p) => ({ value: p, label:
|
|
42
|
+
.map((p) => ({ value: p, label: pricingLabel(lang, p) })),
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
key: 'license',
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { languages, defaultLang, type Lang } from '../i18n/ui';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Helpers for the single `src/pages/[...lang]/` route tree. Every page's
|
|
5
|
+
* getStaticPaths walks {@link allLocales} and emits one path per locale, using
|
|
6
|
+
* {@link langParam} for the `[...lang]` rest param: `undefined` for the default
|
|
7
|
+
* locale (so it renders at the site root) and the code otherwise (so it renders
|
|
8
|
+
* under `/<code>/`). Adding a locale is a site-config change, not a new file.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** All locale codes the site ships, in switcher order (default first). */
|
|
12
|
+
export const allLocales = Object.keys(languages) as Lang[];
|
|
13
|
+
|
|
14
|
+
/** The `[...lang]` param for a locale: `undefined` for the default, else the code. */
|
|
15
|
+
export const langParam = (lang: Lang): string | undefined =>
|
|
16
|
+
lang === defaultLang ? undefined : lang;
|
package/src/lib/pages.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
|
+
import type { Lang } from '../i18n/ui';
|
|
3
|
+
|
|
4
|
+
export type PageEntry = CollectionEntry<'pages'>;
|
|
5
|
+
|
|
6
|
+
/** The url slug of a page, i.e. its id with the `<lang>/` prefix removed. */
|
|
7
|
+
export function pageSlugOf(entry: PageEntry): string {
|
|
8
|
+
return entry.id.replace(/^[a-z]{2}\//, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Standalone pages for one locale, in `order` (then title) order. */
|
|
12
|
+
export async function getPages(lang: Lang): Promise<PageEntry[]> {
|
|
13
|
+
const all = await getCollection('pages');
|
|
14
|
+
return all
|
|
15
|
+
.filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
|
|
16
|
+
.sort(
|
|
17
|
+
(a, b) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The subset of {@link getPages} that opts into a header-nav link. */
|
|
22
|
+
export async function getNavPages(lang: Lang): Promise<PageEntry[]> {
|
|
23
|
+
return (await getPages(lang)).filter((e) => e.data.nav);
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import type { GetStaticPaths } from 'astro';
|
|
4
|
+
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
|
+
import PageDetail from '../../components/PageDetail.astro';
|
|
6
|
+
import { getPages, pageSlugOf } from '../../lib/pages';
|
|
7
|
+
import { allLocales, langParam } from '../../lib/locales';
|
|
8
|
+
|
|
9
|
+
export const getStaticPaths = (async () => {
|
|
10
|
+
const paths: Awaited<ReturnType<GetStaticPaths>> = [];
|
|
11
|
+
for (const lang of allLocales) {
|
|
12
|
+
for (const entry of await getPages(lang)) {
|
|
13
|
+
paths.push({ params: { lang: langParam(lang), page: pageSlugOf(entry) }, props: { lang, entry } });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return paths;
|
|
17
|
+
}) satisfies GetStaticPaths;
|
|
18
|
+
|
|
19
|
+
const { lang, entry } = Astro.props;
|
|
20
|
+
const slug = pageSlugOf(entry);
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<BaseLayout
|
|
24
|
+
title={`${entry.data.title} — ${site.name}`}
|
|
25
|
+
description={entry.data.description}
|
|
26
|
+
lang={lang}
|
|
27
|
+
path={`${slug}/`}
|
|
28
|
+
>
|
|
29
|
+
<PageDetail entry={entry} lang={lang} />
|
|
30
|
+
</BaseLayout>
|
|
@@ -4,21 +4,27 @@ import type { GetStaticPaths } from 'astro';
|
|
|
4
4
|
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
5
|
import ArticleDetail from '../../../components/ArticleDetail.astro';
|
|
6
6
|
import { getArticles, articleSlugOf } from '../../../lib/articles';
|
|
7
|
+
import { allLocales, langParam } from '../../../lib/locales';
|
|
7
8
|
|
|
8
9
|
export const getStaticPaths = (async () => {
|
|
9
|
-
const
|
|
10
|
-
|
|
10
|
+
const paths: Awaited<ReturnType<GetStaticPaths>> = [];
|
|
11
|
+
for (const lang of allLocales) {
|
|
12
|
+
for (const entry of await getArticles(lang)) {
|
|
13
|
+
paths.push({ params: { lang: langParam(lang), id: articleSlugOf(entry) }, props: { lang, entry } });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return paths;
|
|
11
17
|
}) satisfies GetStaticPaths;
|
|
12
18
|
|
|
13
|
-
const { entry } = Astro.props;
|
|
19
|
+
const { lang, entry } = Astro.props;
|
|
14
20
|
const slug = articleSlugOf(entry);
|
|
15
21
|
---
|
|
16
22
|
|
|
17
23
|
<BaseLayout
|
|
18
24
|
title={`${entry.data.title} — ${site.name}`}
|
|
19
25
|
description={entry.data.description}
|
|
20
|
-
lang=
|
|
26
|
+
lang={lang}
|
|
21
27
|
path={`article/${slug}/`}
|
|
22
28
|
>
|
|
23
|
-
<ArticleDetail entry={entry} lang=
|
|
29
|
+
<ArticleDetail entry={entry} lang={lang} />
|
|
24
30
|
</BaseLayout>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import type { GetStaticPaths } from 'astro';
|
|
4
|
+
import BaseLayout from '../../../../layouts/BaseLayout.astro';
|
|
5
|
+
import BlogIndex from '../../../../components/BlogIndex.astro';
|
|
6
|
+
import { articleTree } from '@aas-data/article-categories';
|
|
7
|
+
import { allLocales, langParam } from '../../../../lib/locales';
|
|
8
|
+
|
|
9
|
+
export const getStaticPaths = (() => {
|
|
10
|
+
const paths: Awaited<ReturnType<GetStaticPaths>> = [];
|
|
11
|
+
for (const lang of allLocales) {
|
|
12
|
+
for (const id of articleTree.allIds) {
|
|
13
|
+
paths.push({ params: { lang: langParam(lang), id }, props: { lang, id } });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return paths;
|
|
17
|
+
}) satisfies GetStaticPaths;
|
|
18
|
+
|
|
19
|
+
const { lang, id } = Astro.props as { lang: string; id: string };
|
|
20
|
+
const node = articleTree.map.get(id)!;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<BaseLayout title={`${node.label[lang]} — ${site.name}`} lang={lang} path={`article/category/${id}/`}>
|
|
24
|
+
<BlogIndex lang={lang} categoryId={id} />
|
|
25
|
+
</BaseLayout>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import type { GetStaticPaths } from 'astro';
|
|
4
|
+
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
|
+
import BlogIndex from '../../../components/BlogIndex.astro';
|
|
6
|
+
import { useTranslations } from '../../../i18n/ui';
|
|
7
|
+
import { allLocales, langParam } from '../../../lib/locales';
|
|
8
|
+
|
|
9
|
+
export const getStaticPaths = (() =>
|
|
10
|
+
allLocales.map((lang) => ({ params: { lang: langParam(lang) }, props: { lang } }))) satisfies GetStaticPaths;
|
|
11
|
+
|
|
12
|
+
const { lang } = Astro.props;
|
|
13
|
+
const t = useTranslations(lang);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<BaseLayout title={`${t('blog.title')} — ${site.name}`} lang={lang} path="article/">
|
|
17
|
+
<BlogIndex lang={lang} />
|
|
18
|
+
</BaseLayout>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import type { GetStaticPaths } from 'astro';
|
|
4
|
+
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
|
+
import CategoryIndex from '../../../components/CategoryIndex.astro';
|
|
6
|
+
import { allCategoryIds, categoryMap } from '@aas-data/categories';
|
|
7
|
+
import { getStacksByCategory } from '../../../lib/stacks';
|
|
8
|
+
import { allLocales, langParam } from '../../../lib/locales';
|
|
9
|
+
|
|
10
|
+
export const getStaticPaths = (async () => {
|
|
11
|
+
const paths: Awaited<ReturnType<GetStaticPaths>> = [];
|
|
12
|
+
for (const lang of allLocales) {
|
|
13
|
+
for (const id of allCategoryIds) {
|
|
14
|
+
// Skip nodes with no tools in their subtree for this locale (no empty pages).
|
|
15
|
+
if ((await getStacksByCategory(lang, id)).length === 0) continue;
|
|
16
|
+
paths.push({ params: { lang: langParam(lang), id }, props: { lang, id } });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return paths;
|
|
20
|
+
}) satisfies GetStaticPaths;
|
|
21
|
+
|
|
22
|
+
const { lang, id } = Astro.props;
|
|
23
|
+
const node = categoryMap.get(id)!;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<BaseLayout title={`${node.label[lang]} — ${site.name}`} lang={lang} path={`categories/${id}/`}>
|
|
27
|
+
<CategoryIndex lang={lang} id={id} />
|
|
28
|
+
</BaseLayout>
|