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.
Files changed (72) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +20 -1
  3. package/index.mjs +43 -29
  4. package/markdown.mjs +24 -12
  5. package/package.json +9 -3
  6. package/src/components/ArticleLink.astro +2 -2
  7. package/src/components/ConceptDetail.astro +2 -2
  8. package/src/components/ConceptLink.astro +2 -2
  9. package/src/components/ConceptTools.astro +2 -2
  10. package/src/components/LanguageSwitcher.astro +7 -7
  11. package/src/components/PageDetail.astro +52 -0
  12. package/src/components/ProjectViewer.astro +2 -2
  13. package/src/components/SampleIndex.astro +2 -2
  14. package/src/components/SampleProject.astro +3 -3
  15. package/src/components/StackCard.astro +3 -3
  16. package/src/components/StackDetail.astro +3 -3
  17. package/src/components/ThemeToggle.astro +1 -3
  18. package/src/components/ToolMeta.astro +2 -2
  19. package/src/content.ts +29 -1
  20. package/src/i18n/ui.ts +70 -13
  21. package/src/layouts/BaseLayout.astro +118 -57
  22. package/src/lib/dates.ts +2 -2
  23. package/src/lib/facets.ts +2 -2
  24. package/src/lib/locales.ts +16 -0
  25. package/src/lib/pages.ts +24 -0
  26. package/src/pages/[...lang]/[page].astro +30 -0
  27. package/src/pages/{ko → [...lang]}/article/[...id].astro +11 -5
  28. package/src/pages/[...lang]/article/category/[id].astro +25 -0
  29. package/src/pages/[...lang]/article/index.astro +18 -0
  30. package/src/pages/[...lang]/categories/[id].astro +28 -0
  31. package/src/pages/{ko → [...lang]}/concept/[...id].astro +11 -5
  32. package/src/pages/[...lang]/concept/category/[id].astro +25 -0
  33. package/src/pages/[...lang]/concept/index.astro +18 -0
  34. package/src/pages/[...lang]/glossary.astro +18 -0
  35. package/src/pages/[...lang]/index.astro +16 -0
  36. package/src/pages/[...lang]/sample/[folder].astro +25 -0
  37. package/src/pages/[...lang]/sample/index.astro +18 -0
  38. package/src/pages/[...lang]/slides/[deck].astro +22 -0
  39. package/src/pages/[...lang]/slides/index.astro +18 -0
  40. package/src/pages/{ko → [...lang]}/stack/[...id].astro +20 -13
  41. package/src/pages/[...lang]/tags/[tag].astro +24 -0
  42. package/src/pages/[...lang]/vendors/[vendor].astro +24 -0
  43. package/src/styles/global.css +7 -0
  44. package/src/pages/article/[...id].astro +0 -24
  45. package/src/pages/article/category/[id].astro +0 -17
  46. package/src/pages/article/index.astro +0 -12
  47. package/src/pages/categories/[id].astro +0 -25
  48. package/src/pages/concept/[...id].astro +0 -24
  49. package/src/pages/concept/category/[id].astro +0 -17
  50. package/src/pages/concept/index.astro +0 -12
  51. package/src/pages/glossary.astro +0 -12
  52. package/src/pages/index.astro +0 -9
  53. package/src/pages/ko/article/category/[id].astro +0 -17
  54. package/src/pages/ko/article/index.astro +0 -12
  55. package/src/pages/ko/categories/[id].astro +0 -24
  56. package/src/pages/ko/concept/category/[id].astro +0 -17
  57. package/src/pages/ko/concept/index.astro +0 -12
  58. package/src/pages/ko/glossary.astro +0 -12
  59. package/src/pages/ko/index.astro +0 -9
  60. package/src/pages/ko/sample/[folder].astro +0 -16
  61. package/src/pages/ko/sample/index.astro +0 -12
  62. package/src/pages/ko/slides/[deck].astro +0 -14
  63. package/src/pages/ko/slides/index.astro +0 -12
  64. package/src/pages/ko/tags/[tag].astro +0 -18
  65. package/src/pages/ko/vendors/[vendor].astro +0 -18
  66. package/src/pages/sample/[folder].astro +0 -16
  67. package/src/pages/sample/index.astro +0 -12
  68. package/src/pages/slides/[deck].astro +0 -14
  69. package/src/pages/slides/index.astro +0 -12
  70. package/src/pages/stack/[...id].astro +0 -51
  71. package/src/pages/tags/[tag].astro +0 -18
  72. package/src/pages/vendors/[vendor].astro +0 -18
package/CHANGELOG.md ADDED
@@ -0,0 +1,112 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are recorded in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ `stack-site-builder` is the Astro theme behind the awesome-\*-stack catalog
9
+ sites — it ships every route, component, style, the markdown pipeline and the
10
+ content schema, while a consuming site supplies only content, taxonomy data and
11
+ config. Sites track the theme with `pnpm up stack-site-builder`, so each release
12
+ here is a plain version bump they pull in.
13
+
14
+ ## [1.12.0] - 2026-07-20
15
+
16
+ Locales are now **site-configurable**. The theme was wired to exactly two
17
+ languages (English at the root, Korean under `/ko/`, plus a hand-written `ko/`
18
+ copy of every route); a consuming site could not add a third. Now the theme
19
+ reads the locales from the site's own astro.config `i18n` and renders every
20
+ route for each of them from one source, so a site adds a language (e.g.
21
+ Japanese) by editing its config and data — no theme changes, no per-locale route
22
+ files. Existing en/ko sites are unaffected: the same URLs build, and the theme
23
+ still defaults to en/ko when a site declares nothing.
24
+
25
+ ### Added
26
+
27
+ - **Site-configurable locales** — the header language switcher, route
28
+ generation, date formatting, wikilink resolution and sitemap hreflang all
29
+ derive from the site's configured locales. A site declares them in
30
+ astro.config `i18n` (routing) and, optionally, `site.locales` in
31
+ `src/data/site.ts` (display name + date format per locale); it supplies each
32
+ extra locale's UI strings via `site.ui.<code>`, with any missing key falling
33
+ back to the default locale. The playground ships a third locale (`ja`) to
34
+ demonstrate the path.
35
+
36
+ ### Changed
37
+
38
+ - **One route tree instead of a per-locale mirror** — the hand-maintained
39
+ `src/pages/ko/` copy of every page is gone; all pages now live once under
40
+ `src/pages/[...lang]/` and enumerate the site's locales in `getStaticPaths`.
41
+ This is internal to the theme, but it is what makes adding a locale free.
42
+ - **`Lang` is now `string`** (was the `'en' | 'ko'` union) since locales are no
43
+ longer fixed at build time. Locale-keyed maps (`pricingLabels`, category
44
+ labels, …) resolve through helpers that fall back to the default locale rather
45
+ than assuming a key exists.
46
+
47
+ ## [1.11.0] - 2026-07-20
48
+
49
+ The center of this release is **standalone pages**: a site can now add a
50
+ first-class top-level section — an About/소개 page, contact, terms — that is
51
+ just one Markdown file, rendered on its own and linked from the header
52
+ navigation, without the listing and taxonomy of the catalog collections.
53
+ Alongside it, the header collapses into a menu on phones, the language switcher
54
+ becomes a globe icon, and two rendering bugs on the new pages are fixed.
55
+
56
+ ### Added
57
+
58
+ - **Standalone `pages` collection** — author a top-level page as one MDX file
59
+ (`src/content/pages/{en,ko}/<slug>.mdx`); the theme renders it at `/<slug>/`
60
+ in both locales. Frontmatter drives a hero image, description, and header-nav
61
+ placement (`nav`, `navLabel`, `order`, `draft`). Ships the `PageDetail`
62
+ component, the `getPages` / `getNavPages` / `pageSlugOf` helpers, and a
63
+ per-locale route injected by the integration.
64
+ - **Mobile navigation menu** — below the `sm` breakpoint the section links
65
+ collapse into a labelled dropdown menu (hamburger), so a phone header stays to
66
+ a few controls instead of a long icon run. The icon row (≥`sm`) and the menu
67
+ render from one `navItems` source, so they never drift, and nav pages appear
68
+ in both automatically.
69
+
70
+ ### Changed
71
+
72
+ - **Globe icon for the language switcher** — the switcher shows a globe instead
73
+ of the locale code/name, and the dropdown chevrons are removed from both the
74
+ language and theme switchers, leaving a single clean control each. Adds
75
+ `nav.language` and `nav.menu` UI strings (en/ko).
76
+
77
+ ### Fixed
78
+
79
+ - **In-locale wikilinks on standalone pages** — wikilink targets were emitted as
80
+ `../../glossary/` (and `../../stack/…`, etc.), which assumes the source sits
81
+ two levels deep within its locale, true for the collection detail routes
82
+ (`/ko/stack/<slug>/`) but not for a one-deep standalone page (`/ko/about/`).
83
+ The extra `../` overshot the locale prefix and linked to the default-locale
84
+ glossary; the depth is now derived from where the source actually sits.
85
+ - **In-body images no longer force horizontal scroll** — content images had no
86
+ width cap and Astro's `<Image>` emits intrinsic `width`/`height`, so an image
87
+ wider than the viewport (e.g. a screenshot on a phone) stretched the body and
88
+ scrolled the whole page sideways. `.prose img` is now capped to
89
+ `max-width: 100%; height: auto`.
90
+
91
+ ## [1.10.0] - 2026-07-20
92
+
93
+ First public release. The rendering engine of
94
+ [awesome-ai-stack](https://github.com/codecompose7/awesome-ai-stack) — routes,
95
+ components, styles, the markdown pipeline and the content schema — extracted
96
+ into a reusable, MIT-licensed Astro theme so the same engine can drive other
97
+ catalog sites from a thin content-only repository.
98
+
99
+ ### Added
100
+
101
+ - **The awesome-\*-stack theme** — an integration that injects every route
102
+ (catalog, concepts, articles, slides, samples, glossary, tags, vendors, in
103
+ en/ko), the components and styles, the shared markdown pipeline
104
+ (`[[wikilink]]` glossary, slides, mermaid), and the content schema via
105
+ `defineAasCollections`. A consuming site provides content, taxonomy data
106
+ (`categories`, `glossary`, `site`) and config.
107
+ - **Standalone development setup** — a devcontainer and a minimal `playground/`
108
+ consuming site for developing and previewing the theme on its own.
109
+
110
+ [1.12.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.11.0...v1.12.0
111
+ [1.11.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.10.0...v1.11.0
112
+ [1.10.0]: https://github.com/CodeCompose7/stack-site-builder/releases/tag/v1.10.0
package/README.md CHANGED
@@ -34,12 +34,31 @@ export const collections = defineAasCollections({ categoryMap });
34
34
 
35
35
  | Where | What |
36
36
  | --- | --- |
37
- | `src/data/site.ts` | Site identity: name, repo URL, per-locale UI string overrides |
37
+ | `src/data/site.ts` | Site identity: name, repo URL, the `locales` it ships, per-locale UI string overrides |
38
38
  | `src/data/categories.ts` | The tool-catalog category tree (validated against content) |
39
39
  | `src/data/concept-categories.ts` · `article-categories.ts` | Taxonomies for concepts / articles |
40
40
  | `src/data/glossary.mjs` | `[[Term]]` wikilink targets |
41
41
  | `src/content/{stacks,concepts,articles,slides}/` | The content, one MDX file per locale |
42
+ | `src/content/pages/` | Standalone top-level pages (e.g. an About/소개), rendered at `/<slug>/` and optionally linked in the header nav |
42
43
  | `public/` · `samples/` | Logos/favicons and runnable sample projects |
43
44
 
44
45
  The theme reaches the site's data through the `@aas-data/*` alias (set up by
45
46
  the integration), so everything above is swappable per site.
47
+
48
+ ## Locales
49
+
50
+ The theme defaults to English (at the root) and Korean (under `/ko/`), but the
51
+ locale set is the site's to choose — it renders every route for each configured
52
+ locale from one source. To add a language (say Japanese):
53
+
54
+ 1. List it in astro.config `i18n.locales` (this drives routing):
55
+ `i18n: { locales: ['en', 'ko', 'ja'], defaultLocale: 'en', routing: { prefixDefaultLocale: false } }`.
56
+ The first/`defaultLocale` is served at the root; the others under `/<code>/`.
57
+ 2. Add it to `locales` in `src/data/site.ts` — `{ code, label, dateLocale? }` —
58
+ so it appears (named) in the language switcher and formats dates correctly.
59
+ 3. Supply its UI strings in `src/data/site.ts` under `site.ui.<code>`; any key
60
+ you omit falls back to the default locale. Add the `<code>` translations to
61
+ your content (`src/content/<collection>/<code>/…`), glossary and category
62
+ labels the same way you did for the built-in locales.
63
+
64
+ No theme files change — adding a locale is entirely site config and content.
package/index.mjs CHANGED
@@ -21,11 +21,14 @@ import tailwindcss from '@tailwindcss/vite';
21
21
  import { aasMarkdown } from './markdown.mjs';
22
22
  import { localSamples } from './dev/local-samples.mjs';
23
23
 
24
- // Every page the theme provides, as `src/pages/`-relative entrypoints. The
25
- // same tree is served at `/` (en) and under `/ko/` (physical mirrors, matching
26
- // prefixDefaultLocale: false).
24
+ // Every page the theme provides, relative to the `src/pages/[...lang]/` tree.
25
+ // Each is injected once at `/[...lang]/…`; its getStaticPaths enumerates the
26
+ // site's locales, emitting the default at the root and others under `/<code>/`.
27
27
  const PAGES = [
28
28
  'index.astro',
29
+ // Standalone top-level pages (the `pages` collection), e.g. an About/소개
30
+ // page. A single dynamic route per locale renders every entry at `/<slug>/`.
31
+ '[page].astro',
29
32
  'article/index.astro',
30
33
  'article/[...id].astro',
31
34
  'article/category/[id].astro',
@@ -43,12 +46,12 @@ const PAGES = [
43
46
  'vendors/[vendor].astro',
44
47
  ];
45
48
 
46
- /** `article/[...id].astro` → `/article/[...id]`, `index.astro` → `/` */
47
- /** @param {string} file @param {string} prefix */
48
- function patternOf(file, prefix) {
49
+ /** `[...lang]/article/[...id].astro` → `/[...lang]/article/[...id]`,
50
+ * `[...lang]/index.astro` `/[...lang]` */
51
+ /** @param {string} file */
52
+ function patternOf(file) {
49
53
  const p = file.replace(/\.astro$/, '').replace(/\/?index$/, '');
50
- const full = `/${prefix}${p}`.replace(/\/$/, '');
51
- return full || '/';
54
+ return `/${p}`.replace(/\/$/, '') || '/';
52
55
  }
53
56
 
54
57
  /**
@@ -63,23 +66,43 @@ export default function aasTheme({ glossary }) {
63
66
  name: 'stack-site-builder',
64
67
  hooks: {
65
68
  'astro:config:setup': ({ config, injectRoute, updateConfig }) => {
66
- for (const prefix of ['', 'ko/']) {
67
- for (const file of PAGES) {
68
- const entry = prefix === '' ? file : `ko/${file}`;
69
- injectRoute({
70
- pattern: patternOf(file, prefix),
71
- entrypoint: `stack-site-builder/pages/${entry}`,
72
- });
73
- }
69
+ // Locales the site configured (astro.config `i18n`). Everything locale
70
+ // aware derives from here — route injection, wikilink detection, and the
71
+ // sitemap hreflang so a site adds a language by editing its config, not
72
+ // the theme. `locales` entries may be strings or `{ path }` objects.
73
+ const rawLocales = config.i18n?.locales ?? ['en'];
74
+ const locales = rawLocales.map((l) => (typeof l === 'string' ? l : l.path));
75
+ const defaultLocale = config.i18n?.defaultLocale ?? locales[0];
76
+
77
+ // A single physical page tree under `[...lang]/` serves every locale: the
78
+ // default at the root, each other under `/<code>/`. Each page's
79
+ // getStaticPaths enumerates the locales, so adding one needs no new files.
80
+ for (const file of PAGES) {
81
+ injectRoute({
82
+ pattern: patternOf(`[...lang]/${file}`),
83
+ entrypoint: `stack-site-builder/pages/[...lang]/${file}`,
84
+ });
74
85
  }
75
86
 
76
87
  updateConfig({
77
- markdown: aasMarkdown({ glossary }),
88
+ markdown: aasMarkdown({ glossary, locales, defaultLocale }),
78
89
 
79
90
  // Bind the dev server to 0.0.0.0 so it's reachable from a browser on
80
91
  // the host (outside the Docker container).
81
92
  server: { host: true },
82
93
 
94
+ // hreflang alternates so search engines pair each page with its twin in
95
+ // the other locales. Built from the site's configured locales (added
96
+ // here rather than in the returned array, where `config` isn't known).
97
+ integrations: [
98
+ sitemap({
99
+ i18n: {
100
+ defaultLocale,
101
+ locales: Object.fromEntries(locales.map((l) => [l, l])),
102
+ },
103
+ }),
104
+ ],
105
+
83
106
  vite: {
84
107
  plugins: [tailwindcss(), localSamples()],
85
108
 
@@ -109,16 +132,7 @@ export default function aasTheme({ glossary }) {
109
132
  },
110
133
  };
111
134
 
112
- return [
113
- core,
114
- mdx(),
115
- // i18n option emits hreflang alternates so search engines associate each
116
- // page with its twin in the other locale (/stack/x/ ↔ /ko/stack/x/).
117
- sitemap({
118
- i18n: {
119
- defaultLocale: 'en',
120
- locales: { en: 'en', ko: 'ko' },
121
- },
122
- }),
123
- ];
135
+ // The sitemap integration is added from the core hook's updateConfig (above),
136
+ // where the site's configured locales are known.
137
+ return [core, mdx()];
124
138
  }
package/markdown.mjs CHANGED
@@ -230,12 +230,16 @@ function remarkSlideDirectives() {
230
230
 
231
231
  // Turn `[[Term]]` (and `[[Term|display text]]`) wikilinks into links, resolving
232
232
  // each against the site's central glossary (passed as an option). Internal targets
233
- // emit the `../../stack|concept/<slug>/` relative form (locale- and base-agnostic
234
- // on the depth-3 detail routes); external `href` entries pass through and get
233
+ // emit a `../stack|concept/<slug>/` relative form (locale- and base-agnostic), with
234
+ // the number of `../` set by how deep the source page sits in its locale (see `up`
235
+ // below); external `href` entries pass through and get
235
236
  // target="_blank" from rehype-external-links downstream. An unknown term throws,
236
237
  // failing the build so a typo can't silently degrade to plain text. Code spans
237
238
  // and fenced blocks are untouched (mdast `inlineCode`/`code` carry no children).
238
- function remarkGlossary({ glossary }) {
239
+ function remarkGlossary({ glossary, locales = ['en', 'ko'], defaultLocale = 'en' }) {
240
+ // Non-default locales carry a `/<code>/` segment in their content path; the
241
+ // default locale has none. Match the path against them to pick the locale.
242
+ const prefixed = locales.filter((l) => l !== defaultLocale);
239
243
  const RE = /\[\[\s*([^\]|]+?)\s*(?:\|\s*([^\]]+?)\s*)?\]\]/g;
240
244
  /** @param {string} s */
241
245
  const norm = (s) => s.trim().toLowerCase().replace(/\s+/g, '-');
@@ -262,7 +266,13 @@ function remarkGlossary({ glossary }) {
262
266
  /** @param {any} tree @param {any} file */
263
267
  return (tree, file) => {
264
268
  const path = (file && (file.path || (file.history && file.history[0]))) || '';
265
- const lang = /[/\\]ko[/\\]/.test(path) ? 'ko' : 'en';
269
+ const lang = prefixed.find((l) => new RegExp(`[/\\\\]${l}[/\\\\]`).test(path)) ?? defaultLocale;
270
+ // How far the relative wikilink targets below must climb to reach the locale
271
+ // root. The collection detail routes sit two deep within their locale
272
+ // (/ko/stack/<slug>/), but a standalone `pages` entry is only one deep
273
+ // (/ko/about/). Using the wrong count overshoots the locale prefix — from
274
+ // /ko/about/ a `../../` lands on the default-locale glossary, not /ko/'s.
275
+ const up = /[/\\]content[/\\]pages[/\\]/.test(path) ? '../' : '../../';
266
276
  /** @param {any} l */
267
277
  const labelOf = (l) => (typeof l === 'string' ? l : l[lang]);
268
278
  // Same-document section links ([[#anchor]]) resolve their display text to
@@ -329,15 +339,15 @@ function remarkGlossary({ glossary }) {
329
339
  const def = entry.def ? labelOf(entry.def) : undefined;
330
340
  // A def-only term (no page) links to its entry on the glossary page.
331
341
  let url = entry.stack
332
- ? `../../stack/${entry.stack}/`
342
+ ? `${up}stack/${entry.stack}/`
333
343
  : entry.concept
334
- ? `../../concept/${entry.concept}/`
344
+ ? `${up}concept/${entry.concept}/`
335
345
  : entry.article
336
- ? `../../article/${entry.article}/`
346
+ ? `${up}article/${entry.article}/`
337
347
  : entry.href
338
348
  ? entry.href
339
349
  : def
340
- ? `../../glossary/#${id}`
350
+ ? `${up}glossary/#${id}`
341
351
  : null;
342
352
  if (!url)
343
353
  throw new Error(
@@ -373,17 +383,19 @@ function remarkGlossary({ glossary }) {
373
383
 
374
384
  /**
375
385
  * The full markdown config for `defineConfig({ markdown })`.
376
- * @param {{ glossary: Record<string, any> }} opts the site's glossary
377
- * (wikilink targets); pass `{}` for a site without wikilinks.
386
+ * @param {{ glossary: Record<string, any>, locales?: string[], defaultLocale?: string }} opts
387
+ * `glossary` — the site's wikilink targets (pass `{}` for none). `locales` /
388
+ * `defaultLocale` come from the site's astro.config `i18n` so wikilink locale
389
+ * detection matches whatever locales the site ships (defaults to en/ko).
378
390
  */
379
- export function aasMarkdown({ glossary }) {
391
+ export function aasMarkdown({ glossary, locales = ['en', 'ko'], defaultLocale = 'en' }) {
380
392
  return {
381
393
  remarkPlugins: [
382
394
  remarkHeadingIds,
383
395
  remarkMermaid,
384
396
  remarkDirective,
385
397
  remarkSlideDirectives,
386
- [remarkGlossary, { glossary }],
398
+ [remarkGlossary, { glossary, locales, defaultLocale }],
387
399
  ],
388
400
  rehypePlugins: [
389
401
  rehypeSlug,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.10.0",
4
+ "version": "1.12.0",
5
5
  "license": "MIT",
6
6
  "description": "The engine behind the awesome-*-stack catalog sites: an Astro theme with the catalog/concepts/articles/slides/samples routes, components, styles and markdown pipeline. Sites provide content, taxonomy data and config.",
7
7
  "repository": {
@@ -13,7 +13,8 @@
13
13
  "index.d.ts",
14
14
  "markdown.mjs",
15
15
  "src",
16
- "dev"
16
+ "dev",
17
+ "CHANGELOG.md"
17
18
  ],
18
19
  "exports": {
19
20
  ".": {
@@ -48,5 +49,10 @@
48
49
  "@types/markdown-it": "^14.1.2",
49
50
  "astro": "^5.7.0",
50
51
  "typescript": "^6.0.3"
52
+ },
53
+ "scripts": {
54
+ "dev": "pnpm --filter playground dev",
55
+ "build": "pnpm --filter playground build",
56
+ "check": "pnpm --filter playground check"
51
57
  }
52
- }
58
+ }
@@ -8,7 +8,7 @@
8
8
  import { getRelativeLocaleUrl } from 'astro:i18n';
9
9
  import { getArticles, articleSlugOf, type ArticleEntry } from '../lib/articles';
10
10
  import { formatDate } from '../lib/dates';
11
- import { useTranslations, type Lang } from '../i18n/ui';
11
+ import { useTranslations, defaultLang, type Lang } from '../i18n/ui';
12
12
  import { inlineMd } from '../lib/inline-md';
13
13
 
14
14
  interface Props {
@@ -16,7 +16,7 @@ interface Props {
16
16
  }
17
17
 
18
18
  const { slug } = Astro.props;
19
- const lang: Lang = Astro.currentLocale === 'ko' ? 'ko' : 'en';
19
+ const lang: Lang = (Astro.currentLocale ?? defaultLang) as Lang;
20
20
  const t = useTranslations(lang);
21
21
  const articles = await getArticles(lang);
22
22
  const target = articles.find((a) => articleSlugOf(a) === slug) as ArticleEntry | undefined;
@@ -5,7 +5,7 @@ import { getArticles, articleSlugOf, type ArticleEntry } from '../lib/articles';
5
5
  import { getConcepts, conceptSlugOf, conceptToolSlugs, type ConceptEntry } from '../lib/concepts';
6
6
  import { inlineMd } from '../lib/inline-md';
7
7
  import { Image } from 'astro:assets';
8
- import { useTranslations, type Lang } from '../i18n/ui';
8
+ import { useTranslations, dateLocaleOf, type Lang } from '../i18n/ui';
9
9
  import MermaidLoader from './MermaidLoader.astro';
10
10
  import TocRail from './TocRail.astro';
11
11
  import Breadcrumb from './Breadcrumb.astro';
@@ -41,7 +41,7 @@ const relatedConcepts = data.related
41
41
  .filter(Boolean) as ConceptEntry[];
42
42
 
43
43
  const fmtDate = (d: Date) =>
44
- new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
44
+ new Intl.DateTimeFormat(dateLocaleOf(lang), {
45
45
  year: 'numeric',
46
46
  month: 'long',
47
47
  day: 'numeric',
@@ -7,7 +7,7 @@
7
7
  // from the current page (concepts are served under /<lang>/...).
8
8
  import { getRelativeLocaleUrl } from 'astro:i18n';
9
9
  import { getConcepts, conceptSlugOf, type ConceptEntry } from '../lib/concepts';
10
- import { useTranslations, type Lang } from '../i18n/ui';
10
+ import { useTranslations, defaultLang, type Lang } from '../i18n/ui';
11
11
  import { inlineMd } from '../lib/inline-md';
12
12
 
13
13
  interface Props {
@@ -15,7 +15,7 @@ interface Props {
15
15
  }
16
16
 
17
17
  const { slug } = Astro.props;
18
- const lang: Lang = Astro.currentLocale === 'ko' ? 'ko' : 'en';
18
+ const lang: Lang = (Astro.currentLocale ?? defaultLang) as Lang;
19
19
  const t = useTranslations(lang);
20
20
  const concepts = await getConcepts(lang);
21
21
  const target = concepts.find((c) => conceptSlugOf(c) === slug) as ConceptEntry | undefined;
@@ -7,7 +7,7 @@ import { getRelativeLocaleUrl } from 'astro:i18n';
7
7
  import { getStacks, slugOf, type StackEntry } from '../lib/stacks';
8
8
  import { categoryMap } from '@aas-data/categories';
9
9
  import ToolAvatar from './ToolAvatar.astro';
10
- import type { Lang } from '../i18n/ui';
10
+ import { defaultLang, type Lang } from '../i18n/ui';
11
11
 
12
12
  interface Props {
13
13
  slugs: string[] | string;
@@ -21,7 +21,7 @@ const list = Array.isArray(slugs)
21
21
  .map((s) => s.trim())
22
22
  .filter(Boolean);
23
23
 
24
- const lang: Lang = Astro.currentLocale === 'ko' ? 'ko' : 'en';
24
+ const lang: Lang = (Astro.currentLocale ?? defaultLang) as Lang;
25
25
  const stacks = await getStacks(lang);
26
26
  const bySlug = new Map(stacks.map((s) => [slugOf(s), s]));
27
27
  const tools = list.map((s) => bySlug.get(s)).filter(Boolean) as StackEntry[];
@@ -1,7 +1,6 @@
1
1
  ---
2
2
  import { getRelativeLocaleUrl } from 'astro:i18n';
3
- import ChevronDown from './ChevronDown.astro';
4
- import { languages, type Lang } from '../i18n/ui';
3
+ import { languages, useTranslations, type Lang } from '../i18n/ui';
5
4
 
6
5
  interface Props {
7
6
  lang: Lang;
@@ -10,17 +9,18 @@ interface Props {
10
9
  }
11
10
 
12
11
  const { lang, path } = Astro.props;
12
+ const t = useTranslations(lang);
13
13
  const codes = Object.keys(languages) as Lang[];
14
14
  ---
15
15
 
16
16
  <details class="aas-lang">
17
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"
18
+ 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"
19
+ aria-label={t('nav.language')}
20
+ title={t('nav.language')}
19
21
  >
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)]" />
22
+ {/* A globe stands in for the current language; the dropdown lists names. */}
23
+ <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"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
24
24
  </summary>
25
25
  <ul
26
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"
@@ -0,0 +1,52 @@
1
+ ---
2
+ import { render } from 'astro:content';
3
+ import { Image } from 'astro:assets';
4
+ import type { PageEntry } from '../lib/pages';
5
+ import type { Lang } from '../i18n/ui';
6
+ import { inlineMd } from '../lib/inline-md';
7
+ import TocRail from './TocRail.astro';
8
+ import MermaidLoader from './MermaidLoader.astro';
9
+
10
+ interface Props {
11
+ entry: PageEntry;
12
+ lang: Lang;
13
+ }
14
+
15
+ const { entry, lang } = Astro.props;
16
+ const { Content, headings } = await render(entry);
17
+ const data = entry.data;
18
+ const tocItems = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
19
+ // A standalone page (and any <SampleProject/> README it embeds) may use Mermaid;
20
+ // load the client renderer only when the body actually does.
21
+ const bodyHasMermaid = (entry.body ?? '').includes('```mermaid');
22
+ ---
23
+
24
+ <header class="pb-6">
25
+ <h1 class="text-3xl font-bold tracking-tight">{data.title}</h1>
26
+ {
27
+ data.description && (
28
+ <p
29
+ class="mt-2 aas-md text-lg whitespace-pre-line text-[var(--aas-muted)]"
30
+ set:html={inlineMd(data.description)}
31
+ />
32
+ )
33
+ }
34
+ {
35
+ data.image && (
36
+ <Image
37
+ src={data.image}
38
+ alt={data.imageAlt ?? data.title}
39
+ class="mt-5 aspect-[2/1] w-full rounded-2xl border border-[var(--aas-border)] object-cover"
40
+ />
41
+ )
42
+ }
43
+ </header>
44
+
45
+ <div class="mt-4 flex items-start gap-8">
46
+ <article class="prose max-w-none aas-reading min-w-0 flex-1">
47
+ <Content />
48
+ </article>
49
+ <TocRail lang={lang} items={tocItems} />
50
+ </div>
51
+
52
+ {bodyHasMermaid && <MermaidLoader />}
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { useTranslations, type Lang } from '../i18n/ui';
2
+ import { useTranslations, dateLocaleOf, type Lang } from '../i18n/ui';
3
3
  import type { RenderedProject } from '../lib/project';
4
4
  import ChevronDown from './ChevronDown.astro';
5
5
  import ProjectFileList from './ProjectFileList.astro';
@@ -32,7 +32,7 @@ function splitReadme(html?: string): { titleInner?: string; body?: string } {
32
32
  function formatDate(iso: string): string {
33
33
  const [y, m, d] = iso.split('-').map(Number);
34
34
  if (!y || !m || !d) return iso;
35
- return new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
35
+ return new Intl.DateTimeFormat(dateLocaleOf(lang), {
36
36
  year: 'numeric',
37
37
  month: 'long',
38
38
  day: 'numeric',
@@ -10,7 +10,7 @@ import ChevronDown from './ChevronDown.astro';
10
10
  import { listProjects } from '../lib/project';
11
11
  import { getStacks, slugOf } from '../lib/stacks';
12
12
  import { getArticles } from '../lib/articles';
13
- import { useTranslations, type Lang } from '../i18n/ui';
13
+ import { useTranslations, dateLocaleOf, type Lang } from '../i18n/ui';
14
14
 
15
15
  interface Props {
16
16
  lang: Lang;
@@ -76,7 +76,7 @@ const scopeOptions = [
76
76
  function formatDate(iso: string): string {
77
77
  const [y, m, d] = iso.split('-').map(Number);
78
78
  if (!y || !m || !d) return iso;
79
- return new Intl.DateTimeFormat(lang === 'ko' ? 'ko-KR' : 'en-US', {
79
+ return new Intl.DateTimeFormat(dateLocaleOf(lang), {
80
80
  year: 'numeric',
81
81
  month: 'long',
82
82
  day: 'numeric',
@@ -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
  }