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.
- package/CHANGELOG.md +44 -0
- package/README.md +105 -3
- package/index.d.ts +15 -5
- package/index.mjs +26 -2
- package/package.json +2 -1
- package/src/components/ArticleCard.astro +9 -3
- package/src/components/ArticleLink.astro +7 -2
- package/src/components/BlogIndex.astro +2 -0
- package/src/components/Bookmark.astro +37 -0
- package/src/components/CategoryIndex.astro +2 -0
- package/src/components/CodeSamples.astro +6 -1
- package/src/components/ConceptCard.astro +9 -3
- package/src/components/ConceptIndex.astro +2 -0
- package/src/components/ConceptLink.astro +7 -2
- package/src/components/CourseCard.astro +97 -0
- package/src/components/CourseDetail.astro +116 -0
- package/src/components/CourseIndex.astro +145 -0
- package/src/components/DeckView.astro +17 -2
- package/src/components/DetailTabs.astro +6 -1
- package/src/components/DifficultyStars.astro +34 -0
- package/src/components/Embed.astro +36 -0
- package/src/components/Home.astro +2 -0
- package/src/components/Lead.astro +8 -0
- package/src/components/MermaidLoader.astro +3 -1
- package/src/components/PricingSection.astro +4 -1
- package/src/components/PrivateGate.astro +135 -0
- package/src/components/ProjectViewer.astro +24 -13
- package/src/components/RelatedCourses.astro +64 -0
- package/src/components/SlidesIndex.astro +8 -2
- package/src/components/StackCard.astro +15 -3
- package/src/components/StackDetail.astro +9 -0
- package/src/components/TagIndex.astro +2 -0
- package/src/components/TocRail.astro +12 -6
- package/src/components/VendorIndex.astro +2 -0
- package/src/content.ts +90 -4
- package/src/i18n/ui.ts +47 -0
- package/src/layouts/BaseLayout.astro +26 -4
- package/src/lib/courses.ts +26 -0
- package/src/lib/private-client.ts +160 -0
- package/src/lib/private.ts +129 -0
- package/src/lib/reinit.ts +12 -0
- package/src/lib/sections.ts +13 -2
- package/src/pages/[...lang]/[page].astro +7 -2
- package/src/pages/[...lang]/article/[...id].astro +7 -2
- package/src/pages/[...lang]/concept/[...id].astro +7 -2
- package/src/pages/[...lang]/course/[...id].astro +35 -0
- package/src/pages/[...lang]/course/category/[id].astro +25 -0
- package/src/pages/[...lang]/course/index.astro +18 -0
- package/src/pages/[...lang]/rss.xml.ts +40 -0
- 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
|
+
}
|
package/src/lib/sections.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
}
|