stack-site-builder 1.13.0 → 1.15.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 (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +105 -3
  3. package/index.d.ts +15 -5
  4. package/index.mjs +26 -2
  5. package/package.json +2 -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/Bookmark.astro +37 -0
  10. package/src/components/CategoryIndex.astro +2 -0
  11. package/src/components/CodeSamples.astro +6 -1
  12. package/src/components/ConceptCard.astro +9 -3
  13. package/src/components/ConceptIndex.astro +2 -0
  14. package/src/components/ConceptLink.astro +7 -2
  15. package/src/components/CourseCard.astro +97 -0
  16. package/src/components/CourseDetail.astro +116 -0
  17. package/src/components/CourseIndex.astro +145 -0
  18. package/src/components/DeckView.astro +17 -2
  19. package/src/components/DetailTabs.astro +6 -1
  20. package/src/components/DifficultyStars.astro +34 -0
  21. package/src/components/Embed.astro +36 -0
  22. package/src/components/Home.astro +2 -0
  23. package/src/components/Lead.astro +8 -0
  24. package/src/components/MermaidLoader.astro +3 -1
  25. package/src/components/PricingSection.astro +4 -1
  26. package/src/components/PrivateGate.astro +135 -0
  27. package/src/components/ProjectViewer.astro +24 -13
  28. package/src/components/RelatedCourses.astro +64 -0
  29. package/src/components/SlidesIndex.astro +8 -2
  30. package/src/components/StackCard.astro +15 -3
  31. package/src/components/StackDetail.astro +9 -0
  32. package/src/components/TagIndex.astro +2 -0
  33. package/src/components/TocRail.astro +12 -6
  34. package/src/components/VendorIndex.astro +2 -0
  35. package/src/content.ts +90 -4
  36. package/src/i18n/ui.ts +47 -0
  37. package/src/layouts/BaseLayout.astro +26 -4
  38. package/src/lib/courses.ts +26 -0
  39. package/src/lib/private-client.ts +160 -0
  40. package/src/lib/private.ts +129 -0
  41. package/src/lib/reinit.ts +12 -0
  42. package/src/lib/sections.ts +13 -2
  43. package/src/pages/[...lang]/[page].astro +7 -2
  44. package/src/pages/[...lang]/article/[...id].astro +7 -2
  45. package/src/pages/[...lang]/concept/[...id].astro +7 -2
  46. package/src/pages/[...lang]/course/[...id].astro +35 -0
  47. package/src/pages/[...lang]/course/category/[id].astro +25 -0
  48. package/src/pages/[...lang]/course/index.astro +18 -0
  49. package/src/pages/[...lang]/rss.xml.ts +40 -0
  50. package/src/pages/[...lang]/stack/[...id].astro +7 -2
@@ -0,0 +1,116 @@
1
+ ---
2
+ import { render, getCollection } from 'astro:content';
3
+ import { getRelativeLocaleUrl } from 'astro:i18n';
4
+ import { getCourses, courseSlugOf, type CourseEntry } from '../lib/courses';
5
+ import { inlineMd } from '../lib/inline-md';
6
+ import { Image } from 'astro:assets';
7
+ import { useTranslations, type Lang } from '../i18n/ui';
8
+ import { formatDate } from '../lib/dates';
9
+ import { sectionEnabled } from '../lib/sections';
10
+ import MermaidLoader from './MermaidLoader.astro';
11
+ import TocRail from './TocRail.astro';
12
+ import Breadcrumb from './Breadcrumb.astro';
13
+ import DifficultyStars from './DifficultyStars.astro';
14
+ import { courseTree, courseCatOf } from '@aas-data/course-categories';
15
+ import RelatedCourses from './RelatedCourses.astro';
16
+
17
+ interface Props {
18
+ entry: CourseEntry;
19
+ lang: Lang;
20
+ }
21
+
22
+ const { entry, lang } = Astro.props;
23
+ const t = useTranslations(lang);
24
+ const { Content, headings } = await render(entry);
25
+ const data = entry.data;
26
+ const tocItems = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
27
+ const bodyHasMermaid = (entry.body ?? '').includes('```mermaid');
28
+
29
+ // Sibling courses linked from frontmatter (mutual cross-links, like concepts).
30
+ const courses = await getCourses(lang);
31
+ const courseBySlug = new Map(courses.map((c) => [courseSlugOf(c), c]));
32
+ const relatedCourses = data.related
33
+ .map((sl) => courseBySlug.get(sl))
34
+ .filter(Boolean) as CourseEntry[];
35
+
36
+ // Decks in the `slides` collection that belong to this course. Only linked when
37
+ // the slides section is enabled (otherwise its routes don't exist) and the deck
38
+ // id actually resolves — a typo just drops the chip instead of a dead link.
39
+ const slideDecks = sectionEnabled('slides') && data.slides.length > 0
40
+ ? (await getCollection('slides')).filter(
41
+ (d) => data.slides.includes(d.id) && !d.data.draft,
42
+ )
43
+ : [];
44
+
45
+ // Breadcrumb: All courses › category › subcategory… (each segment linked).
46
+ const crumbs = [
47
+ { label: t('course.backToCourses'), href: getRelativeLocaleUrl(lang, 'course/') },
48
+ ...courseTree.pathOf(courseCatOf(data.category)).map((c) => ({
49
+ label: c.label[lang],
50
+ href: getRelativeLocaleUrl(lang, `course/category/${c.id}/`),
51
+ })),
52
+ ];
53
+ ---
54
+
55
+ <Breadcrumb segments={crumbs} />
56
+
57
+ <header class="mt-4 pb-6">
58
+ <h1 class="text-3xl font-bold tracking-tight">{data.title}</h1>
59
+ <p class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-[var(--aas-muted)]">
60
+ {data.level && <DifficultyStars lang={lang} level={data.level} size={14} />}
61
+ {
62
+ data.hours && (
63
+ <span class="inline-flex items-center gap-1" title={t('course.hours')}>
64
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
65
+ {data.hours}
66
+ </span>
67
+ )
68
+ }
69
+ {data.version && <span>{t('meta.docVersion')} {data.version}</span>}
70
+ {
71
+ data.updated && (
72
+ <span>
73
+ {t('course.updated')} {formatDate(data.updated, lang)}
74
+ </span>
75
+ )
76
+ }
77
+ </p>
78
+ <p class="mt-2 aas-md text-lg whitespace-pre-line text-[var(--aas-muted)]" set:html={inlineMd(data.description)} />
79
+ {
80
+ slideDecks.length > 0 && (
81
+ <p class="mt-3 flex flex-wrap items-center gap-2">
82
+ <span class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
83
+ {t('course.slides')}
84
+ </span>
85
+ {slideDecks.map((d) => (
86
+ <a
87
+ href={getRelativeLocaleUrl(lang, `slides/${d.id}/`)}
88
+ class="rounded-full border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1 text-sm text-[var(--aas-text)] no-underline hover:bg-[var(--aas-tint)]"
89
+ >
90
+ {d.data.title}
91
+ </a>
92
+ ))}
93
+ </p>
94
+ )
95
+ }
96
+ {
97
+ data.image && (
98
+ <Image
99
+ src={data.image}
100
+ alt={data.imageAlt ?? data.title}
101
+ class="mt-5 aspect-[2/1] w-full rounded-2xl border border-[var(--aas-border)] object-cover"
102
+ />
103
+ )
104
+ }
105
+ </header>
106
+
107
+ <RelatedCourses lang={lang} courses={relatedCourses} />
108
+
109
+ <div class="mt-8 flex items-start gap-8">
110
+ <article class="prose max-w-none aas-reading min-w-0 flex-1">
111
+ <Content />
112
+ </article>
113
+ <TocRail lang={lang} items={tocItems} />
114
+ </div>
115
+
116
+ {bodyHasMermaid && <MermaidLoader />}
@@ -0,0 +1,145 @@
1
+ ---
2
+ import { getRelativeLocaleUrl } from 'astro:i18n';
3
+ import CourseCard from './CourseCard.astro';
4
+ import Breadcrumb from './Breadcrumb.astro';
5
+ import ListControls from './ListControls.astro';
6
+ import ArrowUpRight from './ArrowUpRight.astro';
7
+
8
+ const PAGE = 12;
9
+ import { courseTree, courseCatOf } from '@aas-data/course-categories';
10
+ import { getCourses, courseSlugOf, type CourseEntry } from '../lib/courses';
11
+ import { useTranslations, type Lang } from '../i18n/ui';
12
+
13
+ // Doubles as the course index (no `categoryId` → all courses, grouped by
14
+ // category) and a category browse page (`categoryId` → that category, with a
15
+ // breadcrumb, its direct courses, and a section per subcategory).
16
+ interface Props {
17
+ lang: Lang;
18
+ categoryId?: string;
19
+ }
20
+
21
+ const { lang, categoryId } = Astro.props;
22
+ const t = useTranslations(lang);
23
+ // getCourses() order (manual `order` desc, then date) agrees with the client
24
+ // "recent" comparator over CourseCard's data-date keys — no reflow on load.
25
+ const all = await getCourses(lang);
26
+ const catUrl = (cid: string) => getRelativeLocaleUrl(lang, `course/category/${cid}/`);
27
+
28
+ const itemsIn = (cid: string): CourseEntry[] => {
29
+ const ids = new Set(courseTree.descendantIds(cid));
30
+ return all.filter((c) => ids.has(courseCatOf(c.data.category)));
31
+ };
32
+
33
+ const node = categoryId ? courseTree.map.get(categoryId) : undefined;
34
+ const sections = node ? courseTree.childrenOf(node.id) : courseTree.roots;
35
+ const direct = node ? all.filter((c) => courseCatOf(c.data.category) === node.id) : [];
36
+
37
+ // A leaf category page shows one flat list — that grid gets ?page= pagination.
38
+ // Index and parent-category pages stay grouped overviews: each section is
39
+ // capped to PAGE cards with a "see all" link to the fuller list.
40
+ const isLeaf = !!node && sections.length === 0;
41
+
42
+ const path = node ? courseTree.pathOf(node.id) : [];
43
+ const crumbs = node
44
+ ? [
45
+ { label: t('course.backToCourses'), href: getRelativeLocaleUrl(lang, 'course/') },
46
+ ...path.map((c, i) => ({
47
+ label: c.label[lang],
48
+ href: i === path.length - 1 ? undefined : catUrl(c.id),
49
+ })),
50
+ ]
51
+ : [];
52
+
53
+ const card = (c: CourseEntry) => ({
54
+ lang,
55
+ slug: courseSlugOf(c),
56
+ title: c.data.title,
57
+ description: c.data.summary ?? c.data.description,
58
+ tags: c.data.tags,
59
+ image: c.data.image,
60
+ imageAlt: c.data.imageAlt,
61
+ level: c.data.level,
62
+ hours: c.data.hours,
63
+ version: c.data.version,
64
+ date: c.data.date,
65
+ updated: c.data.updated,
66
+ order: c.data.order,
67
+ isPrivate: c.data.private,
68
+ teaser: c.data.teaser,
69
+ });
70
+ ---
71
+
72
+ {
73
+ node ? (
74
+ <>
75
+ <Breadcrumb segments={crumbs} />
76
+ <header class="mt-4">
77
+ <h1 class="text-3xl font-bold tracking-tight">{node.label[lang]}</h1>
78
+ <p class="mt-2 max-w-2xl text-lg text-[var(--aas-muted)]">
79
+ {node.detail?.[lang] ?? node.description[lang]}
80
+ </p>
81
+ </header>
82
+ </>
83
+ ) : (
84
+ <section class="py-6">
85
+ <h1 class="text-4xl font-bold tracking-tight">{t('course.title')}</h1>
86
+ <p class="mt-3 max-w-2xl text-lg text-[var(--aas-muted)]">{t('course.tagline')}</p>
87
+ </section>
88
+ )
89
+ }
90
+
91
+ {all.length === 0 && <p class="mt-8 text-[var(--aas-muted)]">{t('course.empty')}</p>}
92
+
93
+ {all.length > 0 && <div class="mt-6"><ListControls lang={lang} /></div>}
94
+
95
+ {
96
+ direct.length > 0 && (
97
+ <div
98
+ class="mt-4 grid gap-5 sm:grid-cols-2 lg:grid-cols-3"
99
+ data-sortgrid
100
+ data-paginate={isLeaf ? true : undefined}
101
+ data-page-size={isLeaf ? PAGE : undefined}
102
+ >
103
+ {direct.map((c) => (
104
+ <CourseCard {...card(c)} />
105
+ ))}
106
+ </div>
107
+ )
108
+ }
109
+
110
+ {
111
+ sections.map((cat) => {
112
+ const items = itemsIn(cat.id);
113
+ return (
114
+ items.length > 0 && (
115
+ <section class="mt-8">
116
+ <h2 class="text-2xl font-semibold">
117
+ <a
118
+ href={catUrl(cat.id)}
119
+ class="text-[var(--aas-text)] no-underline hover:text-[var(--aas-accent)]"
120
+ >
121
+ {cat.label[lang]}
122
+ <ArrowUpRight size={20} class="ml-1 text-[var(--aas-muted)]" />
123
+ </a>
124
+ </h2>
125
+ <p class="mt-1 text-sm text-[var(--aas-muted)]">{cat.description[lang]}</p>
126
+ <div class="mt-4 grid gap-5 sm:grid-cols-2 lg:grid-cols-3" data-sortgrid>
127
+ {items.slice(0, PAGE).map((c) => (
128
+ <CourseCard {...card(c)} />
129
+ ))}
130
+ </div>
131
+ {items.length > PAGE && (
132
+ <a
133
+ href={catUrl(cat.id)}
134
+ class="mt-3 inline-block text-sm font-medium text-[var(--aas-accent)] no-underline"
135
+ >
136
+ {t('list.seeAll').replace('{n}', String(items.length))} &rarr;
137
+ </a>
138
+ )}
139
+ </section>
140
+ )
141
+ );
142
+ })
143
+ }
144
+
145
+ {isLeaf && <nav data-pager aria-label="Pagination" class="mt-8 flex flex-wrap items-center justify-center gap-1.5" />}
@@ -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>
@@ -0,0 +1,34 @@
1
+ ---
2
+ import StarIcon from './StarIcon.astro';
3
+ import { useTranslations, difficultyLabel, type Lang } from '../i18n/ui';
4
+
5
+ // Course difficulty as a 5-star row (filled up to `level`). The visual is
6
+ // decorative; the level + localized label live in the aria-label/tooltip.
7
+ interface Props {
8
+ lang: Lang;
9
+ /** 1 (beginner) … 5 (expert). */
10
+ level: number;
11
+ size?: number;
12
+ class?: string;
13
+ }
14
+
15
+ const { lang, level, size = 12, class: className } = Astro.props;
16
+ const t = useTranslations(lang);
17
+ const label = `${t('course.level')}: ${difficultyLabel(lang, level)}`;
18
+ ---
19
+
20
+ <span
21
+ class:list={['inline-flex items-center gap-0.5', className]}
22
+ role="img"
23
+ aria-label={label}
24
+ title={label}
25
+ >
26
+ {
27
+ [1, 2, 3, 4, 5].map((i) => (
28
+ <StarIcon
29
+ size={size}
30
+ class={i <= level ? 'text-[var(--aas-accent)]' : 'text-[var(--aas-border)]'}
31
+ />
32
+ ))
33
+ }
34
+ </span>
@@ -0,0 +1,36 @@
1
+ ---
2
+ // Responsive iframe wrapper for MDX bodies (a Hugo `iframe` shortcode
3
+ // successor) — interactive demos, videos, and other embeds:
4
+ // <Embed src="/demos/foo/index.html" title="Foo demo" ratio="4:3" />
5
+ interface Props {
6
+ src: string;
7
+ /** Accessible name for the iframe — required; it is the only label. */
8
+ title: string;
9
+ /** Aspect ratio ("16:9" default, "4:3", …), or "auto" paired with `height`. */
10
+ ratio?: string;
11
+ /** CSS height, used when `ratio` is "auto" (e.g. "32rem"). */
12
+ height?: string;
13
+ /** `sandbox` attribute passthrough (omit = unsandboxed, same-origin demos). */
14
+ sandbox?: string;
15
+ allowFullscreen?: boolean;
16
+ }
17
+
18
+ const { src, title, ratio = '16:9', height, sandbox, allowFullscreen = true } = Astro.props;
19
+ const style =
20
+ ratio === 'auto'
21
+ ? height
22
+ ? `height: ${height};`
23
+ : undefined
24
+ : `aspect-ratio: ${ratio.replace(':', ' / ')};`;
25
+ ---
26
+
27
+ <div class="not-prose my-4" style={style}>
28
+ <iframe
29
+ src={src}
30
+ title={title}
31
+ loading="lazy"
32
+ class="h-full w-full rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)]"
33
+ {...(sandbox !== undefined ? { sandbox } : {})}
34
+ {...(allowFullscreen ? { allowfullscreen: true } : {})}
35
+ ></iframe>
36
+ </div>
@@ -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>
@@ -0,0 +1,8 @@
1
+ ---
2
+ // Lead/intro paragraph for MDX bodies (a Hugo `lead` shortcode successor):
3
+ // <Lead>One-sentence framing of the page.</Lead>
4
+ ---
5
+
6
+ <p class="not-prose my-5 text-xl leading-relaxed text-[var(--aas-muted)]">
7
+ <slot />
8
+ </p>
@@ -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>
@@ -290,7 +290,12 @@ function formatDate(iso: string): string {
290
290
  const PARAM_EX = 'ex'; // sample project folder, e.g. "langgraph_1"
291
291
  const PARAM_FILE = 'file'; // file path within the project, e.g. "app.py"
292
292
 
293
+ import { initOnReady } from '../lib/reinit';
294
+ // Re-runs after private-content decryption; the dataset flag guards each root.
295
+ initOnReady(() =>
293
296
  document.querySelectorAll<HTMLElement>('[data-projects]').forEach((root) => {
297
+ if (root.dataset.aasInit) return;
298
+ root.dataset.aasInit = '1';
294
299
  const projDD = root.querySelector<HTMLDetailsElement>('[data-project-dd]');
295
300
  const projCurrent = root.querySelector<HTMLElement>('[data-project-current]');
296
301
  const projOptions = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-project-option]'));
@@ -392,13 +397,14 @@ function formatDate(iso: string): string {
392
397
  document.addEventListener('aas:tabchange', (e) => {
393
398
  if ((e as CustomEvent<{ id: string }>).detail.id === 'impl') reflectSelection();
394
399
  });
395
- });
400
+ }));
396
401
  </script>
397
402
 
398
403
  <script>
399
404
  // Collapsible README, remembered in localStorage (one preference for all
400
405
  // implementation samples). The inline pre-paint above applies it before paint;
401
406
  // this binds the toggle and keeps every README in sync.
407
+ import { initOnReady } from '../lib/reinit';
402
408
  const README_KEY = 'aas:readme-collapsed';
403
409
  const isReadmeCollapsed = () => {
404
410
  try {
@@ -416,16 +422,21 @@ function formatDate(iso: string): string {
416
422
  if (body) body.hidden = collapsed;
417
423
  });
418
424
  }
419
- applyReadme(isReadmeCollapsed());
420
- document.querySelectorAll<HTMLButtonElement>('[data-readme-toggle]').forEach((btn) =>
421
- btn.addEventListener('click', () => {
422
- const collapsed = !isReadmeCollapsed();
423
- try {
424
- localStorage.setItem(README_KEY, collapsed ? '1' : '0');
425
- } catch {
426
- /* private mode — best effort */
427
- }
428
- applyReadme(collapsed);
429
- }),
430
- );
425
+ // Re-runs after private-content decryption; the dataset flag guards each button.
426
+ initOnReady(() => {
427
+ applyReadme(isReadmeCollapsed());
428
+ document.querySelectorAll<HTMLButtonElement>('[data-readme-toggle]').forEach((btn) => {
429
+ if (btn.dataset.aasInit) return;
430
+ btn.dataset.aasInit = '1';
431
+ btn.addEventListener('click', () => {
432
+ const collapsed = !isReadmeCollapsed();
433
+ try {
434
+ localStorage.setItem(README_KEY, collapsed ? '1' : '0');
435
+ } catch {
436
+ /* private mode — best effort */
437
+ }
438
+ applyReadme(collapsed);
439
+ });
440
+ });
441
+ });
431
442
  </script>