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
package/CHANGELOG.md ADDED
@@ -0,0 +1,126 @@
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.13.0] - 2026-07-20
15
+
16
+ ### Added
17
+
18
+ - **Optional sections** — a site can turn off any of the secondary sections
19
+ (concepts, articles, samples, slides, glossary, and the standalone `pages`
20
+ collection); the core catalog stays on. Disabling one removes both its routes
21
+ and its header-nav item. Declare `sections` in `src/data/site.ts` (hides the
22
+ nav item) and forward it to `aasTheme({ sections })` in astro.config (skips the
23
+ routes); the key type (`SectionKey`) is exported from the theme, so
24
+ `satisfies Partial<Record<SectionKey, boolean>>` gives autocomplete of the
25
+ valid keys. The playground drops `slides` to demonstrate.
26
+
27
+ ## [1.12.0] - 2026-07-20
28
+
29
+ Locales are now **site-configurable**. The theme was wired to exactly two
30
+ languages (English at the root, Korean under `/ko/`, plus a hand-written `ko/`
31
+ copy of every route); a consuming site could not add a third. Now the theme
32
+ reads the locales from the site's own astro.config `i18n` and renders every
33
+ route for each of them from one source, so a site adds a language (e.g.
34
+ Japanese) by editing its config and data — no theme changes, no per-locale route
35
+ files. Existing en/ko sites are unaffected: the same URLs build, and the theme
36
+ still defaults to en/ko when a site declares nothing.
37
+
38
+ ### Added
39
+
40
+ - **Site-configurable locales** — the header language switcher, route
41
+ generation, date formatting, wikilink resolution and sitemap hreflang all
42
+ derive from the site's configured locales. A site declares them in
43
+ astro.config `i18n` (routing) and, optionally, `site.locales` in
44
+ `src/data/site.ts` (display name + date format per locale); it supplies each
45
+ extra locale's UI strings via `site.ui.<code>`, with any missing key falling
46
+ back to the default locale. The playground ships a third locale (`ja`) to
47
+ demonstrate the path.
48
+
49
+ ### Changed
50
+
51
+ - **One route tree instead of a per-locale mirror** — the hand-maintained
52
+ `src/pages/ko/` copy of every page is gone; all pages now live once under
53
+ `src/pages/[...lang]/` and enumerate the site's locales in `getStaticPaths`.
54
+ This is internal to the theme, but it is what makes adding a locale free.
55
+ - **`Lang` is now `string`** (was the `'en' | 'ko'` union) since locales are no
56
+ longer fixed at build time. Locale-keyed maps (`pricingLabels`, category
57
+ labels, …) resolve through helpers that fall back to the default locale rather
58
+ than assuming a key exists.
59
+
60
+ ## [1.11.0] - 2026-07-20
61
+
62
+ The center of this release is **standalone pages**: a site can now add a
63
+ first-class top-level section — an About/소개 page, contact, terms — that is
64
+ just one Markdown file, rendered on its own and linked from the header
65
+ navigation, without the listing and taxonomy of the catalog collections.
66
+ Alongside it, the header collapses into a menu on phones, the language switcher
67
+ becomes a globe icon, and two rendering bugs on the new pages are fixed.
68
+
69
+ ### Added
70
+
71
+ - **Standalone `pages` collection** — author a top-level page as one MDX file
72
+ (`src/content/pages/{en,ko}/<slug>.mdx`); the theme renders it at `/<slug>/`
73
+ in both locales. Frontmatter drives a hero image, description, and header-nav
74
+ placement (`nav`, `navLabel`, `order`, `draft`). Ships the `PageDetail`
75
+ component, the `getPages` / `getNavPages` / `pageSlugOf` helpers, and a
76
+ per-locale route injected by the integration.
77
+ - **Mobile navigation menu** — below the `sm` breakpoint the section links
78
+ collapse into a labelled dropdown menu (hamburger), so a phone header stays to
79
+ a few controls instead of a long icon run. The icon row (≥`sm`) and the menu
80
+ render from one `navItems` source, so they never drift, and nav pages appear
81
+ in both automatically.
82
+
83
+ ### Changed
84
+
85
+ - **Globe icon for the language switcher** — the switcher shows a globe instead
86
+ of the locale code/name, and the dropdown chevrons are removed from both the
87
+ language and theme switchers, leaving a single clean control each. Adds
88
+ `nav.language` and `nav.menu` UI strings (en/ko).
89
+
90
+ ### Fixed
91
+
92
+ - **In-locale wikilinks on standalone pages** — wikilink targets were emitted as
93
+ `../../glossary/` (and `../../stack/…`, etc.), which assumes the source sits
94
+ two levels deep within its locale, true for the collection detail routes
95
+ (`/ko/stack/<slug>/`) but not for a one-deep standalone page (`/ko/about/`).
96
+ The extra `../` overshot the locale prefix and linked to the default-locale
97
+ glossary; the depth is now derived from where the source actually sits.
98
+ - **In-body images no longer force horizontal scroll** — content images had no
99
+ width cap and Astro's `<Image>` emits intrinsic `width`/`height`, so an image
100
+ wider than the viewport (e.g. a screenshot on a phone) stretched the body and
101
+ scrolled the whole page sideways. `.prose img` is now capped to
102
+ `max-width: 100%; height: auto`.
103
+
104
+ ## [1.10.0] - 2026-07-20
105
+
106
+ First public release. The rendering engine of
107
+ [awesome-ai-stack](https://github.com/codecompose7/awesome-ai-stack) — routes,
108
+ components, styles, the markdown pipeline and the content schema — extracted
109
+ into a reusable, MIT-licensed Astro theme so the same engine can drive other
110
+ catalog sites from a thin content-only repository.
111
+
112
+ ### Added
113
+
114
+ - **The awesome-\*-stack theme** — an integration that injects every route
115
+ (catalog, concepts, articles, slides, samples, glossary, tags, vendors, in
116
+ en/ko), the components and styles, the shared markdown pipeline
117
+ (`[[wikilink]]` glossary, slides, mermaid), and the content schema via
118
+ `defineAasCollections`. A consuming site provides content, taxonomy data
119
+ (`categories`, `glossary`, `site`) and config.
120
+ - **Standalone development setup** — a devcontainer and a minimal `playground/`
121
+ consuming site for developing and previewing the theme on its own.
122
+
123
+ [1.13.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.12.0...v1.13.0
124
+ [1.12.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.11.0...v1.12.0
125
+ [1.11.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.10.0...v1.11.0
126
+ [1.10.0]: https://github.com/CodeCompose7/stack-site-builder/releases/tag/v1.10.0
package/README.md CHANGED
@@ -34,12 +34,59 @@ 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, optional `sections` toggles, 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.
65
+
66
+ ## Sections
67
+
68
+ The core catalog (home, tool detail, categories, tags, vendors) is always on.
69
+ The rest are opt-out — **concepts, articles, samples, slides, glossary** and the
70
+ standalone **pages** collection (About/소개, …) — so a site can ship only what it
71
+ needs. Turning one off removes both its routes and its header-nav item. (`pages`
72
+ also has finer control: each page's `nav` / `draft` frontmatter, or simply not
73
+ authoring it.)
74
+
75
+ Declare the toggles once in `src/data/site.ts` and forward them to the theme in
76
+ astro.config (which needs them to skip route injection). Import `SectionKey` from
77
+ the theme so `satisfies` lists the valid keys as you type:
78
+
79
+ ```ts
80
+ // src/data/site.ts
81
+ import type { SectionKey } from 'stack-site-builder';
82
+ export const site = {
83
+ /* … */
84
+ sections: { slides: false } satisfies Partial<Record<SectionKey, boolean>>,
85
+ };
86
+ ```
87
+
88
+ ```js
89
+ // astro.config.mjs
90
+ import { site } from './src/data/site';
91
+ integrations: [aasTheme({ glossary, sections: site.sections })];
92
+ ```
package/index.d.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
 
3
+ /** Optional content sections that a site can turn off. `pages` is the
4
+ * standalone-pages collection (About/소개, …). */
5
+ export type SectionKey = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
6
+
3
7
  export interface AasThemeOptions {
4
8
  /** The site's glossary (`src/data/glossary.mjs`) — `[[wikilink]]` targets. */
5
9
  glossary: Record<string, unknown>;
10
+ /**
11
+ * Turn optional sections off (all on by default), e.g. `{ slides: false }`.
12
+ * A disabled section's routes aren't injected; pass the same object to
13
+ * `src/data/site.ts` `sections` so its header-nav item is hidden too.
14
+ */
15
+ sections?: Partial<Record<SectionKey, boolean>>;
6
16
  }
7
17
 
8
18
  /**
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,43 +46,82 @@ 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(/\/$/, '') || '/';
55
+ }
56
+
57
+ // Which optional section each page belongs to (null = always-on core route).
58
+ // Mirrors SectionKey in src/lib/sections.ts.
59
+ /** @param {string} file @returns {string | null} */
60
+ function sectionOf(file) {
61
+ if (file.startsWith('concept/')) return 'concepts';
62
+ if (file.startsWith('article/')) return 'articles';
63
+ if (file.startsWith('sample/')) return 'samples';
64
+ if (file.startsWith('slides/')) return 'slides';
65
+ if (file === 'glossary.astro') return 'glossary';
66
+ if (file === '[page].astro') return 'pages';
67
+ return null;
52
68
  }
53
69
 
54
70
  /**
55
71
  * @param {object} opts
56
72
  * @param {Record<string, any>} opts.glossary — the site's glossary
57
73
  * (`src/data/glossary.mjs`), used by `[[wikilink]]` resolution.
74
+ * @param {Partial<Record<string, boolean>>} [opts.sections] — optional-section
75
+ * toggles (`{ slides: false }`); a disabled section's routes are not injected.
76
+ * Keep it in sync with `src/data/site.ts` `sections` (which hides the nav item).
58
77
  * @returns {import('astro').AstroIntegration[]}
59
78
  */
60
- export default function aasTheme({ glossary }) {
79
+ export default function aasTheme({ glossary, sections = {} }) {
61
80
  /** @type {import('astro').AstroIntegration} */
62
81
  const core = {
63
82
  name: 'stack-site-builder',
64
83
  hooks: {
65
84
  '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
- }
85
+ // Locales the site configured (astro.config `i18n`). Everything locale
86
+ // aware derives from here — route injection, wikilink detection, and the
87
+ // sitemap hreflang so a site adds a language by editing its config, not
88
+ // the theme. `locales` entries may be strings or `{ path }` objects.
89
+ const rawLocales = config.i18n?.locales ?? ['en'];
90
+ const locales = rawLocales.map((l) => (typeof l === 'string' ? l : l.path));
91
+ const defaultLocale = config.i18n?.defaultLocale ?? locales[0];
92
+
93
+ // A single physical page tree under `[...lang]/` serves every locale: the
94
+ // default at the root, each other under `/<code>/`. Each page's
95
+ // getStaticPaths enumerates the locales, so adding one needs no new files.
96
+ // Skip a page whose optional section the site turned off (`{ slides: false }`).
97
+ for (const file of PAGES) {
98
+ const section = sectionOf(file);
99
+ if (section && sections[section] === false) continue;
100
+ injectRoute({
101
+ pattern: patternOf(`[...lang]/${file}`),
102
+ entrypoint: `stack-site-builder/pages/[...lang]/${file}`,
103
+ });
74
104
  }
75
105
 
76
106
  updateConfig({
77
- markdown: aasMarkdown({ glossary }),
107
+ markdown: aasMarkdown({ glossary, locales, defaultLocale }),
78
108
 
79
109
  // Bind the dev server to 0.0.0.0 so it's reachable from a browser on
80
110
  // the host (outside the Docker container).
81
111
  server: { host: true },
82
112
 
113
+ // hreflang alternates so search engines pair each page with its twin in
114
+ // the other locales. Built from the site's configured locales (added
115
+ // here rather than in the returned array, where `config` isn't known).
116
+ integrations: [
117
+ sitemap({
118
+ i18n: {
119
+ defaultLocale,
120
+ locales: Object.fromEntries(locales.map((l) => [l, l])),
121
+ },
122
+ }),
123
+ ],
124
+
83
125
  vite: {
84
126
  plugins: [tailwindcss(), localSamples()],
85
127
 
@@ -109,16 +151,7 @@ export default function aasTheme({ glossary }) {
109
151
  },
110
152
  };
111
153
 
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
- ];
154
+ // The sitemap integration is added from the core hook's updateConfig (above),
155
+ // where the site's configured locales are known.
156
+ return [core, mdx()];
124
157
  }
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.13.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',