stack-site-builder 1.14.0 → 1.16.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.
@@ -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" />}
@@ -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>
@@ -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>
@@ -0,0 +1,64 @@
1
+ ---
2
+ // "Related courses" card section on a course detail page, mirroring
3
+ // RelatedConcepts so course links present the same way (image + title +
4
+ // level/duration meta + description, in a collapsible grid).
5
+ import { getRelativeLocaleUrl } from 'astro:i18n';
6
+ import { Image } from 'astro:assets';
7
+ import { courseSlugOf, type CourseEntry } from '../lib/courses';
8
+ import { useTranslations, type Lang } from '../i18n/ui';
9
+ import { formatDate } from '../lib/dates';
10
+ import { inlineMd } from '../lib/inline-md';
11
+ import CollapsibleGrid from './CollapsibleGrid.astro';
12
+ import DifficultyStars from './DifficultyStars.astro';
13
+
14
+ interface Props {
15
+ lang: Lang;
16
+ courses: CourseEntry[];
17
+ }
18
+
19
+ const { lang, courses } = Astro.props;
20
+ const t = useTranslations(lang);
21
+ ---
22
+
23
+ {
24
+ courses.length > 0 && (
25
+ <section class="mt-8">
26
+ <h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
27
+ {t('course.relatedCourses')}
28
+ </h2>
29
+ <CollapsibleGrid lang={lang} count={courses.length}>
30
+ {courses.map((c) => (
31
+ <a
32
+ href={getRelativeLocaleUrl(lang, `course/${courseSlugOf(c)}/`)}
33
+ class="aas-lift flex items-stretch overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
34
+ >
35
+ {c.data.image && (
36
+ <Image src={c.data.image} alt="" class="w-28 shrink-0 self-stretch object-cover" />
37
+ )}
38
+ <div class="min-w-0 p-4">
39
+ <span class="block font-semibold text-[var(--aas-text)]">
40
+ {c.data.private && (
41
+ <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>
42
+ )}
43
+ {c.data.title}
44
+ </span>
45
+ <span class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[var(--aas-muted)]">
46
+ {c.data.level && <DifficultyStars lang={lang} level={c.data.level} size={10} />}
47
+ {c.data.hours && <span>{c.data.hours}</span>}
48
+ {c.data.updated && (
49
+ <span>
50
+ {t('course.updated')} {formatDate(c.data.updated, lang)}
51
+ </span>
52
+ )}
53
+ </span>
54
+ <span
55
+ class="aas-md mt-1.5 line-clamp-2 text-xs leading-relaxed text-[var(--aas-muted)] opacity-75"
56
+ set:html={inlineMd(c.data.private ? (c.data.teaser ?? '') : c.data.description)}
57
+ />
58
+ </div>
59
+ </a>
60
+ ))}
61
+ </CollapsibleGrid>
62
+ </section>
63
+ )
64
+ }
package/src/content.ts CHANGED
@@ -3,8 +3,8 @@ import { glob } from 'astro/loaders';
3
3
 
4
4
  /**
5
5
  * The shared content model for awesome-*-stack sites: `stacks` (the tool
6
- * catalog), `articles`, `concepts` and `slides`. A site's content.config.ts
7
- * stays thin:
6
+ * catalog), `articles`, `concepts`, `courses` (opt-in), `slides` and `pages`.
7
+ * A site's content.config.ts stays thin:
8
8
  *
9
9
  * import { defineAasCollections } from 'stack-site-builder/content';
10
10
  * import { categoryMap } from './data/categories';
@@ -15,7 +15,16 @@ import { glob } from 'astro/loaders';
15
15
  * tree — stack entries are validated against it so an unknown category id
16
16
  * fails the build instead of silently dropping the entry from every listing.
17
17
  */
18
- export function defineAasCollections({ categoryMap }: { categoryMap: Map<string, unknown> }) {
18
+ export function defineAasCollections({
19
+ categoryMap,
20
+ courseCategoryMap,
21
+ }: {
22
+ categoryMap: Map<string, unknown>;
23
+ /** The site's course category tree (`src/data/course-categories.ts`). Optional
24
+ * because `courses` is an opt-in section; when provided, course `category`
25
+ * ids are validated against it at build time (like stacks). */
26
+ courseCategoryMap?: Map<string, unknown>;
27
+ }) {
19
28
  /**
20
29
  * The `stacks` collection holds one entry per tool/service used to build
21
30
  * AI agents. Each entry is an MDX file: frontmatter powers the listing and
@@ -197,6 +206,62 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
197
206
  }),
198
207
  });
199
208
 
209
+ /**
210
+ * The `courses` collection holds structured lessons/lectures — an opt-in
211
+ * section (`sections: { courses: true }`) for sites that teach rather than
212
+ * catalog. Each course is one MDX file: frontmatter powers the course cards
213
+ * (difficulty, duration, category), the body is the course page itself. Paid
214
+ * courses use `private` + `teaser` like every other collection.
215
+ *
216
+ * Locale-partitioned like the others: `courses/<lang>/<slug>.mdx`.
217
+ */
218
+ const courses = defineCollection({
219
+ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/courses' }),
220
+ schema: ({ image }) =>
221
+ z.object({
222
+ title: z.string(),
223
+ description: z.string(),
224
+ // Short one-liner for tight card layouts; falls back to `description`.
225
+ summary: z.string().optional(),
226
+ date: z.coerce.date(), // first published
227
+ // Living-doc metadata, mirroring concepts: bump on meaningful edits.
228
+ version: z.string().optional(), // e.g. "1.0"
229
+ updated: z.coerce.date().optional(), // last meaningful update (YYYY-MM-DD)
230
+ image: image().optional(), // hero / card image (optimized; relative to the file)
231
+ imageAlt: z.string().optional(),
232
+ // Leaf id from the site's src/data/course-categories.ts. Validated when the
233
+ // site passes `courseCategoryMap` (strict, like stacks); otherwise resolved
234
+ // at render time with an uncategorized fallback (loose, like concepts).
235
+ category: (courseCategoryMap
236
+ ? z.string().refine((id) => courseCategoryMap.has(id), {
237
+ message:
238
+ 'unknown course category id — must match a node in the site data course category tree',
239
+ })
240
+ : z.string()
241
+ ).optional(),
242
+ tags: z.array(z.string()).default([]),
243
+ // Difficulty, 1 (beginner) … 5 (expert) — rendered as stars on cards
244
+ // and the detail header; localized labels live in src/i18n/ui.ts.
245
+ level: z.number().int().min(1).max(5).optional(),
246
+ // Human-readable duration, e.g. "1:30" or "8주" — displayed verbatim.
247
+ hours: z.string().optional(),
248
+ // Manual sort key, highest first (e.g. "2601-01" = YYMM-seq, so newer
249
+ // cohorts lead). Courses without one sort by `date`, newest first.
250
+ order: z.string().optional(),
251
+ // Free-form kind tag a site can style/filter on (e.g. "special-lecture").
252
+ type: z.string().optional(),
253
+ related: z.array(z.string()).default([]), // related course slugs
254
+ // Decks in the `slides` collection that belong to this course
255
+ // (linked from the course detail header).
256
+ slides: z.array(z.string()).default([]),
257
+ draft: z.boolean().default(false),
258
+ // Login-gated course (encrypted body; listings show title + optional
259
+ // PUBLIC `teaser`). See docs/private-content-design.md.
260
+ private: z.boolean().default(false),
261
+ teaser: z.string().optional(),
262
+ }),
263
+ });
264
+
200
265
  /**
201
266
  * The `slides` collection holds presentation decks — one MDX file per deck, with
202
267
  * each slide wrapped in a <Slide> component (see src/components/Slide.astro). The
@@ -241,6 +306,137 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
241
306
  }),
242
307
  });
243
308
 
309
+ /**
310
+ * The `apps` collection holds product/app pages — a Things-style marketing
311
+ * landing per app (`template: 'landing'`) plus its plain subpages (privacy,
312
+ * terms — `template: 'page'`, the default). Entries are locale-partitioned
313
+ * and may nest: `apps/<lang>/<slug>.mdx` renders at `/apps/<slug>/`,
314
+ * `apps/<lang>/<slug>/privacy.mdx` at `/apps/<slug>/privacy/`.
315
+ *
316
+ * Landing visuals (icons, screenshots, videos, the phone-frame art) are
317
+ * plain `public/` paths — videos can't go through the image pipeline, so
318
+ * the whole landing keeps one convention instead of two.
319
+ */
320
+ const apps = defineCollection({
321
+ loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/apps' }),
322
+ schema: z.object({
323
+ title: z.string(),
324
+ description: z.string().optional(), // meta description
325
+ // 'landing' renders the structured marketing page below; 'page' renders
326
+ // the markdown body like a standalone page (legal subpages).
327
+ template: z.enum(['landing', 'page']).default('page'),
328
+ // Header-nav placement, like the `pages` collection (default off —
329
+ // landings are usually reached from the home cards).
330
+ nav: z.boolean().default(false),
331
+ navLabel: z.string().optional(),
332
+ order: z.number().default(0),
333
+
334
+ // ---- hero ----
335
+ subtitle: z.string().optional(),
336
+ icon: z.string().optional(), // app icon (public/ path)
337
+ // Free-form key a site can target from its CSS (`.aas-hero-bg-<key>`)
338
+ // for a custom hero background.
339
+ heroBg: z.string().optional(),
340
+ tagline: z.string().optional(), // small print under the store buttons (may contain <br>)
341
+ productHunt: z
342
+ .object({ url: z.string().url(), image: z.string().url(), alt: z.string() })
343
+ .optional(),
344
+ // Store links; "#" renders the button disabled (not yet released), the
345
+ // label is small print under a button (e.g. "5월 출시 예정").
346
+ stores: z
347
+ .object({
348
+ appstore: z.string().optional(),
349
+ appstoreLabel: z.string().optional(),
350
+ playstore: z.string().optional(),
351
+ playstoreLabel: z.string().optional(),
352
+ })
353
+ .optional(),
354
+
355
+ // ---- alternating feature rows ----
356
+ // Device-frame art that screenshots render inside when `phoneFrame` is
357
+ // set (one per page; features opt in individually).
358
+ phoneFrameImage: z.string().optional(),
359
+ features: z
360
+ .array(
361
+ z.object({
362
+ label: z.string().optional(), // small eyebrow above the title
363
+ title: z.string(),
364
+ description: z.string(), // may contain <br>
365
+ image: z.string().optional(),
366
+ images: z.array(z.string()).default([]), // 2+ → auto-rotating carousel
367
+ phoneFrame: z.boolean().default(false),
368
+ }),
369
+ )
370
+ .default([]),
371
+
372
+ // ---- highlights grid (icon cards) ----
373
+ highlightsTitle: z.string().optional(),
374
+ highlights: z
375
+ .array(
376
+ z.object({
377
+ icon: z.string().optional(), // public/ path
378
+ title: z.string(),
379
+ description: z.string(),
380
+ }),
381
+ )
382
+ .default([]),
383
+
384
+ // ---- themes showcase (video tabs, auto-rotating) ----
385
+ themesTitle: z.string().optional(),
386
+ themesSubtitle: z.string().optional(),
387
+ themes: z
388
+ .array(
389
+ z.object({
390
+ name: z.string(),
391
+ description: z.string(),
392
+ tabDesc: z.string().optional(),
393
+ video: z.string(), // public/ path (mp4)
394
+ poster: z.string().optional(),
395
+ icon: z.string().optional(), // emoji or short text on the tab button
396
+ }),
397
+ )
398
+ .default([]),
399
+ themesLandscape: z
400
+ .object({ title: z.string(), description: z.string(), image: z.string() })
401
+ .optional(),
402
+
403
+ // ---- pricing ----
404
+ pricingTitle: z.string().optional(),
405
+ pricingSubtitle: z.string().optional(),
406
+ pricingBadge: z.string().optional(), // ribbon on the featured tier
407
+ pricingNotes: z.array(z.string()).default([]),
408
+ pricing: z
409
+ .array(
410
+ z.object({
411
+ name: z.string(),
412
+ price: z.string(),
413
+ period: z.string().optional(), // "/월", "일회성", …
414
+ featured: z.boolean().default(false),
415
+ items: z.array(z.string()).default([]),
416
+ }),
417
+ )
418
+ .default([]),
419
+
420
+ // ---- closing CTA + legal links ----
421
+ ctaTitle: z.string().optional(),
422
+ ctaDescription: z.string().optional(),
423
+ // Localized labels; privacy/terms link to the sibling subpages, support
424
+ // opens mailto site.email.
425
+ legal: z
426
+ .object({
427
+ privacy: z.string().optional(),
428
+ terms: z.string().optional(),
429
+ support: z.string().optional(),
430
+ })
431
+ .optional(),
432
+
433
+ draft: z.boolean().default(false),
434
+ // Login-gated page (encrypted body; see docs/private-content-design.md).
435
+ private: z.boolean().default(false),
436
+ teaser: z.string().optional(),
437
+ }),
438
+ });
439
+
244
440
  /**
245
441
  * The `pages` collection holds standalone top-level pages — an About/소개, a
246
442
  * contact page, terms, etc. Unlike the other collections there's no index or
@@ -273,5 +469,5 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
273
469
  }),
274
470
  });
275
471
 
276
- return { stacks, articles, concepts, slides, pages };
472
+ return { stacks, articles, concepts, courses, slides, apps, pages };
277
473
  }