stack-site-builder 1.23.1 → 1.24.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 (47) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +16 -1
  3. package/markdown.mjs +27 -0
  4. package/package.json +1 -1
  5. package/src/components/CardsHome.astro +2 -2
  6. package/src/components/DeckView.astro +2 -3
  7. package/src/components/Home.astro +2 -2
  8. package/src/i18n/ui.ts +63 -12
  9. package/src/layouts/BaseLayout.astro +10 -6
  10. package/src/lib/articles.ts +3 -2
  11. package/src/lib/concepts.ts +3 -2
  12. package/src/lib/courses.ts +3 -2
  13. package/src/lib/locales.ts +30 -0
  14. package/src/lib/md-tables.ts +21 -0
  15. package/src/lib/pages.ts +3 -2
  16. package/src/lib/papers.ts +3 -2
  17. package/src/lib/products.ts +3 -2
  18. package/src/lib/project.ts +14 -11
  19. package/src/lib/samples.ts +2 -1
  20. package/src/lib/slides.ts +3 -2
  21. package/src/lib/stacks.ts +3 -2
  22. package/src/pages/[...lang]/[page].astro +2 -2
  23. package/src/pages/[...lang]/article/[...id].astro +2 -2
  24. package/src/pages/[...lang]/article/category/[id].astro +2 -2
  25. package/src/pages/[...lang]/article/index.astro +2 -3
  26. package/src/pages/[...lang]/categories/[id].astro +2 -2
  27. package/src/pages/[...lang]/concept/[...id].astro +2 -2
  28. package/src/pages/[...lang]/concept/category/[id].astro +2 -2
  29. package/src/pages/[...lang]/concept/index.astro +2 -3
  30. package/src/pages/[...lang]/course/[...id].astro +2 -2
  31. package/src/pages/[...lang]/course/category/[id].astro +2 -2
  32. package/src/pages/[...lang]/course/index.astro +2 -3
  33. package/src/pages/[...lang]/glossary.astro +2 -3
  34. package/src/pages/[...lang]/index.astro +2 -2
  35. package/src/pages/[...lang]/paper/[...id].astro +2 -2
  36. package/src/pages/[...lang]/paper/category/[id].astro +2 -2
  37. package/src/pages/[...lang]/paper/index.astro +2 -3
  38. package/src/pages/[...lang]/products/[...id].astro +2 -2
  39. package/src/pages/[...lang]/products/index.astro +2 -3
  40. package/src/pages/[...lang]/rss.xml.ts +2 -3
  41. package/src/pages/[...lang]/sample/[folder].astro +2 -2
  42. package/src/pages/[...lang]/sample/index.astro +2 -3
  43. package/src/pages/[...lang]/slides/index.astro +2 -3
  44. package/src/pages/[...lang]/stack/[...id].astro +2 -2
  45. package/src/pages/[...lang]/tags/[tag].astro +2 -2
  46. package/src/pages/[...lang]/vendors/[vendor].astro +2 -2
  47. package/src/styles/global.css +97 -4
package/CHANGELOG.md CHANGED
@@ -11,6 +11,93 @@ 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.24.0] - 2026-08-25
15
+
16
+ Adding a locale was already meant to be site config only — the README said so —
17
+ but four strings never got the memo, so a site shipping a language beyond the
18
+ theme's en/ko had no way to translate its pricing chips, difficulty tooltips or
19
+ license labels, and no way to give the site itself a per-language name. This
20
+ release closes those, along with two hardcodings that assumed the default locale
21
+ is `en` and that a locale code is two bare letters.
22
+
23
+ ### Added
24
+
25
+ - **Sites supply the enum label tables** — `site.pricingLabels` (the `pricing`
26
+ frontmatter enum), `site.difficultyLabels` (course `level` 1–5) and
27
+ `site.licenseLabels` (descriptive licenses like `proprietary`) now feed
28
+ `pricingLabel()` / `difficultyLabel()` / `licenseLabel()` with the same
29
+ precedence `useTranslations` already used: the site's table for this locale,
30
+ the theme's, then both again for the default locale. Each is
31
+ `{ <code>: { <key>: <label> } }`, merged per key, so a site lists only what it
32
+ needs — and a site can retranslate a locale the theme ships, since the site's
33
+ entry wins. Previously these three read en/ko literals with no override point,
34
+ which left a third locale's chips and tooltips rendering in the default
35
+ locale's language with nothing a site could do about it.
36
+ - **`site.name` can be per-locale** — either one string for every locale (as
37
+ before, unchanged) or a record like
38
+ `{ en: 'Advanced Algorithms', ko: '고급 알고리즘' }`, resolved through the new
39
+ `siteName(lang)` export and falling back to the default locale then any entry.
40
+ Every place the name appears — page titles, the header wordmark and its
41
+ aria-label, the RSS channel title, the catalog and cards homepages, the deck
42
+ view — now goes through it.
43
+
44
+ ### Fixed
45
+
46
+ - **The language auto-detect redirect works on non-`en`-default sites** — the
47
+ root home gated its redirect script on `lang === 'en'` instead of
48
+ `lang === defaultLang`, so a site whose first locale is anything else (a
49
+ ko-default lecture site, say) shipped the script on no page at all and never
50
+ auto-detected. The redirect now lands on whichever locale's home is served
51
+ without a prefix.
52
+ - **Locale codes that aren't two bare letters** — every collection derived its
53
+ slugs with a copy of `/^[a-z]{2}\//` and filtered its listings with
54
+ `id.startsWith(`${lang}/`)`, so a `zh-CN` or `pt-BR` locale produced slugs
55
+ with the locale still glued on and listings that matched nothing. Both now go
56
+ through `stripLocale()` / `inLocale()` in `lib/locales.ts`, built from the
57
+ site's own locale codes. The prefix is compared lowercased because Astro's
58
+ glob loader lowercases every path segment when it derives content ids, so the
59
+ content folder may be cased either way (`stacks/zh-CN/` or `stacks/zh-cn/`).
60
+
61
+ ### Changed
62
+
63
+ - **The playground demos a fourth locale, `zh-CN`** — deliberately a code that
64
+ is neither two letters nor all-lowercase, with its own `pricingLabels`,
65
+ `difficultyLabels`, `licenseLabels`, a per-locale `site.name` and a partial
66
+ `site.ui` table, so the fallbacks and the slug derivation stay covered by the
67
+ build.
68
+
69
+ ## [1.23.2] - 2026-08-06
70
+
71
+ ### Fixed
72
+
73
+ - **Wide tables scroll instead of cramming their cells** — a table wider than its
74
+ column had no way out: it needs `display: table` for real column sizing, which
75
+ makes `overflow-x` on the table itself a no-op, so every cell was squeezed into
76
+ a stack of wrapped lines (a 3-column table with 1561px of natural content
77
+ rendered 920px wide and 3.6x its natural height, breaking code samples
78
+ mid-token). Every rendered table now sits in a `.aas-table-scroll` box that
79
+ takes the scrolling while the table keeps its natural widths — on both render
80
+ paths: `rehypeTableScroll` for content collections, `withTableScroll`
81
+ (`lib/md-tables.ts`) for the MarkdownIt paths behind READMEs and sample
82
+ descriptions. In a reading column the wrapper shrinks to the table and centers,
83
+ so a narrow table keeps its own width instead of being stretched; on slides a
84
+ wide table slides sideways. A table that arrives without a wrapper (raw HTML in
85
+ MDX, say) still behaves exactly as before.
86
+ - **Slide diagrams render full-size** — the generic caps (`pre` 62vh, svg 52vh)
87
+ don't know how much room the slide fill rules actually handed the diagram — on
88
+ a full-height slide that box is ~78vh — so a tall flowchart was scaled to fit
89
+ 52vh and the unused width was letterboxed as empty margins either side.
90
+ Diagrams now size against the box they were given (measured on a 1958x2000
91
+ flowchart: 0.34 -> 0.75 scale). `compact`, `.aas-split.scroll` and `scroll-x`
92
+ manage their own overflow and are excluded, so they render exactly as before.
93
+ - **Mermaid subgraph titles stop overlapping** — mermaid draws labels as real
94
+ HTML in a `<foreignObject>` and sizes nodes and cluster frames from the
95
+ measured box; mermaid 11 wraps label text in a `<p>`, which inherited the
96
+ page's paragraph typography (26px/41px on a slide) while mermaid reserves only
97
+ ~25px for a cluster title, so every subgraph title overlapped the first node
98
+ inside it by ~16px. Label typography is pinned back to mermaid's own so its
99
+ measurements hold.
100
+
14
101
  ## [1.23.1] - 2026-07-29
15
102
 
16
103
  ### Fixed
@@ -433,6 +520,8 @@ catalog sites from a thin content-only repository.
433
520
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
434
521
  consuming site for developing and previewing the theme on its own.
435
522
 
523
+ [1.24.0]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.23.2...v1.24.0
524
+ [1.23.2]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.23.1...v1.23.2
436
525
  [1.23.1]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.23.0...v1.23.1
437
526
  [1.23.0]: https://github.com/CodeComposeStudio/stack-site-builder/compare/v1.22.0...v1.23.0
438
527
  [1.19.3]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.19.2...v1.19.3
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
 
package/markdown.mjs CHANGED
@@ -94,6 +94,32 @@ function remarkMermaid() {
94
94
  return (/** @type {any} */ tree) => walk(tree);
95
95
  }
96
96
 
97
+ // Wrap every table in a horizontal scroll box. A table can't scroll itself: the
98
+ // moment it needs `display: table` for real column sizing (and a rounded frame),
99
+ // `overflow-x` on the table is dead, so a table wider than its column has no way
100
+ // out but to squeeze every cell into a stack of wrapped lines — a 120px-tall
101
+ // table becomes 430px of cramped text. The wrapper takes the scrolling, the
102
+ // table keeps its natural widths (see `.aas-table-scroll` in global.css).
103
+ function rehypeTableScroll() {
104
+ /** @param {any} node */
105
+ const walk = (node) => {
106
+ if (!node.children) return;
107
+ node.children.forEach((/** @type {any} */ child, /** @type {number} */ i) => {
108
+ if (child.type === 'element' && child.tagName === 'table') {
109
+ node.children[i] = {
110
+ type: 'element',
111
+ tagName: 'div',
112
+ properties: { className: ['aas-table-scroll'] },
113
+ children: [child],
114
+ };
115
+ } else {
116
+ walk(child);
117
+ }
118
+ });
119
+ };
120
+ return (/** @type {any} */ tree) => walk(tree);
121
+ }
122
+
97
123
  // Slide directives (needs remarkDirective, which runs first). Two are handled:
98
124
  //
99
125
  // :::cols columns, separated by `---`:
@@ -405,6 +431,7 @@ export function aasMarkdown({ glossary, locales = ['en', 'ko'], defaultLocale =
405
431
  rehypePlugins: [
406
432
  rehypeSlug,
407
433
  rehypeHeadingAnchors,
434
+ rehypeTableScroll,
408
435
  [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
409
436
  ],
410
437
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.23.1",
4
+ "version": "1.24.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": {
@@ -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
  }
@@ -2,7 +2,7 @@
2
2
  import '../styles/global.css';
3
3
  import { site } from '@aas-data/site';
4
4
  import { getRelativeLocaleUrl } from 'astro:i18n';
5
- import { useTranslations, languages, type Lang } from '../i18n/ui';
5
+ import { useTranslations, languages, siteName, defaultLang, type Lang } from '../i18n/ui';
6
6
  import LanguageSwitcher from '../components/LanguageSwitcher.astro';
7
7
  import ThemeToggle from '../components/ThemeToggle.astro';
8
8
  import BackToTop from '../components/BackToTop.astro';
@@ -55,7 +55,7 @@ const baseNoSlash = base.replace(/\/$/, '');
55
55
 
56
56
  // Header brand: `site.header = { logo?: '/path.png', logoInvert?: bool,
57
57
  // 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).
58
+ // wordmark (logo-only brand — the link keeps siteName(lang) as its aria-label).
59
59
  const header = (site as { header?: { logo?: string; logoInvert?: boolean; name?: boolean } })
60
60
  .header;
61
61
  const brandLogo = header?.logo;
@@ -65,7 +65,11 @@ const brandName = header?.name !== false;
65
65
  const localeHomes = Object.fromEntries(
66
66
  (Object.keys(languages) as Lang[]).map((c) => [c, getRelativeLocaleUrl(c, '')]),
67
67
  );
68
- const isDefaultHome = path === '' && lang === 'en';
68
+ // The auto-detect redirect belongs on the ROOT home — the one page served
69
+ // without a locale prefix — which is the default locale's, whatever it is. This
70
+ // read 'en' until 1.24.0, so a ko-default (or any non-en-default) site never
71
+ // redirected at all.
72
+ const isDefaultHome = path === '' && lang === defaultLang;
69
73
 
70
74
  // Standalone pages (the `pages` collection) that opt into a header-nav link,
71
75
  // e.g. an About/소개 page. Rendered as their own nav items alongside the
@@ -216,7 +220,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
216
220
  <link
217
221
  rel="alternate"
218
222
  type="application/rss+xml"
219
- title={`${site.name} — ${t('blog.title')}`}
223
+ title={`${siteName(lang)} — ${t('blog.title')}`}
220
224
  href={getRelativeLocaleUrl(lang, 'rss.xml')}
221
225
  />
222
226
  )
@@ -287,7 +291,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
287
291
  <a
288
292
  href={home}
289
293
  data-keep-filters
290
- aria-label={site.name}
294
+ aria-label={siteName(lang)}
291
295
  class="flex min-w-0 items-center gap-1.5 text-base font-bold tracking-tight whitespace-nowrap no-underline sm:text-lg"
292
296
  >
293
297
  {
@@ -302,7 +306,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
302
306
  )
303
307
  }
304
308
  {/* 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>}
309
+ {brandName && <span class="hidden truncate min-[600px]:inline">{siteName(lang)}</span>}
306
310
  </a>
307
311
  {/* Section links are icon-only (labels live in aria-label). On phones
308
312
  (<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 ?? '') ||
@@ -14,3 +14,33 @@ export const allLocales = Object.keys(languages) as Lang[];
14
14
  /** The `[...lang]` param for a locale: `undefined` for the default, else the code. */
15
15
  export const langParam = (lang: Lang): string | undefined =>
16
16
  lang === defaultLang ? undefined : lang;
17
+
18
+ /**
19
+ * Content-id helpers. Every collection is locale-partitioned by folder
20
+ * (`stacks/<code>/<slug>.mdx`), so an entry's id carries a `<code>/` prefix
21
+ * that listings filter on and slugs strip.
22
+ *
23
+ * Two details make the prefix worth deriving instead of hardcoding: Astro's
24
+ * glob loader runs every path segment through github-slugger, which
25
+ * LOWERCASES it, so a `zh-CN` folder yields the id `zh-cn/…`; and a locale
26
+ * code isn't necessarily two bare letters (`zh-CN`, `pt-BR`). Both are handled
27
+ * here once, so adding such a locale stays a site-config change.
28
+ */
29
+
30
+ /** The `<locale>/` prefix an entry id carries for `lang`. */
31
+ export const contentPrefix = (lang: Lang): string => `${lang.toLowerCase()}/`;
32
+
33
+ /** Whether a content id belongs to `lang`. */
34
+ export const inLocale = (id: string, lang: Lang): boolean => id.startsWith(contentPrefix(lang));
35
+
36
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
+ // Longest code first, so `zh` can't shadow `zh-cn` on a site shipping both.
38
+ const localePrefixRe = new RegExp(
39
+ `^(?:${allLocales
40
+ .map((l) => escapeRe(l.toLowerCase()))
41
+ .sort((a, b) => b.length - a.length)
42
+ .join('|')})/`,
43
+ );
44
+
45
+ /** An entry id with its `<locale>/` prefix removed — the basis of every slug. */
46
+ export const stripLocale = (id: string): string => id.replace(localePrefixRe, '');
@@ -0,0 +1,21 @@
1
+ import type MarkdownIt from 'markdown-it';
2
+
3
+ /**
4
+ * Wrap every rendered table in a `.aas-table-scroll` box, matching what
5
+ * `rehypeTableScroll` (markdown.mjs) does for content collections. A table
6
+ * cannot scroll itself — it needs `display: table` for real column sizing,
7
+ * which makes `overflow-x` on the table a no-op — so without the wrapper a
8
+ * README table wider than its column has no escape but to wrap every cell.
9
+ *
10
+ * Only needed on MarkdownIt instances that RENDER (READMEs, sample
11
+ * descriptions); the ones that merely `parse()` for metadata can skip it.
12
+ */
13
+ export function withTableScroll(md: MarkdownIt): MarkdownIt {
14
+ const base = md.renderer.rules.table_open ?? ((t, i, o, _e, s) => s.renderToken(t, i, o));
15
+ md.renderer.rules.table_open = (tokens, idx, opts, env, self) =>
16
+ `<div class="aas-table-scroll">${base(tokens, idx, opts, env, self)}`;
17
+ const baseClose = md.renderer.rules.table_close ?? ((t, i, o, _e, s) => s.renderToken(t, i, o));
18
+ md.renderer.rules.table_close = (tokens, idx, opts, env, self) =>
19
+ `${baseClose(tokens, idx, opts, env, self)}</div>`;
20
+ return md;
21
+ }
package/src/lib/pages.ts CHANGED
@@ -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 PageEntry = CollectionEntry<'pages'>;
5
6
 
6
7
  /** The url slug of a page, i.e. its id with the `<lang>/` prefix removed. */
7
8
  export function pageSlugOf(entry: PageEntry): string {
8
- return entry.id.replace(/^[a-z]{2}\//, '');
9
+ return stripLocale(entry.id);
9
10
  }
10
11
 
11
12
  /** Standalone pages for one locale, in `order` (then title) order. */
12
13
  export async function getPages(lang: Lang): Promise<PageEntry[]> {
13
14
  const all = await getCollection('pages');
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) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title),
18
19
  );
package/src/lib/papers.ts CHANGED
@@ -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 PaperEntry = CollectionEntry<'papers'>;
5
6
 
6
7
  /** The url slug of a paper, i.e. its id with the `<lang>/` prefix removed. */
7
8
  export function paperSlugOf(entry: PaperEntry): string {
8
- return entry.id.replace(/^[a-z]{2}\//, '');
9
+ return stripLocale(entry.id);
9
10
  }
10
11
 
11
12
  /** Published papers for one locale — newest publication year first, then title. */
12
13
  export async function getPapers(lang: Lang): Promise<PaperEntry[]> {
13
14
  const all = await getCollection('papers');
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) => (b.data.year ?? 0) - (a.data.year ?? 0) || a.data.title.localeCompare(b.data.title),
18
19
  );
@@ -1,5 +1,6 @@
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 ProductEntry = CollectionEntry<'products'>;
5
6
 
@@ -7,14 +8,14 @@ export type ProductEntry = CollectionEntry<'products'>;
7
8
  * nested (`flowstate-ai/privacy`), which the catch-all route renders at the
8
9
  * matching subpath. */
9
10
  export function productSlugOf(entry: ProductEntry): string {
10
- return entry.id.replace(/^[a-z]{2}\//, '');
11
+ return stripLocale(entry.id);
11
12
  }
12
13
 
13
14
  /** Published product pages for one locale, in `order` (then title) order. */
14
15
  export async function getProducts(lang: Lang): Promise<ProductEntry[]> {
15
16
  const all = await getCollection('products');
16
17
  return all
17
- .filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
18
+ .filter((e) => inLocale(e.id, lang) && !e.data.draft)
18
19
  .sort((a, b) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title));
19
20
  }
20
21