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 +25 -0
- package/README.md +54 -3
- package/index.d.ts +15 -5
- package/index.mjs +17 -2
- package/package.json +2 -1
- package/src/components/Bookmark.astro +37 -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 +69 -4
- package/src/i18n/ui.ts +33 -0
- package/src/layouts/BaseLayout.astro +16 -0
- package/src/lib/courses.ts +26 -0
- package/src/lib/sections.ts +13 -2
- 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]/rss.xml.ts +40 -0
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
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `src/data/
|
|
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.
|
|
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))} →
|
|
137
|
+
</a>
|
|
138
|
+
)}
|
|
139
|
+
</section>
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
{isLeaf && <nav data-pager aria-label="Pagination" class="mt-8 flex flex-wrap items-center justify-center gap-1.5" />}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
import StarIcon from './StarIcon.astro';
|
|
3
|
+
import { useTranslations, difficultyLabel, type Lang } from '../i18n/ui';
|
|
4
|
+
|
|
5
|
+
// Course difficulty as a 5-star row (filled up to `level`). The visual is
|
|
6
|
+
// decorative; the level + localized label live in the aria-label/tooltip.
|
|
7
|
+
interface Props {
|
|
8
|
+
lang: Lang;
|
|
9
|
+
/** 1 (beginner) … 5 (expert). */
|
|
10
|
+
level: number;
|
|
11
|
+
size?: number;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { lang, level, size = 12, class: className } = Astro.props;
|
|
16
|
+
const t = useTranslations(lang);
|
|
17
|
+
const label = `${t('course.level')}: ${difficultyLabel(lang, level)}`;
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<span
|
|
21
|
+
class:list={['inline-flex items-center gap-0.5', className]}
|
|
22
|
+
role="img"
|
|
23
|
+
aria-label={label}
|
|
24
|
+
title={label}
|
|
25
|
+
>
|
|
26
|
+
{
|
|
27
|
+
[1, 2, 3, 4, 5].map((i) => (
|
|
28
|
+
<StarIcon
|
|
29
|
+
size={size}
|
|
30
|
+
class={i <= level ? 'text-[var(--aas-accent)]' : 'text-[var(--aas-border)]'}
|
|
31
|
+
/>
|
|
32
|
+
))
|
|
33
|
+
}
|
|
34
|
+
</span>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Responsive iframe wrapper for MDX bodies (a Hugo `iframe` shortcode
|
|
3
|
+
// successor) — interactive demos, videos, and other embeds:
|
|
4
|
+
// <Embed src="/demos/foo/index.html" title="Foo demo" ratio="4:3" />
|
|
5
|
+
interface Props {
|
|
6
|
+
src: string;
|
|
7
|
+
/** Accessible name for the iframe — required; it is the only label. */
|
|
8
|
+
title: string;
|
|
9
|
+
/** Aspect ratio ("16:9" default, "4:3", …), or "auto" paired with `height`. */
|
|
10
|
+
ratio?: string;
|
|
11
|
+
/** CSS height, used when `ratio` is "auto" (e.g. "32rem"). */
|
|
12
|
+
height?: string;
|
|
13
|
+
/** `sandbox` attribute passthrough (omit = unsandboxed, same-origin demos). */
|
|
14
|
+
sandbox?: string;
|
|
15
|
+
allowFullscreen?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { src, title, ratio = '16:9', height, sandbox, allowFullscreen = true } = Astro.props;
|
|
19
|
+
const style =
|
|
20
|
+
ratio === 'auto'
|
|
21
|
+
? height
|
|
22
|
+
? `height: ${height};`
|
|
23
|
+
: undefined
|
|
24
|
+
: `aspect-ratio: ${ratio.replace(':', ' / ')};`;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<div class="not-prose my-4" style={style}>
|
|
28
|
+
<iframe
|
|
29
|
+
src={src}
|
|
30
|
+
title={title}
|
|
31
|
+
loading="lazy"
|
|
32
|
+
class="h-full w-full rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)]"
|
|
33
|
+
{...(sandbox !== undefined ? { sandbox } : {})}
|
|
34
|
+
{...(allowFullscreen ? { allowfullscreen: true } : {})}
|
|
35
|
+
></iframe>
|
|
36
|
+
</div>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
// "Related courses" card section on a course detail page, mirroring
|
|
3
|
+
// RelatedConcepts so course links present the same way (image + title +
|
|
4
|
+
// level/duration meta + description, in a collapsible grid).
|
|
5
|
+
import { getRelativeLocaleUrl } from 'astro:i18n';
|
|
6
|
+
import { Image } from 'astro:assets';
|
|
7
|
+
import { courseSlugOf, type CourseEntry } from '../lib/courses';
|
|
8
|
+
import { useTranslations, type Lang } from '../i18n/ui';
|
|
9
|
+
import { formatDate } from '../lib/dates';
|
|
10
|
+
import { inlineMd } from '../lib/inline-md';
|
|
11
|
+
import CollapsibleGrid from './CollapsibleGrid.astro';
|
|
12
|
+
import DifficultyStars from './DifficultyStars.astro';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
lang: Lang;
|
|
16
|
+
courses: CourseEntry[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { lang, courses } = Astro.props;
|
|
20
|
+
const t = useTranslations(lang);
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
courses.length > 0 && (
|
|
25
|
+
<section class="mt-8">
|
|
26
|
+
<h2 class="text-xs font-semibold tracking-wide text-[var(--aas-muted)] uppercase">
|
|
27
|
+
{t('course.relatedCourses')}
|
|
28
|
+
</h2>
|
|
29
|
+
<CollapsibleGrid lang={lang} count={courses.length}>
|
|
30
|
+
{courses.map((c) => (
|
|
31
|
+
<a
|
|
32
|
+
href={getRelativeLocaleUrl(lang, `course/${courseSlugOf(c)}/`)}
|
|
33
|
+
class="aas-lift flex items-stretch overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
|
|
34
|
+
>
|
|
35
|
+
{c.data.image && (
|
|
36
|
+
<Image src={c.data.image} alt="" class="w-28 shrink-0 self-stretch object-cover" />
|
|
37
|
+
)}
|
|
38
|
+
<div class="min-w-0 p-4">
|
|
39
|
+
<span class="block font-semibold text-[var(--aas-text)]">
|
|
40
|
+
{c.data.private && (
|
|
41
|
+
<span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>
|
|
42
|
+
)}
|
|
43
|
+
{c.data.title}
|
|
44
|
+
</span>
|
|
45
|
+
<span class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-[var(--aas-muted)]">
|
|
46
|
+
{c.data.level && <DifficultyStars lang={lang} level={c.data.level} size={10} />}
|
|
47
|
+
{c.data.hours && <span>{c.data.hours}</span>}
|
|
48
|
+
{c.data.updated && (
|
|
49
|
+
<span>
|
|
50
|
+
{t('course.updated')} {formatDate(c.data.updated, lang)}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
</span>
|
|
54
|
+
<span
|
|
55
|
+
class="aas-md mt-1.5 line-clamp-2 text-xs leading-relaxed text-[var(--aas-muted)] opacity-75"
|
|
56
|
+
set:html={inlineMd(c.data.private ? (c.data.teaser ?? '') : c.data.description)}
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
</a>
|
|
60
|
+
))}
|
|
61
|
+
</CollapsibleGrid>
|
|
62
|
+
</section>
|
|
63
|
+
)
|
|
64
|
+
}
|
package/src/content.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { glob } from 'astro/loaders';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The shared content model for awesome-*-stack sites: `stacks` (the tool
|
|
6
|
-
* catalog), `articles`, `concepts`
|
|
7
|
-
* stays thin:
|
|
6
|
+
* catalog), `articles`, `concepts`, `courses` (opt-in), `slides` and `pages`.
|
|
7
|
+
* A site's content.config.ts stays thin:
|
|
8
8
|
*
|
|
9
9
|
* import { defineAasCollections } from 'stack-site-builder/content';
|
|
10
10
|
* import { categoryMap } from './data/categories';
|
|
@@ -15,7 +15,16 @@ import { glob } from 'astro/loaders';
|
|
|
15
15
|
* tree — stack entries are validated against it so an unknown category id
|
|
16
16
|
* fails the build instead of silently dropping the entry from every listing.
|
|
17
17
|
*/
|
|
18
|
-
export function defineAasCollections({
|
|
18
|
+
export function defineAasCollections({
|
|
19
|
+
categoryMap,
|
|
20
|
+
courseCategoryMap,
|
|
21
|
+
}: {
|
|
22
|
+
categoryMap: Map<string, unknown>;
|
|
23
|
+
/** The site's course category tree (`src/data/course-categories.ts`). Optional
|
|
24
|
+
* because `courses` is an opt-in section; when provided, course `category`
|
|
25
|
+
* ids are validated against it at build time (like stacks). */
|
|
26
|
+
courseCategoryMap?: Map<string, unknown>;
|
|
27
|
+
}) {
|
|
19
28
|
/**
|
|
20
29
|
* The `stacks` collection holds one entry per tool/service used to build
|
|
21
30
|
* AI agents. Each entry is an MDX file: frontmatter powers the listing and
|
|
@@ -197,6 +206,62 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
|
|
|
197
206
|
}),
|
|
198
207
|
});
|
|
199
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The `courses` collection holds structured lessons/lectures — an opt-in
|
|
211
|
+
* section (`sections: { courses: true }`) for sites that teach rather than
|
|
212
|
+
* catalog. Each course is one MDX file: frontmatter powers the course cards
|
|
213
|
+
* (difficulty, duration, category), the body is the course page itself. Paid
|
|
214
|
+
* courses use `private` + `teaser` like every other collection.
|
|
215
|
+
*
|
|
216
|
+
* Locale-partitioned like the others: `courses/<lang>/<slug>.mdx`.
|
|
217
|
+
*/
|
|
218
|
+
const courses = defineCollection({
|
|
219
|
+
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/courses' }),
|
|
220
|
+
schema: ({ image }) =>
|
|
221
|
+
z.object({
|
|
222
|
+
title: z.string(),
|
|
223
|
+
description: z.string(),
|
|
224
|
+
// Short one-liner for tight card layouts; falls back to `description`.
|
|
225
|
+
summary: z.string().optional(),
|
|
226
|
+
date: z.coerce.date(), // first published
|
|
227
|
+
// Living-doc metadata, mirroring concepts: bump on meaningful edits.
|
|
228
|
+
version: z.string().optional(), // e.g. "1.0"
|
|
229
|
+
updated: z.coerce.date().optional(), // last meaningful update (YYYY-MM-DD)
|
|
230
|
+
image: image().optional(), // hero / card image (optimized; relative to the file)
|
|
231
|
+
imageAlt: z.string().optional(),
|
|
232
|
+
// Leaf id from the site's src/data/course-categories.ts. Validated when the
|
|
233
|
+
// site passes `courseCategoryMap` (strict, like stacks); otherwise resolved
|
|
234
|
+
// at render time with an uncategorized fallback (loose, like concepts).
|
|
235
|
+
category: (courseCategoryMap
|
|
236
|
+
? z.string().refine((id) => courseCategoryMap.has(id), {
|
|
237
|
+
message:
|
|
238
|
+
'unknown course category id — must match a node in the site data course category tree',
|
|
239
|
+
})
|
|
240
|
+
: z.string()
|
|
241
|
+
).optional(),
|
|
242
|
+
tags: z.array(z.string()).default([]),
|
|
243
|
+
// Difficulty, 1 (beginner) … 5 (expert) — rendered as stars on cards
|
|
244
|
+
// and the detail header; localized labels live in src/i18n/ui.ts.
|
|
245
|
+
level: z.number().int().min(1).max(5).optional(),
|
|
246
|
+
// Human-readable duration, e.g. "1:30" or "8주" — displayed verbatim.
|
|
247
|
+
hours: z.string().optional(),
|
|
248
|
+
// Manual sort key, highest first (e.g. "2601-01" = YYMM-seq, so newer
|
|
249
|
+
// cohorts lead). Courses without one sort by `date`, newest first.
|
|
250
|
+
order: z.string().optional(),
|
|
251
|
+
// Free-form kind tag a site can style/filter on (e.g. "special-lecture").
|
|
252
|
+
type: z.string().optional(),
|
|
253
|
+
related: z.array(z.string()).default([]), // related course slugs
|
|
254
|
+
// Decks in the `slides` collection that belong to this course
|
|
255
|
+
// (linked from the course detail header).
|
|
256
|
+
slides: z.array(z.string()).default([]),
|
|
257
|
+
draft: z.boolean().default(false),
|
|
258
|
+
// Login-gated course (encrypted body; listings show title + optional
|
|
259
|
+
// PUBLIC `teaser`). See docs/private-content-design.md.
|
|
260
|
+
private: z.boolean().default(false),
|
|
261
|
+
teaser: z.string().optional(),
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
|
|
200
265
|
/**
|
|
201
266
|
* The `slides` collection holds presentation decks — one MDX file per deck, with
|
|
202
267
|
* each slide wrapped in a <Slide> component (see src/components/Slide.astro). The
|
|
@@ -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
|
+
}
|
package/src/lib/sections.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|