stack-site-builder 1.13.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 +19 -0
- package/README.md +51 -0
- package/index.mjs +9 -0
- 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 +10 -4
- package/src/lib/private-client.ts +160 -0
- package/src/lib/private.ts +129 -0
- package/src/lib/reinit.ts +12 -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,24 @@ 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
|
+
|
|
14
32
|
## [1.13.0] - 2026-07-20
|
|
15
33
|
|
|
16
34
|
### Added
|
|
@@ -120,6 +138,7 @@ catalog sites from a thin content-only repository.
|
|
|
120
138
|
- **Standalone development setup** — a devcontainer and a minimal `playground/`
|
|
121
139
|
consuming site for developing and previewing the theme on its own.
|
|
122
140
|
|
|
141
|
+
[1.14.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.13.0...v1.14.0
|
|
123
142
|
[1.13.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.12.0...v1.13.0
|
|
124
143
|
[1.12.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.11.0...v1.12.0
|
|
125
144
|
[1.11.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.10.0...v1.11.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
|
|
@@ -90,3 +92,52 @@ export const site = {
|
|
|
90
92
|
import { site } from './src/data/site';
|
|
91
93
|
integrations: [aasTheme({ glossary, sections: site.sections })];
|
|
92
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.mjs
CHANGED
|
@@ -119,6 +119,15 @@ export default function aasTheme({ glossary, sections = {} }) {
|
|
|
119
119
|
defaultLocale,
|
|
120
120
|
locales: Object.fromEntries(locales.map((l) => [l, l])),
|
|
121
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
|
+
},
|
|
122
131
|
}),
|
|
123
132
|
],
|
|
124
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>
|
|
@@ -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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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)]">
|
|
36
|
-
|
|
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">
|