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.
- package/CHANGELOG.md +50 -0
- package/README.md +86 -3
- package/index.d.ts +16 -5
- package/index.mjs +21 -2
- package/package.json +2 -1
- package/src/components/AppLanding.astro +445 -0
- package/src/components/Bookmark.astro +37 -0
- package/src/components/CardsHome.astro +108 -0
- package/src/components/CourseCard.astro +97 -0
- package/src/components/CourseDetail.astro +116 -0
- package/src/components/CourseIndex.astro +145 -0
- package/src/components/DifficultyStars.astro +34 -0
- package/src/components/Embed.astro +36 -0
- package/src/components/Lead.astro +8 -0
- package/src/components/RelatedCourses.astro +64 -0
- package/src/content.ts +200 -4
- package/src/i18n/ui.ts +33 -0
- package/src/layouts/BaseLayout.astro +36 -4
- package/src/lib/apps.ts +24 -0
- package/src/lib/courses.ts +26 -0
- package/src/lib/home.ts +61 -0
- package/src/lib/sections.ts +15 -2
- package/src/pages/[...lang]/apps/[...id].astro +67 -0
- 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]/index.astro +3 -1
- package/src/pages/[...lang]/rss.xml.ts +40 -0
|
@@ -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" />}
|
|
@@ -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,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`
|
|
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({
|
|
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
|
}
|