stack-site-builder 1.13.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +105 -3
  3. package/index.d.ts +15 -5
  4. package/index.mjs +26 -2
  5. package/package.json +2 -1
  6. package/src/components/ArticleCard.astro +9 -3
  7. package/src/components/ArticleLink.astro +7 -2
  8. package/src/components/BlogIndex.astro +2 -0
  9. package/src/components/Bookmark.astro +37 -0
  10. package/src/components/CategoryIndex.astro +2 -0
  11. package/src/components/CodeSamples.astro +6 -1
  12. package/src/components/ConceptCard.astro +9 -3
  13. package/src/components/ConceptIndex.astro +2 -0
  14. package/src/components/ConceptLink.astro +7 -2
  15. package/src/components/CourseCard.astro +97 -0
  16. package/src/components/CourseDetail.astro +116 -0
  17. package/src/components/CourseIndex.astro +145 -0
  18. package/src/components/DeckView.astro +17 -2
  19. package/src/components/DetailTabs.astro +6 -1
  20. package/src/components/DifficultyStars.astro +34 -0
  21. package/src/components/Embed.astro +36 -0
  22. package/src/components/Home.astro +2 -0
  23. package/src/components/Lead.astro +8 -0
  24. package/src/components/MermaidLoader.astro +3 -1
  25. package/src/components/PricingSection.astro +4 -1
  26. package/src/components/PrivateGate.astro +135 -0
  27. package/src/components/ProjectViewer.astro +24 -13
  28. package/src/components/RelatedCourses.astro +64 -0
  29. package/src/components/SlidesIndex.astro +8 -2
  30. package/src/components/StackCard.astro +15 -3
  31. package/src/components/StackDetail.astro +9 -0
  32. package/src/components/TagIndex.astro +2 -0
  33. package/src/components/TocRail.astro +12 -6
  34. package/src/components/VendorIndex.astro +2 -0
  35. package/src/content.ts +90 -4
  36. package/src/i18n/ui.ts +47 -0
  37. package/src/layouts/BaseLayout.astro +26 -4
  38. package/src/lib/courses.ts +26 -0
  39. package/src/lib/private-client.ts +160 -0
  40. package/src/lib/private.ts +129 -0
  41. package/src/lib/reinit.ts +12 -0
  42. package/src/lib/sections.ts +13 -2
  43. package/src/pages/[...lang]/[page].astro +7 -2
  44. package/src/pages/[...lang]/article/[...id].astro +7 -2
  45. package/src/pages/[...lang]/concept/[...id].astro +7 -2
  46. package/src/pages/[...lang]/course/[...id].astro +35 -0
  47. package/src/pages/[...lang]/course/category/[id].astro +25 -0
  48. package/src/pages/[...lang]/course/index.astro +18 -0
  49. package/src/pages/[...lang]/rss.xml.ts +40 -0
  50. package/src/pages/[...lang]/stack/[...id].astro +7 -2
@@ -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
+ }
@@ -32,8 +32,14 @@ const decks = await getDecks(lang);
32
32
  <p class="text-xs font-medium tracking-wide text-[var(--aas-muted)] uppercase">
33
33
  {t('slides.deck')}
34
34
  </p>
35
- <h2 class="mt-1 text-xl font-semibold text-[var(--aas-text)]">{deck.data.title}</h2>
36
- <p class="mt-2 text-sm leading-relaxed text-[var(--aas-muted)]">{deck.data.description}</p>
35
+ <h2 class="mt-1 text-xl font-semibold text-[var(--aas-text)]">
36
+ {deck.data.private && (
37
+ <span aria-label={t('private.badge')} title={t('private.badge')}>๐Ÿ”’ </span>
38
+ )}{deck.data.title}
39
+ </h2>
40
+ <p class="mt-2 text-sm leading-relaxed text-[var(--aas-muted)]">
41
+ {deck.data.private ? (deck.data.teaser ?? '') : deck.data.description}
42
+ </p>
37
43
  <span class="mt-4 inline-flex items-center gap-1 text-sm font-medium text-[var(--aas-text)]">
38
44
  {t('slides.open')}
39
45
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -26,9 +26,12 @@ interface Props {
26
26
  repo?: string;
27
27
  version?: string;
28
28
  deprecated?: boolean;
29
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
30
+ isPrivate?: boolean;
31
+ teaser?: string;
29
32
  }
30
33
 
31
- const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated } =
34
+ const { slug, lang, name, formerNames = [], vendor, description, tags, pricing, license, language, category, logo, logoDark, repo, version, deprecated, isPrivate = false, teaser } =
32
35
  Astro.props;
33
36
  const t = useTranslations(lang);
34
37
  const href = getRelativeLocaleUrl(lang, `stack/${slug}/`);
@@ -54,7 +57,11 @@ const langTokens = (language ?? '')
54
57
  .split('/')
55
58
  .map((s) => s.trim())
56
59
  .filter(Boolean);
57
- const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
60
+ // Private entries: the card carries only public copy โ€” the title and the
61
+ // authored `teaser` (description stays inside the encrypted detail page), and
62
+ // the search index must not include the hidden description either.
63
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
64
+ const searchText = `${name} ${shownDesc} ${tags.join(' ')}`.toLowerCase();
58
65
  ---
59
66
 
60
67
  {/* The card is a container, not a link: the name is the stretched primary link
@@ -85,6 +92,11 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
85
92
  <div class="min-w-0">
86
93
  <h3 class="truncate text-base font-semibold text-[var(--aas-text)]">
87
94
  <a href={href} class="no-underline after:absolute after:inset-0 after:content-['']">
95
+ {isPrivate && (
96
+ <span aria-label={t('private.badge')} title={t('private.badge')}>
97
+ ๐Ÿ”’{' '}
98
+ </span>
99
+ )}
88
100
  {name}
89
101
  </a>
90
102
  </h3>
@@ -100,7 +112,7 @@ const searchText = `${name} ${description} ${tags.join(' ')}`.toLowerCase();
100
112
  <p
101
113
  class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
102
114
  data-card-desc
103
- set:html={inlineMd(description)}
115
+ set:html={inlineMd(shownDesc)}
104
116
  />
105
117
 
106
118
  {/* Secondary facts: version ยท release date ยท language ยท license.
@@ -402,8 +402,16 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
402
402
 
403
403
  <script>
404
404
  import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
405
+ import { initOnReady } from '../lib/reinit';
405
406
 
407
+ // Wrapped in initOnReady: on a private tool page all of this DOM sits inside
408
+ // the encrypted body, so the load-time run wires nothing (null rail, empty
409
+ // lists โ€” its document listeners are inert) and the post-decryption run does
410
+ // the real wiring against the injected DOM.
411
+ initOnReady(() => {
406
412
  const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
413
+ if (rail?.dataset.aasInit) return;
414
+ if (rail) rail.dataset.aasInit = '1';
407
415
  // Scroll-spy + back-to-top + prose-<details> persistence, shared with
408
416
  // TocRail.astro. The tab/version/example handlers below re-run the spy after
409
417
  // changing which headings are visible.
@@ -481,6 +489,7 @@ const relatedToolsByFolder = new Map(projectRail.map((p) => [p.folder, p.tools])
481
489
  });
482
490
  }),
483
491
  );
492
+ });
484
493
  </script>
485
494
 
486
495
  {
@@ -45,6 +45,8 @@ const entries = (await getStacksByTag(lang, tag)).sort((a, b) =>
45
45
  repo={e.data.repo}
46
46
  version={e.data.version}
47
47
  deprecated={e.data.deprecated}
48
+ isPrivate={e.data.private}
49
+ teaser={e.data.teaser}
48
50
  />
49
51
  ))
50
52
  }
@@ -64,11 +64,17 @@ const hasContent = items.length > 0 || relatedTools.length > 0 || Boolean(projec
64
64
 
65
65
  <script>
66
66
  import { startScrollSpy, initBackToTop, initReveals } from '../lib/toc-rail-client';
67
+ import { initOnReady } from '../lib/reinit';
67
68
 
68
- const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
69
- if (rail) {
70
- startScrollSpy(rail);
71
- initBackToTop(rail);
72
- initReveals(rail);
73
- }
69
+ // Re-runs after private-content decryption (the rail ships inside the
70
+ // encrypted body); the dataset flag keeps a live rail from double-wiring.
71
+ initOnReady(() => {
72
+ const rail = document.querySelector<HTMLElement>('[data-toc-rail]');
73
+ if (rail && !rail.dataset.aasInit) {
74
+ rail.dataset.aasInit = '1';
75
+ startScrollSpy(rail);
76
+ initBackToTop(rail);
77
+ initReveals(rail);
78
+ }
79
+ });
74
80
  </script>
@@ -45,6 +45,8 @@ const vendorName = entries[0]?.data.vendor ?? vendor;
45
45
  repo={e.data.repo}
46
46
  version={e.data.version}
47
47
  deprecated={e.data.deprecated}
48
+ isPrivate={e.data.private}
49
+ teaser={e.data.teaser}
48
50
  />
49
51
  ))
50
52
  }
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
@@ -107,6 +116,11 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
107
116
  )
108
117
  .default([]),
109
118
  featured: z.boolean().default(false),
119
+ // Login-gated entry: the body ships encrypted and listings show only the
120
+ // title (+ `teaser`, an explicitly PUBLIC one-liner written for the gate).
121
+ // See docs/private-content-design.md. Requires the AAS_PRIVATE_* env vars.
122
+ private: z.boolean().default(false),
123
+ teaser: z.string().optional(),
110
124
  }),
111
125
  });
112
126
 
@@ -147,6 +161,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
147
161
  .optional(),
148
162
  tags: z.array(z.string()).default([]),
149
163
  draft: z.boolean().default(false),
164
+ // Login-gated entry (encrypted body; listings show title + optional
165
+ // PUBLIC `teaser`). See docs/private-content-design.md.
166
+ private: z.boolean().default(false),
167
+ teaser: z.string().optional(),
150
168
  }),
151
169
  });
152
170
 
@@ -181,6 +199,66 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
181
199
  tags: z.array(z.string()).default([]),
182
200
  order: z.number().optional(), // manual sort on the index (lower first)
183
201
  draft: z.boolean().default(false),
202
+ // Login-gated entry (encrypted body; listings show title + optional
203
+ // PUBLIC `teaser`). See docs/private-content-design.md.
204
+ private: z.boolean().default(false),
205
+ teaser: z.string().optional(),
206
+ }),
207
+ });
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(),
184
262
  }),
185
263
  });
186
264
 
@@ -221,6 +299,10 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
221
299
  toc_level: z.number().int().min(2).max(4).default(2),
222
300
  toc_open: z.boolean().default(true),
223
301
  draft: z.boolean().default(false),
302
+ // Login-gated deck (encrypted body; the slides index shows title +
303
+ // optional PUBLIC `teaser`). See docs/private-content-design.md.
304
+ private: z.boolean().default(false),
305
+ teaser: z.string().optional(),
224
306
  }),
225
307
  });
226
308
 
@@ -249,8 +331,12 @@ export function defineAasCollections({ categoryMap }: { categoryMap: Map<string,
249
331
  navLabel: z.string().optional(),
250
332
  order: z.number().default(0),
251
333
  draft: z.boolean().default(false),
334
+ // Login-gated page (encrypted body; the nav/meta show title + optional
335
+ // PUBLIC `teaser`). See docs/private-content-design.md.
336
+ private: z.boolean().default(false),
337
+ teaser: z.string().optional(),
252
338
  }),
253
339
  });
254
340
 
255
- return { stacks, articles, concepts, slides, pages };
341
+ return { stacks, articles, concepts, courses, slides, pages };
256
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',
@@ -67,6 +68,13 @@ export const ui = {
67
68
  'nav.menu': 'Menu',
68
69
  'code.copy': 'Copy code',
69
70
  'code.copied': 'Copied',
71
+ 'private.badge': 'Private',
72
+ 'private.locked': 'This content is private. Log in to view it.',
73
+ 'private.id': 'ID',
74
+ 'private.password': 'Password',
75
+ 'private.submit': 'Unlock',
76
+ 'private.error': 'Wrong ID or password.',
77
+ 'private.logout': 'Log out',
70
78
  'slides.title': 'Slides',
71
79
  'slides.tagline': 'Concept decks โ€” the same ideas as the concept pages, in slide form.',
72
80
  'slides.deck': 'Deck',
@@ -110,6 +118,15 @@ export const ui = {
110
118
  'concept.learnMore': 'Learn more',
111
119
  'concept.backToConcepts': 'All concepts',
112
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',
113
130
  'detail.usedInConcepts': 'Used in concepts',
114
131
  'sort.label': 'Sort',
115
132
  'sort.alpha': 'Aโ€“Z',
@@ -200,6 +217,7 @@ export const ui = {
200
217
  'AI ์‹œ์Šคํ…œ์„ ๋งŒ๋“ค ๋•Œ ์‹ค์ œ๋กœ ์“ฐ๋Š” ๋„๊ตฌ์™€ ์„œ๋น„์Šค๋ฅผ ๋ชจ์•˜์Šต๋‹ˆ๋‹ค โ€” ๊ฐ ํ•ญ๋ชฉ๋งˆ๋‹ค ์ƒ์„ธ ํŽ˜์ด์ง€์™€ ๋ฐ”๋กœ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ƒ˜ํ”Œ ์ฝ”๋“œ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.',
201
218
  'nav.browse': '๋‘˜๋Ÿฌ๋ณด๊ธฐ',
202
219
  'nav.concepts': '๊ฐœ๋…',
220
+ 'nav.courses': '๊ฐ•์˜',
203
221
  'nav.blog': '๊ธ€',
204
222
  'nav.samples': '์ƒ˜ํ”Œ',
205
223
  'nav.slides': '์Šฌ๋ผ์ด๋“œ',
@@ -209,6 +227,13 @@ export const ui = {
209
227
  'nav.menu': '๋ฉ”๋‰ด',
210
228
  'code.copy': '์ฝ”๋“œ ๋ณต์‚ฌ',
211
229
  'code.copied': '๋ณต์‚ฌ๋จ',
230
+ 'private.badge': '๋น„๊ณต๊ฐœ',
231
+ 'private.locked': '๋น„๊ณต๊ฐœ ์ฝ˜ํ…์ธ ์ž…๋‹ˆ๋‹ค. ๋กœ๊ทธ์ธ ํ›„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.',
232
+ 'private.id': '์•„์ด๋””',
233
+ 'private.password': '๋น„๋ฐ€๋ฒˆํ˜ธ',
234
+ 'private.submit': '์ž ๊ธˆ ํ•ด์ œ',
235
+ 'private.error': '์•„์ด๋”” ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.',
236
+ 'private.logout': '๋กœ๊ทธ์•„์›ƒ',
212
237
  'slides.title': '์Šฌ๋ผ์ด๋“œ',
213
238
  'slides.tagline': '๊ฐœ๋… ๋ฑ โ€” ๊ฐœ๋… ํŽ˜์ด์ง€์™€ ๊ฐ™์€ ๋‚ด์šฉ์„ ์Šฌ๋ผ์ด๋“œ๋กœ ์˜ฎ๊ฒผ์Šต๋‹ˆ๋‹ค.',
214
239
  'slides.deck': '๋ฑ',
@@ -251,6 +276,15 @@ export const ui = {
251
276
  'concept.learnMore': '๋” ์•Œ์•„๋ณด๊ธฐ',
252
277
  'concept.backToConcepts': '๊ฐœ๋… ์ „์ฒด',
253
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': '์Šฌ๋ผ์ด๋“œ',
254
288
  'detail.usedInConcepts': '์ด ๋„๊ตฌ๊ฐ€ ์“ฐ์ด๋Š” ๊ฐœ๋…',
255
289
  'sort.label': '์ •๋ ฌ',
256
290
  'sort.alpha': '์ด๋ฆ„์ˆœ',
@@ -384,6 +418,19 @@ export const pricingLabels: Record<string, Record<string, string>> = {
384
418
  },
385
419
  };
386
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
+
387
434
  /** Descriptive (non-name) licenses get localized; real license names pass through. */
388
435
  const licenseLabels: Record<string, Record<string, string>> = {
389
436
  en: { proprietary: 'Proprietary' },
@@ -17,9 +17,11 @@ interface Props {
17
17
  path: string;
18
18
  /** Wider container โ€” used by detail pages that have a right-rail TOC. */
19
19
  wide?: boolean;
20
+ /** Private entries: ask crawlers not to index the gate page. */
21
+ noindex?: boolean;
20
22
  }
21
23
 
22
- const { title, lang, path, wide = false } = Astro.props;
24
+ const { title, lang, path, wide = false, noindex = false } = Astro.props;
23
25
  const t = useTranslations(lang);
24
26
  // Collapse any authored line breaks (used for on-page wrapping) to a single
25
27
  // space so the SEO meta description stays one clean line.
@@ -58,6 +60,12 @@ const allNavItems: {
58
60
  keepFilters: true,
59
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"/>',
60
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
+ },
61
69
  {
62
70
  href: getRelativeLocaleUrl(lang, 'concept/'),
63
71
  label: t('nav.concepts'),
@@ -113,7 +121,18 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
113
121
  <meta name="generator" content={Astro.generator} />
114
122
  <title>{title}</title>
115
123
  <meta name="description" content={description} />
124
+ {noindex && <meta name="robots" content="noindex" />}
116
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
+ }
117
136
  {/* On the root (default-locale) home, send first-time visitors to the
118
137
  locale matching their browser โ€” unless they've already picked one (the
119
138
  language switcher stores `aas:lang`). Runs before paint, no history entry. */}
@@ -262,6 +281,8 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
262
281
  <BackToTop lang={lang} />
263
282
 
264
283
  <script>
284
+ import { initOnReady } from '../lib/reinit';
285
+
265
286
  // Clicking a heading's "#" anchor copies that section's URL to the
266
287
  // clipboard (and updates the address bar) instead of just navigating.
267
288
  document.addEventListener('click', (e) => {
@@ -279,8 +300,9 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
279
300
  // Mount a copy-to-clipboard button on every code block: README/overview
280
301
  // prose (.prose pre), the file viewer (.aas-projfile), and the Code tab
281
302
  // (.aas-code). The button is pinned to a non-scrolling host so it stays in
282
- // place on long, horizontally-scrolling code.
283
- (function mountCopyButtons() {
303
+ // place on long, horizontally-scrolling code. Re-runs after private-content
304
+ // decryption (mount() skips hosts that already have a button).
305
+ initOnReady(function mountCopyButtons() {
284
306
  const LABEL = document.documentElement.dataset.copyLabel || 'Copy code';
285
307
  const DONE = document.documentElement.dataset.copiedLabel || 'Copied';
286
308
  const COPY_ICON =
@@ -334,7 +356,7 @@ const navItems = allNavItems.filter((item) => !item.section || sectionEnabled(it
334
356
  const fig = el.closest('figure');
335
357
  mount(el, (fig as HTMLElement) ?? wrap(el));
336
358
  });
337
- })();
359
+ });
338
360
 
339
361
  // Close any open <details> dropdown when clicking outside it (or on Esc).
340
362
  // Only nav dropdowns โ€” leave file-tree folders (.aas-tree-dir) and prose
@@ -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
+ }