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 +17 -0
- package/index.mjs +7 -0
- package/package.json +1 -1
- package/src/i18n/ui.ts +2 -0
- package/src/layouts/BaseLayout.astro +111 -6
- package/src/lib/private-client.ts +45 -1
- package/src/pages/aas-auth.json.ts +22 -0
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.
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
};
|