stack-site-builder 1.18.0 → 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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,22 @@ 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.19.0] - 2026-07-23
15
+
16
+ ### Added
17
+
18
+ - **Header member login/logout** — sites with private content get an account
19
+ control in the header: a login dropdown (id/password, same credential
20
+ machinery as the page gates) and a logout button once signed in. The
21
+ control reveals itself only when the new `/aas-auth.json` endpoint reports
22
+ configured login users (it serves the salted user table every private page
23
+ already embeds — nothing new is exposed); sites without `AAS_PRIVATE_*`
24
+ env see nothing. Credential verification moved into a shared
25
+ `loginWithCredentials` helper in `lib/private-client`.
26
+ - **Slides nav hides without listed decks** — a site whose decks are all
27
+ private+unlisted reaches them from course pages, so the header's Slides
28
+ item now appears only when the index actually lists at least one deck.
29
+
14
30
  ## [1.18.0] - 2026-07-23
15
31
 
16
32
  ### Added
@@ -303,6 +319,7 @@ catalog sites from a thin content-only repository.
303
319
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
304
320
  consuming site for developing and previewing the theme on its own.
305
321
 
322
+ [1.19.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.18.0...v1.19.0
306
323
  [1.18.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.17.4...v1.18.0
307
324
  [1.17.4]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.17.3...v1.17.4
308
325
  [1.17.3]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.17.2...v1.17.3
package/index.mjs CHANGED
@@ -129,6 +129,13 @@ export default function aasTheme({ glossary, sections = {} }) {
129
129
  });
130
130
  }
131
131
 
132
+ // Global (locale-less) login user table for the header login control —
133
+ // `{ enabled: false }` on sites without private-content env config.
134
+ injectRoute({
135
+ pattern: '/aas-auth.json',
136
+ entrypoint: 'stack-site-builder/pages/aas-auth.json.ts',
137
+ });
138
+
132
139
  updateConfig({
133
140
  markdown: aasMarkdown({ glossary, locales, defaultLocale }),
134
141
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.18.0",
4
+ "version": "1.19.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": {
package/src/i18n/ui.ts CHANGED
@@ -71,6 +71,7 @@ export const ui = {
71
71
  'code.copy': 'Copy code',
72
72
  'code.copied': 'Copied',
73
73
  'private.badge': 'Private',
74
+ 'private.login': 'Log in',
74
75
  'private.locked': 'This content is private. Log in to view it.',
75
76
  'private.id': 'ID',
76
77
  'private.password': 'Password',
@@ -247,6 +248,7 @@ export const ui = {
247
248
  'code.copy': '코드 복사',
248
249
  'code.copied': '복사됨',
249
250
  'private.badge': '비공개',
251
+ 'private.login': '로그인',
250
252
  'private.locked': '비공개 콘텐츠입니다. 로그인 후 볼 수 있습니다.',
251
253
  'private.id': '아이디',
252
254
  'private.password': '비밀번호',
@@ -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 =
@@ -119,12 +124,16 @@ const allNavItems: {
119
124
  section: 'samples',
120
125
  svg: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
121
126
  },
122
- {
123
- href: getRelativeLocaleUrl(lang, 'slides/'),
124
- label: t('nav.slides'),
125
- section: 'slides',
126
- 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"/>',
127
- },
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
+ : []),
128
137
  {
129
138
  href: getRelativeLocaleUrl(lang, 'glossary/'),
130
139
  label: t('nav.glossary'),
@@ -263,6 +272,53 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
263
272
  ))
264
273
  }
265
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>
266
322
  <ThemeToggle lang={lang} />
267
323
  <LanguageSwitcher lang={lang} path={path} />
268
324
  <details class="aas-menu relative sm:hidden">
@@ -328,6 +384,55 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
328
384
  <BackToTop lang={lang} />
329
385
 
330
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
+
331
436
  import { initOnReady } from '../lib/reinit';
332
437
 
333
438
  // Clicking a heading's "#" anchor copies that section's URL to the
@@ -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');
@@ -0,0 +1,22 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { privateClientData } from '../lib/private';
3
+
4
+ /**
5
+ * The site's login user table (salted id hashes + per-user wrapped keys —
6
+ * the same data every private page already embeds in its gate payload, so
7
+ * this exposes nothing new). Powers the header login control on sites with
8
+ * private content; sites without the AAS_PRIVATE_* env render
9
+ * `{ enabled: false }` and the control stays hidden.
10
+ */
11
+ export const GET: APIRoute = () => {
12
+ try {
13
+ const data = privateClientData();
14
+ return new Response(JSON.stringify({ enabled: true, ...data }), {
15
+ headers: { 'Content-Type': 'application/json' },
16
+ });
17
+ } catch {
18
+ return new Response(JSON.stringify({ enabled: false }), {
19
+ headers: { 'Content-Type': 'application/json' },
20
+ });
21
+ }
22
+ };