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
package/src/i18n/ui.ts
CHANGED
|
@@ -58,6 +58,7 @@ export const ui = {
|
|
|
58
58
|
'A curated stack of the tools and services you actually use to build AI systems — each with a detail page and runnable sample code.',
|
|
59
59
|
'nav.browse': 'Browse',
|
|
60
60
|
'nav.concepts': 'Concepts',
|
|
61
|
+
'nav.courses': 'Courses',
|
|
61
62
|
'nav.blog': 'Writing',
|
|
62
63
|
'nav.samples': 'Samples',
|
|
63
64
|
'nav.slides': 'Slides',
|
|
@@ -117,6 +118,15 @@ export const ui = {
|
|
|
117
118
|
'concept.learnMore': 'Learn more',
|
|
118
119
|
'concept.backToConcepts': 'All concepts',
|
|
119
120
|
'concept.updated': 'Updated',
|
|
121
|
+
'course.title': 'Courses',
|
|
122
|
+
'course.tagline': 'Structured lessons and lectures — from fundamentals to hands-on practice.',
|
|
123
|
+
'course.empty': 'No courses yet.',
|
|
124
|
+
'course.backToCourses': 'All courses',
|
|
125
|
+
'course.level': 'Level',
|
|
126
|
+
'course.hours': 'Duration',
|
|
127
|
+
'course.updated': 'Updated',
|
|
128
|
+
'course.relatedCourses': 'Related courses',
|
|
129
|
+
'course.slides': 'Slides',
|
|
120
130
|
'detail.usedInConcepts': 'Used in concepts',
|
|
121
131
|
'sort.label': 'Sort',
|
|
122
132
|
'sort.alpha': 'A–Z',
|
|
@@ -207,6 +217,7 @@ export const ui = {
|
|
|
207
217
|
'AI 시스템을 만들 때 실제로 쓰는 도구와 서비스를 모았습니다 — 각 항목마다 상세 페이지와 바로 실행 가능한 샘플 코드를 제공합니다.',
|
|
208
218
|
'nav.browse': '둘러보기',
|
|
209
219
|
'nav.concepts': '개념',
|
|
220
|
+
'nav.courses': '강의',
|
|
210
221
|
'nav.blog': '글',
|
|
211
222
|
'nav.samples': '샘플',
|
|
212
223
|
'nav.slides': '슬라이드',
|
|
@@ -265,6 +276,15 @@ export const ui = {
|
|
|
265
276
|
'concept.learnMore': '더 알아보기',
|
|
266
277
|
'concept.backToConcepts': '개념 전체',
|
|
267
278
|
'concept.updated': '업데이트',
|
|
279
|
+
'course.title': '강의',
|
|
280
|
+
'course.tagline': '기초부터 실습까지, 체계적으로 구성한 강의.',
|
|
281
|
+
'course.empty': '아직 강의가 없습니다.',
|
|
282
|
+
'course.backToCourses': '강의 전체',
|
|
283
|
+
'course.level': '난이도',
|
|
284
|
+
'course.hours': '수강 시간',
|
|
285
|
+
'course.updated': '업데이트',
|
|
286
|
+
'course.relatedCourses': '관련 강의',
|
|
287
|
+
'course.slides': '슬라이드',
|
|
268
288
|
'detail.usedInConcepts': '이 도구가 쓰이는 개념',
|
|
269
289
|
'sort.label': '정렬',
|
|
270
290
|
'sort.alpha': '이름순',
|
|
@@ -398,6 +418,19 @@ export const pricingLabels: Record<string, Record<string, string>> = {
|
|
|
398
418
|
},
|
|
399
419
|
};
|
|
400
420
|
|
|
421
|
+
/** Localized label for a course `level` (1–5), falling back to the default
|
|
422
|
+
* locale then the bare number (so a site-added locale never crashes). */
|
|
423
|
+
export function difficultyLabel(lang: Lang, level: number): string {
|
|
424
|
+
const key = String(level);
|
|
425
|
+
return difficultyLabels[lang]?.[key] ?? difficultyLabels[defaultLang]?.[key] ?? key;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Human labels for the course `level` frontmatter (1–5), per locale. */
|
|
429
|
+
export const difficultyLabels: Record<string, Record<string, string>> = {
|
|
430
|
+
en: { '1': 'Beginner', '2': 'Elementary', '3': 'Intermediate', '4': 'Advanced', '5': 'Expert' },
|
|
431
|
+
ko: { '1': '입문', '2': '초급', '3': '중급', '4': '고급', '5': '전문가' },
|
|
432
|
+
};
|
|
433
|
+
|
|
401
434
|
/** Descriptive (non-name) licenses get localized; real license names pass through. */
|
|
402
435
|
const licenseLabels: Record<string, Record<string, string>> = {
|
|
403
436
|
en: { proprietary: 'Proprietary' },
|
|
@@ -7,6 +7,8 @@ import LanguageSwitcher from '../components/LanguageSwitcher.astro';
|
|
|
7
7
|
import ThemeToggle from '../components/ThemeToggle.astro';
|
|
8
8
|
import BackToTop from '../components/BackToTop.astro';
|
|
9
9
|
import { getNavPages, pageSlugOf } from '../lib/pages';
|
|
10
|
+
import { getNavApps, appSlugOf } from '../lib/apps';
|
|
11
|
+
import { homeTemplate } from '../lib/home';
|
|
10
12
|
import { sectionEnabled, type SectionKey } from '../lib/sections';
|
|
11
13
|
|
|
12
14
|
interface Props {
|
|
@@ -41,6 +43,8 @@ const isDefaultHome = path === '' && lang === 'en';
|
|
|
41
43
|
// e.g. an About/소개 page. Rendered as their own nav items alongside the
|
|
42
44
|
// built-in sections; the accessible label comes from the page's own frontmatter.
|
|
43
45
|
const navPages = await getNavPages(lang);
|
|
46
|
+
// App landings that opt into a header link (the `apps` collection's `nav`).
|
|
47
|
+
const navApps = await getNavApps(lang);
|
|
44
48
|
|
|
45
49
|
// The header sections, as data — rendered twice from one source so the two
|
|
46
50
|
// layouts never drift: an icon-only row (≥sm) and a labelled dropdown menu
|
|
@@ -54,11 +58,23 @@ const allNavItems: {
|
|
|
54
58
|
keepFilters?: boolean;
|
|
55
59
|
section?: SectionKey;
|
|
56
60
|
}[] = [
|
|
61
|
+
// Browse anchors into the catalog home's category sections — it only makes
|
|
62
|
+
// sense when the site keeps the catalog home template.
|
|
63
|
+
...(homeTemplate === 'catalog'
|
|
64
|
+
? [
|
|
65
|
+
{
|
|
66
|
+
href: `${home}#categories`,
|
|
67
|
+
label: t('nav.browse'),
|
|
68
|
+
keepFilters: true,
|
|
69
|
+
svg: '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
: []),
|
|
57
73
|
{
|
|
58
|
-
href:
|
|
59
|
-
label: t('nav.
|
|
60
|
-
|
|
61
|
-
svg: '<
|
|
74
|
+
href: getRelativeLocaleUrl(lang, 'course/'),
|
|
75
|
+
label: t('nav.courses'),
|
|
76
|
+
section: 'courses',
|
|
77
|
+
svg: '<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/>',
|
|
62
78
|
},
|
|
63
79
|
{
|
|
64
80
|
href: getRelativeLocaleUrl(lang, 'concept/'),
|
|
@@ -90,6 +106,12 @@ const allNavItems: {
|
|
|
90
106
|
section: 'glossary',
|
|
91
107
|
svg: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
|
|
92
108
|
},
|
|
109
|
+
...navApps.map((a) => ({
|
|
110
|
+
href: getRelativeLocaleUrl(lang, `apps/${appSlugOf(a)}/`),
|
|
111
|
+
label: a.data.navLabel ?? a.data.title,
|
|
112
|
+
section: 'apps' as SectionKey,
|
|
113
|
+
svg: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
|
114
|
+
})),
|
|
93
115
|
...navPages.map((p) => ({
|
|
94
116
|
href: getRelativeLocaleUrl(lang, `${pageSlugOf(p)}/`),
|
|
95
117
|
label: p.data.navLabel ?? p.data.title,
|
|
@@ -117,6 +139,16 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
|
|
|
117
139
|
<meta name="description" content={description} />
|
|
118
140
|
{noindex && <meta name="robots" content="noindex" />}
|
|
119
141
|
<link rel="icon" type="image/svg+xml" href={`${base.replace(/\/$/, '')}/favicon.svg`} />
|
|
142
|
+
{
|
|
143
|
+
sectionEnabled('articles') && (
|
|
144
|
+
<link
|
|
145
|
+
rel="alternate"
|
|
146
|
+
type="application/rss+xml"
|
|
147
|
+
title={`${site.name} — ${t('blog.title')}`}
|
|
148
|
+
href={getRelativeLocaleUrl(lang, 'rss.xml')}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
120
152
|
{/* On the root (default-locale) home, send first-time visitors to the
|
|
121
153
|
locale matching their browser — unless they've already picked one (the
|
|
122
154
|
language switcher stores `aas:lang`). Runs before paint, no history entry. */}
|
package/src/lib/apps.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
|
+
import type { Lang } from '../i18n/ui';
|
|
3
|
+
|
|
4
|
+
export type AppEntry = CollectionEntry<'apps'>;
|
|
5
|
+
|
|
6
|
+
/** The url slug of an app page — its id minus the `<lang>/` prefix. May be
|
|
7
|
+
* nested (`flowstate-ai/privacy`), which the catch-all route renders at the
|
|
8
|
+
* matching subpath. */
|
|
9
|
+
export function appSlugOf(entry: AppEntry): string {
|
|
10
|
+
return entry.id.replace(/^[a-z]{2}\//, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Published app pages for one locale, in `order` (then title) order. */
|
|
14
|
+
export async function getApps(lang: Lang): Promise<AppEntry[]> {
|
|
15
|
+
const all = await getCollection('apps');
|
|
16
|
+
return all
|
|
17
|
+
.filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
|
|
18
|
+
.sort((a, b) => a.data.order - b.data.order || a.data.title.localeCompare(b.data.title));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The subset of {@link getApps} that opts into a header-nav link. */
|
|
22
|
+
export async function getNavApps(lang: Lang): Promise<AppEntry[]> {
|
|
23
|
+
return (await getApps(lang)).filter((e) => e.data.nav);
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getCollection, type CollectionEntry } from 'astro:content';
|
|
2
|
+
import type { Lang } from '../i18n/ui';
|
|
3
|
+
|
|
4
|
+
export type CourseEntry = CollectionEntry<'courses'>;
|
|
5
|
+
|
|
6
|
+
/** The url slug of a course, i.e. its id with the `<lang>/` prefix removed. */
|
|
7
|
+
export function courseSlugOf(entry: CourseEntry): string {
|
|
8
|
+
return entry.id.replace(/^[a-z]{2}\//, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Published courses for one locale. `order` keys sort highest-first (newer
|
|
13
|
+
* cohorts lead, e.g. "2601-01" before "2512-02") and beat order-less entries;
|
|
14
|
+
* ties fall back to `date` (newest first), then title.
|
|
15
|
+
*/
|
|
16
|
+
export async function getCourses(lang: Lang): Promise<CourseEntry[]> {
|
|
17
|
+
const all = await getCollection('courses');
|
|
18
|
+
return all
|
|
19
|
+
.filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
|
|
20
|
+
.sort(
|
|
21
|
+
(a, b) =>
|
|
22
|
+
(b.data.order ?? '').localeCompare(a.data.order ?? '') ||
|
|
23
|
+
b.data.date.valueOf() - a.data.date.valueOf() ||
|
|
24
|
+
a.data.title.localeCompare(b.data.title),
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/lib/home.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { site } from '@aas-data/site';
|
|
2
|
+
import { defaultLang, type Lang } from '../i18n/ui';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The data-driven "cards" homepage. By default the theme home is the stack
|
|
6
|
+
* catalog; a site that isn't a catalog (a studio/product site) declares
|
|
7
|
+
* `home: { template: 'cards', … }` in `src/data/site.ts` and gets a
|
|
8
|
+
* hero + card grid + CTA home instead. The catalog routes still build (they
|
|
9
|
+
* are simply empty when the site has no stacks), and the header's Browse
|
|
10
|
+
* link — which anchors into the catalog home — hides itself.
|
|
11
|
+
*
|
|
12
|
+
* Strings are `Localized`: either one string for every locale, or a
|
|
13
|
+
* per-locale record (`{ ko: '…', en: '…' }`) that falls back to the default
|
|
14
|
+
* locale, matching how category labels work.
|
|
15
|
+
*/
|
|
16
|
+
export type Localized = string | Record<string, string>;
|
|
17
|
+
|
|
18
|
+
export interface HomeCard {
|
|
19
|
+
href: string;
|
|
20
|
+
external?: boolean;
|
|
21
|
+
name: Localized;
|
|
22
|
+
description?: Localized;
|
|
23
|
+
icon?: string; // public/ path
|
|
24
|
+
rounded?: boolean; // app-icon style rounding for the icon
|
|
25
|
+
tags?: string[] | Record<string, string[]>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HomeConfig {
|
|
29
|
+
template: 'cards';
|
|
30
|
+
hero?: {
|
|
31
|
+
icon?: string; // public/ path, shown above the title
|
|
32
|
+
title?: Localized; // defaults to site.name
|
|
33
|
+
subtitle?: Localized; // defaults to the site.tagline UI string
|
|
34
|
+
};
|
|
35
|
+
cardsTitle?: Localized;
|
|
36
|
+
cards?: HomeCard[];
|
|
37
|
+
cta?: {
|
|
38
|
+
title?: Localized;
|
|
39
|
+
description?: Localized;
|
|
40
|
+
button?: { label: Localized; href: string; external?: boolean };
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const home: HomeConfig | undefined = (site as { home?: HomeConfig }).home;
|
|
45
|
+
|
|
46
|
+
/** Which homepage the site gets: the catalog (default) or the cards home. */
|
|
47
|
+
export const homeTemplate: 'catalog' | 'cards' = home?.template === 'cards' ? 'cards' : 'catalog';
|
|
48
|
+
|
|
49
|
+
/** Resolve a Localized string for `lang` (default locale, then any, as fallback). */
|
|
50
|
+
export function loc(v: Localized | undefined, lang: Lang): string | undefined {
|
|
51
|
+
if (v == null) return undefined;
|
|
52
|
+
if (typeof v === 'string') return v;
|
|
53
|
+
return v[lang] ?? v[defaultLang] ?? Object.values(v)[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolve a per-locale (or shared) string list for `lang`. */
|
|
57
|
+
export function locList(v: string[] | Record<string, string[]> | undefined, lang: Lang): string[] {
|
|
58
|
+
if (v == null) return [];
|
|
59
|
+
if (Array.isArray(v)) return v;
|
|
60
|
+
return v[lang] ?? v[defaultLang] ?? Object.values(v)[0] ?? [];
|
|
61
|
+
}
|
package/src/lib/sections.ts
CHANGED
|
@@ -11,13 +11,26 @@ import { site } from '@aas-data/site';
|
|
|
11
11
|
* the `nav` / `draft` frontmatter, or by not authoring the page.
|
|
12
12
|
*
|
|
13
13
|
* A site sets overrides in `src/data/site.ts` (`sections`), which astro.config
|
|
14
|
-
* also forwards to the theme integration for route filtering. Omitted = enabled
|
|
14
|
+
* also forwards to the theme integration for route filtering. Omitted = enabled —
|
|
15
|
+
* except `courses`, which is opt-IN (`{ courses: true }`): enabling it requires
|
|
16
|
+
* site-side data (`src/data/course-categories.ts`), so it must never switch on
|
|
17
|
+
* by a theme upgrade alone.
|
|
15
18
|
*/
|
|
16
|
-
export type SectionKey =
|
|
19
|
+
export type SectionKey =
|
|
20
|
+
| 'concepts'
|
|
21
|
+
| 'articles'
|
|
22
|
+
| 'courses'
|
|
23
|
+
| 'apps'
|
|
24
|
+
| 'samples'
|
|
25
|
+
| 'slides'
|
|
26
|
+
| 'glossary'
|
|
27
|
+
| 'pages';
|
|
17
28
|
|
|
18
29
|
const DEFAULTS: Record<SectionKey, boolean> = {
|
|
19
30
|
concepts: true,
|
|
20
31
|
articles: true,
|
|
32
|
+
courses: false, // opt-in: needs src/data/course-categories.ts on the site
|
|
33
|
+
apps: true, // no site data needed; an empty collection builds zero pages
|
|
21
34
|
samples: true,
|
|
22
35
|
slides: true,
|
|
23
36
|
glossary: true,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import { render } from 'astro:content';
|
|
4
|
+
import type { GetStaticPaths } from 'astro';
|
|
5
|
+
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
6
|
+
import AppLanding from '../../../components/AppLanding.astro';
|
|
7
|
+
import PrivateGate from '../../../components/PrivateGate.astro';
|
|
8
|
+
import TocRail from '../../../components/TocRail.astro';
|
|
9
|
+
import MermaidLoader from '../../../components/MermaidLoader.astro';
|
|
10
|
+
import { getApps, appSlugOf } from '../../../lib/apps';
|
|
11
|
+
import { allLocales, langParam } from '../../../lib/locales';
|
|
12
|
+
import { inlineMd } from '../../../lib/inline-md';
|
|
13
|
+
|
|
14
|
+
export const getStaticPaths = (async () => {
|
|
15
|
+
const paths: Awaited<ReturnType<GetStaticPaths>> = [];
|
|
16
|
+
for (const lang of allLocales) {
|
|
17
|
+
for (const entry of await getApps(lang)) {
|
|
18
|
+
paths.push({ params: { lang: langParam(lang), id: appSlugOf(entry) }, props: { lang, entry } });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return paths;
|
|
22
|
+
}) satisfies GetStaticPaths;
|
|
23
|
+
|
|
24
|
+
const { lang, entry } = Astro.props;
|
|
25
|
+
const slug = appSlugOf(entry);
|
|
26
|
+
const priv = entry.data.private;
|
|
27
|
+
const isLanding = entry.data.template === 'landing';
|
|
28
|
+
// Plain 'page' template (privacy/terms subpages) renders the markdown body.
|
|
29
|
+
const { Content, headings } = await render(entry);
|
|
30
|
+
const tocItems = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
|
|
31
|
+
const bodyHasMermaid = (entry.body ?? '').includes('```mermaid');
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<BaseLayout
|
|
35
|
+
title={`${entry.data.title} — ${site.name}`}
|
|
36
|
+
description={priv ? entry.data.teaser : (entry.data.description ?? entry.data.subtitle)}
|
|
37
|
+
lang={lang}
|
|
38
|
+
path={`apps/${slug}/`}
|
|
39
|
+
noindex={priv}
|
|
40
|
+
>
|
|
41
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
42
|
+
{
|
|
43
|
+
isLanding ? (
|
|
44
|
+
<AppLanding entry={entry} lang={lang} />
|
|
45
|
+
) : (
|
|
46
|
+
<>
|
|
47
|
+
<header class="pb-6">
|
|
48
|
+
<h1 class="text-3xl font-bold tracking-tight">{entry.data.title}</h1>
|
|
49
|
+
{entry.data.description && (
|
|
50
|
+
<p
|
|
51
|
+
class="mt-2 aas-md text-lg whitespace-pre-line text-[var(--aas-muted)]"
|
|
52
|
+
set:html={inlineMd(entry.data.description)}
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
</header>
|
|
56
|
+
<div class="mt-4 flex items-start gap-8">
|
|
57
|
+
<article class="prose max-w-none aas-reading min-w-0 flex-1">
|
|
58
|
+
<Content />
|
|
59
|
+
</article>
|
|
60
|
+
<TocRail lang={lang} items={tocItems} />
|
|
61
|
+
</div>
|
|
62
|
+
{bodyHasMermaid && <MermaidLoader />}
|
|
63
|
+
</>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
</PrivateGate>
|
|
67
|
+
</BaseLayout>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import type { GetStaticPaths } from 'astro';
|
|
4
|
+
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
|
+
import CourseDetail from '../../../components/CourseDetail.astro';
|
|
6
|
+
import PrivateGate from '../../../components/PrivateGate.astro';
|
|
7
|
+
import { getCourses, courseSlugOf } from '../../../lib/courses';
|
|
8
|
+
import { allLocales, langParam } from '../../../lib/locales';
|
|
9
|
+
|
|
10
|
+
export const getStaticPaths = (async () => {
|
|
11
|
+
const paths: Awaited<ReturnType<GetStaticPaths>> = [];
|
|
12
|
+
for (const lang of allLocales) {
|
|
13
|
+
for (const entry of await getCourses(lang)) {
|
|
14
|
+
paths.push({ params: { lang: langParam(lang), id: courseSlugOf(entry) }, props: { lang, entry } });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return paths;
|
|
18
|
+
}) satisfies GetStaticPaths;
|
|
19
|
+
|
|
20
|
+
const { lang, entry } = Astro.props;
|
|
21
|
+
const slug = courseSlugOf(entry);
|
|
22
|
+
const priv = entry.data.private;
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<BaseLayout
|
|
26
|
+
title={`${entry.data.title} — ${site.name}`}
|
|
27
|
+
description={priv ? entry.data.teaser : entry.data.description}
|
|
28
|
+
lang={lang}
|
|
29
|
+
path={`course/${slug}/`}
|
|
30
|
+
noindex={priv}
|
|
31
|
+
>
|
|
32
|
+
<PrivateGate enabled={priv} lang={lang} title={entry.data.title} teaser={entry.data.teaser}>
|
|
33
|
+
<CourseDetail entry={entry} lang={lang} />
|
|
34
|
+
</PrivateGate>
|
|
35
|
+
</BaseLayout>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import type { GetStaticPaths } from 'astro';
|
|
4
|
+
import BaseLayout from '../../../../layouts/BaseLayout.astro';
|
|
5
|
+
import CourseIndex from '../../../../components/CourseIndex.astro';
|
|
6
|
+
import { courseTree } from '@aas-data/course-categories';
|
|
7
|
+
import { allLocales, langParam } from '../../../../lib/locales';
|
|
8
|
+
|
|
9
|
+
export const getStaticPaths = (() => {
|
|
10
|
+
const paths: Awaited<ReturnType<GetStaticPaths>> = [];
|
|
11
|
+
for (const lang of allLocales) {
|
|
12
|
+
for (const id of courseTree.allIds) {
|
|
13
|
+
paths.push({ params: { lang: langParam(lang), id }, props: { lang, id } });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return paths;
|
|
17
|
+
}) satisfies GetStaticPaths;
|
|
18
|
+
|
|
19
|
+
const { lang, id } = Astro.props as { lang: string; id: string };
|
|
20
|
+
const node = courseTree.map.get(id)!;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<BaseLayout title={`${node.label[lang]} — ${site.name}`} lang={lang} path={`course/category/${id}/`}>
|
|
24
|
+
<CourseIndex lang={lang} categoryId={id} />
|
|
25
|
+
</BaseLayout>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { site } from '@aas-data/site';
|
|
3
|
+
import type { GetStaticPaths } from 'astro';
|
|
4
|
+
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
|
5
|
+
import CourseIndex from '../../../components/CourseIndex.astro';
|
|
6
|
+
import { useTranslations } from '../../../i18n/ui';
|
|
7
|
+
import { allLocales, langParam } from '../../../lib/locales';
|
|
8
|
+
|
|
9
|
+
export const getStaticPaths = (() =>
|
|
10
|
+
allLocales.map((lang) => ({ params: { lang: langParam(lang) }, props: { lang } }))) satisfies GetStaticPaths;
|
|
11
|
+
|
|
12
|
+
const { lang } = Astro.props;
|
|
13
|
+
const t = useTranslations(lang);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<BaseLayout title={`${t('course.title')} — ${site.name}`} lang={lang} path="course/">
|
|
17
|
+
<CourseIndex lang={lang} />
|
|
18
|
+
</BaseLayout>
|
|
@@ -3,6 +3,8 @@ import { site } from '@aas-data/site';
|
|
|
3
3
|
import type { GetStaticPaths } from 'astro';
|
|
4
4
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
5
5
|
import Home from '../../components/Home.astro';
|
|
6
|
+
import CardsHome from '../../components/CardsHome.astro';
|
|
7
|
+
import { homeTemplate } from '../../lib/home';
|
|
6
8
|
import { allLocales, langParam } from '../../lib/locales';
|
|
7
9
|
|
|
8
10
|
export const getStaticPaths = (() =>
|
|
@@ -12,5 +14,5 @@ const { lang } = Astro.props;
|
|
|
12
14
|
---
|
|
13
15
|
|
|
14
16
|
<BaseLayout title={site.name} lang={lang} path="">
|
|
15
|
-
<Home lang={lang} />
|
|
17
|
+
{homeTemplate === 'cards' ? <CardsHome lang={lang} /> : <Home lang={lang} />}
|
|
16
18
|
</BaseLayout>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import rss from '@astrojs/rss';
|
|
2
|
+
import type { APIContext, GetStaticPaths } from 'astro';
|
|
3
|
+
import { getRelativeLocaleUrl } from 'astro:i18n';
|
|
4
|
+
import { site } from '@aas-data/site';
|
|
5
|
+
import { getArticles, articleSlugOf } from '../../lib/articles';
|
|
6
|
+
import { allLocales, langParam } from '../../lib/locales';
|
|
7
|
+
import { useTranslations } from '../../i18n/ui';
|
|
8
|
+
|
|
9
|
+
// Per-locale RSS feed of the articles collection: /rss.xml (default locale)
|
|
10
|
+
// and /<code>/rss.xml. Injected with the `articles` section, so a site that
|
|
11
|
+
// turns the blog off has no feed either.
|
|
12
|
+
export const getStaticPaths = (() =>
|
|
13
|
+
allLocales.map((lang) => ({
|
|
14
|
+
params: { lang: langParam(lang) },
|
|
15
|
+
props: { lang },
|
|
16
|
+
}))) satisfies GetStaticPaths;
|
|
17
|
+
|
|
18
|
+
export async function GET(context: APIContext) {
|
|
19
|
+
const { lang } = context.props as { lang: string };
|
|
20
|
+
const t = useTranslations(lang);
|
|
21
|
+
if (!context.site) {
|
|
22
|
+
throw new Error('RSS needs `site` set in astro.config (it already powers the sitemap).');
|
|
23
|
+
}
|
|
24
|
+
// getArticles drops drafts; private entries stay out of the feed entirely,
|
|
25
|
+
// mirroring the sitemap's private-page filter.
|
|
26
|
+
const articles = (await getArticles(lang)).filter((a) => !a.data.private);
|
|
27
|
+
return rss({
|
|
28
|
+
title: `${site.name} — ${t('blog.title')}`,
|
|
29
|
+
description: t('blog.tagline'),
|
|
30
|
+
site: context.site,
|
|
31
|
+
customData: `<language>${lang}</language>`,
|
|
32
|
+
items: articles.map((a) => ({
|
|
33
|
+
title: a.data.title,
|
|
34
|
+
description: a.data.description,
|
|
35
|
+
pubDate: a.data.date,
|
|
36
|
+
// Locale-relative and base-aware; rss() resolves it against `site`.
|
|
37
|
+
link: getRelativeLocaleUrl(lang, `article/${articleSlugOf(a)}/`),
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
}
|