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.
- package/CHANGELOG.md +44 -0
- package/README.md +105 -3
- package/index.d.ts +15 -5
- package/index.mjs +26 -2
- package/package.json +2 -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/Bookmark.astro +37 -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/CourseCard.astro +97 -0
- package/src/components/CourseDetail.astro +116 -0
- package/src/components/CourseIndex.astro +145 -0
- package/src/components/DeckView.astro +17 -2
- package/src/components/DetailTabs.astro +6 -1
- package/src/components/DifficultyStars.astro +34 -0
- package/src/components/Embed.astro +36 -0
- package/src/components/Home.astro +2 -0
- package/src/components/Lead.astro +8 -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/RelatedCourses.astro +64 -0
- 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 +90 -4
- package/src/i18n/ui.ts +47 -0
- package/src/layouts/BaseLayout.astro +26 -4
- package/src/lib/courses.ts +26 -0
- 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 +13 -2
- 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]/course/[...id].astro +35 -0
- package/src/pages/[...lang]/course/category/[id].astro +25 -0
- package/src/pages/[...lang]/course/index.astro +18 -0
- package/src/pages/[...lang]/rss.xml.ts +40 -0
- 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))} →
|
|
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>
|
|
@@ -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>
|