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
package/CHANGELOG.md CHANGED
@@ -11,6 +11,37 @@ 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.14.0] - 2026-07-21
15
+
16
+ ### Added
17
+
18
+ - **Private (login-gated) content** — mark any entry in any collection
19
+ (stacks, concepts, articles, slides, pages) with `private: true`. The
20
+ rendered body ships AES-256-GCM-encrypted and is decrypted in the browser
21
+ after login, so it works on any static host with no server; the users
22
+ (`AAS_PRIVATE_USERS`, `id:password` pairs) and the master secret
23
+ (`AAS_PRIVATE_MASTER_SECRET` — rotate to force every device to log in again)
24
+ come from env vars, with sessions cached per device for
25
+ `AAS_PRIVATE_SESSION_DAYS` (default 30). Listings show only the title + 🔒
26
+ plus an optional public `teaser`; private pages are `noindex` and excluded
27
+ from the sitemap; the client re-initializes embedded behaviors (TOC,
28
+ mermaid, tabs, copy buttons, the slide deck engine) after decryption. The
29
+ source repo must be private — `.mdx` files stay plaintext; see
30
+ docs/private-content-design.md.
31
+
32
+ ## [1.13.0] - 2026-07-20
33
+
34
+ ### Added
35
+
36
+ - **Optional sections** — a site can turn off any of the secondary sections
37
+ (concepts, articles, samples, slides, glossary, and the standalone `pages`
38
+ collection); the core catalog stays on. Disabling one removes both its routes
39
+ and its header-nav item. Declare `sections` in `src/data/site.ts` (hides the
40
+ nav item) and forward it to `aasTheme({ sections })` in astro.config (skips the
41
+ routes); the key type (`SectionKey`) is exported from the theme, so
42
+ `satisfies Partial<Record<SectionKey, boolean>>` gives autocomplete of the
43
+ valid keys. The playground drops `slides` to demonstrate.
44
+
14
45
  ## [1.12.0] - 2026-07-20
15
46
 
16
47
  Locales are now **site-configurable**. The theme was wired to exactly two
@@ -107,6 +138,8 @@ catalog sites from a thin content-only repository.
107
138
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
108
139
  consuming site for developing and previewing the theme on its own.
109
140
 
141
+ [1.14.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.13.0...v1.14.0
142
+ [1.13.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.12.0...v1.13.0
110
143
  [1.12.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.11.0...v1.12.0
111
144
  [1.11.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.10.0...v1.11.0
112
145
  [1.10.0]: https://github.com/CodeCompose7/stack-site-builder/releases/tag/v1.10.0
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # stack-site-builder
2
2
 
3
+ > 한국어: [docs/readme.ko.md](docs/readme.ko.md)
4
+
3
5
  An [Astro](https://astro.build) theme for curated "awesome stack" catalog
4
6
  sites — a tool catalog with per-entry detail pages, concepts, articles,
5
7
  presentation slides, runnable samples, a `[[wikilink]]` glossary, and full
@@ -34,7 +36,7 @@ export const collections = defineAasCollections({ categoryMap });
34
36
 
35
37
  | Where | What |
36
38
  | --- | --- |
37
- | `src/data/site.ts` | Site identity: name, repo URL, the `locales` it ships, per-locale UI string overrides |
39
+ | `src/data/site.ts` | Site identity: name, repo URL, the `locales` it ships, optional `sections` toggles, per-locale UI string overrides |
38
40
  | `src/data/categories.ts` | The tool-catalog category tree (validated against content) |
39
41
  | `src/data/concept-categories.ts` · `article-categories.ts` | Taxonomies for concepts / articles |
40
42
  | `src/data/glossary.mjs` | `[[Term]]` wikilink targets |
@@ -62,3 +64,80 @@ locale from one source. To add a language (say Japanese):
62
64
  labels the same way you did for the built-in locales.
63
65
 
64
66
  No theme files change — adding a locale is entirely site config and content.
67
+
68
+ ## Sections
69
+
70
+ The core catalog (home, tool detail, categories, tags, vendors) is always on.
71
+ The rest are opt-out — **concepts, articles, samples, slides, glossary** and the
72
+ standalone **pages** collection (About/소개, …) — so a site can ship only what it
73
+ needs. Turning one off removes both its routes and its header-nav item. (`pages`
74
+ also has finer control: each page's `nav` / `draft` frontmatter, or simply not
75
+ authoring it.)
76
+
77
+ Declare the toggles once in `src/data/site.ts` and forward them to the theme in
78
+ astro.config (which needs them to skip route injection). Import `SectionKey` from
79
+ the theme so `satisfies` lists the valid keys as you type:
80
+
81
+ ```ts
82
+ // src/data/site.ts
83
+ import type { SectionKey } from 'stack-site-builder';
84
+ export const site = {
85
+ /* … */
86
+ sections: { slides: false } satisfies Partial<Record<SectionKey, boolean>>,
87
+ };
88
+ ```
89
+
90
+ ```js
91
+ // astro.config.mjs
92
+ import { site } from './src/data/site';
93
+ integrations: [aasTheme({ glossary, sections: site.sections })];
94
+ ```
95
+
96
+ ## Private content
97
+
98
+ Any entry (tools, concepts, articles, slides, pages) can require login:
99
+
100
+ ```yaml
101
+ private: true
102
+ teaser: A public one-liner shown on cards and above the login form. # optional
103
+ ```
104
+
105
+ The body ships **encrypted** (AES-256-GCM, key wrapped per user with PBKDF2) and
106
+ is decrypted in the browser after login — no server needed, works on any static
107
+ host. Listings show only the title + 🔒 (+ teaser); private URLs get `noindex`
108
+ and stay out of the sitemap. Users and keys come from env vars (see
109
+ `playground/.env.sample`): `AAS_PRIVATE_USERS` (`id:password,…`),
110
+ `AAS_PRIVATE_MASTER_SECRET` (rotate it to log every device out) and
111
+ `AAS_PRIVATE_SESSION_DAYS`. Set them in `.env` locally or as CI secrets.
112
+
113
+ Two rules: the **source repo must be private** (the `.mdx` files are plaintext —
114
+ only the built output is encrypted), and when you remove a user, also rotate the
115
+ master secret. Full design: `docs/private-content-design.md`.
116
+
117
+ ## Deploying (and where the secrets live)
118
+
119
+ Encryption happens wherever `astro build` runs, reading `process.env` — the
120
+ values just come from a different place per environment:
121
+
122
+ | Where you build | Where the `AAS_PRIVATE_*` values live |
123
+ | --- | --- |
124
+ | Your machine (`pnpm build`, `firebase deploy`) | `.env` file (gitignored — copy `.env.sample`) |
125
+ | GitHub Actions (deploy on push) | **Repository secrets**: Settings → Secrets and variables → Actions |
126
+
127
+ `.env` never reaches GitHub; in CI the workflow maps repository secrets to env
128
+ vars on the build step:
129
+
130
+ ```yaml
131
+ - name: Build
132
+ run: pnpm install && pnpm build # encryption happens here
133
+ env:
134
+ AAS_PRIVATE_USERS: ${{ secrets.AAS_PRIVATE_USERS }}
135
+ AAS_PRIVATE_MASTER_SECRET: ${{ secrets.AAS_PRIVATE_MASTER_SECRET }}
136
+ AAS_PRIVATE_SESSION_DAYS: '30'
137
+ ```
138
+
139
+ A complete GitHub Pages workflow a site can copy is in
140
+ [`playground/.github/workflows/deploy.yml`](playground/.github/workflows/deploy.yml)
141
+ (inert inside the playground — workflows only run from a repo's root
142
+ `.github/`). Managing users/keys then never touches git: edit the secrets and
143
+ re-run the workflow. Sites without private content need none of this.
package/index.d.ts CHANGED
@@ -1,8 +1,18 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
 
3
+ /** Optional content sections that a site can turn off. `pages` is the
4
+ * standalone-pages collection (About/소개, …). */
5
+ export type SectionKey = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
6
+
3
7
  export interface AasThemeOptions {
4
8
  /** The site's glossary (`src/data/glossary.mjs`) — `[[wikilink]]` targets. */
5
9
  glossary: Record<string, unknown>;
10
+ /**
11
+ * Turn optional sections off (all on by default), e.g. `{ slides: false }`.
12
+ * A disabled section's routes aren't injected; pass the same object to
13
+ * `src/data/site.ts` `sections` so its header-nav item is hidden too.
14
+ */
15
+ sections?: Partial<Record<SectionKey, boolean>>;
6
16
  }
7
17
 
8
18
  /**
package/index.mjs CHANGED
@@ -54,13 +54,29 @@ function patternOf(file) {
54
54
  return `/${p}`.replace(/\/$/, '') || '/';
55
55
  }
56
56
 
57
+ // Which optional section each page belongs to (null = always-on core route).
58
+ // Mirrors SectionKey in src/lib/sections.ts.
59
+ /** @param {string} file @returns {string | null} */
60
+ function sectionOf(file) {
61
+ if (file.startsWith('concept/')) return 'concepts';
62
+ if (file.startsWith('article/')) return 'articles';
63
+ if (file.startsWith('sample/')) return 'samples';
64
+ if (file.startsWith('slides/')) return 'slides';
65
+ if (file === 'glossary.astro') return 'glossary';
66
+ if (file === '[page].astro') return 'pages';
67
+ return null;
68
+ }
69
+
57
70
  /**
58
71
  * @param {object} opts
59
72
  * @param {Record<string, any>} opts.glossary — the site's glossary
60
73
  * (`src/data/glossary.mjs`), used by `[[wikilink]]` resolution.
74
+ * @param {Partial<Record<string, boolean>>} [opts.sections] — optional-section
75
+ * toggles (`{ slides: false }`); a disabled section's routes are not injected.
76
+ * Keep it in sync with `src/data/site.ts` `sections` (which hides the nav item).
61
77
  * @returns {import('astro').AstroIntegration[]}
62
78
  */
63
- export default function aasTheme({ glossary }) {
79
+ export default function aasTheme({ glossary, sections = {} }) {
64
80
  /** @type {import('astro').AstroIntegration} */
65
81
  const core = {
66
82
  name: 'stack-site-builder',
@@ -77,7 +93,10 @@ export default function aasTheme({ glossary }) {
77
93
  // A single physical page tree under `[...lang]/` serves every locale: the
78
94
  // default at the root, each other under `/<code>/`. Each page's
79
95
  // getStaticPaths enumerates the locales, so adding one needs no new files.
96
+ // Skip a page whose optional section the site turned off (`{ slides: false }`).
80
97
  for (const file of PAGES) {
98
+ const section = sectionOf(file);
99
+ if (section && sections[section] === false) continue;
81
100
  injectRoute({
82
101
  pattern: patternOf(`[...lang]/${file}`),
83
102
  entrypoint: `stack-site-builder/pages/[...lang]/${file}`,
@@ -100,6 +119,15 @@ export default function aasTheme({ glossary }) {
100
119
  defaultLocale,
101
120
  locales: Object.fromEntries(locales.map((l) => [l, l])),
102
121
  },
122
+ // Private entries stay out of the sitemap. PrivateGate records each
123
+ // private pathname (via globalThis — it lives in a different module
124
+ // graph) while pages render; @astrojs/sitemap runs this after.
125
+ filter: (page) => {
126
+ const paths = globalThis.__aasPrivatePaths;
127
+ if (!paths) return true;
128
+ const p = new URL(page).pathname.replace(/\/?$/, '/');
129
+ return !paths.has(p);
130
+ },
103
131
  }),
104
132
  ],
105
133
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.12.0",
4
+ "version": "1.14.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": {
@@ -16,10 +16,14 @@ interface Props {
16
16
  image?: ImageMetadata;
17
17
  imageAlt?: string;
18
18
  version?: string;
19
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
20
+ isPrivate?: boolean;
21
+ teaser?: string;
19
22
  }
20
23
 
21
- const { slug, lang, title, description, date, tags, image, imageAlt, version } = Astro.props;
24
+ const { slug, lang, title, description, date, tags, image, imageAlt, version, isPrivate = false, teaser } = Astro.props;
22
25
  const t = useTranslations(lang);
26
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
23
27
  const href = getRelativeLocaleUrl(lang, `article/${slug}/`);
24
28
  const dateStr = date.toISOString().slice(0, 10);
25
29
  ---
@@ -36,10 +40,12 @@ const dateStr = date.toISOString().slice(0, 10);
36
40
  <time datetime={dateStr}>{formatDate(date, lang)}</time>
37
41
  {version && <span> · {t('meta.docVersion')} {version}</span>}
38
42
  </p>
39
- <h3 class="mt-1 line-clamp-2 text-base font-semibold text-[var(--aas-text)]">{title}</h3>
43
+ <h3 class="mt-1 line-clamp-2 text-base font-semibold text-[var(--aas-text)]">
44
+ {isPrivate && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{title}
45
+ </h3>
40
46
  <p
41
47
  class="aas-md mt-1.5 line-clamp-2 text-sm leading-relaxed text-[var(--aas-muted)]"
42
- set:html={inlineMd(description)}
48
+ set:html={inlineMd(shownDesc)}
43
49
  />
44
50
  {
45
51
  tags.length > 0 && (
@@ -33,8 +33,13 @@ const target = articles.find((a) => articleSlugOf(a) === slug) as ArticleEntry |
33
33
  <span class="block text-xs font-semibold tracking-wide text-[var(--aas-accent)] uppercase">
34
34
  {t('detail.relatedWriting')}
35
35
  </span>
36
- <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">{target.data.title}</span>
37
- <span class="aas-md mt-1 block text-sm text-[var(--aas-muted)]" set:html={inlineMd(target.data.description)} />
36
+ <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">
37
+ {target.data.private && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{target.data.title}
38
+ </span>
39
+ <span
40
+ class="aas-md mt-1 block text-sm text-[var(--aas-muted)]"
41
+ set:html={inlineMd(target.data.private ? (target.data.teaser ?? '') : target.data.description)}
42
+ />
38
43
  <span class="mt-1.5 block text-xs text-[var(--aas-muted)]">
39
44
  {formatDate(target.data.date, lang)}
40
45
  </span>
@@ -58,6 +58,8 @@ const card = (a: ArticleEntry) => ({
58
58
  tags: a.data.tags,
59
59
  image: a.data.image,
60
60
  imageAlt: a.data.imageAlt,
61
+ isPrivate: a.data.private,
62
+ teaser: a.data.teaser,
61
63
  });
62
64
  ---
63
65
 
@@ -59,6 +59,8 @@ const cardProps = (e: StackEntry) => ({
59
59
  repo: e.data.repo,
60
60
  version: e.data.version,
61
61
  deprecated: e.data.deprecated,
62
+ isPrivate: e.data.private,
63
+ teaser: e.data.teaser,
62
64
  });
63
65
  ---
64
66
 
@@ -90,7 +90,12 @@ const t = useTranslations(lang);
90
90
  </style>
91
91
 
92
92
  <script>
93
+ import { initOnReady } from '../lib/reinit';
94
+ // Re-runs after private-content decryption; the dataset flag guards each root.
95
+ initOnReady(() =>
93
96
  document.querySelectorAll<HTMLElement>('[data-samples]').forEach((root) => {
97
+ if (root.dataset.aasInit) return;
98
+ root.dataset.aasInit = '1';
94
99
  const dd = root.querySelector<HTMLDetailsElement>('[data-samples-dd]');
95
100
  const current = root.querySelector<HTMLElement>('[data-sample-current]');
96
101
  const options = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-sample-option]'));
@@ -137,5 +142,5 @@ const t = useTranslations(lang);
137
142
  const cur = options.find((o) => o.getAttribute('aria-current') === 'true');
138
143
  if (cur) writeV(cur.dataset.sampleOption!);
139
144
  });
140
- });
145
+ }));
141
146
  </script>
@@ -16,10 +16,14 @@ interface Props {
16
16
  imageAlt?: string;
17
17
  version?: string;
18
18
  updated?: Date;
19
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
20
+ isPrivate?: boolean;
21
+ teaser?: string;
19
22
  }
20
23
 
21
- const { lang, slug, title, description, tags, image, imageAlt, version, updated } = Astro.props;
24
+ const { lang, slug, title, description, tags, image, imageAlt, version, updated, isPrivate = false, teaser } = Astro.props;
22
25
  const t = useTranslations(lang);
26
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
23
27
  ---
24
28
 
25
29
  <a
@@ -39,10 +43,12 @@ const t = useTranslations(lang);
39
43
  </p>
40
44
  )
41
45
  }
42
- <h3 class="mt-1 text-lg font-semibold text-[var(--aas-text)]">{title}</h3>
46
+ <h3 class="mt-1 text-lg font-semibold text-[var(--aas-text)]">
47
+ {isPrivate && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{title}
48
+ </h3>
43
49
  <p
44
50
  class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
45
- set:html={inlineMd(description)}
51
+ set:html={inlineMd(shownDesc)}
46
52
  />
47
53
  {
48
54
  tags.length > 0 && (
@@ -64,6 +64,8 @@ const card = (c: ConceptEntry) => ({
64
64
  imageAlt: c.data.imageAlt,
65
65
  version: c.data.version,
66
66
  updated: c.data.updated,
67
+ isPrivate: c.data.private,
68
+ teaser: c.data.teaser,
67
69
  });
68
70
  ---
69
71
 
@@ -32,8 +32,13 @@ const target = concepts.find((c) => conceptSlugOf(c) === slug) as ConceptEntry |
32
32
  <span class="block text-xs font-semibold tracking-wide text-[var(--aas-accent)] uppercase">
33
33
  {t('concept.learnMore')}
34
34
  </span>
35
- <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">{target.data.title}</span>
36
- <span class="aas-md mt-1 block text-sm text-[var(--aas-muted)]" set:html={inlineMd(target.data.description)} />
35
+ <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">
36
+ {target.data.private && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{target.data.title}
37
+ </span>
38
+ <span
39
+ class="aas-md mt-1 block text-sm text-[var(--aas-muted)]"
40
+ set:html={inlineMd(target.data.private ? (target.data.teaser ?? '') : target.data.description)}
41
+ />
37
42
  </div>
38
43
  <span aria-hidden="true" class="shrink-0 text-xl text-[var(--aas-accent)]">
39
44
  &rarr;
@@ -4,6 +4,7 @@ import '../styles/global.css';
4
4
  import { getRelativeLocaleUrl } from 'astro:i18n';
5
5
  import { render, type CollectionEntry } from 'astro:content';
6
6
  import MermaidLoader from './MermaidLoader.astro';
7
+ import PrivateGate from './PrivateGate.astro';
7
8
  import { useTranslations, type Lang } from '../i18n/ui';
8
9
 
9
10
  // Renders a full-screen slide deck as a horizontal scroll-snap presentation.
@@ -21,6 +22,7 @@ const hasMermaid = (entry.body ?? '').includes('```mermaid');
21
22
  const base = import.meta.env.BASE_URL.replace(/\/$/, '');
22
23
  const backHref = getRelativeLocaleUrl(lang, 'slides/');
23
24
  const themeClass = `aas-theme-${entry.data.theme}`;
25
+ const priv = entry.data.private;
24
26
  ---
25
27
 
26
28
  <!doctype html>
@@ -29,7 +31,8 @@ const themeClass = `aas-theme-${entry.data.theme}`;
29
31
  <meta charset="utf-8" />
30
32
  <meta name="viewport" content="width=device-width, initial-scale=1" />
31
33
  <title>{entry.data.title} — {site.name}</title>
32
- <meta name="description" content={entry.data.description} />
34
+ <meta name="description" content={(priv ? entry.data.teaser : entry.data.description) ?? ''} />
35
+ {priv && <meta name="robots" content="noindex" />}
33
36
  <link rel="icon" type="image/svg+xml" href={`${base}/favicon.svg`} />
34
37
  {/* Follow the site's light/dark preference before first paint (same logic as
35
38
  BaseLayout). The deck's visual theme is a separate CSS class, below. */}
@@ -46,6 +49,11 @@ const themeClass = `aas-theme-${entry.data.theme}`;
46
49
  </script>
47
50
  </head>
48
51
  <body class="aas-deck-body" data-aspect={entry.data.aspect}>
52
+ {/* Private decks: the whole deck DOM (slides + chrome) ships encrypted; the
53
+ engine script below no-ops until decryption re-runs it via initOnReady.
54
+ MermaidLoader stays OUTSIDE the gate so its loader script is always on
55
+ the page and can render diagrams after decryption. */}
56
+ <PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
49
57
  <div class="aas-deck-progress"><div class="aas-deck-progress-fill" data-progress></div></div>
50
58
 
51
59
  {/* The slides: <Content/> renders the deck MDX, whose <Slide> blocks become
@@ -137,12 +145,18 @@ const themeClass = `aas-theme-${entry.data.theme}`;
137
145
  </button>
138
146
  <div class="aas-zoom-stage" data-zoom-stage></div>
139
147
  </div>
148
+ </PrivateGate>
140
149
 
141
150
  {hasMermaid && <MermaidLoader />}
142
151
 
143
152
  <script>
153
+ import { initOnReady } from '../lib/reinit';
154
+ // Wrapped in initOnReady: on a private deck [data-deck] is inside the
155
+ // encrypted payload, so this no-ops at load and runs after decryption.
156
+ initOnReady(() => {
144
157
  const deck = document.querySelector<HTMLElement>('[data-deck]');
145
- if (deck) {
158
+ if (deck && !deck.dataset.aasInit) {
159
+ deck.dataset.aasInit = '1';
146
160
  // Animate slide changes unless the deck opts out (data-transition="none")
147
161
  // or the viewer prefers reduced motion.
148
162
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -767,6 +781,7 @@ const themeClass = `aas-theme-${entry.data.theme}`;
767
781
  go(Number.isFinite(fromHash) && fromHash >= 1 ? fromHash - 1 : 0, false);
768
782
  paint();
769
783
  }
784
+ });
770
785
  </script>
771
786
  </body>
772
787
  </html>
@@ -131,7 +131,12 @@ const tabClass =
131
131
  if (tab !== id) ps.forEach((p) => url.searchParams.delete(p));
132
132
  }
133
133
 
134
+ import { initOnReady } from '../lib/reinit';
135
+ // Re-runs after private-content decryption; the dataset flag guards each root.
136
+ initOnReady(() =>
134
137
  document.querySelectorAll<HTMLElement>('[data-tabs]').forEach((root) => {
138
+ if (root.dataset.aasInit) return;
139
+ root.dataset.aasInit = '1';
135
140
  const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-tab]'));
136
141
  const panels = Array.from(root.querySelectorAll<HTMLElement>('[data-panel]'));
137
142
  const tabIds = tabs.map((t) => t.dataset.tab);
@@ -215,5 +220,5 @@ const tabClass =
215
220
  document.dispatchEvent(new CustomEvent('aas:select-sample', { detail: { index: Number(m[1]) } }));
216
221
  document.getElementById(raw)?.scrollIntoView();
217
222
  }
218
- });
223
+ }));
219
224
  </script>
@@ -80,6 +80,8 @@ const facets = deriveFacets(entries, lang);
80
80
  repo={e.data.repo}
81
81
  version={e.data.version}
82
82
  deprecated={e.data.deprecated}
83
+ isPrivate={e.data.private}
84
+ teaser={e.data.teaser}
83
85
  />
84
86
  ))}
85
87
  </div>
@@ -6,6 +6,7 @@
6
6
 
7
7
  <script>
8
8
  import mermaid from 'mermaid';
9
+ import { initOnReady } from '../lib/reinit';
9
10
 
10
11
  // Read the live site palette so Mermaid matches the current theme exactly.
11
12
  function palette() {
@@ -67,7 +68,8 @@
67
68
  }
68
69
  }
69
70
 
70
- renderAll();
71
+ // Re-runs after private-content decryption too (diagrams arrive with it).
72
+ initOnReady(() => void renderAll());
71
73
 
72
74
  // Allow tab/version switches to re-render diagrams that were hidden (and thus
73
75
  // mis-sized) at first paint.
@@ -84,8 +84,11 @@ const hasTable = tiers.length > 0;
84
84
  <script>
85
85
  // Flag prices that haven't been re-checked recently, relative to the viewer's
86
86
  // current date (so a stale static build still warns). Mirrors stalenessOf().
87
+ import { initOnReady } from '../lib/reinit';
87
88
  const SIX_MONTHS = 182 * 24 * 60 * 60 * 1000;
88
89
  const ONE_YEAR = 365 * 24 * 60 * 60 * 1000;
90
+ // Re-runs after private-content decryption (idempotent per element).
91
+ initOnReady(() =>
89
92
  document.querySelectorAll<HTMLElement>('[data-pricing-freshness]').forEach((el) => {
90
93
  const ts = Date.parse(el.dataset.checked || '');
91
94
  if (Number.isNaN(ts)) return;
@@ -96,5 +99,5 @@ const hasTable = tiers.length > 0;
96
99
  el.title = kind === 'stale' ? el.dataset.staleHint! : el.dataset.agingHint!;
97
100
  el.classList.add(kind === 'stale' ? 'aas-stale' : 'aas-aging');
98
101
  el.classList.remove('hidden');
99
- });
102
+ }));
100
103
  </script>
@@ -0,0 +1,135 @@
1
+ ---
2
+ // Login gate for private entries (docs/private-content-design.md). Wrap a
3
+ // detail component in it; when `enabled` the slot's rendered HTML ships
4
+ // encrypted with a login form in its place, and the client (private-client.ts)
5
+ // decrypts after login — or instantly when this device already holds the key.
6
+ // When `enabled` is false it renders the slot untouched, so callers can wrap
7
+ // unconditionally. In dev the gate is skipped: authors see the content with a
8
+ // "private" banner instead of logging in on localhost.
9
+ import { useTranslations, type Lang } from '../i18n/ui';
10
+ import { encryptHtml, privateClientData, registerPrivatePath } from '../lib/private';
11
+
12
+ interface Props {
13
+ enabled: boolean;
14
+ lang: Lang;
15
+ /** Entry title, shown above the login form (titles are public by design). */
16
+ title?: string;
17
+ /** The entry's PUBLIC teaser, shown under the title on the gate. */
18
+ teaser?: string;
19
+ }
20
+
21
+ const { enabled, lang, title, teaser } = Astro.props;
22
+ const t = useTranslations(lang);
23
+ const dev = import.meta.env.DEV;
24
+
25
+ let payload: string | null = null;
26
+ if (enabled && !dev) {
27
+ const html = await Astro.slots.render('default');
28
+ registerPrivatePath(Astro.url.pathname);
29
+ payload = JSON.stringify({ ...encryptHtml(html), ...privateClientData() });
30
+ }
31
+ ---
32
+
33
+ {!enabled && <slot />}
34
+
35
+ {
36
+ enabled && dev && (
37
+ <div>
38
+ <p class="aas-private-devbanner">
39
+ 🔒 {t('private.badge')} — dev preview (built sites require login)
40
+ </p>
41
+ <slot />
42
+ </div>
43
+ )
44
+ }
45
+
46
+ {
47
+ enabled && !dev && (
48
+ <div class="aas-private-gate" data-private-gate data-logout-label={t('private.logout')}>
49
+ <script type="application/json" data-private-data set:html={payload} />
50
+ <div class="aas-private-card">
51
+ <p class="aas-private-badge" aria-hidden="true">🔒</p>
52
+ {title && <h1 class="text-2xl font-bold tracking-tight">{title}</h1>}
53
+ {teaser && <p class="mt-2 text-[var(--aas-muted)]">{teaser}</p>}
54
+ <p class="mt-2 text-sm text-[var(--aas-muted)]">{t('private.locked')}</p>
55
+ <div data-private-form hidden>
56
+ <form class="mt-5 flex flex-col gap-3">
57
+ <label class="flex flex-col gap-1 text-sm">
58
+ <span>{t('private.id')}</span>
59
+ <input
60
+ name="id"
61
+ type="text"
62
+ required
63
+ autocomplete="username"
64
+ class="rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-2"
65
+ />
66
+ </label>
67
+ <label class="flex flex-col gap-1 text-sm">
68
+ <span>{t('private.password')}</span>
69
+ <input
70
+ name="password"
71
+ type="password"
72
+ required
73
+ autocomplete="current-password"
74
+ class="rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-2"
75
+ />
76
+ </label>
77
+ <p data-private-error hidden class="text-sm text-red-600 dark:text-red-400">
78
+ {t('private.error')}
79
+ </p>
80
+ <button
81
+ type="submit"
82
+ class="mt-1 cursor-pointer rounded-lg bg-[var(--aas-accent)] px-4 py-2 font-semibold text-white"
83
+ >
84
+ {t('private.submit')}
85
+ </button>
86
+ </form>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ <style>
94
+ .aas-private-card {
95
+ max-width: 26rem;
96
+ margin: 3rem auto;
97
+ padding: 2rem;
98
+ border: 1px solid var(--aas-border);
99
+ border-radius: 1rem;
100
+ background: var(--aas-panel);
101
+ text-align: center;
102
+ }
103
+ .aas-private-badge {
104
+ font-size: 2rem;
105
+ }
106
+ .aas-private-devbanner {
107
+ margin-bottom: 1rem;
108
+ padding: 0.5rem 1rem;
109
+ border: 1px dashed var(--aas-border);
110
+ border-radius: 0.75rem;
111
+ color: var(--aas-muted);
112
+ font-size: 0.875rem;
113
+ }
114
+ /* Injected by private-client.ts (global: it lives outside this component's scope). */
115
+ :global(.aas-private-bar) {
116
+ display: flex;
117
+ justify-content: flex-end;
118
+ }
119
+ :global(.aas-private-logout) {
120
+ cursor: pointer;
121
+ font-size: 0.8rem;
122
+ color: var(--aas-muted);
123
+ padding: 0.15rem 0.5rem;
124
+ border-radius: 0.5rem;
125
+ }
126
+ :global(.aas-private-logout:hover) {
127
+ color: var(--aas-text);
128
+ background: var(--aas-tint);
129
+ }
130
+ </style>
131
+
132
+ <script>
133
+ import { mountGate } from '../lib/private-client';
134
+ document.querySelectorAll<HTMLElement>('[data-private-gate]').forEach(mountGate);
135
+ </script>