stack-site-builder 1.23.2 → 1.25.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 (46) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +17 -2
  3. package/index.d.ts +8 -0
  4. package/index.mjs +6 -2
  5. package/markdown.mjs +61 -6
  6. package/package.json +4 -1
  7. package/src/components/CardsHome.astro +2 -2
  8. package/src/components/DeckView.astro +2 -3
  9. package/src/components/Home.astro +2 -2
  10. package/src/i18n/ui.ts +63 -12
  11. package/src/layouts/BaseLayout.astro +18 -6
  12. package/src/lib/articles.ts +3 -2
  13. package/src/lib/concepts.ts +3 -2
  14. package/src/lib/courses.ts +3 -2
  15. package/src/lib/locales.ts +30 -0
  16. package/src/lib/pages.ts +3 -2
  17. package/src/lib/papers.ts +3 -2
  18. package/src/lib/products.ts +3 -2
  19. package/src/lib/slides.ts +3 -2
  20. package/src/lib/stacks.ts +3 -2
  21. package/src/pages/[...lang]/[page].astro +2 -2
  22. package/src/pages/[...lang]/article/[...id].astro +2 -2
  23. package/src/pages/[...lang]/article/category/[id].astro +2 -2
  24. package/src/pages/[...lang]/article/index.astro +2 -3
  25. package/src/pages/[...lang]/categories/[id].astro +2 -2
  26. package/src/pages/[...lang]/concept/[...id].astro +2 -2
  27. package/src/pages/[...lang]/concept/category/[id].astro +2 -2
  28. package/src/pages/[...lang]/concept/index.astro +2 -3
  29. package/src/pages/[...lang]/course/[...id].astro +2 -2
  30. package/src/pages/[...lang]/course/category/[id].astro +2 -2
  31. package/src/pages/[...lang]/course/index.astro +2 -3
  32. package/src/pages/[...lang]/glossary.astro +2 -3
  33. package/src/pages/[...lang]/index.astro +2 -2
  34. package/src/pages/[...lang]/paper/[...id].astro +2 -2
  35. package/src/pages/[...lang]/paper/category/[id].astro +2 -2
  36. package/src/pages/[...lang]/paper/index.astro +2 -3
  37. package/src/pages/[...lang]/products/[...id].astro +2 -2
  38. package/src/pages/[...lang]/products/index.astro +2 -3
  39. package/src/pages/[...lang]/rss.xml.ts +2 -3
  40. package/src/pages/[...lang]/sample/[folder].astro +2 -2
  41. package/src/pages/[...lang]/sample/index.astro +2 -3
  42. package/src/pages/[...lang]/slides/index.astro +2 -3
  43. package/src/pages/[...lang]/stack/[...id].astro +2 -2
  44. package/src/pages/[...lang]/tags/[tag].astro +2 -2
  45. package/src/pages/[...lang]/vendors/[vendor].astro +2 -2
  46. package/src/styles/global.css +40 -0
package/CHANGELOG.md CHANGED
@@ -11,6 +11,110 @@ content schema, while a consuming site supplies only content, taxonomy data and
11
11
  config. Sites track the theme with `pnpm up stack-site-builder`, so each release
12
12
  here is a plain version bump they pull in.
13
13
 
14
+ ## [1.25.0] - 2026-08-31
15
+
16
+ Papers and courses routinely carry formulas, and until now a site's only
17
+ option was to drop in a screenshot or fake it with Unicode. This release adds
18
+ LaTeX math to the markdown pipeline, rendered by KaTeX at build time, with the
19
+ theme's existing dollar-sign prices left alone by default.
20
+
21
+ ### Added
22
+
23
+ - **LaTeX math in the markdown pipeline** — `$$…$$` renders as math: inline
24
+ when it sits mid-line, a centered display block when the fence stands alone
25
+ on its own lines. A longer run of dollars (`$$$…$$$`) opens an equivalent
26
+ fence for a formula that itself contains `$$`. Single `$…$` is deliberately
27
+ left as plain text — these are catalog sites full of prices, and "plans
28
+ start at $8 and go to $20" would otherwise silently become an equation — so
29
+ a site opts in explicitly with `math: { singleDollar: true }`, now
30
+ available on both `aasMarkdown()` and, newly, `aasTheme()`, which previously
31
+ had no way to forward the option at all. KaTeX runs at build time via
32
+ rehype-katex, so pages ship plain HTML + MathML with no math runtime on the
33
+ client, and `\text{}` renders unicode text (`\text{처리량}`) without
34
+ KaTeX's usual strict-mode warning, since that's the normal case in
35
+ bilingual content rather than a mistake.
36
+ - **A formula KaTeX can't parse fails the build** — rehype-katex's own answer
37
+ to unparsable LaTeX is a vfile warning nobody reads plus red error text
38
+ baked into the published page, the same silent degradation this pipeline
39
+ already refuses for an unknown wikilink. `rehypeMathErrors` turns each such
40
+ warning into a thrown build error naming the file and the `line:column`
41
+ alongside KaTeX's own diagnostic.
42
+ - **KaTeX ships themed and scrollable** — its stylesheet and fonts load
43
+ site-wide (~7 kB gzipped added to the shared bundle; the 20 font faces are
44
+ only fetched by a page that actually renders math) and draw in
45
+ `currentColor`, so math follows the light/dark theme with no palette work
46
+ needed. A display block wider than its column scrolls horizontally instead
47
+ of squeezing, matching how wide tables already behave, and inline math is
48
+ sized to 1.1em to match body text. KaTeX's Hangul/kana/Han fallback
49
+ classes, which ship with no font by design, now point at the site's own
50
+ sans, so `\text{처리량}` renders in the site font instead of falling
51
+ through to a mismatched system pick.
52
+
53
+ ### Changed
54
+
55
+ - **The playground's Attention paper demonstrates the math syntax**, in both
56
+ locales — inline math, a `$$` display fence, a `$$$` fence, prices left
57
+ untouched, and `\text{처리량}` rendering warning-free.
58
+ `docs/content-authoring.md` gains a matching Math section covering both
59
+ fence forms, the single-dollar opt-in, the build-fails-on-error behavior,
60
+ and the one MDX escaping rule that does not apply inside math (braces need
61
+ no `\{`).
62
+
63
+ ## [1.24.0] - 2026-08-25
64
+
65
+ Adding a locale was already meant to be site config only — the README said so —
66
+ but four strings never got the memo, so a site shipping a language beyond the
67
+ theme's en/ko had no way to translate its pricing chips, difficulty tooltips or
68
+ license labels, and no way to give the site itself a per-language name. This
69
+ release closes those, along with two hardcodings that assumed the default locale
70
+ is `en` and that a locale code is two bare letters.
71
+
72
+ ### Added
73
+
74
+ - **Sites supply the enum label tables** — `site.pricingLabels` (the `pricing`
75
+ frontmatter enum), `site.difficultyLabels` (course `level` 1–5) and
76
+ `site.licenseLabels` (descriptive licenses like `proprietary`) now feed
77
+ `pricingLabel()` / `difficultyLabel()` / `licenseLabel()` with the same
78
+ precedence `useTranslations` already used: the site's table for this locale,
79
+ the theme's, then both again for the default locale. Each is
80
+ `{ <code>: { <key>: <label> } }`, merged per key, so a site lists only what it
81
+ needs — and a site can retranslate a locale the theme ships, since the site's
82
+ entry wins. Previously these three read en/ko literals with no override point,
83
+ which left a third locale's chips and tooltips rendering in the default
84
+ locale's language with nothing a site could do about it.
85
+ - **`site.name` can be per-locale** — either one string for every locale (as
86
+ before, unchanged) or a record like
87
+ `{ en: 'Advanced Algorithms', ko: '고급 알고리즘' }`, resolved through the new
88
+ `siteName(lang)` export and falling back to the default locale then any entry.
89
+ Every place the name appears — page titles, the header wordmark and its
90
+ aria-label, the RSS channel title, the catalog and cards homepages, the deck
91
+ view — now goes through it.
92
+
93
+ ### Fixed
94
+
95
+ - **The language auto-detect redirect works on non-`en`-default sites** — the
96
+ root home gated its redirect script on `lang === 'en'` instead of
97
+ `lang === defaultLang`, so a site whose first locale is anything else (a
98
+ ko-default lecture site, say) shipped the script on no page at all and never
99
+ auto-detected. The redirect now lands on whichever locale's home is served
100
+ without a prefix.
101
+ - **Locale codes that aren't two bare letters** — every collection derived its
102
+ slugs with a copy of `/^[a-z]{2}\//` and filtered its listings with
103
+ `id.startsWith(`${lang}/`)`, so a `zh-CN` or `pt-BR` locale produced slugs
104
+ with the locale still glued on and listings that matched nothing. Both now go
105
+ through `stripLocale()` / `inLocale()` in `lib/locales.ts`, built from the
106
+ site's own locale codes. The prefix is compared lowercased because Astro's
107
+ glob loader lowercases every path segment when it derives content ids, so the
108
+ content folder may be cased either way (`stacks/zh-CN/` or `stacks/zh-cn/`).
109
+
110
+ ### Changed
111
+
112
+ - **The playground demos a fourth locale, `zh-CN`** — deliberately a code that
113
+ is neither two letters nor all-lowercase, with its own `pricingLabels`,
114
+ `difficultyLabels`, `licenseLabels`, a per-locale `site.name` and a partial
115
+ `site.ui` table, so the fallbacks and the slug derivation stay covered by the
116
+ build.
117
+
14
118
  ## [1.23.2] - 2026-08-06
15
119
 
16
120
  ### Fixed
@@ -465,6 +569,8 @@ catalog sites from a thin content-only repository.
465
569
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
466
570
  consuming site for developing and previewing the theme on its own.
467
571
 
572
+ [1.25.0]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.24.0...v1.25.0
573
+ [1.24.0]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.23.2...v1.24.0
468
574
  [1.23.2]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.23.1...v1.23.2
469
575
  [1.23.1]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.23.0...v1.23.1
470
576
  [1.23.0]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.22.0...v1.23.0
package/README.md CHANGED
@@ -36,7 +36,7 @@ export const collections = defineAasCollections({ categoryMap });
36
36
 
37
37
  | Where | What |
38
38
  | --- | --- |
39
- | `src/data/site.ts` | Site identity: name, repo URL (`repoNav: false` hides the header's GitHub link), the `locales` it ships, optional `sections` toggles, browser icons (`icons: { favicon, appleTouch, manifest }`), the `home` template, per-locale UI string overrides |
39
+ | `src/data/site.ts` | Site identity: name (one string, or per-locale), repo URL (`repoNav: false` hides the header's GitHub link), the `locales` it ships, optional `sections` toggles, browser icons (`icons: { favicon, appleTouch, manifest }`), the `home` template, per-locale UI string overrides and enum label tables (`pricingLabels`, `difficultyLabels`, `licenseLabels`) |
40
40
  | `src/data/categories.ts` | The tool-catalog category tree (validated against content) |
41
41
  | `src/data/concept-categories.ts` · `article-categories.ts` · `course-categories.ts` · `product-categories.ts` · `paper-categories.ts` (opt-in) | Taxonomies for concepts / articles / courses / products / papers |
42
42
  | `src/data/glossary.mjs` | `[[Term]]` wikilink targets — each entry links a term to a `stack` / `concept` / `article` / `course` / `paper` page, an external `href`, or is a definition-only term (`def`) |
@@ -63,6 +63,21 @@ locale from one source. To add a language (say Japanese):
63
63
  you omit falls back to the default locale. Add the `<code>` translations to
64
64
  your content (`src/content/<collection>/<code>/…`), glossary and category
65
65
  labels the same way you did for the built-in locales.
66
+ 4. Supply the enum labels the theme only ships in en/ko, under the same
67
+ per-locale shape: `site.pricingLabels` (the `pricing` frontmatter enum),
68
+ `site.difficultyLabels` (course `level` 1–5) and `site.licenseLabels`
69
+ (descriptive licenses like `proprietary`; real license names pass through).
70
+ Each is `{ <code>: { <key>: <label> } }`, merged per key over the theme's
71
+ table, so you list only what you need. A site can also retranslate a locale
72
+ the theme ships — the site's entry wins.
73
+ 5. If the site's own name differs per language, make `site.name` a per-locale
74
+ record — `name: { en: 'Advanced Algorithms', ko: '고급 알고리즘' }` — instead of
75
+ one string. A locale it omits falls back to the default locale.
76
+
77
+ A locale code doesn't have to be two letters: `zh-CN` and `pt-BR` work, and the
78
+ content folder may be cased either way (`stacks/zh-CN/` or `stacks/zh-cn/`) —
79
+ Astro lowercases the path segment when it derives content ids, which the theme
80
+ accounts for.
66
81
 
67
82
  No theme files change — adding a locale is entirely site config and content.
68
83
 
@@ -203,7 +218,7 @@ import Bookmark from 'stack-site-builder/components/Bookmark.astro';
203
218
  ```
204
219
 
205
220
  Markdown/MDX authoring gotchas (nested-list indentation, blockquotes, hard
206
- breaks, wikilinks): `docs/content-authoring.md`.
221
+ breaks, wikilinks, math): `docs/content-authoring.md`.
207
222
 
208
223
  ## RSS
209
224
 
package/index.d.ts CHANGED
@@ -25,6 +25,14 @@ export interface AasThemeOptions {
25
25
  * `sections` so its header-nav item is hidden too.
26
26
  */
27
27
  sections?: Partial<Record<SectionKey, boolean>>;
28
+ /**
29
+ * Math syntax. `$$…$$` (inline, or a fence on its own lines for a display
30
+ * block) is always on. `singleDollar` additionally reads `$…$` as inline
31
+ * math; it is off by default because these are catalog sites whose prose is
32
+ * full of prices, and "plans start at $8 and go to $20" would otherwise
33
+ * silently render as an equation.
34
+ */
35
+ math?: { singleDollar?: boolean };
28
36
  }
29
37
 
30
38
  /**
package/index.mjs CHANGED
@@ -100,9 +100,13 @@ const OPT_IN_SECTIONS = new Set(['courses', 'products', 'papers']);
100
100
  * toggles (`{ slides: false }`); a disabled section's routes are not injected.
101
101
  * `courses` is opt-IN (`{ courses: true }`) — it needs site-side course data.
102
102
  * Keep it in sync with `src/data/site.ts` `sections` (which hides the nav item).
103
+ * @param {{ singleDollar?: boolean }} [opts.math] — math syntax. `$$…$$` is
104
+ * always on; `{ singleDollar: true }` additionally reads `$…$` as inline math.
105
+ * Off by default because a catalog site's prose is full of prices — see
106
+ * MATH_SYNTAX in markdown.mjs.
103
107
  * @returns {import('astro').AstroIntegration[]}
104
108
  */
105
- export default function aasTheme({ glossary, sections = {} }) {
109
+ export default function aasTheme({ glossary, sections = {}, math = {} }) {
106
110
  /** @type {import('astro').AstroIntegration} */
107
111
  const core = {
108
112
  name: 'stack-site-builder',
@@ -138,7 +142,7 @@ export default function aasTheme({ glossary, sections = {} }) {
138
142
  });
139
143
 
140
144
  updateConfig({
141
- markdown: aasMarkdown({ glossary, locales, defaultLocale }),
145
+ markdown: aasMarkdown({ glossary, locales, defaultLocale, math }),
142
146
 
143
147
  // Bind the dev server to 0.0.0.0 so it's reachable from a browser on
144
148
  // the host (outside the Docker container).
package/markdown.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  // @ts-check
2
2
  // The shared Markdown/MDX pipeline for awesome-*-stack sites: heading ids +
3
- // copy-link anchors, mermaid fences, slide directives, and [[wikilink]]
4
- // resolution against the site's glossary. Sites get the whole pipeline from
5
- // the theme integration (index.mjs); `aasMarkdown({ glossary })` is also
6
- // exported for direct use.
3
+ // copy-link anchors, mermaid fences, slide directives, LaTeX math, and
4
+ // [[wikilink]] resolution against the site's glossary. Sites get the whole
5
+ // pipeline from the theme integration (index.mjs); `aasMarkdown({ glossary })`
6
+ // is also exported for direct use.
7
7
  import rehypeExternalLinks from 'rehype-external-links';
8
+ import rehypeKatex from 'rehype-katex';
8
9
  import rehypeSlug from 'rehype-slug';
9
10
  import remarkDirective from 'remark-directive';
11
+ import remarkMath from 'remark-math';
10
12
 
11
13
  // Prepend a "#" copy-link anchor to h2/h3/h4 headings (a global click handler
12
14
  // in BaseLayout copies the section URL). The "#" count per level is drawn via
@@ -412,25 +414,78 @@ function remarkGlossary({ glossary, locales = ['en', 'ko'], defaultLocale = 'en'
412
414
  };
413
415
  }
414
416
 
417
+ // KaTeX cannot parse everything, and rehype-katex's answer to a formula it
418
+ // chokes on is a warning nobody reads plus red text in the published page. That
419
+ // is the silent-degradation failure mode this pipeline already refuses for
420
+ // wikilinks, so promote it the same way: fail the build. Runs immediately after
421
+ // rehypeKatex, which records each failure on the vfile.
422
+ function rehypeMathErrors() {
423
+ return (/** @type {any} */ _tree, /** @type {any} */ file) => {
424
+ /** @type {any[]} */
425
+ const messages = file.messages || [];
426
+ const bad = messages.filter((m) => m.source === 'rehype-katex');
427
+ if (!bad.length) return;
428
+ // Drop them from the vfile so the thrown error is the only report.
429
+ file.messages = messages.filter((m) => m.source !== 'rehype-katex');
430
+ const where = file.path || (file.history && file.history[0]) || 'unknown file';
431
+ const detail = bad
432
+ .map((m) => {
433
+ const at = m.place && m.place.start ? `${m.place.start.line}:${m.place.start.column} ` : '';
434
+ return ` - ${at}${(m.cause && m.cause.message) || m.reason}`;
435
+ })
436
+ .join('\n');
437
+ throw new Error(`[math] KaTeX could not parse ${bad.length} formula(s) in ${where}:\n${detail}`);
438
+ };
439
+ }
440
+
441
+ // Math is written with DOUBLE dollars — `$$E = mc^2$$` inline, or a `$$` fence
442
+ // on its own lines for a display block (three or more dollars work the same, so
443
+ // `$$$ … $$$` is a valid fence too, as long as the closing run is at least as
444
+ // long as the opening one). Single `$…$` is deliberately OFF: these are catalog
445
+ // sites full of prices, and "plans start at $8 and go to $20" would otherwise
446
+ // silently turn into math. A site that wants single-dollar math can opt in with
447
+ // `aasMarkdown({ math: { singleDollar: true } })`.
448
+ const MATH_SYNTAX = { singleDollarTextMath: false };
449
+
450
+ // KaTeX renders at BUILD time (rehype-katex), so pages ship plain HTML + MathML
451
+ // with no client-side math runtime; the stylesheet is pulled in by BaseLayout.
452
+ const KATEX_OPTIONS = {
453
+ // KaTeX's `strict` mode warns about non-Latin characters inside `\text{…}`,
454
+ // which for bilingual ko/en content is the normal case, not a mistake
455
+ // (`\text{처리량}`). Silence just that rule and keep the rest of the warnings.
456
+ /** @param {string} code */
457
+ strict: (/** @type {string} */ code) => (code === 'unicodeTextInMathMode' ? 'ignore' : 'warn'),
458
+ };
459
+
415
460
  /**
416
461
  * The full markdown config for `defineConfig({ markdown })`.
417
- * @param {{ glossary: Record<string, any>, locales?: string[], defaultLocale?: string }} opts
462
+ * @param {{ glossary: Record<string, any>, locales?: string[], defaultLocale?: string,
463
+ * math?: { singleDollar?: boolean } }} opts
418
464
  * `glossary` — the site's wikilink targets (pass `{}` for none). `locales` /
419
465
  * `defaultLocale` come from the site's astro.config `i18n` so wikilink locale
420
466
  * detection matches whatever locales the site ships (defaults to en/ko).
467
+ * `math.singleDollar` opts into `$…$` inline math (see MATH_SYNTAX).
421
468
  */
422
- export function aasMarkdown({ glossary, locales = ['en', 'ko'], defaultLocale = 'en' }) {
469
+ export function aasMarkdown({ glossary, locales = ['en', 'ko'], defaultLocale = 'en', math = {} }) {
423
470
  return {
424
471
  remarkPlugins: [
425
472
  remarkHeadingIds,
473
+ // Before remarkMermaid/remarkDirective only for readability — all three
474
+ // are micromark syntax extensions, so the parser applies them together.
475
+ [remarkMath, { ...MATH_SYNTAX, singleDollarTextMath: math.singleDollar === true }],
426
476
  remarkMermaid,
427
477
  remarkDirective,
428
478
  remarkSlideDirectives,
429
479
  [remarkGlossary, { glossary, locales, defaultLocale }],
430
480
  ],
431
481
  rehypePlugins: [
482
+ // rehypeSlug runs first so a heading with math slugs from the SOURCE
483
+ // (`## $$x^2$$` → "x2"); after KaTeX the heading's text content is the
484
+ // whole rendered glyph soup and the id would be unusable.
432
485
  rehypeSlug,
433
486
  rehypeHeadingAnchors,
487
+ [rehypeKatex, KATEX_OPTIONS],
488
+ rehypeMathErrors,
434
489
  rehypeTableScroll,
435
490
  [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
436
491
  ],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.23.2",
4
+ "version": "1.25.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": {
@@ -38,11 +38,14 @@
38
38
  "@astrojs/rss": "^4.0.0",
39
39
  "@astrojs/sitemap": "^3.3.0",
40
40
  "@tailwindcss/vite": "^4.1.0",
41
+ "katex": "^0.16.47",
41
42
  "markdown-it": "^14.2.0",
42
43
  "mermaid": "^11.15.0",
43
44
  "rehype-external-links": "^3.0.0",
45
+ "rehype-katex": "^7.0.1",
44
46
  "rehype-slug": "^6.0.0",
45
47
  "remark-directive": "^4.0.0",
48
+ "remark-math": "^6.0.0",
46
49
  "shiki": "^4.2.0",
47
50
  "tailwindcss": "^4.1.0"
48
51
  },
@@ -6,7 +6,7 @@
6
6
  import { site } from '@aas-data/site';
7
7
  import { getRelativeLocaleUrl } from 'astro:i18n';
8
8
  import { home, loc, locList } from '../lib/home';
9
- import { useTranslations, type Lang } from '../i18n/ui';
9
+ import { useTranslations, siteName, type Lang } from '../i18n/ui';
10
10
 
11
11
  interface Props {
12
12
  lang: Lang;
@@ -18,7 +18,7 @@ const h = home;
18
18
  const base = import.meta.env.BASE_URL.replace(/\/$/, '');
19
19
  const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
20
20
 
21
- const heroTitle = loc(h?.hero?.title, lang) ?? site.name;
21
+ const heroTitle = loc(h?.hero?.title, lang) ?? siteName(lang);
22
22
  const heroSubtitle = loc(h?.hero?.subtitle, lang) ?? t('site.tagline');
23
23
  const extAttrs = (external?: boolean) =>
24
24
  external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
@@ -1,11 +1,10 @@
1
1
  ---
2
- import { site } from '@aas-data/site';
3
2
  import '../styles/global.css';
4
3
  import { getRelativeLocaleUrl } from 'astro:i18n';
5
4
  import { render, type CollectionEntry } from 'astro:content';
6
5
  import MermaidLoader from './MermaidLoader.astro';
7
6
  import PrivateGate from './PrivateGate.astro';
8
- import { useTranslations, type Lang } from '../i18n/ui';
7
+ import { useTranslations, siteName, type Lang } from '../i18n/ui';
9
8
  import { siteIcons } from '../lib/icons';
10
9
 
11
10
  // Renders a full-screen slide deck as a horizontal scroll-snap presentation.
@@ -31,7 +30,7 @@ const priv = entry.data.private;
31
30
  <head>
32
31
  <meta charset="utf-8" />
33
32
  <meta name="viewport" content="width=device-width, initial-scale=1" />
34
- <title>{entry.data.title} — {site.name}</title>
33
+ <title>{entry.data.title} — {siteName(lang)}</title>
35
34
  <meta name="description" content={(priv ? entry.data.teaser : entry.data.description) ?? ''} />
36
35
  {priv && <meta name="robots" content="noindex" />}
37
36
  <link rel="icon" {...(siteIcons.faviconType ? { type: siteIcons.faviconType } : {})} href={`${base}${siteIcons.favicon}`} />
@@ -8,7 +8,7 @@ import { getStacks, slugOf } from '../lib/stacks';
8
8
  import { site } from '@aas-data/site';
9
9
  import { deriveFacets } from '../lib/facets';
10
10
  import { memberOnlyInIndex } from '../lib/listing';
11
- import { useTranslations, type Lang } from '../i18n/ui';
11
+ import { useTranslations, siteName, type Lang } from '../i18n/ui';
12
12
 
13
13
  interface Props {
14
14
  lang: Lang;
@@ -33,7 +33,7 @@ const facets = deriveFacets(entries, lang);
33
33
  ---
34
34
 
35
35
  <section class="py-6">
36
- <h1 class="text-4xl font-bold tracking-tight">{site.name}</h1>
36
+ <h1 class="text-4xl font-bold tracking-tight">{siteName(lang)}</h1>
37
37
  <p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('site.tagline')}</p>
38
38
  </section>
39
39
 
package/src/i18n/ui.ts CHANGED
@@ -25,9 +25,23 @@ const themeLocales: LocaleDef[] = [
25
25
  { code: 'en', label: 'English', dateLocale: 'en-US' },
26
26
  { code: 'ko', label: '한국어', dateLocale: 'ko-KR' },
27
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;
28
+ /** A per-locale label table: `{ <locale>: { <key>: <label> } }`. Sites supply
29
+ * these for locales the theme doesn't ship (see {@link pricingLabels}). */
30
+ export type LabelTable = Record<string, Record<string, string>>;
31
+
32
+ // Read defensively: every field below is optional, and a site that declares
33
+ // none of them must not be a type error — the `@aas-data/site` alias points at
34
+ // the site's own object, whose exact shape the theme can't know.
35
+ const siteCfg = site as {
36
+ name?: string | Record<string, string>;
37
+ locales?: LocaleDef[];
38
+ pricingLabels?: LabelTable;
39
+ difficultyLabels?: LabelTable;
40
+ licenseLabels?: LabelTable;
41
+ };
42
+ // A site that hasn't opted into custom locales simply has no `locales` field,
43
+ // and should keep the theme's en/ko default.
44
+ const siteLocales = siteCfg.locales;
31
45
  const localeList: LocaleDef[] =
32
46
  Array.isArray(siteLocales) && siteLocales.length ? siteLocales : themeLocales;
33
47
 
@@ -51,6 +65,20 @@ export function dateLocaleOf(lang: Lang): string {
51
65
  );
52
66
  }
53
67
 
68
+ /**
69
+ * The site's name in `lang`. `site.name` is either one string used for every
70
+ * locale (the common case) or a per-locale record — `{ ko: '고급 알고리즘', en:
71
+ * 'Advanced Algorithms' }` — which falls back to the default locale, then to
72
+ * any entry. Same rule as `loc()` in lib/home.ts, inlined here because that
73
+ * module imports this one.
74
+ */
75
+ export function siteName(lang: Lang): string {
76
+ const n = siteCfg.name;
77
+ if (n == null) return '';
78
+ if (typeof n === 'string') return n;
79
+ return n[lang] ?? n[defaultLang] ?? Object.values(n)[0] ?? '';
80
+ }
81
+
54
82
  /** UI chrome strings, keyed by a dotted id. */
55
83
  export const ui = {
56
84
  en: {
@@ -436,14 +464,35 @@ export function useTranslations(lang: Lang) {
436
464
  };
437
465
  }
438
466
 
467
+ /**
468
+ * Look one label up with the same precedence `useTranslations` uses: the site's
469
+ * table for this locale, the theme's, then both again for the default locale.
470
+ * The site-first order lets a site retranslate a locale the theme ships; the
471
+ * default-locale fallback keeps a site-added locale from rendering a bare key.
472
+ */
473
+ function lookupLabel(
474
+ siteTable: LabelTable | undefined,
475
+ themeTable: LabelTable,
476
+ lang: Lang,
477
+ key: string,
478
+ ): string | undefined {
479
+ return (
480
+ siteTable?.[lang]?.[key] ??
481
+ themeTable[lang]?.[key] ??
482
+ siteTable?.[defaultLang]?.[key] ??
483
+ themeTable[defaultLang]?.[key]
484
+ );
485
+ }
486
+
439
487
  /** Localized label for a `pricing` enum value, falling back to the default
440
488
  * locale then the raw value (so a site-added locale never crashes). */
441
489
  export function pricingLabel(lang: Lang, value: string): string {
442
- return pricingLabels[lang]?.[value] ?? pricingLabels[defaultLang]?.[value] ?? value;
490
+ return lookupLabel(siteCfg.pricingLabels, pricingLabels, lang, value) ?? value;
443
491
  }
444
492
 
445
- /** Human labels for the `pricing` frontmatter enum, per locale. */
446
- export const pricingLabels: Record<string, Record<string, string>> = {
493
+ /** Human labels for the `pricing` frontmatter enum, per locale. A site adds its
494
+ * own locales via `site.pricingLabels` same shape, merged per-key. */
495
+ export const pricingLabels: LabelTable = {
447
496
  en: {
448
497
  'completely-free': 'Completely free',
449
498
  'open-source': 'Open source',
@@ -464,20 +513,22 @@ export const pricingLabels: Record<string, Record<string, string>> = {
464
513
  * locale then the bare number (so a site-added locale never crashes). */
465
514
  export function difficultyLabel(lang: Lang, level: number): string {
466
515
  const key = String(level);
467
- return difficultyLabels[lang]?.[key] ?? difficultyLabels[defaultLang]?.[key] ?? key;
516
+ return lookupLabel(siteCfg.difficultyLabels, difficultyLabels, lang, key) ?? key;
468
517
  }
469
518
 
470
- /** Human labels for the course `level` frontmatter (1–5), per locale. */
471
- export const difficultyLabels: Record<string, Record<string, string>> = {
519
+ /** Human labels for the course `level` frontmatter (1–5), per locale. A site
520
+ * adds its own locales via `site.difficultyLabels`. */
521
+ export const difficultyLabels: LabelTable = {
472
522
  en: { '1': 'Beginner', '2': 'Elementary', '3': 'Intermediate', '4': 'Advanced', '5': 'Expert' },
473
523
  ko: { '1': '입문', '2': '초급', '3': '중급', '4': '고급', '5': '전문가' },
474
524
  };
475
525
 
476
- /** Descriptive (non-name) licenses get localized; real license names pass through. */
477
- const licenseLabels: Record<string, Record<string, string>> = {
526
+ /** Descriptive (non-name) licenses get localized; real license names pass
527
+ * through. A site adds its own locales via `site.licenseLabels`. */
528
+ const licenseLabels: LabelTable = {
478
529
  en: { proprietary: 'Proprietary' },
479
530
  ko: { proprietary: '독점' },
480
531
  };
481
532
  export function licenseLabel(lang: Lang, value: string): string {
482
- return licenseLabels[lang]?.[value] ?? licenseLabels[defaultLang]?.[value] ?? value;
533
+ return lookupLabel(siteCfg.licenseLabels, licenseLabels, lang, value) ?? value;
483
534
  }
@@ -1,8 +1,16 @@
1
1
  ---
2
2
  import '../styles/global.css';
3
+ // KaTeX's stylesheet, loaded site-wide rather than per-page. An Astro `import`
4
+ // is resolved statically, so a runtime `{hasMath && …}` guard (the trick that
5
+ // keeps the Mermaid loader off pages without diagrams) would not actually keep
6
+ // it out of the bundle. Site-wide is also the cheaper end of the trade: it
7
+ // merges into the one global stylesheet every page already downloads and
8
+ // caches, ~7 kB gzipped, and the font files are only fetched by a browser that
9
+ // has math to draw. See markdown.mjs for the pipeline that produces the markup.
10
+ import 'katex/dist/katex.min.css';
3
11
  import { site } from '@aas-data/site';
4
12
  import { getRelativeLocaleUrl } from 'astro:i18n';
5
- import { useTranslations, languages, type Lang } from '../i18n/ui';
13
+ import { useTranslations, languages, siteName, defaultLang, type Lang } from '../i18n/ui';
6
14
  import LanguageSwitcher from '../components/LanguageSwitcher.astro';
7
15
  import ThemeToggle from '../components/ThemeToggle.astro';
8
16
  import BackToTop from '../components/BackToTop.astro';
@@ -55,7 +63,7 @@ const baseNoSlash = base.replace(/\/$/, '');
55
63
 
56
64
  // Header brand: `site.header = { logo?: '/path.png', logoInvert?: bool,
57
65
  // name?: bool }`. A logo replaces the ⚡ bolt; `name: false` drops the
58
- // wordmark (logo-only brand — the link keeps site.name as its aria-label).
66
+ // wordmark (logo-only brand — the link keeps siteName(lang) as its aria-label).
59
67
  const header = (site as { header?: { logo?: string; logoInvert?: boolean; name?: boolean } })
60
68
  .header;
61
69
  const brandLogo = header?.logo;
@@ -65,7 +73,11 @@ const brandName = header?.name !== false;
65
73
  const localeHomes = Object.fromEntries(
66
74
  (Object.keys(languages) as Lang[]).map((c) => [c, getRelativeLocaleUrl(c, '')]),
67
75
  );
68
- const isDefaultHome = path === '' && lang === 'en';
76
+ // The auto-detect redirect belongs on the ROOT home — the one page served
77
+ // without a locale prefix — which is the default locale's, whatever it is. This
78
+ // read 'en' until 1.24.0, so a ko-default (or any non-en-default) site never
79
+ // redirected at all.
80
+ const isDefaultHome = path === '' && lang === defaultLang;
69
81
 
70
82
  // Standalone pages (the `pages` collection) that opt into a header-nav link,
71
83
  // e.g. an About/소개 page. Rendered as their own nav items alongside the
@@ -216,7 +228,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
216
228
  <link
217
229
  rel="alternate"
218
230
  type="application/rss+xml"
219
- title={`${site.name} — ${t('blog.title')}`}
231
+ title={`${siteName(lang)} — ${t('blog.title')}`}
220
232
  href={getRelativeLocaleUrl(lang, 'rss.xml')}
221
233
  />
222
234
  )
@@ -287,7 +299,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
287
299
  <a
288
300
  href={home}
289
301
  data-keep-filters
290
- aria-label={site.name}
302
+ aria-label={siteName(lang)}
291
303
  class="flex min-w-0 items-center gap-1.5 text-base font-bold tracking-tight whitespace-nowrap no-underline sm:text-lg"
292
304
  >
293
305
  {
@@ -302,7 +314,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
302
314
  )
303
315
  }
304
316
  {/* Below 600px only the mark remains, so the narrow header never truncates the wordmark. */}
305
- {brandName && <span class="hidden truncate min-[600px]:inline">{site.name}</span>}
317
+ {brandName && <span class="hidden truncate min-[600px]:inline">{siteName(lang)}</span>}
306
318
  </a>
307
319
  {/* Section links are icon-only (labels live in aria-label). On phones
308
320
  (<sm) they collapse into the labelled dropdown menu below, so the
@@ -1,18 +1,19 @@
1
1
  import { getCollection, type CollectionEntry } from 'astro:content';
2
2
  import type { Lang } from '../i18n/ui';
3
+ import { inLocale, stripLocale } from './locales';
3
4
 
4
5
  export type ArticleEntry = CollectionEntry<'articles'>;
5
6
 
6
7
  /** The url slug of an article, i.e. its id with the `<lang>/` prefix removed. */
7
8
  export function articleSlugOf(entry: ArticleEntry): string {
8
- return entry.id.replace(/^[a-z]{2}\//, '');
9
+ return stripLocale(entry.id);
9
10
  }
10
11
 
11
12
  /** Published articles for one locale, newest first. */
12
13
  export async function getArticles(lang: Lang): Promise<ArticleEntry[]> {
13
14
  const all = await getCollection('articles');
14
15
  return all
15
- .filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
16
+ .filter((e) => inLocale(e.id, lang) && !e.data.draft)
16
17
  .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
17
18
  }
18
19
 
@@ -1,18 +1,19 @@
1
1
  import { getCollection, type CollectionEntry } from 'astro:content';
2
2
  import type { Lang } from '../i18n/ui';
3
+ import { inLocale, stripLocale } from './locales';
3
4
 
4
5
  export type ConceptEntry = CollectionEntry<'concepts'>;
5
6
 
6
7
  /** The url slug of a concept, i.e. its id with the `<lang>/` prefix removed. */
7
8
  export function conceptSlugOf(entry: ConceptEntry): string {
8
- return entry.id.replace(/^[a-z]{2}\//, '');
9
+ return stripLocale(entry.id);
9
10
  }
10
11
 
11
12
  /** Published concepts for one locale, by `order` then title. */
12
13
  export async function getConcepts(lang: Lang): Promise<ConceptEntry[]> {
13
14
  const all = await getCollection('concepts');
14
15
  return all
15
- .filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
16
+ .filter((e) => inLocale(e.id, lang) && !e.data.draft)
16
17
  .sort(
17
18
  (a, b) =>
18
19
  (a.data.order ?? 999) - (b.data.order ?? 999) ||
@@ -1,11 +1,12 @@
1
1
  import { getCollection, type CollectionEntry } from 'astro:content';
2
2
  import type { Lang } from '../i18n/ui';
3
+ import { inLocale, stripLocale } from './locales';
3
4
 
4
5
  export type CourseEntry = CollectionEntry<'courses'>;
5
6
 
6
7
  /** The url slug of a course, i.e. its id with the `<lang>/` prefix removed. */
7
8
  export function courseSlugOf(entry: CourseEntry): string {
8
- return entry.id.replace(/^[a-z]{2}\//, '');
9
+ return stripLocale(entry.id);
9
10
  }
10
11
 
11
12
  /**
@@ -16,7 +17,7 @@ export function courseSlugOf(entry: CourseEntry): string {
16
17
  export async function getCourses(lang: Lang): Promise<CourseEntry[]> {
17
18
  const all = await getCollection('courses');
18
19
  return all
19
- .filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
20
+ .filter((e) => inLocale(e.id, lang) && !e.data.draft)
20
21
  .sort(
21
22
  (a, b) =>
22
23
  (b.data.order ?? '').localeCompare(a.data.order ?? '') ||