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.
- package/CHANGELOG.md +33 -0
- package/README.md +80 -1
- package/index.d.ts +10 -0
- package/index.mjs +29 -1
- package/package.json +1 -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/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/DeckView.astro +17 -2
- package/src/components/DetailTabs.astro +6 -1
- package/src/components/Home.astro +2 -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/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 +21 -0
- package/src/i18n/ui.ts +14 -0
- package/src/layouts/BaseLayout.astro +21 -5
- 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 +34 -0
- 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]/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.
|
|
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)]">
|
|
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(
|
|
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)]">
|
|
37
|
-
|
|
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>
|
|
@@ -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)]">
|
|
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(
|
|
51
|
+
set:html={inlineMd(shownDesc)}
|
|
46
52
|
/>
|
|
47
53
|
{
|
|
48
54
|
tags.length > 0 && (
|
|
@@ -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)]">
|
|
36
|
-
|
|
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
|
→
|
|
@@ -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>
|
|
@@ -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
|
-
|
|
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>
|