stack-site-builder 1.17.4 → 1.18.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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,26 @@ 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.18.0] - 2026-07-23
15
+
16
+ ### Added
17
+
18
+ - **`papers` collection — the reading room** — an opt-in section
19
+ (`sections: { papers: true }` + `src/data/paper-categories.ts`, with an
20
+ optional `paperCategoryMap` for build-time category validation) for sites
21
+ that review the literature behind their stack. Frontmatter carries the
22
+ full author list (cards abbreviate to "et al."), venue/year, arXiv /
23
+ publisher / released-code links, and an **open-source availability badge**
24
+ (`openSource` + `repo`); `tools` cross-links catalog stacks on the detail
25
+ page. Routes mirror concepts: `/paper/`, `/paper/<id>/`,
26
+ `/paper/category/<id>/`, with a category-grouped index.
27
+ - **Private entries are unlisted by default** — index listings (catalog
28
+ home, categories, tags, vendors, blog, concepts, courses, papers,
29
+ products, slides) now skip `private` entries unless they opt in with
30
+ `listed: true`, which shows the familiar locked teaser card. Unlisted
31
+ private entries still build and stay reachable via direct links and the
32
+ related sections on detail pages — the discovery path for gated content.
33
+
14
34
  ## [1.17.4] - 2026-07-22
15
35
 
16
36
  ### Changed
@@ -23,6 +43,12 @@ here is a plain version bump they pull in.
23
43
  its slide leaves the screen — the deck never carries a live embed between
24
44
  slides.
25
45
 
46
+ ### Fixed
47
+
48
+ - **Table label columns wrapped** — in a wide table the first column got
49
+ squeezed to its minimum width, breaking short labels ("Part 1") onto two
50
+ lines. The first column of prose and slide tables no longer wraps.
51
+
26
52
  ## [1.17.3] - 2026-07-22
27
53
 
28
54
  ### Fixed
@@ -277,6 +303,7 @@ catalog sites from a thin content-only repository.
277
303
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
278
304
  consuming site for developing and previewing the theme on its own.
279
305
 
306
+ [1.18.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.17.4...v1.18.0
280
307
  [1.17.4]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.17.3...v1.17.4
281
308
  [1.17.3]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.17.2...v1.17.3
282
309
  [1.17.2]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.17.1...v1.17.2
package/README.md CHANGED
@@ -38,9 +38,9 @@ export const collections = defineAasCollections({ categoryMap });
38
38
  | --- | --- |
39
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 |
40
40
  | `src/data/categories.ts` | The tool-catalog category tree (validated against content) |
41
- | `src/data/concept-categories.ts` · `article-categories.ts` · `course-categories.ts` · `product-categories.ts` (opt-in) | Taxonomies for concepts / articles / courses / products |
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 |
43
- | `src/content/{stacks,concepts,courses,products,articles,slides}/` | The content, one MDX file per locale |
43
+ | `src/content/{stacks,concepts,courses,products,papers,articles,slides}/` | The content, one MDX file per locale |
44
44
  | `src/content/pages/` | Standalone top-level pages (e.g. an About/소개), rendered at `/<slug>/` and optionally linked in the header nav |
45
45
  | `public/` · `samples/` | Logos/favicons and runnable sample projects |
46
46
 
@@ -72,7 +72,7 @@ The rest are opt-out — **concepts, articles, samples, slides, glossary** and t
72
72
  standalone **pages** collection (About/소개, …) — so a site can ship only what it
73
73
  needs. Turning one off removes both its routes and its header-nav item. (`pages`
74
74
  also has finer control: each page's `nav` / `draft` frontmatter, or simply not
75
- authoring it.) **courses** and **products** are the opt-IN sections — see below.
75
+ authoring it.) **courses**, **products** and **papers** are the opt-IN sections — see below.
76
76
 
77
77
  Declare the toggles once in `src/data/site.ts` and forward them to the theme in
78
78
  astro.config (which needs them to skip route injection). Import `SectionKey` from
@@ -166,6 +166,16 @@ Localized values are either one string or a per-locale record with
166
166
  default-locale fallback. On a cards home the header's Browse link (which
167
167
  anchors into the catalog) hides itself.
168
168
 
169
+ ## Papers (opt-in)
170
+
171
+ A reading room for the literature behind the stack: each entry is one paper
172
+ with its full author list, venue/year, arXiv / publisher / code links and an
173
+ open-source availability badge; the body is your reading of it. `tools`
174
+ cross-links catalog stacks. Enable with `sections: { papers: true }` plus
175
+ `src/data/paper-categories.ts` (exporting `paperTree` / `paperCatOf`), and
176
+ optionally pass `paperCategoryMap` to `defineAasCollections`. Routes mirror
177
+ concepts: `/paper/`, `/paper/<id>/`, `/paper/category/<id>/`.
178
+
169
179
  ## Body components
170
180
 
171
181
  Reusable MDX-body components, importable from any collection's content:
@@ -196,8 +206,10 @@ teaser: A public one-liner shown on cards and above the login form. # optional
196
206
 
197
207
  The body ships **encrypted** (AES-256-GCM, key wrapped per user with PBKDF2) and
198
208
  is decrypted in the browser after login — no server needed, works on any static
199
- host. Listings show only the title + 🔒 (+ teaser); private URLs get `noindex`
200
- and stay out of the sitemap. Users and keys come from env vars (see
209
+ host. By default a private entry stays OUT of index listings (reachable via
210
+ direct links and the related sections on detail pages); set `listed: true` to
211
+ show it as a locked teaser card (title + 🔒 + teaser). Private URLs get
212
+ `noindex` and stay out of the sitemap. Users and keys come from env vars (see
201
213
  `playground/.env.sample`): `AAS_PRIVATE_USERS` (`id:password,…`),
202
214
  `AAS_PRIVATE_MASTER_SECRET` (rotate it to log every device out) and
203
215
  `AAS_PRIVATE_SESSION_DAYS`. Set them in `.env` locally or as CI secrets.
package/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export type SectionKey =
8
8
  | 'articles'
9
9
  | 'courses'
10
10
  | 'products'
11
+ | 'papers'
11
12
  | 'samples'
12
13
  | 'slides'
13
14
  | 'glossary'
package/index.mjs CHANGED
@@ -44,6 +44,9 @@ const PAGES = [
44
44
  'concept/[...id].astro',
45
45
  'concept/category/[id].astro',
46
46
  'course/index.astro',
47
+ 'paper/index.astro',
48
+ 'paper/[...id].astro',
49
+ 'paper/category/[id].astro',
47
50
  'course/[...id].astro',
48
51
  'course/category/[id].astro',
49
52
  'glossary.astro',
@@ -72,6 +75,7 @@ function sectionOf(file) {
72
75
  if (file.startsWith('concept/')) return 'concepts';
73
76
  if (file.startsWith('article/')) return 'articles';
74
77
  if (file.startsWith('course/')) return 'courses';
78
+ if (file.startsWith('paper/')) return 'papers';
75
79
  if (file.startsWith('products/')) return 'products';
76
80
  if (file.startsWith('sample/')) return 'samples';
77
81
  if (file.startsWith('slides/')) return 'slides';
@@ -85,7 +89,7 @@ function sectionOf(file) {
85
89
  // when the site passes `{ <key>: true }`. Both need site-side data
86
90
  // (src/data/{course,product}-categories.ts), so a theme upgrade alone must
87
91
  // not enable them.
88
- const OPT_IN_SECTIONS = new Set(['courses', 'products']);
92
+ const OPT_IN_SECTIONS = new Set(['courses', 'products', 'papers']);
89
93
 
90
94
  /**
91
95
  * @param {object} opts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.17.4",
4
+ "version": "1.18.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": {
@@ -8,6 +8,7 @@ import ArrowUpRight from './ArrowUpRight.astro';
8
8
  const PAGE = 12;
9
9
  import { articleTree, articleCatOf } from '@aas-data/article-categories';
10
10
  import { getArticles, articleSlugOf, type ArticleEntry } from '../lib/articles';
11
+ import { listedInIndex } from '../lib/listing';
11
12
  import { useTranslations, type Lang } from '../i18n/ui';
12
13
 
13
14
  // Doubles as the writing index (no `categoryId` → all articles, grouped by
@@ -20,7 +21,7 @@ interface Props {
20
21
 
21
22
  const { lang, categoryId } = Astro.props;
22
23
  const t = useTranslations(lang);
23
- const all = await getArticles(lang);
24
+ const all = (await getArticles(lang)).filter((e) => listedInIndex(e.data));
24
25
  const catUrl = (cid: string) => getRelativeLocaleUrl(lang, `article/category/${cid}/`);
25
26
 
26
27
  const itemsIn = (cid: string): ArticleEntry[] => {
@@ -7,6 +7,7 @@ import ArrowUpRight from './ArrowUpRight.astro';
7
7
  import { categoryMap, childrenOf, pathOf } from '@aas-data/categories';
8
8
  import { getStacks, getStacksByCategory, slugOf, type StackEntry } from '../lib/stacks';
9
9
  import { deriveFacets } from '../lib/facets';
10
+ import { listedInIndex } from '../lib/listing';
10
11
  import { useTranslations, type Lang } from '../i18n/ui';
11
12
 
12
13
  interface Props {
@@ -31,16 +32,21 @@ const crumbs = [
31
32
 
32
33
  const children = childrenOf(id);
33
34
  // Stacks attached directly to this node (vs. nested under a subcategory).
34
- const direct = (await getStacksByCategory(lang, id)).filter((s) => s.data.category === id);
35
+ const direct = (await getStacksByCategory(lang, id)).filter(
36
+ (s) => s.data.category === id && listedInIndex(s.data),
37
+ );
35
38
  // Each child subcategory with its own rolled-up stacks.
36
39
  const childGroups = await Promise.all(
37
- children.map(async (c) => ({ cat: c, items: await getStacksByCategory(lang, c.id) })),
40
+ children.map(async (c) => ({
41
+ cat: c,
42
+ items: (await getStacksByCategory(lang, c.id)).filter((s) => listedInIndex(s.data)),
43
+ })),
38
44
  );
39
45
 
40
46
  // Filter facets are built from the WHOLE catalog (not just this page's tools) so
41
47
  // they're identical on every page — a sort/filter carried in from elsewhere can
42
48
  // always be restored here, even if no tool in this category has that value.
43
- const facets = deriveFacets(await getStacks(lang), lang);
49
+ const facets = deriveFacets((await getStacks(lang)).filter((e) => listedInIndex(e.data)), lang);
44
50
 
45
51
  const cardProps = (e: StackEntry) => ({
46
52
  slug: slugOf(e),
@@ -8,6 +8,7 @@ import ArrowUpRight from './ArrowUpRight.astro';
8
8
  const PAGE = 12;
9
9
  import { conceptTree, conceptCatOf } from '@aas-data/concept-categories';
10
10
  import { getConcepts, conceptSlugOf, type ConceptEntry } from '../lib/concepts';
11
+ import { listedInIndex } from '../lib/listing';
11
12
  import { useTranslations, type Lang } from '../i18n/ui';
12
13
 
13
14
  // Doubles as the concept index (no `categoryId` → all concepts, grouped by
@@ -22,7 +23,7 @@ const { lang, categoryId } = Astro.props;
22
23
  const t = useTranslations(lang);
23
24
  // Default order is most-recently-updated first (matches the ListControls
24
25
  // default), so the server render and the client sort agree — no reflow on load.
25
- const all = (await getConcepts(lang)).sort(
26
+ const all = (await getConcepts(lang)).filter((e) => listedInIndex(e.data)).sort(
26
27
  (a, b) =>
27
28
  (b.data.updated?.getTime() ?? 0) - (a.data.updated?.getTime() ?? 0) ||
28
29
  a.data.title.localeCompare(b.data.title),
@@ -8,6 +8,7 @@ import ArrowUpRight from './ArrowUpRight.astro';
8
8
  const PAGE = 12;
9
9
  import { courseTree, courseCatOf } from '@aas-data/course-categories';
10
10
  import { getCourses, courseSlugOf, type CourseEntry } from '../lib/courses';
11
+ import { listedInIndex } from '../lib/listing';
11
12
  import { useTranslations, type Lang } from '../i18n/ui';
12
13
 
13
14
  // Doubles as the course index (no `categoryId` → all courses, grouped by
@@ -22,7 +23,7 @@ const { lang, categoryId } = Astro.props;
22
23
  const t = useTranslations(lang);
23
24
  // getCourses() order (manual `order` desc, then date) agrees with the client
24
25
  // "recent" comparator over CourseCard's data-date keys — no reflow on load.
25
- const all = await getCourses(lang);
26
+ const all = (await getCourses(lang)).filter((e) => listedInIndex(e.data));
26
27
  const catUrl = (cid: string) => getRelativeLocaleUrl(lang, `course/category/${cid}/`);
27
28
 
28
29
  const itemsIn = (cid: string): CourseEntry[] => {
@@ -7,6 +7,7 @@ import { rootCategories, descendantIds } from '@aas-data/categories';
7
7
  import { getStacks, slugOf } from '../lib/stacks';
8
8
  import { site } from '@aas-data/site';
9
9
  import { deriveFacets } from '../lib/facets';
10
+ import { listedInIndex } from '../lib/listing';
10
11
  import { useTranslations, type Lang } from '../i18n/ui';
11
12
 
12
13
  interface Props {
@@ -15,7 +16,7 @@ interface Props {
15
16
 
16
17
  const { lang } = Astro.props;
17
18
  const t = useTranslations(lang);
18
- const entries = await getStacks(lang);
19
+ const entries = (await getStacks(lang)).filter((e) => listedInIndex(e.data));
19
20
 
20
21
  // Default order is alphabetical (matches the client's default sort, so no flash).
21
22
  // Group by top-level category, rolling up every subcategory in its subtree.
@@ -0,0 +1,85 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+ import { getRelativeLocaleUrl } from 'astro:i18n';
4
+ import { useTranslations, type Lang } from '../i18n/ui';
5
+ import { shortAuthors } from '../lib/papers';
6
+ import { inlineMd } from '../lib/inline-md';
7
+ import type { ImageMetadata } from 'astro';
8
+
9
+ interface Props {
10
+ lang: Lang;
11
+ slug: string;
12
+ title: string;
13
+ description: string;
14
+ authors: string[];
15
+ year?: number;
16
+ venue?: string;
17
+ openSource?: boolean;
18
+ tags: string[];
19
+ image?: ImageMetadata;
20
+ imageAlt?: string;
21
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
22
+ isPrivate?: boolean;
23
+ teaser?: string;
24
+ }
25
+
26
+ const {
27
+ lang,
28
+ slug,
29
+ title,
30
+ description,
31
+ authors,
32
+ year,
33
+ venue,
34
+ openSource = false,
35
+ tags,
36
+ image,
37
+ imageAlt,
38
+ isPrivate = false,
39
+ teaser,
40
+ } = Astro.props;
41
+ const t = useTranslations(lang);
42
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
43
+ ---
44
+
45
+ <a
46
+ href={getRelativeLocaleUrl(lang, `paper/${slug}/`)}
47
+ data-name={title.toLowerCase()}
48
+ data-date={year ? String(year) : ''}
49
+ class="aas-lift block overflow-hidden rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
50
+ >
51
+ {image && <Image src={image} alt={imageAlt ?? title} class="aspect-[16/9] w-full object-cover" />}
52
+ <div class="p-5">
53
+ <p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-[var(--aas-muted)]">
54
+ {venue && <span>{venue}</span>}
55
+ {venue && year && <span>·</span>}
56
+ {year && <span>{year}</span>}
57
+ {
58
+ openSource && (
59
+ <span class="rounded-full border border-[var(--aas-accent)] px-2 py-0.5 text-[var(--aas-accent)]">
60
+ {t('paper.openSource')}
61
+ </span>
62
+ )
63
+ }
64
+ </p>
65
+ <h3 class="mt-1 text-lg font-semibold text-[var(--aas-text)]">
66
+ {isPrivate && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{title}
67
+ </h3>
68
+ {authors.length > 0 && <p class="mt-1 text-sm text-[var(--aas-muted)]">{shortAuthors(authors)}</p>}
69
+ <p
70
+ class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
71
+ set:html={inlineMd(shownDesc)}
72
+ />
73
+ {
74
+ tags.length > 0 && (
75
+ <div class="mt-3 flex flex-wrap gap-1.5">
76
+ {tags.slice(0, 4).map((tag) => (
77
+ <span class="rounded-full border border-[var(--aas-border)] px-2 py-0.5 text-xs text-[var(--aas-muted)]">
78
+ {tag}
79
+ </span>
80
+ ))}
81
+ </div>
82
+ )
83
+ }
84
+ </div>
85
+ </a>
@@ -0,0 +1,123 @@
1
+ ---
2
+ import { render } from 'astro:content';
3
+ import { getRelativeLocaleUrl } from 'astro:i18n';
4
+ import { getPapers, paperSlugOf, type PaperEntry } from '../lib/papers';
5
+ import { inlineMd } from '../lib/inline-md';
6
+ import { Image } from 'astro:assets';
7
+ import { useTranslations, type Lang } from '../i18n/ui';
8
+ import { formatDate } from '../lib/dates';
9
+ import MermaidLoader from './MermaidLoader.astro';
10
+ import TocRail from './TocRail.astro';
11
+ import Breadcrumb from './Breadcrumb.astro';
12
+ import ArrowUpRight from './ArrowUpRight.astro';
13
+ import { paperTree, paperCatOf } from '@aas-data/paper-categories';
14
+ import RelatedPapers from './RelatedPapers.astro';
15
+ import RelatedTools from './RelatedTools.astro';
16
+
17
+ interface Props {
18
+ entry: PaperEntry;
19
+ lang: Lang;
20
+ }
21
+
22
+ const { entry, lang } = Astro.props;
23
+ const t = useTranslations(lang);
24
+ const { Content, headings } = await render(entry);
25
+ const data = entry.data;
26
+ const tocItems = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
27
+ const bodyHasMermaid = (entry.body ?? '').includes('```mermaid');
28
+
29
+ // Sibling papers linked from frontmatter (mutual cross-links, like concepts).
30
+ const papers = await getPapers(lang);
31
+ const paperBySlug = new Map(papers.map((c) => [paperSlugOf(c), c]));
32
+ const relatedPapers = data.related
33
+ .map((sl) => paperBySlug.get(sl))
34
+ .filter(Boolean) as PaperEntry[];
35
+
36
+ // External resource buttons: arXiv, the publisher page, and released code.
37
+ const links = [
38
+ data.arxiv && { label: t('paper.arxiv'), href: data.arxiv },
39
+ data.paperUrl && { label: t('paper.paper'), href: data.paperUrl },
40
+ data.repo && { label: t('paper.code'), href: data.repo },
41
+ ].filter(Boolean) as { label: string; href: string }[];
42
+
43
+ // Breadcrumb: All papers › category › subcategory… (each segment linked).
44
+ const crumbs = [
45
+ { label: t('paper.backToPapers'), href: getRelativeLocaleUrl(lang, 'paper/') },
46
+ ...paperTree.pathOf(paperCatOf(data.category)).map((c) => ({
47
+ label: c.label[lang],
48
+ href: getRelativeLocaleUrl(lang, `paper/category/${c.id}/`),
49
+ })),
50
+ ];
51
+ ---
52
+
53
+ <Breadcrumb segments={crumbs} />
54
+
55
+ <header class="mt-4 pb-6">
56
+ <h1 class="text-3xl font-bold tracking-tight">{data.title}</h1>
57
+ {
58
+ data.authors.length > 0 && (
59
+ <p class="mt-2 text-sm text-[var(--aas-muted)]">
60
+ <span class="font-semibold">{t('paper.authors')}:</span> {data.authors.join(', ')}
61
+ </p>
62
+ )
63
+ }
64
+ <p class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--aas-muted)]">
65
+ {data.venue && <span>{data.venue}</span>}
66
+ {data.year && <span>{data.year}</span>}
67
+ {
68
+ data.openSource && (
69
+ <span class="rounded-full border border-[var(--aas-accent)] px-2 py-0.5 text-xs text-[var(--aas-accent)]">
70
+ {t('paper.openSource')}
71
+ </span>
72
+ )
73
+ }
74
+ {data.version && <span>{t('meta.docVersion')} {data.version}</span>}
75
+ {
76
+ data.updated && (
77
+ <span>
78
+ {t('paper.updated')} {formatDate(data.updated, lang)}
79
+ </span>
80
+ )
81
+ }
82
+ </p>
83
+ {
84
+ links.length > 0 && (
85
+ <p class="mt-3 flex flex-wrap items-center gap-2">
86
+ {links.map((l) => (
87
+ <a
88
+ href={l.href}
89
+ target="_blank"
90
+ rel="noopener noreferrer"
91
+ class="rounded-full border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1 text-sm text-[var(--aas-text)] no-underline hover:bg-[var(--aas-tint)]"
92
+ >
93
+ {l.label}
94
+ <ArrowUpRight size={14} class="ml-0.5 text-[var(--aas-muted)]" />
95
+ </a>
96
+ ))}
97
+ </p>
98
+ )
99
+ }
100
+ <p class="mt-2 aas-md text-lg whitespace-pre-line text-[var(--aas-muted)]" set:html={inlineMd(data.description)} />
101
+ {
102
+ data.image && (
103
+ <Image
104
+ src={data.image}
105
+ alt={data.imageAlt ?? data.title}
106
+ class="mt-5 aspect-[2/1] w-full rounded-2xl border border-[var(--aas-border)] object-cover"
107
+ />
108
+ )
109
+ }
110
+ </header>
111
+
112
+ <RelatedPapers lang={lang} papers={relatedPapers} />
113
+
114
+ <div class="mt-8 flex items-start gap-8">
115
+ <article class="prose max-w-none aas-reading min-w-0 flex-1">
116
+ <Content />
117
+ </article>
118
+ <TocRail lang={lang} items={tocItems} />
119
+ </div>
120
+
121
+ <RelatedTools lang={lang} slugs={data.tools} />
122
+
123
+ {bodyHasMermaid && <MermaidLoader />}
@@ -0,0 +1,144 @@
1
+ ---
2
+ import { getRelativeLocaleUrl } from 'astro:i18n';
3
+ import PaperCard from './PaperCard.astro';
4
+ import Breadcrumb from './Breadcrumb.astro';
5
+ import ListControls from './ListControls.astro';
6
+ import ArrowUpRight from './ArrowUpRight.astro';
7
+
8
+ const PAGE = 12;
9
+ import { paperTree, paperCatOf } from '@aas-data/paper-categories';
10
+ import { getPapers, paperSlugOf, type PaperEntry } from '../lib/papers';
11
+ import { listedInIndex } from '../lib/listing';
12
+ import { useTranslations, type Lang } from '../i18n/ui';
13
+
14
+ // Doubles as the papers index (no `categoryId` → all papers, grouped by
15
+ // category) and a category browse page (`categoryId` → that category, with a
16
+ // breadcrumb, its direct papers, and a section per subcategory).
17
+ interface Props {
18
+ lang: Lang;
19
+ categoryId?: string;
20
+ }
21
+
22
+ const { lang, categoryId } = Astro.props;
23
+ const t = useTranslations(lang);
24
+ // getPapers() sorts newest publication year first, matching the client-side
25
+ // "recent" comparator over PaperCard's data-date (= year) — no reflow on load.
26
+ const all = (await getPapers(lang)).filter((e) => listedInIndex(e.data));
27
+ const catUrl = (cid: string) => getRelativeLocaleUrl(lang, `paper/category/${cid}/`);
28
+
29
+ const itemsIn = (cid: string): PaperEntry[] => {
30
+ const ids = new Set(paperTree.descendantIds(cid));
31
+ return all.filter((c) => ids.has(paperCatOf(c.data.category)));
32
+ };
33
+
34
+ const node = categoryId ? paperTree.map.get(categoryId) : undefined;
35
+ const sections = node ? paperTree.childrenOf(node.id) : paperTree.roots;
36
+ const direct = node ? all.filter((c) => paperCatOf(c.data.category) === node.id) : [];
37
+
38
+ // A leaf category page shows one flat list — that grid gets ?page= pagination.
39
+ // Index and parent-category pages stay grouped overviews: each section is
40
+ // capped to PAGE cards with a "see all" link to the fuller list.
41
+ const isLeaf = !!node && sections.length === 0;
42
+
43
+ const path = node ? paperTree.pathOf(node.id) : [];
44
+ const crumbs = node
45
+ ? [
46
+ { label: t('paper.backToPapers'), href: getRelativeLocaleUrl(lang, 'paper/') },
47
+ ...path.map((c, i) => ({
48
+ label: c.label[lang],
49
+ href: i === path.length - 1 ? undefined : catUrl(c.id),
50
+ })),
51
+ ]
52
+ : [];
53
+
54
+ const card = (c: PaperEntry) => ({
55
+ lang,
56
+ slug: paperSlugOf(c),
57
+ title: c.data.title,
58
+ description: c.data.description,
59
+ authors: c.data.authors,
60
+ year: c.data.year,
61
+ venue: c.data.venue,
62
+ openSource: c.data.openSource,
63
+ tags: c.data.tags,
64
+ image: c.data.image,
65
+ imageAlt: c.data.imageAlt,
66
+ isPrivate: c.data.private,
67
+ teaser: c.data.teaser,
68
+ });
69
+ ---
70
+
71
+ {
72
+ node ? (
73
+ <>
74
+ <Breadcrumb segments={crumbs} />
75
+ <header class="mt-4">
76
+ <h1 class="text-3xl font-bold tracking-tight">{node.label[lang]}</h1>
77
+ <p class="mt-2 max-w-2xl text-lg text-[var(--aas-muted)]">
78
+ {node.detail?.[lang] ?? node.description[lang]}
79
+ </p>
80
+ </header>
81
+ </>
82
+ ) : (
83
+ <section class="py-6">
84
+ <h1 class="text-4xl font-bold tracking-tight">{t('paper.title')}</h1>
85
+ <p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('paper.tagline')}</p>
86
+ </section>
87
+ )
88
+ }
89
+
90
+ {all.length === 0 && <p class="mt-8 text-[var(--aas-muted)]">{t('paper.empty')}</p>}
91
+
92
+ {all.length > 0 && <div class="mt-6"><ListControls lang={lang} /></div>}
93
+
94
+ {
95
+ direct.length > 0 && (
96
+ <div
97
+ class="mt-4 grid gap-5 sm:grid-cols-2 lg:grid-cols-3"
98
+ data-sortgrid
99
+ data-paginate={isLeaf ? true : undefined}
100
+ data-page-size={isLeaf ? PAGE : undefined}
101
+ >
102
+ {direct.map((c) => (
103
+ <PaperCard {...card(c)} />
104
+ ))}
105
+ </div>
106
+ )
107
+ }
108
+
109
+ {
110
+ sections.map((cat) => {
111
+ const items = itemsIn(cat.id);
112
+ return (
113
+ items.length > 0 && (
114
+ <section class="mt-8">
115
+ <h2 class="text-2xl font-semibold">
116
+ <a
117
+ href={catUrl(cat.id)}
118
+ class="text-[var(--aas-text)] no-underline hover:text-[var(--aas-accent)]"
119
+ >
120
+ {cat.label[lang]}
121
+ <ArrowUpRight size={20} class="ml-1 text-[var(--aas-muted)]" />
122
+ </a>
123
+ </h2>
124
+ <p class="mt-1 text-sm text-[var(--aas-muted)]">{cat.description[lang]}</p>
125
+ <div class="mt-4 grid gap-5 sm:grid-cols-2 lg:grid-cols-3" data-sortgrid>
126
+ {items.slice(0, PAGE).map((c) => (
127
+ <PaperCard {...card(c)} />
128
+ ))}
129
+ </div>
130
+ {items.length > PAGE && (
131
+ <a
132
+ href={catUrl(cat.id)}
133
+ class="mt-3 inline-block text-sm font-medium text-[var(--aas-accent)] no-underline"
134
+ >
135
+ {t('list.seeAll').replace('{n}', String(items.length))} &rarr;
136
+ </a>
137
+ )}
138
+ </section>
139
+ )
140
+ );
141
+ })
142
+ }
143
+
144
+ {isLeaf && <nav data-pager aria-label="Pagination" class="mt-8 flex flex-wrap items-center justify-center gap-1.5" />}
@@ -7,6 +7,7 @@
7
7
  import { getRelativeLocaleUrl } from 'astro:i18n';
8
8
  import { productTree, productCatOf } from '@aas-data/product-categories';
9
9
  import { getTopProducts, productSlugOf, type ProductEntry } from '../lib/products';
10
+ import { listedInIndex } from '../lib/listing';
10
11
  import { useTranslations, type Lang } from '../i18n/ui';
11
12
 
12
13
  interface Props {
@@ -15,7 +16,7 @@ interface Props {
15
16
 
16
17
  const { lang } = Astro.props;
17
18
  const t = useTranslations(lang);
18
- const all = await getTopProducts(lang);
19
+ const all = (await getTopProducts(lang)).filter((e) => listedInIndex(e.data));
19
20
  const base = import.meta.env.BASE_URL.replace(/\/$/, '');
20
21
  const withBase = (p?: string) => (p && p.startsWith('/') ? base + p : p);
21
22
 
@@ -0,0 +1,59 @@
1
+ ---
2
+ // "Related papers" card section on a paper detail page, mirroring
3
+ // RelatedConcepts (title + authors/venue meta + description, collapsible grid).
4
+ import { getRelativeLocaleUrl } from 'astro:i18n';
5
+ import { Image } from 'astro:assets';
6
+ import { paperSlugOf, shortAuthors, type PaperEntry } from '../lib/papers';
7
+ import { useTranslations, type Lang } from '../i18n/ui';
8
+ import { inlineMd } from '../lib/inline-md';
9
+ import CollapsibleGrid from './CollapsibleGrid.astro';
10
+
11
+ interface Props {
12
+ lang: Lang;
13
+ papers: PaperEntry[];
14
+ }
15
+
16
+ const { lang, papers } = Astro.props;
17
+ const t = useTranslations(lang);
18
+ ---
19
+
20
+ {
21
+ papers.length > 0 && (
22
+ <section class="mt-8">
23
+ <h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
24
+ {t('paper.relatedPapers')}
25
+ </h2>
26
+ <CollapsibleGrid lang={lang} count={papers.length}>
27
+ {papers.map((c) => (
28
+ <a
29
+ href={getRelativeLocaleUrl(lang, `paper/${paperSlugOf(c)}/`)}
30
+ class="aas-lift flex items-stretch overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
31
+ >
32
+ {c.data.image && (
33
+ <Image src={c.data.image} alt="" class="w-28 shrink-0 self-stretch object-cover" />
34
+ )}
35
+ <div class="min-w-0 p-4">
36
+ <span class="block font-semibold text-[var(--aas-text)]">
37
+ {c.data.private && (
38
+ <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>
39
+ )}
40
+ {c.data.title}
41
+ </span>
42
+ <span class="mt-0.5 block text-xs text-[var(--aas-muted)]">
43
+ {shortAuthors(c.data.authors)}
44
+ {c.data.authors.length > 0 && (c.data.venue || c.data.year) && ' · '}
45
+ {c.data.venue}
46
+ {c.data.venue && c.data.year && ' '}
47
+ {c.data.year}
48
+ </span>
49
+ <span
50
+ class="aas-md mt-1.5 line-clamp-2 text-xs leading-relaxed text-[var(--aas-muted)] opacity-75"
51
+ set:html={inlineMd(c.data.private ? (c.data.teaser ?? '') : c.data.description)}
52
+ />
53
+ </div>
54
+ </a>
55
+ ))}
56
+ </CollapsibleGrid>
57
+ </section>
58
+ )
59
+ }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { getRelativeLocaleUrl } from 'astro:i18n';
3
+ import { listedInIndex } from '../lib/listing';
3
4
  import { useTranslations, type Lang } from '../i18n/ui';
4
5
  import { getDecks, deckSlugOf } from '../lib/slides';
5
6
 
@@ -12,7 +13,7 @@ const t = useTranslations(lang);
12
13
 
13
14
  // Decks are locale-partitioned (content/slides/<lang>/); list this locale's and
14
15
  // link to its localized deck page. `related` points at the concept it summarizes.
15
- const decks = await getDecks(lang);
16
+ const decks = (await getDecks(lang)).filter((e) => listedInIndex(e.data));
16
17
  ---
17
18
 
18
19
  <section class="py-6">
@@ -2,6 +2,7 @@
2
2
  import { getRelativeLocaleUrl } from 'astro:i18n';
3
3
  import StackCard from './StackCard.astro';
4
4
  import { getStacksByTag, slugOf } from '../lib/stacks';
5
+ import { listedInIndex } from '../lib/listing';
5
6
  import { useTranslations, type Lang } from '../i18n/ui';
6
7
 
7
8
  interface Props {
@@ -11,7 +12,7 @@ interface Props {
11
12
 
12
13
  const { lang, tag } = Astro.props;
13
14
  const t = useTranslations(lang);
14
- const entries = (await getStacksByTag(lang, tag)).sort((a, b) =>
15
+ const entries = (await getStacksByTag(lang, tag)).filter((e) => listedInIndex(e.data)).sort((a, b) =>
15
16
  a.data.name.localeCompare(b.data.name),
16
17
  );
17
18
  ---
@@ -2,6 +2,7 @@
2
2
  import { getRelativeLocaleUrl } from 'astro:i18n';
3
3
  import StackCard from './StackCard.astro';
4
4
  import { getStacksByVendor, slugOf } from '../lib/stacks';
5
+ import { listedInIndex } from '../lib/listing';
5
6
  import { useTranslations, type Lang } from '../i18n/ui';
6
7
 
7
8
  interface Props {
@@ -11,7 +12,7 @@ interface Props {
11
12
 
12
13
  const { lang, vendor } = Astro.props;
13
14
  const t = useTranslations(lang);
14
- const entries = await getStacksByVendor(lang, vendor);
15
+ const entries = (await getStacksByVendor(lang, vendor)).filter((e) => listedInIndex(e.data));
15
16
  // The display name is whatever the entries wrote (e.g. "Hugging Face").
16
17
  const vendorName = entries[0]?.data.vendor ?? vendor;
17
18
  ---
package/src/content.ts CHANGED
@@ -19,6 +19,7 @@ export function defineAasCollections({
19
19
  categoryMap,
20
20
  courseCategoryMap,
21
21
  productCategoryMap,
22
+ paperCategoryMap,
22
23
  }: {
23
24
  categoryMap: Map<string, unknown>;
24
25
  /** The site's course category tree (`src/data/course-categories.ts`). Optional
@@ -28,6 +29,9 @@ export function defineAasCollections({
28
29
  /** The site's product category tree (`src/data/product-categories.ts`).
29
30
  * Optional like `courseCategoryMap` — `products` is opt-in too. */
30
31
  productCategoryMap?: Map<string, unknown>;
32
+ /** The site's paper category tree (`src/data/paper-categories.ts`).
33
+ * Optional like the others — `papers` is opt-in too. */
34
+ paperCategoryMap?: Map<string, unknown>;
31
35
  }) {
32
36
  /**
33
37
  * The `stacks` collection holds one entry per tool/service used to build
@@ -125,6 +129,10 @@ export function defineAasCollections({
125
129
  // See docs/private-content-design.md. Requires the AAS_PRIVATE_* env vars.
126
130
  private: z.boolean().default(false),
127
131
  teaser: z.string().optional(),
132
+ // Show this PRIVATE entry on index listings as a locked teaser card.
133
+ // Default: private entries stay OUT of listings and are reached via
134
+ // direct links / the related sections on detail pages.
135
+ listed: z.boolean().default(false),
128
136
  }),
129
137
  });
130
138
 
@@ -169,6 +177,10 @@ export function defineAasCollections({
169
177
  // PUBLIC `teaser`). See docs/private-content-design.md.
170
178
  private: z.boolean().default(false),
171
179
  teaser: z.string().optional(),
180
+ // Show this PRIVATE entry on index listings as a locked teaser card.
181
+ // Default: private entries stay OUT of listings and are reached via
182
+ // direct links / the related sections on detail pages.
183
+ listed: z.boolean().default(false),
172
184
  }),
173
185
  });
174
186
 
@@ -207,6 +219,10 @@ export function defineAasCollections({
207
219
  // PUBLIC `teaser`). See docs/private-content-design.md.
208
220
  private: z.boolean().default(false),
209
221
  teaser: z.string().optional(),
222
+ // Show this PRIVATE entry on index listings as a locked teaser card.
223
+ // Default: private entries stay OUT of listings and are reached via
224
+ // direct links / the related sections on detail pages.
225
+ listed: z.boolean().default(false),
210
226
  }),
211
227
  });
212
228
 
@@ -263,6 +279,64 @@ export function defineAasCollections({
263
279
  // PUBLIC `teaser`). See docs/private-content-design.md.
264
280
  private: z.boolean().default(false),
265
281
  teaser: z.string().optional(),
282
+ // Show this PRIVATE entry on index listings as a locked teaser card.
283
+ // Default: private entries stay OUT of listings and are reached via
284
+ // direct links / the related sections on detail pages.
285
+ listed: z.boolean().default(false),
286
+ }),
287
+ });
288
+
289
+ /**
290
+ * The `papers` collection is the reading room — one entry per academic
291
+ * paper, an opt-in section (`sections: { papers: true }`) for sites that
292
+ * review the literature behind their stack. Frontmatter powers the cards
293
+ * (authors, venue/year, open-source availability, links); the body is the
294
+ * site's own reading/review of the paper. Locale-partitioned:
295
+ * `papers/<lang>/<slug>.mdx`.
296
+ */
297
+ const papers = defineCollection({
298
+ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/papers' }),
299
+ schema: ({ image }) =>
300
+ z.object({
301
+ title: z.string(),
302
+ // The full author list, in publication order. Cards abbreviate to the
303
+ // first names + "et al."; the detail page shows everyone.
304
+ authors: z.array(z.string()).default([]),
305
+ year: z.number().int().optional(),
306
+ venue: z.string().optional(), // NeurIPS, ICML, arXiv, JMLR, …
307
+ description: z.string(), // one-line takeaway, shown on cards
308
+ // Leaf id from the site's src/data/paper-categories.ts. Validated when
309
+ // the site passes `paperCategoryMap`; otherwise resolved at render
310
+ // with an uncategorized fallback.
311
+ category: (paperCategoryMap
312
+ ? z.string().refine((id) => paperCategoryMap.has(id), {
313
+ message:
314
+ 'unknown paper category id — must match a node in the site data paper category tree',
315
+ })
316
+ : z.string()
317
+ ).optional(),
318
+ image: image().optional(), // key figure / teaser image
319
+ imageAlt: z.string().optional(),
320
+ arxiv: z.string().url().optional(),
321
+ paperUrl: z.string().url().optional(), // publisher / DOI page
322
+ // Has the paper's code been released? Shown as a badge and filterable
323
+ // at a glance; `repo` links the released implementation.
324
+ openSource: z.boolean().default(false),
325
+ repo: z.string().url().optional(),
326
+ tools: z.array(z.string()).default([]), // catalog stacks this paper underpins
327
+ related: z.array(z.string()).default([]), // related paper slugs
328
+ tags: z.array(z.string()).default([]),
329
+ // Living-doc metadata for the review itself, mirroring concepts.
330
+ version: z.string().optional(),
331
+ updated: z.coerce.date().optional(),
332
+ draft: z.boolean().default(false),
333
+ // Login-gated entry (encrypted body; see docs/private-content-design.md).
334
+ private: z.boolean().default(false),
335
+ teaser: z.string().optional(),
336
+ // Show this PRIVATE entry on index listings as a locked teaser card.
337
+ // Default: private entries stay OUT of listings and are reached via
338
+ // direct links / the related sections on detail pages.
339
+ listed: z.boolean().default(false),
266
340
  }),
267
341
  });
268
342
 
@@ -307,6 +381,10 @@ export function defineAasCollections({
307
381
  // optional PUBLIC `teaser`). See docs/private-content-design.md.
308
382
  private: z.boolean().default(false),
309
383
  teaser: z.string().optional(),
384
+ // Show this PRIVATE entry on index listings as a locked teaser card.
385
+ // Default: private entries stay OUT of listings and are reached via
386
+ // direct links / the related sections on detail pages.
387
+ listed: z.boolean().default(false),
310
388
  }),
311
389
  });
312
390
 
@@ -452,6 +530,10 @@ export function defineAasCollections({
452
530
  // Login-gated page (encrypted body; see docs/private-content-design.md).
453
531
  private: z.boolean().default(false),
454
532
  teaser: z.string().optional(),
533
+ // Show this PRIVATE entry on index listings as a locked teaser card.
534
+ // Default: private entries stay OUT of listings and are reached via
535
+ // direct links / the related sections on detail pages.
536
+ listed: z.boolean().default(false),
455
537
  }),
456
538
  });
457
539
 
@@ -484,8 +566,12 @@ export function defineAasCollections({
484
566
  // PUBLIC `teaser`). See docs/private-content-design.md.
485
567
  private: z.boolean().default(false),
486
568
  teaser: z.string().optional(),
569
+ // Show this PRIVATE entry on index listings as a locked teaser card.
570
+ // Default: private entries stay OUT of listings and are reached via
571
+ // direct links / the related sections on detail pages.
572
+ listed: z.boolean().default(false),
487
573
  }),
488
574
  });
489
575
 
490
- return { stacks, articles, concepts, courses, slides, products, pages };
576
+ return { stacks, articles, concepts, courses, papers, slides, products, pages };
491
577
  }
package/src/i18n/ui.ts CHANGED
@@ -60,6 +60,7 @@ export const ui = {
60
60
  'nav.concepts': 'Concepts',
61
61
  'nav.courses': 'Courses',
62
62
  'nav.products': 'Products',
63
+ 'nav.papers': 'Papers',
63
64
  'nav.blog': 'Writing',
64
65
  'nav.samples': 'Samples',
65
66
  'nav.slides': 'Slides',
@@ -129,6 +130,17 @@ export const ui = {
129
130
  'course.updated': 'Updated',
130
131
  'course.relatedCourses': 'Related courses',
131
132
  'course.slides': 'Slides',
133
+ 'paper.title': 'Papers',
134
+ 'paper.tagline': 'Paper readings and reviews — what matters and why.',
135
+ 'paper.empty': 'No papers yet.',
136
+ 'paper.backToPapers': 'All papers',
137
+ 'paper.authors': 'Authors',
138
+ 'paper.openSource': 'Code available',
139
+ 'paper.arxiv': 'arXiv',
140
+ 'paper.paper': 'Paper',
141
+ 'paper.code': 'Code',
142
+ 'paper.relatedPapers': 'Related papers',
143
+ 'paper.updated': 'Updated',
132
144
  'products.title': 'Products',
133
145
  'products.tagline': 'What we build and offer.',
134
146
  'products.empty': 'No products yet.',
@@ -224,6 +236,7 @@ export const ui = {
224
236
  'nav.concepts': '개념',
225
237
  'nav.courses': '강의',
226
238
  'nav.products': '제품',
239
+ 'nav.papers': '논문',
227
240
  'nav.blog': '글',
228
241
  'nav.samples': '샘플',
229
242
  'nav.slides': '슬라이드',
@@ -292,6 +305,17 @@ export const ui = {
292
305
  'course.updated': '업데이트',
293
306
  'course.relatedCourses': '관련 강의',
294
307
  'course.slides': '슬라이드',
308
+ 'paper.title': '논문',
309
+ 'paper.tagline': '논문을 읽고 정리합니다 — 무엇이, 왜 중요한지.',
310
+ 'paper.empty': '아직 논문이 없습니다.',
311
+ 'paper.backToPapers': '논문 전체',
312
+ 'paper.authors': '저자',
313
+ 'paper.openSource': '코드 공개',
314
+ 'paper.arxiv': 'arXiv',
315
+ 'paper.paper': '원문',
316
+ 'paper.code': '코드',
317
+ 'paper.relatedPapers': '관련 논문',
318
+ 'paper.updated': '업데이트',
295
319
  'products.title': '제품',
296
320
  'products.tagline': '우리가 만들고 제공하는 것.',
297
321
  'products.empty': '아직 제품이 없습니다.',
@@ -95,6 +95,12 @@ const allNavItems: {
95
95
  section: 'courses',
96
96
  svg: '<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/>',
97
97
  },
98
+ {
99
+ href: getRelativeLocaleUrl(lang, 'paper/'),
100
+ label: t('nav.papers'),
101
+ section: 'papers' as SectionKey,
102
+ svg: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/>',
103
+ },
98
104
  {
99
105
  href: getRelativeLocaleUrl(lang, 'concept/'),
100
106
  label: t('nav.concepts'),
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether an entry belongs on index listings. Public entries always do;
3
+ * PRIVATE entries only when they opt in with `listed: true` (shown as a
4
+ * locked teaser card). Unlisted private entries still build and stay
5
+ * reachable via direct links and the related-entry sections on detail pages —
6
+ * that's the discovery path for gated content.
7
+ */
8
+ export const listedInIndex = (d: { private?: boolean; listed?: boolean }): boolean =>
9
+ !d.private || d.listed === true;
@@ -0,0 +1,25 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import type { Lang } from '../i18n/ui';
3
+
4
+ export type PaperEntry = CollectionEntry<'papers'>;
5
+
6
+ /** The url slug of a paper, i.e. its id with the `<lang>/` prefix removed. */
7
+ export function paperSlugOf(entry: PaperEntry): string {
8
+ return entry.id.replace(/^[a-z]{2}\//, '');
9
+ }
10
+
11
+ /** Published papers for one locale — newest publication year first, then title. */
12
+ export async function getPapers(lang: Lang): Promise<PaperEntry[]> {
13
+ const all = await getCollection('papers');
14
+ return all
15
+ .filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
16
+ .sort(
17
+ (a, b) => (b.data.year ?? 0) - (a.data.year ?? 0) || a.data.title.localeCompare(b.data.title),
18
+ );
19
+ }
20
+
21
+ /** Cards abbreviate the author list: first `max` names, then "et al.". */
22
+ export function shortAuthors(authors: string[], max = 3): string {
23
+ if (authors.length === 0) return '';
24
+ return authors.length <= max ? authors.join(', ') : `${authors.slice(0, max).join(', ')} et al.`;
25
+ }
@@ -21,6 +21,7 @@ export type SectionKey =
21
21
  | 'articles'
22
22
  | 'courses'
23
23
  | 'products'
24
+ | 'papers'
24
25
  | 'samples'
25
26
  | 'slides'
26
27
  | 'glossary'
@@ -31,6 +32,7 @@ const DEFAULTS: Record<SectionKey, boolean> = {
31
32
  articles: true,
32
33
  courses: false, // opt-in: needs src/data/course-categories.ts on the site
33
34
  products: false, // opt-in: needs src/data/product-categories.ts on the site
35
+ papers: false, // opt-in: needs src/data/paper-categories.ts on the site
34
36
  samples: true,
35
37
  slides: true,
36
38
  glossary: true,
@@ -0,0 +1,35 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import type { GetStaticPaths } from 'astro';
4
+ import BaseLayout from '../../../layouts/BaseLayout.astro';
5
+ import PaperDetail from '../../../components/PaperDetail.astro';
6
+ import PrivateGate from '../../../components/PrivateGate.astro';
7
+ import { getPapers, paperSlugOf } from '../../../lib/papers';
8
+ import { allLocales, langParam } from '../../../lib/locales';
9
+
10
+ export const getStaticPaths = (async () => {
11
+ const paths: Awaited<ReturnType<GetStaticPaths>> = [];
12
+ for (const lang of allLocales) {
13
+ for (const entry of await getPapers(lang)) {
14
+ paths.push({ params: { lang: langParam(lang), id: paperSlugOf(entry) }, props: { lang, entry } });
15
+ }
16
+ }
17
+ return paths;
18
+ }) satisfies GetStaticPaths;
19
+
20
+ const { lang, entry } = Astro.props;
21
+ const slug = paperSlugOf(entry);
22
+ const priv = entry.data.private;
23
+ ---
24
+
25
+ <BaseLayout
26
+ title={`${entry.data.title} — ${site.name}`}
27
+ description={priv ? entry.data.teaser : entry.data.description}
28
+ lang={lang}
29
+ path={`paper/${slug}/`}
30
+ noindex={priv}
31
+ >
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <PaperDetail entry={entry} lang={lang} />
34
+ </PrivateGate>
35
+ </BaseLayout>
@@ -0,0 +1,25 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import type { GetStaticPaths } from 'astro';
4
+ import BaseLayout from '../../../../layouts/BaseLayout.astro';
5
+ import PapersIndex from '../../../../components/PapersIndex.astro';
6
+ import { paperTree } from '@aas-data/paper-categories';
7
+ import { allLocales, langParam } from '../../../../lib/locales';
8
+
9
+ export const getStaticPaths = (() => {
10
+ const paths: Awaited<ReturnType<GetStaticPaths>> = [];
11
+ for (const lang of allLocales) {
12
+ for (const id of paperTree.allIds) {
13
+ paths.push({ params: { lang: langParam(lang), id }, props: { lang, id } });
14
+ }
15
+ }
16
+ return paths;
17
+ }) satisfies GetStaticPaths;
18
+
19
+ const { lang, id } = Astro.props as { lang: string; id: string };
20
+ const node = paperTree.map.get(id)!;
21
+ ---
22
+
23
+ <BaseLayout title={`${node.label[lang]} — ${site.name}`} lang={lang} path={`paper/category/${id}/`}>
24
+ <PapersIndex lang={lang} categoryId={id} />
25
+ </BaseLayout>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import type { GetStaticPaths } from 'astro';
4
+ import BaseLayout from '../../../layouts/BaseLayout.astro';
5
+ import PapersIndex from '../../../components/PapersIndex.astro';
6
+ import { useTranslations } from '../../../i18n/ui';
7
+ import { allLocales, langParam } from '../../../lib/locales';
8
+
9
+ export const getStaticPaths = (() =>
10
+ allLocales.map((lang) => ({ params: { lang: langParam(lang) }, props: { lang } }))) satisfies GetStaticPaths;
11
+
12
+ const { lang } = Astro.props;
13
+ const t = useTranslations(lang);
14
+ ---
15
+
16
+ <BaseLayout title={`${t('paper.title')} — ${site.name}`} lang={lang} path="paper/">
17
+ <PapersIndex lang={lang} />
18
+ </BaseLayout>
@@ -2384,3 +2384,11 @@ summary {
2384
2384
  .aas-embed.running .aas-embed-run {
2385
2385
  display: none;
2386
2386
  }
2387
+
2388
+ /* Tables: keep the first (label) column on one line — a wide table otherwise
2389
+ squeezes short labels like "Part 1" into two rows. Applies to reading prose
2390
+ and slide-deck tables alike; label columns are short by convention. */
2391
+ .prose :where(table) :where(th, td):first-child,
2392
+ .aas-slide :where(table) :where(th, td):first-child {
2393
+ white-space: nowrap;
2394
+ }