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/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: `${home}#categories`,
59
- label: t('nav.browse'),
60
- keepFilters: true,
61
- 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"/>',
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. */}
@@ -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
+ }
@@ -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
+ }
@@ -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 = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
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
+ }