stack-site-builder 1.10.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +48 -1
  3. package/index.d.ts +10 -0
  4. package/index.mjs +63 -30
  5. package/markdown.mjs +24 -12
  6. package/package.json +9 -3
  7. package/src/components/ArticleLink.astro +2 -2
  8. package/src/components/ConceptDetail.astro +2 -2
  9. package/src/components/ConceptLink.astro +2 -2
  10. package/src/components/ConceptTools.astro +2 -2
  11. package/src/components/LanguageSwitcher.astro +7 -7
  12. package/src/components/PageDetail.astro +52 -0
  13. package/src/components/ProjectViewer.astro +2 -2
  14. package/src/components/SampleIndex.astro +2 -2
  15. package/src/components/SampleProject.astro +3 -3
  16. package/src/components/StackCard.astro +3 -3
  17. package/src/components/StackDetail.astro +3 -3
  18. package/src/components/ThemeToggle.astro +1 -3
  19. package/src/components/ToolMeta.astro +2 -2
  20. package/src/content.ts +29 -1
  21. package/src/i18n/ui.ts +70 -13
  22. package/src/layouts/BaseLayout.astro +128 -57
  23. package/src/lib/dates.ts +2 -2
  24. package/src/lib/facets.ts +2 -2
  25. package/src/lib/locales.ts +16 -0
  26. package/src/lib/pages.ts +24 -0
  27. package/src/lib/sections.ts +34 -0
  28. package/src/pages/[...lang]/[page].astro +30 -0
  29. package/src/pages/{ko → [...lang]}/article/[...id].astro +11 -5
  30. package/src/pages/[...lang]/article/category/[id].astro +25 -0
  31. package/src/pages/[...lang]/article/index.astro +18 -0
  32. package/src/pages/[...lang]/categories/[id].astro +28 -0
  33. package/src/pages/{ko → [...lang]}/concept/[...id].astro +11 -5
  34. package/src/pages/[...lang]/concept/category/[id].astro +25 -0
  35. package/src/pages/[...lang]/concept/index.astro +18 -0
  36. package/src/pages/[...lang]/glossary.astro +18 -0
  37. package/src/pages/[...lang]/index.astro +16 -0
  38. package/src/pages/[...lang]/sample/[folder].astro +25 -0
  39. package/src/pages/[...lang]/sample/index.astro +18 -0
  40. package/src/pages/[...lang]/slides/[deck].astro +22 -0
  41. package/src/pages/[...lang]/slides/index.astro +18 -0
  42. package/src/pages/{ko → [...lang]}/stack/[...id].astro +20 -13
  43. package/src/pages/[...lang]/tags/[tag].astro +24 -0
  44. package/src/pages/[...lang]/vendors/[vendor].astro +24 -0
  45. package/src/styles/global.css +7 -0
  46. package/src/pages/article/[...id].astro +0 -24
  47. package/src/pages/article/category/[id].astro +0 -17
  48. package/src/pages/article/index.astro +0 -12
  49. package/src/pages/categories/[id].astro +0 -25
  50. package/src/pages/concept/[...id].astro +0 -24
  51. package/src/pages/concept/category/[id].astro +0 -17
  52. package/src/pages/concept/index.astro +0 -12
  53. package/src/pages/glossary.astro +0 -12
  54. package/src/pages/index.astro +0 -9
  55. package/src/pages/ko/article/category/[id].astro +0 -17
  56. package/src/pages/ko/article/index.astro +0 -12
  57. package/src/pages/ko/categories/[id].astro +0 -24
  58. package/src/pages/ko/concept/category/[id].astro +0 -17
  59. package/src/pages/ko/concept/index.astro +0 -12
  60. package/src/pages/ko/glossary.astro +0 -12
  61. package/src/pages/ko/index.astro +0 -9
  62. package/src/pages/ko/sample/[folder].astro +0 -16
  63. package/src/pages/ko/sample/index.astro +0 -12
  64. package/src/pages/ko/slides/[deck].astro +0 -14
  65. package/src/pages/ko/slides/index.astro +0 -12
  66. package/src/pages/ko/tags/[tag].astro +0 -18
  67. package/src/pages/ko/vendors/[vendor].astro +0 -18
  68. package/src/pages/sample/[folder].astro +0 -16
  69. package/src/pages/sample/index.astro +0 -12
  70. package/src/pages/slides/[deck].astro +0 -14
  71. package/src/pages/slides/index.astro +0 -12
  72. package/src/pages/stack/[...id].astro +0 -51
  73. package/src/pages/tags/[tag].astro +0 -18
  74. package/src/pages/vendors/[vendor].astro +0 -18
@@ -7,21 +7,21 @@
7
7
  // on the linked sample page instead of being embedded in the article.
8
8
  import { getRelativeLocaleUrl } from 'astro:i18n';
9
9
  import { listProjects } from '../lib/project';
10
- import { useTranslations, type Lang } from '../i18n/ui';
10
+ import { useTranslations, dateLocaleOf, defaultLang, type Lang } from '../i18n/ui';
11
11
 
12
12
  interface Props {
13
13
  folder: string;
14
14
  }
15
15
 
16
16
  const { folder } = Astro.props;
17
- const lang: Lang = Astro.currentLocale === 'ko' ? 'ko' : 'en';
17
+ const lang: Lang = (Astro.currentLocale ?? defaultLang) as Lang;
18
18
  const t = useTranslations(lang);
19
19
  const target = listProjects(lang).find((p) => p.folder === folder);
20
20
 
21
21
  function formatDate(iso: string): string {
22
22
  const [y, m, d] = iso.split('-').map(Number);
23
23
  if (!y || !m || !d) return iso;
24
- return new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
24
+ return new Intl.DateTimeFormat(dateLocaleOf(lang), {
25
25
  year: 'numeric',
26
26
  month: 'long',
27
27
  day: 'numeric',
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  import { getRelativeLocaleUrl } from 'astro:i18n';
3
- import { pricingLabels, useTranslations, type Lang } from '../i18n/ui';
3
+ import { pricingLabel, useTranslations, type Lang } from '../i18n/ui';
4
4
  import { getRepoStats, formatStars, stalenessOf } from '../lib/github';
5
5
  import { pricingTags, COMPLETELY_FREE } from '../lib/pricing';
6
6
  import { isImageRef, resolveImageUrl } from '../lib/assets';
@@ -155,7 +155,7 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
155
155
  pricingTags(pricing).map((p) => (
156
156
  <span
157
157
  data-pricing-tag={p}
158
- title={pricingLabels[lang][p] ?? p}
158
+ title={pricingLabel(lang, p)}
159
159
  class:list={[p === COMPLETELY_FREE ? 'aas-free' : 'aas-pricing', 'rounded-lg px-2.5 py-1']}
160
160
  >
161
161
  {p === 'open-source' && (
@@ -175,7 +175,7 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
175
175
  <polyline points="8 6 2 12 8 18" />
176
176
  </svg>
177
177
  )}
178
- <span data-chip-label>{pricingLabels[lang][p] ?? p}</span>
178
+ <span data-chip-label>{pricingLabel(lang, p)}</span>
179
179
  </span>
180
180
  ))
181
181
  }
@@ -3,7 +3,7 @@ import { render } from 'astro:content';
3
3
  import { Image } from 'astro:assets';
4
4
  import { getRelativeLocaleUrl } from 'astro:i18n';
5
5
  import { pathOf } from '@aas-data/categories';
6
- import { pricingLabels, useTranslations, type Lang } from '../i18n/ui';
6
+ import { pricingLabel, useTranslations, type Lang } from '../i18n/ui';
7
7
  import { pricingTags, COMPLETELY_FREE } from '../lib/pricing';
8
8
  import StarIcon from './StarIcon.astro';
9
9
  import { slugOf, slugifyName, toolResolver, type StackEntry } from '../lib/stacks';
@@ -203,7 +203,7 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
203
203
  pricingTags(data.pricing).map((p) => (
204
204
  <span
205
205
  data-pricing-tag={p}
206
- title={pricingLabels[lang][p] ?? p}
206
+ title={pricingLabel(lang, p)}
207
207
  class:list={[p === COMPLETELY_FREE ? 'aas-free' : 'aas-pricing', 'inline-flex items-center gap-1 rounded-lg px-2.5 py-1']}
208
208
  >
209
209
  {p === 'open-source' && (
@@ -223,7 +223,7 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
223
223
  <polyline points="8 6 2 12 8 18" />
224
224
  </svg>
225
225
  )}
226
- <span>{pricingLabels[lang][p] ?? p}</span>
226
+ <span>{pricingLabel(lang, p)}</span>
227
227
  </span>
228
228
  ))
229
229
  }
@@ -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 gap-1 rounded-lg border border-[var(--aas-border)] px-2.5 py-1 text-[var(--aas-text)] select-none"
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 { pricingLabels, licenseLabel, useTranslations, type Lang } from '../i18n/ui';
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
- {pricingLabels[lang][p] ?? p}
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
- return { stacks, articles, concepts, slides };
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
- export const languages = {
12
- en: 'English',
13
- ko: '한국어',
14
- } as const;
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
- export type Lang = keyof typeof languages;
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 = 'en';
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. A site can
303
- * override any string (taglines, headings…) via `site.ui.<lang>` in its
304
- * `src/data/site.ts` overrides win over the theme defaults here.
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 site.ui?.[lang]?.[key] ?? ui[lang][key] ?? ui[defaultLang][key];
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<Lang, Record<string, string>> = {
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<Lang, Record<string, string>> = {
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,8 @@ 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';
10
+ import { sectionEnabled, type SectionKey } from '../lib/sections';
9
11
 
10
12
  interface Props {
11
13
  title: string;
@@ -32,10 +34,79 @@ const localeHomes = Object.fromEntries(
32
34
  (Object.keys(languages) as Lang[]).map((c) => [c, getRelativeLocaleUrl(c, '')]),
33
35
  );
34
36
  const isDefaultHome = path === '' && lang === 'en';
37
+
38
+ // Standalone pages (the `pages` collection) that opt into a header-nav link,
39
+ // e.g. an About/소개 page. Rendered as their own nav items alongside the
40
+ // built-in sections; the accessible label comes from the page's own frontmatter.
41
+ const navPages = await getNavPages(lang);
42
+
43
+ // The header sections, as data — rendered twice from one source so the two
44
+ // layouts never drift: an icon-only row (≥sm) and a labelled dropdown menu
45
+ // (<sm, so a phone header stays to a few controls instead of a long icon run).
46
+ // `svg` is the icon's inner markup (drawn into a shared <svg> shell below).
47
+ const allNavItems: {
48
+ href: string;
49
+ label: string;
50
+ svg: string;
51
+ external?: boolean;
52
+ keepFilters?: boolean;
53
+ section?: SectionKey;
54
+ }[] = [
55
+ {
56
+ href: `${home}#categories`,
57
+ label: t('nav.browse'),
58
+ keepFilters: true,
59
+ 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"/>',
60
+ },
61
+ {
62
+ href: getRelativeLocaleUrl(lang, 'concept/'),
63
+ label: t('nav.concepts'),
64
+ section: 'concepts',
65
+ 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"/>',
66
+ },
67
+ {
68
+ href: getRelativeLocaleUrl(lang, 'article/'),
69
+ label: t('nav.blog'),
70
+ section: 'articles',
71
+ 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"/>',
72
+ },
73
+ {
74
+ href: getRelativeLocaleUrl(lang, 'sample/'),
75
+ label: t('nav.samples'),
76
+ section: 'samples',
77
+ svg: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
78
+ },
79
+ {
80
+ href: getRelativeLocaleUrl(lang, 'slides/'),
81
+ label: t('nav.slides'),
82
+ section: 'slides',
83
+ 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"/>',
84
+ },
85
+ {
86
+ href: getRelativeLocaleUrl(lang, 'glossary/'),
87
+ label: t('nav.glossary'),
88
+ section: 'glossary',
89
+ 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"/>',
90
+ },
91
+ ...navPages.map((p) => ({
92
+ href: getRelativeLocaleUrl(lang, `${pageSlugOf(p)}/`),
93
+ label: p.data.navLabel ?? p.data.title,
94
+ section: 'pages' as SectionKey,
95
+ svg: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
96
+ })),
97
+ {
98
+ href: site.repoUrl,
99
+ label: 'GitHub',
100
+ external: true,
101
+ 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"/>',
102
+ },
103
+ ];
104
+ // Drop the nav links for sections the site turned off (their routes aren't built).
105
+ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(item.section));
35
106
  ---
36
107
 
37
108
  <!doctype html>
38
- <html lang={lang}>
109
+ <html lang={lang} data-copy-label={t('code.copy')} data-copied-label={t('code.copied')}>
39
110
  <head>
40
111
  <meta charset="utf-8" />
41
112
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -107,66 +178,67 @@ const isDefaultHome = path === '' && lang === 'en';
107
178
  {/* Below 600px only the bolt remains, so the narrow header never truncates the wordmark. */}
108
179
  <span class="hidden truncate min-[600px]:inline">{site.name}</span>
109
180
  </a>
110
- {/* Nav links are icon-only at every width (labels live in aria-label). */}
181
+ {/* Section links are icon-only (labels live in aria-label). On phones
182
+ (<sm) they collapse into the labelled dropdown menu below, so the
183
+ header stays to a few controls; from sm up the full icon row shows. */}
111
184
  <nav class="flex shrink-0 items-center gap-3 text-sm text-[var(--aas-muted)] sm:gap-4">
112
- <a
113
- href={`${home}#categories`}
114
- data-keep-filters
115
- class="flex items-center no-underline hover:text-[var(--aas-text)]"
116
- aria-label={t('nav.browse')}
117
- >
118
- <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"><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"/></svg>
119
- </a>
120
- <a
121
- href={getRelativeLocaleUrl(lang, 'concept/')}
122
- class="flex items-center no-underline hover:text-[var(--aas-text)]"
123
- aria-label={t('nav.concepts')}
124
- >
125
- <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.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"/></svg>
126
- </a>
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>
185
+ <div class="hidden items-center gap-3 sm:flex sm:gap-4">
186
+ {
187
+ navItems.map((item) => (
188
+ <a
189
+ href={item.href}
190
+ class="flex items-center no-underline hover:text-[var(--aas-text)]"
191
+ aria-label={item.label}
192
+ {...(item.keepFilters ? { 'data-keep-filters': true } : {})}
193
+ {...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
194
+ >
195
+ <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} />
196
+ </a>
197
+ ))
198
+ }
199
+ </div>
164
200
  <ThemeToggle lang={lang} />
165
201
  <LanguageSwitcher lang={lang} path={path} />
202
+ <details class="aas-menu relative sm:hidden">
203
+ <summary
204
+ 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"
205
+ aria-label={t('nav.menu')}
206
+ title={t('nav.menu')}
207
+ >
208
+ <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>
209
+ </summary>
210
+ <ul
211
+ 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"
212
+ >
213
+ {
214
+ navItems.map((item) => (
215
+ <li>
216
+ <a
217
+ href={item.href}
218
+ class="flex items-center gap-2.5 px-3 py-2 whitespace-nowrap text-[var(--aas-text)] no-underline hover:bg-[var(--aas-bg)]"
219
+ {...(item.keepFilters ? { 'data-keep-filters': true } : {})}
220
+ {...(item.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
221
+ >
222
+ <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} />
223
+ <span>{item.label}</span>
224
+ </a>
225
+ </li>
226
+ ))
227
+ }
228
+ </ul>
229
+ </details>
166
230
  </nav>
167
231
  </div>
168
232
  </header>
169
233
 
234
+ <style>
235
+ /* Hide the default <details> disclosure triangle on the mobile menu
236
+ (Safari needs the -webkit rule even with list-style: none). */
237
+ .aas-menu > summary::-webkit-details-marker {
238
+ display: none;
239
+ }
240
+ </style>
241
+
170
242
  <main class:list={['mx-auto px-5 py-10', container]}>
171
243
  <slot />
172
244
  </main>
@@ -209,9 +281,8 @@ const isDefaultHome = path === '' && lang === 'en';
209
281
  // (.aas-code). The button is pinned to a non-scrolling host so it stays in
210
282
  // place on long, horizontally-scrolling code.
211
283
  (function mountCopyButtons() {
212
- const koLang = document.documentElement.lang === 'ko';
213
- const LABEL = koLang ? '코드 복사' : 'Copy code';
214
- const DONE = koLang ? '복사됨' : 'Copied';
284
+ const LABEL = document.documentElement.dataset.copyLabel || 'Copy code';
285
+ const DONE = document.documentElement.dataset.copiedLabel || 'Copied';
215
286
  const COPY_ICON =
216
287
  '<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
288
  const CHECK_ICON =
package/src/lib/dates.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Lang } from '../i18n/ui';
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 === 'ko' ? 'ko-KR' : 'en-US', {
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 { pricingLabels, licenseLabel, useTranslations, type Lang } from '../i18n/ui';
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: pricingLabels[lang][p] ?? p })),
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;
@@ -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,34 @@
1
+ import { site } from '@aas-data/site';
2
+
3
+ /**
4
+ * Optional content sections a site can turn off. The core catalog (home, stack
5
+ * detail, categories, tags, vendors) is always on; these are opt-out. Disabling
6
+ * one removes both its routes and its header-nav item (routes are skipped in the
7
+ * integration, the nav link in BaseLayout).
8
+ *
9
+ * `pages` is the standalone-pages collection (About/소개, contact, …); turning it
10
+ * off drops every page and its nav item at once — finer control is per-page via
11
+ * the `nav` / `draft` frontmatter, or by not authoring the page.
12
+ *
13
+ * A site sets overrides in `src/data/site.ts` (`sections`), which astro.config
14
+ * also forwards to the theme integration for route filtering. Omitted = enabled.
15
+ */
16
+ export type SectionKey = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
17
+
18
+ const DEFAULTS: Record<SectionKey, boolean> = {
19
+ concepts: true,
20
+ articles: true,
21
+ samples: true,
22
+ slides: true,
23
+ glossary: true,
24
+ pages: true,
25
+ };
26
+
27
+ /** Whether each optional section is enabled, after applying the site's overrides. */
28
+ export const sections: Record<SectionKey, boolean> = {
29
+ ...DEFAULTS,
30
+ ...((site as { sections?: Partial<Record<SectionKey, boolean>> }).sections ?? {}),
31
+ };
32
+
33
+ /** True unless the site explicitly turned `key` off. */
34
+ export const sectionEnabled = (key: SectionKey): boolean => sections[key];
@@ -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>