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.
- package/CHANGELOG.md +44 -0
- package/README.md +105 -3
- package/index.d.ts +15 -5
- package/index.mjs +26 -2
- package/package.json +2 -1
- package/src/components/ArticleCard.astro +9 -3
- package/src/components/ArticleLink.astro +7 -2
- package/src/components/BlogIndex.astro +2 -0
- package/src/components/Bookmark.astro +37 -0
- package/src/components/CategoryIndex.astro +2 -0
- package/src/components/CodeSamples.astro +6 -1
- package/src/components/ConceptCard.astro +9 -3
- package/src/components/ConceptIndex.astro +2 -0
- package/src/components/ConceptLink.astro +7 -2
- package/src/components/CourseCard.astro +97 -0
- package/src/components/CourseDetail.astro +116 -0
- package/src/components/CourseIndex.astro +145 -0
- package/src/components/DeckView.astro +17 -2
- package/src/components/DetailTabs.astro +6 -1
- package/src/components/DifficultyStars.astro +34 -0
- package/src/components/Embed.astro +36 -0
- package/src/components/Home.astro +2 -0
- package/src/components/Lead.astro +8 -0
- package/src/components/MermaidLoader.astro +3 -1
- package/src/components/PricingSection.astro +4 -1
- package/src/components/PrivateGate.astro +135 -0
- package/src/components/ProjectViewer.astro +24 -13
- package/src/components/RelatedCourses.astro +64 -0
- package/src/components/SlidesIndex.astro +8 -2
- package/src/components/StackCard.astro +15 -3
- package/src/components/StackDetail.astro +9 -0
- package/src/components/TagIndex.astro +2 -0
- package/src/components/TocRail.astro +12 -6
- package/src/components/VendorIndex.astro +2 -0
- package/src/content.ts +90 -4
- package/src/i18n/ui.ts +47 -0
- package/src/layouts/BaseLayout.astro +26 -4
- package/src/lib/courses.ts +26 -0
- package/src/lib/private-client.ts +160 -0
- package/src/lib/private.ts +129 -0
- package/src/lib/reinit.ts +12 -0
- package/src/lib/sections.ts +13 -2
- package/src/pages/[...lang]/[page].astro +7 -2
- package/src/pages/[...lang]/article/[...id].astro +7 -2
- package/src/pages/[...lang]/concept/[...id].astro +7 -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/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)]">
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
{
|
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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>
|
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
|
|
@@ -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
|
-
|
|
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
|
+
}
|