stack-site-builder 1.17.5 โ†’ 1.19.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.
@@ -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',
@@ -70,6 +71,7 @@ export const ui = {
70
71
  'code.copy': 'Copy code',
71
72
  'code.copied': 'Copied',
72
73
  'private.badge': 'Private',
74
+ 'private.login': 'Log in',
73
75
  'private.locked': 'This content is private. Log in to view it.',
74
76
  'private.id': 'ID',
75
77
  'private.password': 'Password',
@@ -129,6 +131,17 @@ export const ui = {
129
131
  'course.updated': 'Updated',
130
132
  'course.relatedCourses': 'Related courses',
131
133
  'course.slides': 'Slides',
134
+ 'paper.title': 'Papers',
135
+ 'paper.tagline': 'Paper readings and reviews โ€” what matters and why.',
136
+ 'paper.empty': 'No papers yet.',
137
+ 'paper.backToPapers': 'All papers',
138
+ 'paper.authors': 'Authors',
139
+ 'paper.openSource': 'Code available',
140
+ 'paper.arxiv': 'arXiv',
141
+ 'paper.paper': 'Paper',
142
+ 'paper.code': 'Code',
143
+ 'paper.relatedPapers': 'Related papers',
144
+ 'paper.updated': 'Updated',
132
145
  'products.title': 'Products',
133
146
  'products.tagline': 'What we build and offer.',
134
147
  'products.empty': 'No products yet.',
@@ -224,6 +237,7 @@ export const ui = {
224
237
  'nav.concepts': '๊ฐœ๋…',
225
238
  'nav.courses': '๊ฐ•์˜',
226
239
  'nav.products': '์ œํ’ˆ',
240
+ 'nav.papers': '๋…ผ๋ฌธ',
227
241
  'nav.blog': '๊ธ€',
228
242
  'nav.samples': '์ƒ˜ํ”Œ',
229
243
  'nav.slides': '์Šฌ๋ผ์ด๋“œ',
@@ -234,6 +248,7 @@ export const ui = {
234
248
  'code.copy': '์ฝ”๋“œ ๋ณต์‚ฌ',
235
249
  'code.copied': '๋ณต์‚ฌ๋จ',
236
250
  'private.badge': '๋น„๊ณต๊ฐœ',
251
+ 'private.login': '๋กœ๊ทธ์ธ',
237
252
  'private.locked': '๋น„๊ณต๊ฐœ ์ฝ˜ํ…์ธ ์ž…๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ํ›„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.',
238
253
  'private.id': '์•„์ด๋””',
239
254
  'private.password': '๋น„๋ฐ€๋ฒˆํ˜ธ',
@@ -292,6 +307,17 @@ export const ui = {
292
307
  'course.updated': '์—…๋ฐ์ดํŠธ',
293
308
  'course.relatedCourses': '๊ด€๋ จ ๊ฐ•์˜',
294
309
  'course.slides': '์Šฌ๋ผ์ด๋“œ',
310
+ 'paper.title': '๋…ผ๋ฌธ',
311
+ 'paper.tagline': '๋…ผ๋ฌธ์„ ์ฝ๊ณ  ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค โ€” ๋ฌด์—‡์ด, ์™œ ์ค‘์š”ํ•œ์ง€.',
312
+ 'paper.empty': '์•„์ง ๋…ผ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.',
313
+ 'paper.backToPapers': '๋…ผ๋ฌธ ์ „์ฒด',
314
+ 'paper.authors': '์ €์ž',
315
+ 'paper.openSource': '์ฝ”๋“œ ๊ณต๊ฐœ',
316
+ 'paper.arxiv': 'arXiv',
317
+ 'paper.paper': '์›๋ฌธ',
318
+ 'paper.code': '์ฝ”๋“œ',
319
+ 'paper.relatedPapers': '๊ด€๋ จ ๋…ผ๋ฌธ',
320
+ 'paper.updated': '์—…๋ฐ์ดํŠธ',
295
321
  'products.title': '์ œํ’ˆ',
296
322
  'products.tagline': '์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“ค๊ณ  ์ œ๊ณตํ•˜๋Š” ๊ฒƒ.',
297
323
  'products.empty': '์•„์ง ์ œํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค.',
@@ -8,6 +8,8 @@ import ThemeToggle from '../components/ThemeToggle.astro';
8
8
  import BackToTop from '../components/BackToTop.astro';
9
9
  import { getNavPages, pageSlugOf } from '../lib/pages';
10
10
  import { getNavProducts, getTopProducts, productSlugOf } from '../lib/products';
11
+ import { getDecks } from '../lib/slides';
12
+ import { listedInIndex } from '../lib/listing';
11
13
  import { homeTemplate } from '../lib/home';
12
14
  import { sectionEnabled, type SectionKey } from '../lib/sections';
13
15
  import { siteIcons } from '../lib/icons';
@@ -50,6 +52,9 @@ const navPages = await getNavPages(lang);
50
52
  // the Products-index link, which appears once the locale has any product.
51
53
  const navProducts = await getNavProducts(lang);
52
54
  const hasProducts = (await getTopProducts(lang)).length > 0;
55
+ // The slides nav item only makes sense when the index actually lists decks โ€”
56
+ // a site whose decks are all private+unlisted reaches them from course pages.
57
+ const hasListedDecks = (await getDecks(lang)).some((d) => listedInIndex(d.data));
53
58
  // The header's GitHub link โ€” hidden for sites whose repo is private
54
59
  // (`repoNav: false` in site.ts) or that declare no repoUrl at all.
55
60
  const showRepoLink =
@@ -95,6 +100,12 @@ const allNavItems: {
95
100
  section: 'courses',
96
101
  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
102
  },
103
+ {
104
+ href: getRelativeLocaleUrl(lang, 'paper/'),
105
+ label: t('nav.papers'),
106
+ section: 'papers' as SectionKey,
107
+ 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"/>',
108
+ },
98
109
  {
99
110
  href: getRelativeLocaleUrl(lang, 'concept/'),
100
111
  label: t('nav.concepts'),
@@ -113,12 +124,16 @@ const allNavItems: {
113
124
  section: 'samples',
114
125
  svg: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
115
126
  },
116
- {
117
- href: getRelativeLocaleUrl(lang, 'slides/'),
118
- label: t('nav.slides'),
119
- section: 'slides',
120
- svg: '<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/><path d="M12 12v9"/>',
121
- },
127
+ ...(hasListedDecks
128
+ ? [
129
+ {
130
+ href: getRelativeLocaleUrl(lang, 'slides/'),
131
+ label: t('nav.slides'),
132
+ section: 'slides' as SectionKey,
133
+ svg: '<path d="M2 3h20"/><path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/><path d="m7 21 5-5 5 5"/><path d="M12 12v9"/>',
134
+ },
135
+ ]
136
+ : []),
122
137
  {
123
138
  href: getRelativeLocaleUrl(lang, 'glossary/'),
124
139
  label: t('nav.glossary'),
@@ -257,6 +272,53 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
257
272
  ))
258
273
  }
259
274
  </div>
275
+ {/* Member login/logout for private content โ€” hidden until
276
+ /aas-auth.json reports the site has login users configured. */}
277
+ <details class="aas-menu relative" data-auth hidden>
278
+ <summary
279
+ class="flex cursor-pointer list-none items-center rounded-lg border border-[var(--aas-border)] px-2.5 py-1 text-[var(--aas-text)] select-none"
280
+ aria-label={t('private.login')}
281
+ title={t('private.login')}
282
+ >
283
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
284
+ </summary>
285
+ <div
286
+ class="absolute right-0 z-20 mt-1 w-64 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] p-3 shadow-lg"
287
+ >
288
+ <form data-auth-form class="flex flex-col gap-2">
289
+ <input
290
+ name="id"
291
+ required
292
+ autocomplete="username"
293
+ placeholder={t('private.id')}
294
+ class="rounded-lg border border-[var(--aas-border)] bg-[var(--aas-bg)] px-3 py-1.5 text-sm text-[var(--aas-text)]"
295
+ />
296
+ <input
297
+ name="password"
298
+ type="password"
299
+ required
300
+ autocomplete="current-password"
301
+ placeholder={t('private.password')}
302
+ class="rounded-lg border border-[var(--aas-border)] bg-[var(--aas-bg)] px-3 py-1.5 text-sm text-[var(--aas-text)]"
303
+ />
304
+ <p data-auth-error hidden class="text-xs text-[var(--aas-accent)]">{t('private.error')}</p>
305
+ <button
306
+ type="submit"
307
+ class="rounded-lg bg-[var(--aas-accent)] px-3 py-1.5 text-sm font-semibold text-white"
308
+ >
309
+ {t('private.submit')}
310
+ </button>
311
+ </form>
312
+ <button
313
+ type="button"
314
+ data-auth-logout
315
+ hidden
316
+ class="w-full rounded-lg border border-[var(--aas-border)] px-3 py-1.5 text-sm text-[var(--aas-text)] hover:bg-[var(--aas-tint)]"
317
+ >
318
+ ๐Ÿ”“ {t('private.logout')}
319
+ </button>
320
+ </div>
321
+ </details>
260
322
  <ThemeToggle lang={lang} />
261
323
  <LanguageSwitcher lang={lang} path={path} />
262
324
  <details class="aas-menu relative sm:hidden">
@@ -322,6 +384,55 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
322
384
  <BackToTop lang={lang} />
323
385
 
324
386
  <script>
387
+ import {
388
+ hasSession,
389
+ clearSession,
390
+ loginWithCredentials,
391
+ type AuthData,
392
+ } from '../lib/private-client';
393
+
394
+ // Header member login/logout: reveal the control only when the site has
395
+ // login users configured (/aas-auth.json โ€” { enabled: false } otherwise).
396
+ const authCtrl = document.querySelector<HTMLDetailsElement>('[data-auth]');
397
+ if (authCtrl) {
398
+ (async () => {
399
+ try {
400
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
401
+ const res = await fetch(`${base}/aas-auth.json`);
402
+ const data = (await res.json()) as { enabled: boolean } & AuthData;
403
+ if (!data.enabled) return;
404
+ authCtrl.hidden = false;
405
+ const form = authCtrl.querySelector<HTMLFormElement>('[data-auth-form]')!;
406
+ const logoutBtn = authCtrl.querySelector<HTMLButtonElement>('[data-auth-logout]')!;
407
+ const errorEl = authCtrl.querySelector<HTMLElement>('[data-auth-error]')!;
408
+ const sync = () => {
409
+ const on = hasSession(data.days);
410
+ form.hidden = on;
411
+ logoutBtn.hidden = !on;
412
+ };
413
+ sync();
414
+ logoutBtn.addEventListener('click', () => {
415
+ clearSession();
416
+ location.reload();
417
+ });
418
+ form.addEventListener('submit', async (e) => {
419
+ e.preventDefault();
420
+ errorEl.hidden = true;
421
+ const fd = new FormData(form);
422
+ const ok = await loginWithCredentials(
423
+ data,
424
+ String(fd.get('id') ?? ''),
425
+ String(fd.get('password') ?? ''),
426
+ );
427
+ if (ok) location.reload();
428
+ else errorEl.hidden = false;
429
+ });
430
+ } catch {
431
+ /* endpoint unavailable โ€” control stays hidden */
432
+ }
433
+ })();
434
+ }
435
+
325
436
  import { initOnReady } from '../lib/reinit';
326
437
 
327
438
  // Clicking a heading's "#" anchor copies that section's URL to the
@@ -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
+ }
@@ -60,7 +60,7 @@ function storedKey(days: number): Uint8Array | null {
60
60
  }
61
61
  }
62
62
 
63
- function clearSession(): void {
63
+ export function clearSession(): void {
64
64
  try {
65
65
  localStorage.removeItem(STORE);
66
66
  } catch {
@@ -68,6 +68,50 @@ function clearSession(): void {
68
68
  }
69
69
  }
70
70
 
71
+ /** The user table a login form needs โ€” a GateData without the content. */
72
+ export interface AuthData {
73
+ users: { h: string; s: string; iv: string; w: string }[];
74
+ salt: string;
75
+ days: number;
76
+ }
77
+
78
+ /** Whether this device holds a live session key. */
79
+ export function hasSession(days: number): boolean {
80
+ return storedKey(days) !== null;
81
+ }
82
+
83
+ /**
84
+ * Verify credentials by unwrapping the site key (AES-GCM authentication
85
+ * fails on a wrong password) and store the session on success. Shared by
86
+ * the per-page gate form and the header login control.
87
+ */
88
+ export async function loginWithCredentials(
89
+ data: AuthData,
90
+ id: string,
91
+ password: string,
92
+ ): Promise<boolean> {
93
+ const salt = dec(data.salt);
94
+ const idBytes = new TextEncoder().encode(id.trim().toLowerCase());
95
+ const joined = new Uint8Array(salt.length + idBytes.length);
96
+ joined.set(salt);
97
+ joined.set(idBytes, salt.length);
98
+ const h = await sha256Hex(joined);
99
+ const user = data.users.find((u) => u.h === h);
100
+ if (!user) return false;
101
+ try {
102
+ const kek = await deriveKek(password, dec(user.s));
103
+ const k = new Uint8Array(await aesDecrypt(kek, dec(user.iv), dec(user.w)));
104
+ try {
105
+ localStorage.setItem(STORE, JSON.stringify({ k: enc(k), t: Date.now() }));
106
+ } catch {
107
+ /* private browsing โ€” session just won't persist */
108
+ }
109
+ return true;
110
+ } catch {
111
+ return false; // wrong password (unwrap failed)
112
+ }
113
+ }
114
+
71
115
  /** Swap the gate for the decrypted HTML; re-run inline scripts; notify re-init hooks. */
72
116
  function inject(gate: HTMLElement, html: string, logoutLabel: string): void {
73
117
  const host = document.createElement('div');
@@ -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>