stack-site-builder 1.13.0 → 1.15.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 (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +105 -3
  3. package/index.d.ts +15 -5
  4. package/index.mjs +26 -2
  5. package/package.json +2 -1
  6. package/src/components/ArticleCard.astro +9 -3
  7. package/src/components/ArticleLink.astro +7 -2
  8. package/src/components/BlogIndex.astro +2 -0
  9. package/src/components/Bookmark.astro +37 -0
  10. package/src/components/CategoryIndex.astro +2 -0
  11. package/src/components/CodeSamples.astro +6 -1
  12. package/src/components/ConceptCard.astro +9 -3
  13. package/src/components/ConceptIndex.astro +2 -0
  14. package/src/components/ConceptLink.astro +7 -2
  15. package/src/components/CourseCard.astro +97 -0
  16. package/src/components/CourseDetail.astro +116 -0
  17. package/src/components/CourseIndex.astro +145 -0
  18. package/src/components/DeckView.astro +17 -2
  19. package/src/components/DetailTabs.astro +6 -1
  20. package/src/components/DifficultyStars.astro +34 -0
  21. package/src/components/Embed.astro +36 -0
  22. package/src/components/Home.astro +2 -0
  23. package/src/components/Lead.astro +8 -0
  24. package/src/components/MermaidLoader.astro +3 -1
  25. package/src/components/PricingSection.astro +4 -1
  26. package/src/components/PrivateGate.astro +135 -0
  27. package/src/components/ProjectViewer.astro +24 -13
  28. package/src/components/RelatedCourses.astro +64 -0
  29. package/src/components/SlidesIndex.astro +8 -2
  30. package/src/components/StackCard.astro +15 -3
  31. package/src/components/StackDetail.astro +9 -0
  32. package/src/components/TagIndex.astro +2 -0
  33. package/src/components/TocRail.astro +12 -6
  34. package/src/components/VendorIndex.astro +2 -0
  35. package/src/content.ts +90 -4
  36. package/src/i18n/ui.ts +47 -0
  37. package/src/layouts/BaseLayout.astro +26 -4
  38. package/src/lib/courses.ts +26 -0
  39. package/src/lib/private-client.ts +160 -0
  40. package/src/lib/private.ts +129 -0
  41. package/src/lib/reinit.ts +12 -0
  42. package/src/lib/sections.ts +13 -2
  43. package/src/pages/[...lang]/[page].astro +7 -2
  44. package/src/pages/[...lang]/article/[...id].astro +7 -2
  45. package/src/pages/[...lang]/concept/[...id].astro +7 -2
  46. package/src/pages/[...lang]/course/[...id].astro +35 -0
  47. package/src/pages/[...lang]/course/category/[id].astro +25 -0
  48. package/src/pages/[...lang]/course/index.astro +18 -0
  49. package/src/pages/[...lang]/rss.xml.ts +40 -0
  50. package/src/pages/[...lang]/stack/[...id].astro +7 -2
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Browser half of private content: login (PBKDF2 → unwrap the site key),
3
+ * session caching in localStorage, AES-GCM decryption and DOM injection.
4
+ * Crypto parameters must match src/lib/private.ts. Loaded only by PrivateGate.
5
+ */
6
+ import { PRIVATE_DECRYPTED_EVENT } from './reinit';
7
+
8
+ const KDF_ITERATIONS = 600_000;
9
+ const STORE = 'aas:pk';
10
+
11
+ interface GateData {
12
+ iv: string;
13
+ ct: string;
14
+ users: { h: string; s: string; iv: string; w: string }[];
15
+ salt: string; // build salt for id hashing, base64
16
+ days: number; // session lifetime (0 = never expires)
17
+ }
18
+
19
+ const dec = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
20
+ const enc = (b: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(b)));
21
+
22
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
23
+ const d = await crypto.subtle.digest('SHA-256', bytes as BufferSource);
24
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
25
+ }
26
+
27
+ async function deriveKek(password: string, salt: Uint8Array): Promise<CryptoKey> {
28
+ const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, [
29
+ 'deriveKey',
30
+ ]);
31
+ return crypto.subtle.deriveKey(
32
+ { name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations: KDF_ITERATIONS },
33
+ base,
34
+ { name: 'AES-GCM', length: 256 },
35
+ false,
36
+ ['decrypt'],
37
+ );
38
+ }
39
+
40
+ async function aesDecrypt(keyBytes: Uint8Array | CryptoKey, iv: Uint8Array, data: Uint8Array): Promise<ArrayBuffer> {
41
+ const key =
42
+ keyBytes instanceof CryptoKey
43
+ ? keyBytes
44
+ : await crypto.subtle.importKey('raw', keyBytes as BufferSource, 'AES-GCM', false, ['decrypt']);
45
+ return crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, data as BufferSource);
46
+ }
47
+
48
+ function storedKey(days: number): Uint8Array | null {
49
+ try {
50
+ const raw = localStorage.getItem(STORE);
51
+ if (!raw) return null;
52
+ const { k, t } = JSON.parse(raw) as { k: string; t: number };
53
+ if (days > 0 && Date.now() - t > days * 86_400_000) {
54
+ localStorage.removeItem(STORE);
55
+ return null;
56
+ }
57
+ return dec(k);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function clearSession(): void {
64
+ try {
65
+ localStorage.removeItem(STORE);
66
+ } catch {
67
+ /* ignore */
68
+ }
69
+ }
70
+
71
+ /** Swap the gate for the decrypted HTML; re-run inline scripts; notify re-init hooks. */
72
+ function inject(gate: HTMLElement, html: string, logoutLabel: string): void {
73
+ const host = document.createElement('div');
74
+ host.innerHTML = html;
75
+ // innerHTML-injected <script> tags don't execute — recreate each one so
76
+ // classic/inline scripts run (hoisted module scripts re-init via the event).
77
+ host.querySelectorAll('script').forEach((old) => {
78
+ const s = document.createElement('script');
79
+ for (const a of old.attributes) s.setAttribute(a.name, a.value);
80
+ s.textContent = old.textContent;
81
+ old.replaceWith(s);
82
+ });
83
+ // A discreet logout control above the content.
84
+ const bar = document.createElement('div');
85
+ bar.className = 'aas-private-bar';
86
+ const btn = document.createElement('button');
87
+ btn.type = 'button';
88
+ btn.className = 'aas-private-logout';
89
+ btn.textContent = `\u{1F513} ${logoutLabel}`;
90
+ btn.addEventListener('click', () => {
91
+ clearSession();
92
+ location.reload();
93
+ });
94
+ bar.appendChild(btn);
95
+ gate.replaceWith(bar, ...host.childNodes);
96
+ document.dispatchEvent(new CustomEvent(PRIVATE_DECRYPTED_EVENT));
97
+ }
98
+
99
+ async function tryDecrypt(gate: HTMLElement, data: GateData, keyBytes: Uint8Array, logoutLabel: string): Promise<boolean> {
100
+ try {
101
+ const html = new TextDecoder().decode(await aesDecrypt(keyBytes, dec(data.iv), dec(data.ct)));
102
+ inject(gate, html, logoutLabel);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /** Wire one gate element (PrivateGate renders exactly one per page). */
110
+ export async function mountGate(gate: HTMLElement): Promise<void> {
111
+ const data = JSON.parse(gate.querySelector('[data-private-data]')!.textContent!) as GateData;
112
+ const logoutLabel = gate.dataset.logoutLabel ?? 'Log out';
113
+
114
+ // Already logged in on this device → decrypt with no form flash. A failure
115
+ // means the master secret rotated: drop the stale key and show the form.
116
+ const cachedK = storedKey(data.days);
117
+ if (cachedK && (await tryDecrypt(gate, data, cachedK, logoutLabel))) return;
118
+ if (cachedK) clearSession();
119
+ gate.querySelector<HTMLElement>('[data-private-form]')!.hidden = false;
120
+
121
+ const form = gate.querySelector('form')!;
122
+ const error = gate.querySelector<HTMLElement>('[data-private-error]')!;
123
+ form.addEventListener('submit', async (e) => {
124
+ e.preventDefault();
125
+ error.hidden = true;
126
+ const idInput = form.querySelector<HTMLInputElement>('input[name="id"]')!;
127
+ const pwInput = form.querySelector<HTMLInputElement>('input[name="password"]')!;
128
+ const button = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
129
+ button.disabled = true;
130
+ try {
131
+ const id = idInput.value.trim().toLowerCase();
132
+ const salt = dec(data.salt);
133
+ const idBytes = new TextEncoder().encode(id);
134
+ const joined = new Uint8Array(salt.length + idBytes.length);
135
+ joined.set(salt);
136
+ joined.set(idBytes, salt.length);
137
+ const h = await sha256Hex(joined);
138
+ const user = data.users.find((u) => u.h === h);
139
+ if (user) {
140
+ const kek = await deriveKek(pwInput.value, dec(user.s));
141
+ try {
142
+ const k = new Uint8Array(await aesDecrypt(kek, dec(user.iv), dec(user.w)));
143
+ if (await tryDecrypt(gate, data, k, logoutLabel)) {
144
+ try {
145
+ localStorage.setItem(STORE, JSON.stringify({ k: enc(k), t: Date.now() }));
146
+ } catch {
147
+ /* private browsing — session just won't persist */
148
+ }
149
+ return;
150
+ }
151
+ } catch {
152
+ /* wrong password (unwrap failed) — fall through to the error */
153
+ }
154
+ }
155
+ error.hidden = false;
156
+ } finally {
157
+ button.disabled = false;
158
+ }
159
+ });
160
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Build-side half of private (login-gated) content. Runs only during
3
+ * `astro build` / `astro dev` (Node): derives the site content key, wraps it
4
+ * per user, and encrypts rendered HTML. The browser half (login + decrypt)
5
+ * lives in ./private-client.ts; the whole design is documented in
6
+ * docs/private-content-design.md.
7
+ *
8
+ * Env contract (see .env.sample in a consuming site):
9
+ * AAS_PRIVATE_USERS "id:password,id2:password2" (plaintext passwords)
10
+ * AAS_PRIVATE_MASTER_SECRET stable secret K derives from; rotate = global logout
11
+ * AAS_PRIVATE_SESSION_DAYS client session lifetime in days (0 = never; default 30)
12
+ */
13
+ import { createCipheriv, createHash, hkdfSync, pbkdf2Sync, randomBytes } from 'node:crypto';
14
+
15
+ /** Must match private-client.ts. */
16
+ export const KDF_ITERATIONS = 600_000;
17
+ const HKDF_INFO = 'aas-private-v1';
18
+ const MIN_PASSWORD_LENGTH = 10;
19
+
20
+ export interface PrivateUserRecord {
21
+ /** SHA-256(buildSalt + lowercase(id)), hex — the deployed site never carries raw ids. */
22
+ h: string;
23
+ /** PBKDF2 salt, base64. */
24
+ s: string;
25
+ /** AES-GCM IV for the wrapped key, base64. */
26
+ iv: string;
27
+ /** wrappedK = AES-256-GCM(KEK, K) with auth tag appended, base64. */
28
+ w: string;
29
+ }
30
+
31
+ const b64 = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64');
32
+
33
+ interface PrivateConfig {
34
+ key: Buffer; // K — the site content key
35
+ users: PrivateUserRecord[];
36
+ buildSalt: string; // base64; used by the client to hash the entered id
37
+ sessionDays: number;
38
+ }
39
+
40
+ let cached: PrivateConfig | null = null;
41
+
42
+ /**
43
+ * Parse env + derive keys, once per build. Throws (failing the build loudly)
44
+ * when private entries exist but the env contract isn't met — a misconfigured
45
+ * build must not ship silently locked-forever pages.
46
+ */
47
+ function getConfig(): PrivateConfig {
48
+ if (cached) return cached;
49
+
50
+ const secret = process.env.AAS_PRIVATE_MASTER_SECRET?.trim();
51
+ const usersRaw = process.env.AAS_PRIVATE_USERS?.trim();
52
+ if (!secret || !usersRaw) {
53
+ throw new Error(
54
+ '[private] this site has `private: true` entries, but AAS_PRIVATE_MASTER_SECRET ' +
55
+ 'and/or AAS_PRIVATE_USERS is not set. Set both in .env (locally) or as CI ' +
56
+ 'secrets — see docs/private-content-design.md.',
57
+ );
58
+ }
59
+
60
+ // K derives from the master secret (not random): ordinary redeploys keep
61
+ // logged-in devices working; rotating the secret is the global-logout switch.
62
+ const key = Buffer.from(hkdfSync('sha256', secret, 'aas-private', HKDF_INFO, 32));
63
+
64
+ const buildSaltBytes = randomBytes(16);
65
+ const users: PrivateUserRecord[] = [];
66
+ const seen = new Set<string>();
67
+ for (const pair of usersRaw.split(',')) {
68
+ const idx = pair.indexOf(':');
69
+ if (idx < 1) throw new Error(`[private] AAS_PRIVATE_USERS entry is not "id:password": "${pair.trim()}"`);
70
+ const id = pair.slice(0, idx).trim().toLowerCase();
71
+ const password = pair.slice(idx + 1).trim();
72
+ if (seen.has(id)) throw new Error(`[private] duplicate user id "${id}" in AAS_PRIVATE_USERS`);
73
+ seen.add(id);
74
+ if (password.length < MIN_PASSWORD_LENGTH)
75
+ throw new Error(
76
+ `[private] password for user "${id}" is shorter than ${MIN_PASSWORD_LENGTH} chars — ` +
77
+ 'wrapped keys can be brute-forced offline, use a long passphrase',
78
+ );
79
+ const salt = randomBytes(16);
80
+ const kek = pbkdf2Sync(password, salt, KDF_ITERATIONS, 32, 'sha256');
81
+ const iv = randomBytes(12);
82
+ const cipher = createCipheriv('aes-256-gcm', kek, iv);
83
+ const wrapped = Buffer.concat([cipher.update(key), cipher.final(), cipher.getAuthTag()]);
84
+ users.push({
85
+ h: createHash('sha256').update(Buffer.concat([buildSaltBytes, Buffer.from(id)])).digest('hex'),
86
+ s: b64(salt),
87
+ iv: b64(iv),
88
+ w: b64(wrapped),
89
+ });
90
+ }
91
+
92
+ const daysRaw = process.env.AAS_PRIVATE_SESSION_DAYS?.trim();
93
+ const sessionDays = daysRaw === undefined || daysRaw === '' ? 30 : Number(daysRaw);
94
+ if (!Number.isFinite(sessionDays) || sessionDays < 0)
95
+ throw new Error(`[private] AAS_PRIVATE_SESSION_DAYS must be a number ≥ 0 (got "${daysRaw}")`);
96
+
97
+ console.warn(
98
+ '[private] this build contains private entries — remember: the SOURCE repo must be ' +
99
+ 'private (the .mdx files are plaintext); only the built output is encrypted.',
100
+ );
101
+
102
+ cached = { key, users, buildSalt: b64(buildSaltBytes), sessionDays };
103
+ return cached;
104
+ }
105
+
106
+ /** Everything PrivateGate embeds for the client (records are ~120 bytes/user). */
107
+ export function privateClientData(): { users: PrivateUserRecord[]; salt: string; days: number } {
108
+ const { users, buildSalt, sessionDays } = getConfig();
109
+ return { users, salt: buildSalt, days: sessionDays };
110
+ }
111
+
112
+ /** AES-256-GCM encrypt rendered HTML with the site content key. */
113
+ export function encryptHtml(html: string): { iv: string; ct: string } {
114
+ const { key } = getConfig();
115
+ const iv = randomBytes(12);
116
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
117
+ const ct = Buffer.concat([cipher.update(html, 'utf8'), cipher.final(), cipher.getAuthTag()]);
118
+ return { iv: b64(iv), ct: b64(ct) };
119
+ }
120
+
121
+ /**
122
+ * Pathname registry for the sitemap filter: PrivateGate records every private
123
+ * page it renders; index.mjs (a separate module graph — hence globalThis) reads
124
+ * it in the sitemap `filter`, which @astrojs/sitemap runs after the pages built.
125
+ */
126
+ export function registerPrivatePath(pathname: string): void {
127
+ const g = globalThis as { __aasPrivatePaths?: Set<string> };
128
+ (g.__aasPrivatePaths ??= new Set()).add(pathname.replace(/\/?$/, '/'));
129
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Run a DOM-wiring routine now AND again whenever private content is decrypted
3
+ * and injected (the injected HTML wasn't there when page scripts first ran).
4
+ * Component scripts whose targets can sit inside a private gate wrap their init
5
+ * in this. `fn` must be idempotent or guard against double-wiring itself.
6
+ */
7
+ export const PRIVATE_DECRYPTED_EVENT = 'aas:private-decrypted';
8
+
9
+ export function initOnReady(fn: () => void): void {
10
+ fn();
11
+ document.addEventListener(PRIVATE_DECRYPTED_EVENT, () => fn());
12
+ }
@@ -11,13 +11,24 @@ import { site } from '@aas-data/site';
11
11
  * the `nav` / `draft` frontmatter, or by not authoring the page.
12
12
  *
13
13
  * A site sets overrides in `src/data/site.ts` (`sections`), which astro.config
14
- * also forwards to the theme integration for route filtering. Omitted = enabled.
14
+ * also forwards to the theme integration for route filtering. Omitted = enabled
15
+ * except `courses`, which is opt-IN (`{ courses: true }`): enabling it requires
16
+ * site-side data (`src/data/course-categories.ts`), so it must never switch on
17
+ * by a theme upgrade alone.
15
18
  */
16
- export type SectionKey = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
19
+ export type SectionKey =
20
+ | 'concepts'
21
+ | 'articles'
22
+ | 'courses'
23
+ | 'samples'
24
+ | 'slides'
25
+ | 'glossary'
26
+ | 'pages';
17
27
 
18
28
  const DEFAULTS: Record<SectionKey, boolean> = {
19
29
  concepts: true,
20
30
  articles: true,
31
+ courses: false, // opt-in: needs src/data/course-categories.ts on the site
21
32
  samples: true,
22
33
  slides: true,
23
34
  glossary: true,
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
3
3
  import type { GetStaticPaths } from 'astro';
4
4
  import BaseLayout from '../../layouts/BaseLayout.astro';
5
5
  import PageDetail from '../../components/PageDetail.astro';
6
+ import PrivateGate from '../../components/PrivateGate.astro';
6
7
  import { getPages, pageSlugOf } from '../../lib/pages';
7
8
  import { allLocales, langParam } from '../../lib/locales';
8
9
 
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
18
19
 
19
20
  const { lang, entry } = Astro.props;
20
21
  const slug = pageSlugOf(entry);
22
+ const priv = entry.data.private;
21
23
  ---
22
24
 
23
25
  <BaseLayout
24
26
  title={`${entry.data.title} — ${site.name}`}
25
- description={entry.data.description}
27
+ description={priv ? entry.data.teaser : entry.data.description}
26
28
  lang={lang}
27
29
  path={`${slug}/`}
30
+ noindex={priv}
28
31
  >
29
- <PageDetail entry={entry} lang={lang} />
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <PageDetail entry={entry} lang={lang} />
34
+ </PrivateGate>
30
35
  </BaseLayout>
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
3
3
  import type { GetStaticPaths } from 'astro';
4
4
  import BaseLayout from '../../../layouts/BaseLayout.astro';
5
5
  import ArticleDetail from '../../../components/ArticleDetail.astro';
6
+ import PrivateGate from '../../../components/PrivateGate.astro';
6
7
  import { getArticles, articleSlugOf } from '../../../lib/articles';
7
8
  import { allLocales, langParam } from '../../../lib/locales';
8
9
 
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
18
19
 
19
20
  const { lang, entry } = Astro.props;
20
21
  const slug = articleSlugOf(entry);
22
+ const priv = entry.data.private;
21
23
  ---
22
24
 
23
25
  <BaseLayout
24
26
  title={`${entry.data.title} — ${site.name}`}
25
- description={entry.data.description}
27
+ description={priv ? entry.data.teaser : entry.data.description}
26
28
  lang={lang}
27
29
  path={`article/${slug}/`}
30
+ noindex={priv}
28
31
  >
29
- <ArticleDetail entry={entry} lang={lang} />
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <ArticleDetail entry={entry} lang={lang} />
34
+ </PrivateGate>
30
35
  </BaseLayout>
@@ -3,6 +3,7 @@ import { site } from '@aas-data/site';
3
3
  import type { GetStaticPaths } from 'astro';
4
4
  import BaseLayout from '../../../layouts/BaseLayout.astro';
5
5
  import ConceptDetail from '../../../components/ConceptDetail.astro';
6
+ import PrivateGate from '../../../components/PrivateGate.astro';
6
7
  import { getConcepts, conceptSlugOf } from '../../../lib/concepts';
7
8
  import { allLocales, langParam } from '../../../lib/locales';
8
9
 
@@ -18,13 +19,17 @@ export const getStaticPaths = (async () => {
18
19
 
19
20
  const { lang, entry } = Astro.props;
20
21
  const slug = conceptSlugOf(entry);
22
+ const priv = entry.data.private;
21
23
  ---
22
24
 
23
25
  <BaseLayout
24
26
  title={`${entry.data.title} — ${site.name}`}
25
- description={entry.data.description}
27
+ description={priv ? entry.data.teaser : entry.data.description}
26
28
  lang={lang}
27
29
  path={`concept/${slug}/`}
30
+ noindex={priv}
28
31
  >
29
- <ConceptDetail entry={entry} lang={lang} />
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <ConceptDetail entry={entry} lang={lang} />
34
+ </PrivateGate>
30
35
  </BaseLayout>
@@ -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 CourseDetail from '../../../components/CourseDetail.astro';
6
+ import PrivateGate from '../../../components/PrivateGate.astro';
7
+ import { getCourses, courseSlugOf } from '../../../lib/courses';
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 getCourses(lang)) {
14
+ paths.push({ params: { lang: langParam(lang), id: courseSlugOf(entry) }, props: { lang, entry } });
15
+ }
16
+ }
17
+ return paths;
18
+ }) satisfies GetStaticPaths;
19
+
20
+ const { lang, entry } = Astro.props;
21
+ const slug = courseSlugOf(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={`course/${slug}/`}
30
+ noindex={priv}
31
+ >
32
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
33
+ <CourseDetail 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 CourseIndex from '../../../../components/CourseIndex.astro';
6
+ import { courseTree } from '@aas-data/course-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 courseTree.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 = courseTree.map.get(id)!;
21
+ ---
22
+
23
+ <BaseLayout title={`${node.label[lang]} — ${site.name}`} lang={lang} path={`course/category/${id}/`}>
24
+ <CourseIndex 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 CourseIndex from '../../../components/CourseIndex.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('course.title')} — ${site.name}`} lang={lang} path="course/">
17
+ <CourseIndex lang={lang} />
18
+ </BaseLayout>
@@ -0,0 +1,40 @@
1
+ import rss from '@astrojs/rss';
2
+ import type { APIContext, GetStaticPaths } from 'astro';
3
+ import { getRelativeLocaleUrl } from 'astro:i18n';
4
+ import { site } from '@aas-data/site';
5
+ import { getArticles, articleSlugOf } from '../../lib/articles';
6
+ import { allLocales, langParam } from '../../lib/locales';
7
+ import { useTranslations } from '../../i18n/ui';
8
+
9
+ // Per-locale RSS feed of the articles collection: /rss.xml (default locale)
10
+ // and /<code>/rss.xml. Injected with the `articles` section, so a site that
11
+ // turns the blog off has no feed either.
12
+ export const getStaticPaths = (() =>
13
+ allLocales.map((lang) => ({
14
+ params: { lang: langParam(lang) },
15
+ props: { lang },
16
+ }))) satisfies GetStaticPaths;
17
+
18
+ export async function GET(context: APIContext) {
19
+ const { lang } = context.props as { lang: string };
20
+ const t = useTranslations(lang);
21
+ if (!context.site) {
22
+ throw new Error('RSS needs `site` set in astro.config (it already powers the sitemap).');
23
+ }
24
+ // getArticles drops drafts; private entries stay out of the feed entirely,
25
+ // mirroring the sitemap's private-page filter.
26
+ const articles = (await getArticles(lang)).filter((a) => !a.data.private);
27
+ return rss({
28
+ title: `${site.name} — ${t('blog.title')}`,
29
+ description: t('blog.tagline'),
30
+ site: context.site,
31
+ customData: `<language>${lang}</language>`,
32
+ items: articles.map((a) => ({
33
+ title: a.data.title,
34
+ description: a.data.description,
35
+ pubDate: a.data.date,
36
+ // Locale-relative and base-aware; rss() resolves it against `site`.
37
+ link: getRelativeLocaleUrl(lang, `article/${articleSlugOf(a)}/`),
38
+ })),
39
+ });
40
+ }
@@ -4,6 +4,7 @@ import type { GetStaticPaths } from 'astro';
4
4
  import { getRelativeLocaleUrl } from 'astro:i18n';
5
5
  import BaseLayout from '../../../layouts/BaseLayout.astro';
6
6
  import StackDetail from '../../../components/StackDetail.astro';
7
+ import PrivateGate from '../../../components/PrivateGate.astro';
7
8
  import { getStacks, getStackAliases, slugOf, type StackEntry } from '../../../lib/stacks';
8
9
  import { allLocales, langParam } from '../../../lib/locales';
9
10
 
@@ -28,6 +29,7 @@ const { lang, entry, redirectTo } = Astro.props as {
28
29
  redirectTo: string | null;
29
30
  };
30
31
  const target = redirectTo ? getRelativeLocaleUrl(lang, `stack/${redirectTo}/`) : null;
32
+ const priv = entry?.data.private ?? false;
31
33
  ---
32
34
 
33
35
  {
@@ -48,11 +50,14 @@ const target = redirectTo ? getRelativeLocaleUrl(lang, `stack/${redirectTo}/`) :
48
50
  ) : (
49
51
  <BaseLayout
50
52
  title={`${entry!.data.name} — ${site.name}`}
51
- description={entry!.data.description}
53
+ description={priv ? entry!.data.teaser : entry!.data.description}
52
54
  lang={lang}
53
55
  path={`stack/${slugOf(entry!)}/`}
56
+ noindex={priv}
54
57
  >
55
- <StackDetail entry={entry!} lang={lang} />
58
+ <PrivateGate enabled={priv} lang={lang} title={entry!.data.name} teaser={entry!.data.teaser}>
59
+ <StackDetail entry={entry!} lang={lang} />
60
+ </PrivateGate>
56
61
  </BaseLayout>
57
62
  )
58
63
  }