stack-site-builder 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,30 @@ content schema, while a consuming site supplies only content, taxonomy data and
11
11
  config. Sites track the theme with `pnpm up stack-site-builder`, so each release
12
12
  here is a plain version bump they pull in.
13
13
 
14
+ ## [1.15.0] - 2026-07-22
15
+
16
+ ### Added
17
+
18
+ - **`courses` collection** — an opt-in section (`sections: { courses: true }`)
19
+ for sites that teach rather than catalog: course cards with difficulty stars
20
+ (`level` 1–5, localized labels), duration (`hours`), a manual sort key
21
+ (`order`, highest first — e.g. `"2601-01"` cohort keys), a `type` tag, linked
22
+ slide decks (`slides`), related courses, and the usual draft/private/teaser
23
+ flags. Routes mirror concepts (`/course/`, `/course/<id>/`,
24
+ `/course/category/<id>/`); an enabling site adds
25
+ `src/data/course-categories.ts` (exporting `courseTree` / `courseCatOf`) and
26
+ may pass `courseCategoryMap` to `defineAasCollections` for build-time
27
+ category validation. Because the section needs that site data, it stays off
28
+ until a site explicitly opts in — a theme upgrade alone changes nothing.
29
+ - **Body components** — `Bookmark` (link-preview card), `Embed` (responsive
30
+ iframe wrapper for demos/videos, with `ratio`/`height`/`sandbox`), and
31
+ `Lead` (intro paragraph), importable from
32
+ `stack-site-builder/components/*` in any MDX body.
33
+ - **RSS feed** — a per-locale feed of the articles collection at `/rss.xml`
34
+ (default locale) and `/<code>/rss.xml`, injected with the `articles` section
35
+ and advertised via `<link rel="alternate">`. Drafts and private entries stay
36
+ out, mirroring the sitemap.
37
+
14
38
  ## [1.14.0] - 2026-07-21
15
39
 
16
40
  ### Added
@@ -138,6 +162,7 @@ catalog sites from a thin content-only repository.
138
162
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
139
163
  consuming site for developing and previewing the theme on its own.
140
164
 
165
+ [1.15.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.14.0...v1.15.0
141
166
  [1.14.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.13.0...v1.14.0
142
167
  [1.13.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.12.0...v1.13.0
143
168
  [1.12.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.11.0...v1.12.0
package/README.md CHANGED
@@ -38,9 +38,9 @@ export const collections = defineAasCollections({ categoryMap });
38
38
  | --- | --- |
39
39
  | `src/data/site.ts` | Site identity: name, repo URL, the `locales` it ships, optional `sections` toggles, per-locale UI string overrides |
40
40
  | `src/data/categories.ts` | The tool-catalog category tree (validated against content) |
41
- | `src/data/concept-categories.ts` · `article-categories.ts` | Taxonomies for concepts / articles |
41
+ | `src/data/concept-categories.ts` · `article-categories.ts` · `course-categories.ts` (opt-in) | Taxonomies for concepts / articles / courses |
42
42
  | `src/data/glossary.mjs` | `[[Term]]` wikilink targets |
43
- | `src/content/{stacks,concepts,articles,slides}/` | The content, one MDX file per locale |
43
+ | `src/content/{stacks,concepts,courses,articles,slides}/` | The content, one MDX file per locale |
44
44
  | `src/content/pages/` | Standalone top-level pages (e.g. an About/소개), rendered at `/<slug>/` and optionally linked in the header nav |
45
45
  | `public/` · `samples/` | Logos/favicons and runnable sample projects |
46
46
 
@@ -72,7 +72,7 @@ The rest are opt-out — **concepts, articles, samples, slides, glossary** and t
72
72
  standalone **pages** collection (About/소개, …) — so a site can ship only what it
73
73
  needs. Turning one off removes both its routes and its header-nav item. (`pages`
74
74
  also has finer control: each page's `nav` / `draft` frontmatter, or simply not
75
- authoring it.)
75
+ authoring it.) **courses** is the one opt-IN section — see below.
76
76
 
77
77
  Declare the toggles once in `src/data/site.ts` and forward them to the theme in
78
78
  astro.config (which needs them to skip route injection). Import `SectionKey` from
@@ -93,6 +93,57 @@ import { site } from './src/data/site';
93
93
  integrations: [aasTheme({ glossary, sections: site.sections })];
94
94
  ```
95
95
 
96
+ ## Courses (opt-in)
97
+
98
+ A course section for sites that teach: cards with difficulty stars and
99
+ duration, cohort ordering, category browse pages, and paid courses gated by
100
+ the same private-content machinery. It stays off until a site opts in, because
101
+ it needs site data:
102
+
103
+ 1. `sections: { courses: true }` in `src/data/site.ts` (forwarded to
104
+ `aasTheme` as above).
105
+ 2. `src/data/course-categories.ts` exporting `courseTree` / `courseCatOf`
106
+ (copy `playground/src/data/course-categories.ts` and edit the tree).
107
+ 3. Optionally pass the map to
108
+ `defineAasCollections({ categoryMap, courseCategoryMap })` so course
109
+ category ids are validated at build time.
110
+
111
+ Then author `src/content/courses/<lang>/<slug>.mdx`:
112
+
113
+ ```yaml
114
+ title: Getting Started with AI Agents
115
+ description: A hands-on introduction.
116
+ date: 2026-06-01
117
+ category: ai-basics # id from course-categories.ts
118
+ level: 2 # difficulty 1–5, shown as stars
119
+ hours: "1:30" # duration, shown verbatim
120
+ order: "2601-01" # manual sort key, highest first (optional)
121
+ slides: [deck-id] # decks in the slides collection (optional)
122
+ private: true # paid course — body ships encrypted
123
+ teaser: A public one-liner for the login gate.
124
+ ```
125
+
126
+ Routes mirror concepts: `/course/`, `/course/<slug>/`, `/course/category/<id>/`.
127
+
128
+ ## Body components
129
+
130
+ Reusable MDX-body components, importable from any collection's content:
131
+ `Bookmark` (link-preview card), `Embed` (responsive iframe for demos/videos —
132
+ `ratio`, `height`, `sandbox`), `Lead` (intro paragraph).
133
+
134
+ ```mdx
135
+ import Bookmark from 'stack-site-builder/components/Bookmark.astro';
136
+
137
+ <Bookmark url="https://…" title="…" description="…" />
138
+ ```
139
+
140
+ ## RSS
141
+
142
+ The articles collection feeds `/rss.xml` (default locale) and
143
+ `/<code>/rss.xml`, advertised with `<link rel="alternate">`. Drafts and
144
+ private entries stay out (mirroring the sitemap); the feed is injected only
145
+ while the `articles` section is on.
146
+
96
147
  ## Private content
97
148
 
98
149
  Any entry (tools, concepts, articles, slides, pages) can require login:
package/index.d.ts CHANGED
@@ -1,16 +1,26 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
 
3
3
  /** Optional content sections that a site can turn off. `pages` is the
4
- * standalone-pages collection (About/소개, …). */
5
- export type SectionKey = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
4
+ * standalone-pages collection (About/소개, …); `courses` is opt-IN
5
+ * (default off enabling it requires site-side course data). */
6
+ export type SectionKey =
7
+ | 'concepts'
8
+ | 'articles'
9
+ | 'courses'
10
+ | 'samples'
11
+ | 'slides'
12
+ | 'glossary'
13
+ | 'pages';
6
14
 
7
15
  export interface AasThemeOptions {
8
16
  /** The site's glossary (`src/data/glossary.mjs`) — `[[wikilink]]` targets. */
9
17
  glossary: Record<string, unknown>;
10
18
  /**
11
- * Turn optional sections off (all on by default), e.g. `{ slides: false }`.
12
- * A disabled section's routes aren't injected; pass the same object to
13
- * `src/data/site.ts` `sections` so its header-nav item is hidden too.
19
+ * Section toggles, e.g. `{ slides: false }`. Every section is on by default
20
+ * except `courses`, which is opt-in (`{ courses: true }`) and additionally
21
+ * requires `src/data/course-categories.ts` on the site. A disabled section's
22
+ * routes aren't injected; pass the same object to `src/data/site.ts`
23
+ * `sections` so its header-nav item is hidden too.
14
24
  */
15
25
  sections?: Partial<Record<SectionKey, boolean>>;
16
26
  }
package/index.mjs CHANGED
@@ -29,6 +29,8 @@ const PAGES = [
29
29
  // Standalone top-level pages (the `pages` collection), e.g. an About/소개
30
30
  // page. A single dynamic route per locale renders every entry at `/<slug>/`.
31
31
  '[page].astro',
32
+ // Per-locale RSS feed of the articles collection (an endpoint, not a page).
33
+ 'rss.xml.ts',
32
34
  'article/index.astro',
33
35
  'article/[...id].astro',
34
36
  'article/category/[id].astro',
@@ -36,6 +38,9 @@ const PAGES = [
36
38
  'concept/index.astro',
37
39
  'concept/[...id].astro',
38
40
  'concept/category/[id].astro',
41
+ 'course/index.astro',
42
+ 'course/[...id].astro',
43
+ 'course/category/[id].astro',
39
44
  'glossary.astro',
40
45
  'sample/index.astro',
41
46
  'sample/[folder].astro',
@@ -47,10 +52,11 @@ const PAGES = [
47
52
  ];
48
53
 
49
54
  /** `[...lang]/article/[...id].astro` → `/[...lang]/article/[...id]`,
50
- * `[...lang]/index.astro` → `/[...lang]` */
55
+ * `[...lang]/index.astro` → `/[...lang]`,
56
+ * `[...lang]/rss.xml.ts` → `/[...lang]/rss.xml` (endpoints keep their name). */
51
57
  /** @param {string} file */
52
58
  function patternOf(file) {
53
- const p = file.replace(/\.astro$/, '').replace(/\/?index$/, '');
59
+ const p = file.replace(/\.(astro|ts)$/, '').replace(/\/?index$/, '');
54
60
  return `/${p}`.replace(/\/$/, '') || '/';
55
61
  }
56
62
 
@@ -60,19 +66,27 @@ function patternOf(file) {
60
66
  function sectionOf(file) {
61
67
  if (file.startsWith('concept/')) return 'concepts';
62
68
  if (file.startsWith('article/')) return 'articles';
69
+ if (file.startsWith('course/')) return 'courses';
63
70
  if (file.startsWith('sample/')) return 'samples';
64
71
  if (file.startsWith('slides/')) return 'slides';
65
72
  if (file === 'glossary.astro') return 'glossary';
66
73
  if (file === '[page].astro') return 'pages';
74
+ if (file === 'rss.xml.ts') return 'articles'; // the feed is the blog's
67
75
  return null;
68
76
  }
69
77
 
78
+ // Sections that are opt-IN rather than opt-out: their routes are injected only
79
+ // when the site passes `{ <key>: true }`. `courses` needs site-side data
80
+ // (src/data/course-categories.ts), so a theme upgrade alone must not enable it.
81
+ const OPT_IN_SECTIONS = new Set(['courses']);
82
+
70
83
  /**
71
84
  * @param {object} opts
72
85
  * @param {Record<string, any>} opts.glossary — the site's glossary
73
86
  * (`src/data/glossary.mjs`), used by `[[wikilink]]` resolution.
74
87
  * @param {Partial<Record<string, boolean>>} [opts.sections] — optional-section
75
88
  * toggles (`{ slides: false }`); a disabled section's routes are not injected.
89
+ * `courses` is opt-IN (`{ courses: true }`) — it needs site-side course data.
76
90
  * Keep it in sync with `src/data/site.ts` `sections` (which hides the nav item).
77
91
  * @returns {import('astro').AstroIntegration[]}
78
92
  */
@@ -97,6 +111,7 @@ export default function aasTheme({ glossary, sections = {} }) {
97
111
  for (const file of PAGES) {
98
112
  const section = sectionOf(file);
99
113
  if (section && sections[section] === false) continue;
114
+ if (section && OPT_IN_SECTIONS.has(section) && sections[section] !== true) continue;
100
115
  injectRoute({
101
116
  pattern: patternOf(`[...lang]/${file}`),
102
117
  entrypoint: `stack-site-builder/pages/[...lang]/${file}`,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.14.0",
4
+ "version": "1.15.0",
5
5
  "license": "MIT",
6
6
  "description": "The engine behind the awesome-*-stack catalog sites: an Astro theme with the catalog/concepts/articles/slides/samples routes, components, styles and markdown pipeline. Sites provide content, taxonomy data and config.",
7
7
  "repository": {
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@astrojs/mdx": "^4.2.0",
38
+ "@astrojs/rss": "^4.0.0",
38
39
  "@astrojs/sitemap": "^3.3.0",
39
40
  "@tailwindcss/vite": "^4.1.0",
40
41
  "markdown-it": "^14.2.0",
@@ -0,0 +1,37 @@
1
+ ---
2
+ // Link-preview card for MDX bodies (a Hugo `bookmark` shortcode successor):
3
+ // <Bookmark url="https://…" title="…" description="…" />
4
+ import ArrowUpRight from './ArrowUpRight.astro';
5
+
6
+ interface Props {
7
+ url: string;
8
+ title: string;
9
+ description?: string;
10
+ /** Host label under the title; derived from `url` when omitted. */
11
+ host?: string;
12
+ }
13
+
14
+ const { url, title, description, host = new URL(url).host } = Astro.props;
15
+ ---
16
+
17
+ <a
18
+ href={url}
19
+ target="_blank"
20
+ rel="noopener noreferrer"
21
+ class="not-prose aas-lift my-4 block rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] p-4 no-underline"
22
+ >
23
+ <span class="flex items-center justify-between gap-3">
24
+ <span class="min-w-0">
25
+ <span class="block truncate font-semibold text-[var(--aas-text)]">{title}</span>
26
+ {
27
+ description && (
28
+ <span class="mt-1 line-clamp-2 block text-sm leading-relaxed text-[var(--aas-muted)]">
29
+ {description}
30
+ </span>
31
+ )
32
+ }
33
+ <span class="mt-1.5 block text-xs text-[var(--aas-muted)] opacity-75">{host}</span>
34
+ </span>
35
+ <ArrowUpRight size={18} class="shrink-0 text-[var(--aas-muted)]" />
36
+ </span>
37
+ </a>
@@ -0,0 +1,97 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+ import { getRelativeLocaleUrl } from 'astro:i18n';
4
+ import { useTranslations, type Lang } from '../i18n/ui';
5
+ import { formatDate, isoDate } from '../lib/dates';
6
+ import { inlineMd } from '../lib/inline-md';
7
+ import DifficultyStars from './DifficultyStars.astro';
8
+ import type { ImageMetadata } from 'astro';
9
+
10
+ interface Props {
11
+ lang: Lang;
12
+ slug: string;
13
+ title: string;
14
+ description: string;
15
+ tags: string[];
16
+ image?: ImageMetadata;
17
+ imageAlt?: string;
18
+ level?: number;
19
+ hours?: string;
20
+ version?: string;
21
+ date: Date;
22
+ updated?: Date;
23
+ /** Manual sort key (`order` frontmatter) — beats date recency in the list sort. */
24
+ order?: string;
25
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
26
+ isPrivate?: boolean;
27
+ teaser?: string;
28
+ }
29
+
30
+ const {
31
+ lang,
32
+ slug,
33
+ title,
34
+ description,
35
+ tags,
36
+ image,
37
+ imageAlt,
38
+ level,
39
+ hours,
40
+ version,
41
+ date,
42
+ updated,
43
+ order,
44
+ isPrivate = false,
45
+ teaser,
46
+ } = Astro.props;
47
+ const t = useTranslations(lang);
48
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
49
+ // ListControls' "recent" sort compares data-date strings descending. Putting
50
+ // `order` in the key keeps the manual curation working client-side too: order
51
+ // keys ("2601-01") outrank ISO dates ("20…") lexicographically, matching the
52
+ // server-side getCourses() order — so the on-load sort causes no reflow.
53
+ const sortKey = order ?? isoDate(updated ?? date);
54
+ const shownDate = updated ?? date;
55
+ ---
56
+
57
+ <a
58
+ href={getRelativeLocaleUrl(lang, `course/${slug}/`)}
59
+ data-name={title.toLowerCase()}
60
+ data-date={sortKey}
61
+ class="aas-lift block overflow-hidden rounded-3xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
62
+ >
63
+ {image && <Image src={image} alt={imageAlt ?? title} class="aspect-[16/9] w-full object-cover" />}
64
+ <div class="p-5">
65
+ <p class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--aas-muted)]">
66
+ {level && <DifficultyStars lang={lang} level={level} />}
67
+ {
68
+ hours && (
69
+ <span class="inline-flex items-center gap-1" title={t('course.hours')}>
70
+ <svg width="12" height="12" 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>
71
+ {hours}
72
+ </span>
73
+ )
74
+ }
75
+ <time datetime={isoDate(shownDate)}>{formatDate(shownDate, lang)}</time>
76
+ {version && <span>{t('meta.docVersion')} {version}</span>}
77
+ </p>
78
+ <h3 class="mt-1 text-lg font-semibold text-[var(--aas-text)]">
79
+ {isPrivate && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{title}
80
+ </h3>
81
+ <p
82
+ class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
83
+ set:html={inlineMd(shownDesc)}
84
+ />
85
+ {
86
+ tags.length > 0 && (
87
+ <div class="mt-3 flex flex-wrap gap-1.5">
88
+ {tags.slice(0, 4).map((tag) => (
89
+ <span class="rounded-full border border-[var(--aas-border)] px-2 py-0.5 text-xs text-[var(--aas-muted)]">
90
+ {tag}
91
+ </span>
92
+ ))}
93
+ </div>
94
+ )
95
+ }
96
+ </div>
97
+ </a>
@@ -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
@@ -273,5 +338,5 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
273
338
  }),
274
339
  });
275
340
 
276
- return { stacks, articles, concepts, slides, pages };
341
+ return { stacks, articles, concepts, courses, slides, pages };
277
342
  }
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' },
@@ -60,6 +60,12 @@ const allNavItems: {
60
60
  keepFilters: true,
61
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"/>',
62
62
  },
63
+ {
64
+ href: getRelativeLocaleUrl(lang, 'course/'),
65
+ label: t('nav.courses'),
66
+ section: 'courses',
67
+ 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"/>',
68
+ },
63
69
  {
64
70
  href: getRelativeLocaleUrl(lang, 'concept/'),
65
71
  label: t('nav.concepts'),
@@ -117,6 +123,16 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
117
123
  <meta name="description" content={description} />
118
124
  {noindex && <meta name="robots" content="noindex" />}
119
125
  <link rel="icon" type="image/svg+xml" href={`${base.replace(/\/$/, '')}/favicon.svg`} />
126
+ {
127
+ sectionEnabled('articles') && (
128
+ <link
129
+ rel="alternate"
130
+ type="application/rss+xml"
131
+ title={`${site.name} — ${t('blog.title')}`}
132
+ href={getRelativeLocaleUrl(lang, 'rss.xml')}
133
+ />
134
+ )
135
+ }
120
136
  {/* On the root (default-locale) home, send first-time visitors to the
121
137
  locale matching their browser — unless they've already picked one (the
122
138
  language switcher stores `aas:lang`). Runs before paint, no history entry. */}
@@ -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
+ }
@@ -11,13 +11,24 @@ 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
+ | 'samples'
24
+ | 'slides'
25
+ | 'glossary'
26
+ | 'pages';
17
27
 
18
28
  const DEFAULTS: Record<SectionKey, boolean> = {
19
29
  concepts: true,
20
30
  articles: true,
31
+ courses: false, // opt-in: needs src/data/course-categories.ts on the site
21
32
  samples: true,
22
33
  slides: true,
23
34
  glossary: true,
@@ -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>
@@ -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
+ }