stack-site-builder 1.12.0 → 1.14.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 (37) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +80 -1
  3. package/index.d.ts +10 -0
  4. package/index.mjs +29 -1
  5. package/package.json +1 -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/CategoryIndex.astro +2 -0
  10. package/src/components/CodeSamples.astro +6 -1
  11. package/src/components/ConceptCard.astro +9 -3
  12. package/src/components/ConceptIndex.astro +2 -0
  13. package/src/components/ConceptLink.astro +7 -2
  14. package/src/components/DeckView.astro +17 -2
  15. package/src/components/DetailTabs.astro +6 -1
  16. package/src/components/Home.astro +2 -0
  17. package/src/components/MermaidLoader.astro +3 -1
  18. package/src/components/PricingSection.astro +4 -1
  19. package/src/components/PrivateGate.astro +135 -0
  20. package/src/components/ProjectViewer.astro +24 -13
  21. package/src/components/SlidesIndex.astro +8 -2
  22. package/src/components/StackCard.astro +15 -3
  23. package/src/components/StackDetail.astro +9 -0
  24. package/src/components/TagIndex.astro +2 -0
  25. package/src/components/TocRail.astro +12 -6
  26. package/src/components/VendorIndex.astro +2 -0
  27. package/src/content.ts +21 -0
  28. package/src/i18n/ui.ts +14 -0
  29. package/src/layouts/BaseLayout.astro +21 -5
  30. package/src/lib/private-client.ts +160 -0
  31. package/src/lib/private.ts +129 -0
  32. package/src/lib/reinit.ts +12 -0
  33. package/src/lib/sections.ts +34 -0
  34. package/src/pages/[...lang]/[page].astro +7 -2
  35. package/src/pages/[...lang]/article/[...id].astro +7 -2
  36. package/src/pages/[...lang]/concept/[...id].astro +7 -2
  37. package/src/pages/[...lang]/stack/[...id].astro +7 -2
@@ -290,7 +290,12 @@ function formatDate(iso: string): string {
290
290
  const PARAM_EX = 'ex'; // sample project folder, e.g. "langgraph_1"
291
291
  const PARAM_FILE = 'file'; // file path within the project, e.g. "app.py"
292
292
 
293
+ import { initOnReady } from '../lib/reinit';
294
+ // Re-runs after private-content decryption; the dataset flag guards each root.
295
+ initOnReady(() =>
293
296
  document.querySelectorAll<HTMLElement>('[data-projects]').forEach((root) => {
297
+ if (root.dataset.aasInit) return;
298
+ root.dataset.aasInit = '1';
294
299
  const projDD = root.querySelector<HTMLDetailsElement>('[data-project-dd]');
295
300
  const projCurrent = root.querySelector<HTMLElement>('[data-project-current]');
296
301
  const projOptions = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-project-option]'));
@@ -392,13 +397,14 @@ function formatDate(iso: string): string {
392
397
  document.addEventListener('aas:tabchange', (e) => {
393
398
  if ((e as CustomEvent<{ id: string }>).detail.id === 'impl') reflectSelection();
394
399
  });
395
- });
400
+ }));
396
401
  </script>
397
402
 
398
403
  <script>
399
404
  // Collapsible README, remembered in localStorage (one preference for all
400
405
  // implementation samples). The inline pre-paint above applies it before paint;
401
406
  // this binds the toggle and keeps every README in sync.
407
+ import { initOnReady } from '../lib/reinit';
402
408
  const README_KEY = 'aas:readme-collapsed';
403
409
  const isReadmeCollapsed = () => {
404
410
  try {
@@ -416,16 +422,21 @@ function formatDate(iso: string): string {
416
422
  if (body) body.hidden = collapsed;
417
423
  });
418
424
  }
419
- applyReadme(isReadmeCollapsed());
420
- document.querySelectorAll<HTMLButtonElement>('[data-readme-toggle]').forEach((btn) =>
421
- btn.addEventListener('click', () => {
422
- const collapsed = !isReadmeCollapsed();
423
- try {
424
- localStorage.setItem(README_KEY, collapsed ? '1' : '0');
425
- } catch {
426
- /* private mode — best effort */
427
- }
428
- applyReadme(collapsed);
429
- }),
430
- );
425
+ // Re-runs after private-content decryption; the dataset flag guards each button.
426
+ initOnReady(() => {
427
+ applyReadme(isReadmeCollapsed());
428
+ document.querySelectorAll<HTMLButtonElement>('[data-readme-toggle]').forEach((btn) => {
429
+ if (btn.dataset.aasInit) return;
430
+ btn.dataset.aasInit = '1';
431
+ btn.addEventListener('click', () => {
432
+ const collapsed = !isReadmeCollapsed();
433
+ try {
434
+ localStorage.setItem(README_KEY, collapsed ? '1' : '0');
435
+ } catch {
436
+ /* private mode — best effort */
437
+ }
438
+ applyReadme(collapsed);
439
+ });
440
+ });
441
+ });
431
442
  </script>
@@ -32,8 +32,14 @@ const decks = await getDecks(lang);
32
32
  <p class="text-xs font-medium tracking-wide text-[var(--aas-muted)] uppercase">
33
33
  {t('slides.deck')}
34
34
  </p>
35
- <h2 class="mt-1 text-xl font-semibold text-[var(--aas-text)]">{deck.data.title}</h2>
36
- <p class="mt-2 text-sm leading-relaxed text-[var(--aas-muted)]">{deck.data.description}</p>
35
+ <h2 class="mt-1 text-xl font-semibold text-[var(--aas-text)]">
36
+ {deck.data.private && (
37
+ <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>
38
+ )}{deck.data.title}
39
+ </h2>
40
+ <p class="mt-2 text-sm leading-relaxed text-[var(--aas-muted)]">
41
+ {deck.data.private ? (deck.data.teaser ?? '') : deck.data.description}
42
+ </p>
37
43
  <span class="mt-4 inline-flex items-center gap-1 text-sm font-medium text-[var(--aas-text)]">
38
44
  {t('slides.open')}
39
45
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -26,9 +26,12 @@ interface Props {
26
26
  repo?: string;
27
27
  version?: string;
28
28
  deprecated?: boolean;
29
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
30
+ isPrivate?: boolean;
31
+ teaser?: string;
29
32
  }
30
33
 
31
- const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated } =
34
+ const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated, isPrivate = false, teaser } =
32
35
  Astro.props;
33
36
  const t = useTranslations(lang);
34
37
  const href = getRelativeLocaleUrl(lang, `stack/${slug}/`);
@@ -54,7 +57,11 @@ const langTokens = (language ?? '')
54
57
  .split('/')
55
58
  .map((s) => s.trim())
56
59
  .filter(Boolean);
57
- const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
60
+ // Private entries: the card carries only public copy — the title and the
61
+ // authored `teaser` (description stays inside the encrypted detail page), and
62
+ // the search index must not include the hidden description either.
63
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
64
+ const searchText = `${name} ${shownDesc} ${tags.join(' ')}`.toLowerCase();
58
65
  ---
59
66
 
60
67
  {/* The card is a container, not a link: the name is the stretched primary link
@@ -85,6 +92,11 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
85
92
  <div class="min-w-0">
86
93
  <h3 class="truncate text-base font-semibold text-[var(--aas-text)]">
87
94
  <a href={href} class="no-underline after:absolute after:inset-0 after:content-['']">
95
+ {isPrivate && (
96
+ <span aria-label={t('private.badge')} title={t('private.badge')}>
97
+ 🔒{' '}
98
+ </span>
99
+ )}
88
100
  {name}
89
101
  </a>
90
102
  </h3>
@@ -100,7 +112,7 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
100
112
  <p
101
113
  class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
102
114
  data-card-desc
103
- set:html={inlineMd(description)}
115
+ set:html={inlineMd(shownDesc)}
104
116
  />
105
117
 
106
118
  {/* Secondary facts: version · release date · language · license.
@@ -402,8 +402,16 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
402
402
 
403
403
  <script>
404
404
  import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
405
+ import { initOnReady } from '../lib/reinit';
405
406
 
407
+ // Wrapped in initOnReady: on a private tool page all of this DOM sits inside
408
+ // the encrypted body, so the load-time run wires nothing (null rail, empty
409
+ // lists — its document listeners are inert) and the post-decryption run does
410
+ // the real wiring against the injected DOM.
411
+ initOnReady(() => {
406
412
  const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
413
+ if (rail?.dataset.aasInit) return;
414
+ if (rail) rail.dataset.aasInit = '1';
407
415
  // Scroll-spy + back-to-top + prose-<details> persistence, shared with
408
416
  // TocRail.astro. The tab/version/example handlers below re-run the spy after
409
417
  // changing which headings are visible.
@@ -481,6 +489,7 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
481
489
  });
482
490
  }),
483
491
  );
492
+ });
484
493
  </script>
485
494
 
486
495
  {
@@ -45,6 +45,8 @@ const entries = (await getStacksByTag(lang, tag)).sort((a, b) =>
45
45
  repo={e.data.repo}
46
46
  version={e.data.version}
47
47
  deprecated={e.data.deprecated}
48
+ isPrivate={e.data.private}
49
+ teaser={e.data.teaser}
48
50
  />
49
51
  ))
50
52
  }
@@ -64,11 +64,17 @@ const hasContent = items.length > 0 || relatedTools.length > 0 || Boolean(projec
64
64
 
65
65
  <script>
66
66
  import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
67
+ import { initOnReady } from '../lib/reinit';
67
68
 
68
- const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
69
- if (rail) {
70
- startScrollSpy(rail);
71
- initBackToTop(rail);
72
- initReveals(rail);
73
- }
69
+ // Re-runs after private-content decryption (the rail ships inside the
70
+ // encrypted body); the dataset flag keeps a live rail from double-wiring.
71
+ initOnReady(() => {
72
+ const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
73
+ if (rail && !rail.dataset.aasInit) {
74
+ rail.dataset.aasInit = '1';
75
+ startScrollSpy(rail);
76
+ initBackToTop(rail);
77
+ initReveals(rail);
78
+ }
79
+ });
74
80
  </script>
@@ -45,6 +45,8 @@ const vendorName = entries[0]?.data.vendor ?? vendor;
45
45
  repo={e.data.repo}
46
46
  version={e.data.version}
47
47
  deprecated={e.data.deprecated}
48
+ isPrivate={e.data.private}
49
+ teaser={e.data.teaser}
48
50
  />
49
51
  ))
50
52
  }
package/src/content.ts CHANGED
@@ -107,6 +107,11 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
107
107
  )
108
108
  .default([]),
109
109
  featured: z.boolean().default(false),
110
+ // Login-gated entry: the body ships encrypted and listings show only the
111
+ // title (+ `teaser`, an explicitly PUBLIC one-liner written for the gate).
112
+ // See docs/private-content-design.md. Requires the AAS_PRIVATE_* env vars.
113
+ private: z.boolean().default(false),
114
+ teaser: z.string().optional(),
110
115
  }),
111
116
  });
112
117
 
@@ -147,6 +152,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
147
152
  .optional(),
148
153
  tags: z.array(z.string()).default([]),
149
154
  draft: z.boolean().default(false),
155
+ // Login-gated entry (encrypted body; listings show title + optional
156
+ // PUBLIC `teaser`). See docs/private-content-design.md.
157
+ private: z.boolean().default(false),
158
+ teaser: z.string().optional(),
150
159
  }),
151
160
  });
152
161
 
@@ -181,6 +190,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
181
190
  tags: z.array(z.string()).default([]),
182
191
  order: z.number().optional(), // manual sort on the index (lower first)
183
192
  draft: z.boolean().default(false),
193
+ // Login-gated entry (encrypted body; listings show title + optional
194
+ // PUBLIC `teaser`). See docs/private-content-design.md.
195
+ private: z.boolean().default(false),
196
+ teaser: z.string().optional(),
184
197
  }),
185
198
  });
186
199
 
@@ -221,6 +234,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
221
234
  toc_level: z.number().int().min(2).max(4).default(2),
222
235
  toc_open: z.boolean().default(true),
223
236
  draft: z.boolean().default(false),
237
+ // Login-gated deck (encrypted body; the slides index shows title +
238
+ // optional PUBLIC `teaser`). See docs/private-content-design.md.
239
+ private: z.boolean().default(false),
240
+ teaser: z.string().optional(),
224
241
  }),
225
242
  });
226
243
 
@@ -249,6 +266,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
249
266
  navLabel: z.string().optional(),
250
267
  order: z.number().default(0),
251
268
  draft: z.boolean().default(false),
269
+ // Login-gated page (encrypted body; the nav/meta show title + optional
270
+ // PUBLIC `teaser`). See docs/private-content-design.md.
271
+ private: z.boolean().default(false),
272
+ teaser: z.string().optional(),
252
273
  }),
253
274
  });
254
275
 
package/src/i18n/ui.ts CHANGED
@@ -67,6 +67,13 @@ export const ui = {
67
67
  'nav.menu': 'Menu',
68
68
  'code.copy': 'Copy code',
69
69
  'code.copied': 'Copied',
70
+ 'private.badge': 'Private',
71
+ 'private.locked': 'This content is private. Log in to view it.',
72
+ 'private.id': 'ID',
73
+ 'private.password': 'Password',
74
+ 'private.submit': 'Unlock',
75
+ 'private.error': 'Wrong ID or password.',
76
+ 'private.logout': 'Log out',
70
77
  'slides.title': 'Slides',
71
78
  'slides.tagline': 'Concept decks — the same ideas as the concept pages, in slide form.',
72
79
  'slides.deck': 'Deck',
@@ -209,6 +216,13 @@ export const ui = {
209
216
  'nav.menu': '메뉴',
210
217
  'code.copy': '코드 복사',
211
218
  'code.copied': '복사됨',
219
+ 'private.badge': '비공개',
220
+ 'private.locked': '비공개 콘텐츠입니다. 로그인 후 볼 수 있습니다.',
221
+ 'private.id': '아이디',
222
+ 'private.password': '비밀번호',
223
+ 'private.submit': '잠금 해제',
224
+ 'private.error': '아이디 또는 비밀번호가 올바르지 않습니다.',
225
+ 'private.logout': '로그아웃',
212
226
  'slides.title': '슬라이드',
213
227
  'slides.tagline': '개념 덱 — 개념 페이지와 같은 내용을 슬라이드로 옮겼습니다.',
214
228
  'slides.deck': '덱',
@@ -7,6 +7,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher.astro';
7
7
  import ThemeToggle from '../components/ThemeToggle.astro';
8
8
  import BackToTop from '../components/BackToTop.astro';
9
9
  import { getNavPages, pageSlugOf } from '../lib/pages';
10
+ import { sectionEnabled, type SectionKey } from '../lib/sections';
10
11
 
11
12
  interface Props {
12
13
  title: string;
@@ -16,9 +17,11 @@ interface Props {
16
17
  path: string;
17
18
  /** Wider container — used by detail pages that have a right-rail TOC. */
18
19
  wide?: boolean;
20
+ /** Private entries: ask crawlers not to index the gate page. */
21
+ noindex?: boolean;
19
22
  }
20
23
 
21
- const { title, lang, path, wide = false } = Astro.props;
24
+ const { title, lang, path, wide = false, noindex = false } = Astro.props;
22
25
  const t = useTranslations(lang);
23
26
  // Collapse any authored line breaks (used for on-page wrapping) to a single
24
27
  // space so the SEO meta description stays one clean line.
@@ -43,12 +46,13 @@ const navPages = await getNavPages(lang);
43
46
  // layouts never drift: an icon-only row (≥sm) and a labelled dropdown menu
44
47
  // (<sm, so a phone header stays to a few controls instead of a long icon run).
45
48
  // `svg` is the icon's inner markup (drawn into a shared <svg> shell below).
46
- const navItems: {
49
+ const allNavItems: {
47
50
  href: string;
48
51
  label: string;
49
52
  svg: string;
50
53
  external?: boolean;
51
54
  keepFilters?: boolean;
55
+ section?: SectionKey;
52
56
  }[] = [
53
57
  {
54
58
  href: `${home}#categories`,
@@ -59,31 +63,37 @@ const navItems: {
59
63
  {
60
64
  href: getRelativeLocaleUrl(lang, 'concept/'),
61
65
  label: t('nav.concepts'),
66
+ section: 'concepts',
62
67
  svg: '<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>',
63
68
  },
64
69
  {
65
70
  href: getRelativeLocaleUrl(lang, 'article/'),
66
71
  label: t('nav.blog'),
72
+ section: 'articles',
67
73
  svg: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
68
74
  },
69
75
  {
70
76
  href: getRelativeLocaleUrl(lang, 'sample/'),
71
77
  label: t('nav.samples'),
78
+ section: 'samples',
72
79
  svg: '<polyline points="4 17 10 11 4 5"/><line x1="12" x2="20" y1="19" y2="19"/>',
73
80
  },
74
81
  {
75
82
  href: getRelativeLocaleUrl(lang, 'slides/'),
76
83
  label: t('nav.slides'),
84
+ section: 'slides',
77
85
  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"/>',
78
86
  },
79
87
  {
80
88
  href: getRelativeLocaleUrl(lang, 'glossary/'),
81
89
  label: t('nav.glossary'),
90
+ section: 'glossary',
82
91
  svg: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
83
92
  },
84
93
  ...navPages.map((p) => ({
85
94
  href: getRelativeLocaleUrl(lang, `${pageSlugOf(p)}/`),
86
95
  label: p.data.navLabel ?? p.data.title,
96
+ section: 'pages' as SectionKey,
87
97
  svg: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
88
98
  })),
89
99
  {
@@ -93,6 +103,8 @@ const navItems: {
93
103
  svg: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C4 2 3 2 3 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 2 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>',
94
104
  },
95
105
  ];
106
+ // Drop the nav links for sections the site turned off (their routes aren't built).
107
+ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(item.section));
96
108
  ---
97
109
 
98
110
  <!doctype html>
@@ -103,6 +115,7 @@ const navItems: {
103
115
  <meta name="generator" content={Astro.generator} />
104
116
  <title>{title}</title>
105
117
  <meta name="description" content={description} />
118
+ {noindex && <meta name="robots" content="noindex" />}
106
119
  <link rel="icon" type="image/svg+xml" href={`${base.replace(/\/$/, '')}/favicon.svg`} />
107
120
  {/* On the root (default-locale) home, send first-time visitors to the
108
121
  locale matching their browser — unless they've already picked one (the
@@ -252,6 +265,8 @@ const navItems: {
252
265
  <BackToTop lang={lang} />
253
266
 
254
267
  <script>
268
+ import { initOnReady } from '../lib/reinit';
269
+
255
270
  // Clicking a heading's "#" anchor copies that section's URL to the
256
271
  // clipboard (and updates the address bar) instead of just navigating.
257
272
  document.addEventListener('click', (e) => {
@@ -269,8 +284,9 @@ const navItems: {
269
284
  // Mount a copy-to-clipboard button on every code block: README/overview
270
285
  // prose (.prose pre), the file viewer (.aas-projfile), and the Code tab
271
286
  // (.aas-code). The button is pinned to a non-scrolling host so it stays in
272
- // place on long, horizontally-scrolling code.
273
- (function mountCopyButtons() {
287
+ // place on long, horizontally-scrolling code. Re-runs after private-content
288
+ // decryption (mount() skips hosts that already have a button).
289
+ initOnReady(function mountCopyButtons() {
274
290
  const LABEL = document.documentElement.dataset.copyLabel || 'Copy code';
275
291
  const DONE = document.documentElement.dataset.copiedLabel || 'Copied';
276
292
  const COPY_ICON =
@@ -324,7 +340,7 @@ const navItems: {
324
340
  const fig = el.closest('figure');
325
341
  mount(el, (fig as HTMLElement) ?? wrap(el));
326
342
  });
327
- })();
343
+ });
328
344
 
329
345
  // Close any open <details> dropdown when clicking outside it (or on Esc).
330
346
  // Only nav dropdowns — leave file-tree folders (.aas-tree-dir) and prose
@@ -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
+ }