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
package/CHANGELOG.md CHANGED
@@ -11,6 +11,48 @@ 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
+
38
+ ## [1.14.0] - 2026-07-21
39
+
40
+ ### Added
41
+
42
+ - **Private (login-gated) content** — mark any entry in any collection
43
+ (stacks, concepts, articles, slides, pages) with `private: true`. The
44
+ rendered body ships AES-256-GCM-encrypted and is decrypted in the browser
45
+ after login, so it works on any static host with no server; the users
46
+ (`AAS_PRIVATE_USERS`, `id:password` pairs) and the master secret
47
+ (`AAS_PRIVATE_MASTER_SECRET` — rotate to force every device to log in again)
48
+ come from env vars, with sessions cached per device for
49
+ `AAS_PRIVATE_SESSION_DAYS` (default 30). Listings show only the title + 🔒
50
+ plus an optional public `teaser`; private pages are `noindex` and excluded
51
+ from the sitemap; the client re-initializes embedded behaviors (TOC,
52
+ mermaid, tabs, copy buttons, the slide deck engine) after decryption. The
53
+ source repo must be private — `.mdx` files stay plaintext; see
54
+ docs/private-content-design.md.
55
+
14
56
  ## [1.13.0] - 2026-07-20
15
57
 
16
58
  ### Added
@@ -120,6 +162,8 @@ catalog sites from a thin content-only repository.
120
162
  - **Standalone development setup** — a devcontainer and a minimal `playground/`
121
163
  consuming site for developing and previewing the theme on its own.
122
164
 
165
+ [1.15.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.14.0...v1.15.0
166
+ [1.14.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.13.0...v1.14.0
123
167
  [1.13.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.12.0...v1.13.0
124
168
  [1.12.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.11.0...v1.12.0
125
169
  [1.11.0]: https://github.com/CodeCompose7/stack-site-builder/compare/v1.10.0...v1.11.0
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # stack-site-builder
2
2
 
3
+ > 한국어: [docs/readme.ko.md](docs/readme.ko.md)
4
+
3
5
  An [Astro](https://astro.build) theme for curated "awesome stack" catalog
4
6
  sites — a tool catalog with per-entry detail pages, concepts, articles,
5
7
  presentation slides, runnable samples, a `[[wikilink]]` glossary, and full
@@ -36,9 +38,9 @@ export const collections = defineAasCollections({ categoryMap });
36
38
  | --- | --- |
37
39
  | `src/data/site.ts` | Site identity: name, repo URL, the `locales` it ships, optional `sections` toggles, per-locale UI string overrides |
38
40
  | `src/data/categories.ts` | The tool-catalog category tree (validated against content) |
39
- | `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 |
40
42
  | `src/data/glossary.mjs` | `[[Term]]` wikilink targets |
41
- | `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 |
42
44
  | `src/content/pages/` | Standalone top-level pages (e.g. an About/소개), rendered at `/<slug>/` and optionally linked in the header nav |
43
45
  | `public/` · `samples/` | Logos/favicons and runnable sample projects |
44
46
 
@@ -70,7 +72,7 @@ The rest are opt-out — **concepts, articles, samples, slides, glossary** and t
70
72
  standalone **pages** collection (About/소개, …) — so a site can ship only what it
71
73
  needs. Turning one off removes both its routes and its header-nav item. (`pages`
72
74
  also has finer control: each page's `nav` / `draft` frontmatter, or simply not
73
- authoring it.)
75
+ authoring it.) **courses** is the one opt-IN section — see below.
74
76
 
75
77
  Declare the toggles once in `src/data/site.ts` and forward them to the theme in
76
78
  astro.config (which needs them to skip route injection). Import `SectionKey` from
@@ -90,3 +92,103 @@ export const site = {
90
92
  import { site } from './src/data/site';
91
93
  integrations: [aasTheme({ glossary, sections: site.sections })];
92
94
  ```
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
+
147
+ ## Private content
148
+
149
+ Any entry (tools, concepts, articles, slides, pages) can require login:
150
+
151
+ ```yaml
152
+ private: true
153
+ teaser: A public one-liner shown on cards and above the login form. # optional
154
+ ```
155
+
156
+ The body ships **encrypted** (AES-256-GCM, key wrapped per user with PBKDF2) and
157
+ is decrypted in the browser after login — no server needed, works on any static
158
+ host. Listings show only the title + 🔒 (+ teaser); private URLs get `noindex`
159
+ and stay out of the sitemap. Users and keys come from env vars (see
160
+ `playground/.env.sample`): `AAS_PRIVATE_USERS` (`id:password,…`),
161
+ `AAS_PRIVATE_MASTER_SECRET` (rotate it to log every device out) and
162
+ `AAS_PRIVATE_SESSION_DAYS`. Set them in `.env` locally or as CI secrets.
163
+
164
+ Two rules: the **source repo must be private** (the `.mdx` files are plaintext —
165
+ only the built output is encrypted), and when you remove a user, also rotate the
166
+ master secret. Full design: `docs/private-content-design.md`.
167
+
168
+ ## Deploying (and where the secrets live)
169
+
170
+ Encryption happens wherever `astro build` runs, reading `process.env` — the
171
+ values just come from a different place per environment:
172
+
173
+ | Where you build | Where the `AAS_PRIVATE_*` values live |
174
+ | --- | --- |
175
+ | Your machine (`pnpm build`, `firebase deploy`) | `.env` file (gitignored — copy `.env.sample`) |
176
+ | GitHub Actions (deploy on push) | **Repository secrets**: Settings → Secrets and variables → Actions |
177
+
178
+ `.env` never reaches GitHub; in CI the workflow maps repository secrets to env
179
+ vars on the build step:
180
+
181
+ ```yaml
182
+ - name: Build
183
+ run: pnpm install && pnpm build # encryption happens here
184
+ env:
185
+ AAS_PRIVATE_USERS: ${{ secrets.AAS_PRIVATE_USERS }}
186
+ AAS_PRIVATE_MASTER_SECRET: ${{ secrets.AAS_PRIVATE_MASTER_SECRET }}
187
+ AAS_PRIVATE_SESSION_DAYS: '30'
188
+ ```
189
+
190
+ A complete GitHub Pages workflow a site can copy is in
191
+ [`playground/.github/workflows/deploy.yml`](playground/.github/workflows/deploy.yml)
192
+ (inert inside the playground — workflows only run from a repo's root
193
+ `.github/`). Managing users/keys then never touches git: edit the secrets and
194
+ re-run the workflow. Sites without private content need none of this.
package/index.d.ts CHANGED
@@ -1,16 +1,26 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
 
3
3
  /** Optional content sections that a site can turn off. `pages` is the
4
- * standalone-pages collection (About/소개, …). */
5
- export type SectionKey = 'concepts' | 'articles' | 'samples' | 'slides' | 'glossary' | 'pages';
4
+ * standalone-pages collection (About/소개, …); `courses` is opt-IN
5
+ * (default off enabling it requires site-side course data). */
6
+ export type SectionKey =
7
+ | 'concepts'
8
+ | 'articles'
9
+ | 'courses'
10
+ | 'samples'
11
+ | 'slides'
12
+ | 'glossary'
13
+ | 'pages';
6
14
 
7
15
  export interface AasThemeOptions {
8
16
  /** The site's glossary (`src/data/glossary.mjs`) — `[[wikilink]]` targets. */
9
17
  glossary: Record<string, unknown>;
10
18
  /**
11
- * Turn optional sections off (all on by default), e.g. `{ slides: false }`.
12
- * A disabled section's routes aren't injected; pass the same object to
13
- * `src/data/site.ts` `sections` so its header-nav item is hidden too.
19
+ * Section toggles, e.g. `{ slides: false }`. Every section is on by default
20
+ * except `courses`, which is opt-in (`{ courses: true }`) and additionally
21
+ * requires `src/data/course-categories.ts` on the site. A disabled section's
22
+ * routes aren't injected; pass the same object to `src/data/site.ts`
23
+ * `sections` so its header-nav item is hidden too.
14
24
  */
15
25
  sections?: Partial<Record<SectionKey, boolean>>;
16
26
  }
package/index.mjs CHANGED
@@ -29,6 +29,8 @@ const PAGES = [
29
29
  // Standalone top-level pages (the `pages` collection), e.g. an About/소개
30
30
  // page. A single dynamic route per locale renders every entry at `/<slug>/`.
31
31
  '[page].astro',
32
+ // Per-locale RSS feed of the articles collection (an endpoint, not a page).
33
+ 'rss.xml.ts',
32
34
  'article/index.astro',
33
35
  'article/[...id].astro',
34
36
  'article/category/[id].astro',
@@ -36,6 +38,9 @@ const PAGES = [
36
38
  'concept/index.astro',
37
39
  'concept/[...id].astro',
38
40
  'concept/category/[id].astro',
41
+ 'course/index.astro',
42
+ 'course/[...id].astro',
43
+ 'course/category/[id].astro',
39
44
  'glossary.astro',
40
45
  'sample/index.astro',
41
46
  'sample/[folder].astro',
@@ -47,10 +52,11 @@ const PAGES = [
47
52
  ];
48
53
 
49
54
  /** `[...lang]/article/[...id].astro` → `/[...lang]/article/[...id]`,
50
- * `[...lang]/index.astro` → `/[...lang]` */
55
+ * `[...lang]/index.astro` → `/[...lang]`,
56
+ * `[...lang]/rss.xml.ts` → `/[...lang]/rss.xml` (endpoints keep their name). */
51
57
  /** @param {string} file */
52
58
  function patternOf(file) {
53
- const p = file.replace(/\.astro$/, '').replace(/\/?index$/, '');
59
+ const p = file.replace(/\.(astro|ts)$/, '').replace(/\/?index$/, '');
54
60
  return `/${p}`.replace(/\/$/, '') || '/';
55
61
  }
56
62
 
@@ -60,19 +66,27 @@ function patternOf(file) {
60
66
  function sectionOf(file) {
61
67
  if (file.startsWith('concept/')) return 'concepts';
62
68
  if (file.startsWith('article/')) return 'articles';
69
+ if (file.startsWith('course/')) return 'courses';
63
70
  if (file.startsWith('sample/')) return 'samples';
64
71
  if (file.startsWith('slides/')) return 'slides';
65
72
  if (file === 'glossary.astro') return 'glossary';
66
73
  if (file === '[page].astro') return 'pages';
74
+ if (file === 'rss.xml.ts') return 'articles'; // the feed is the blog's
67
75
  return null;
68
76
  }
69
77
 
78
+ // Sections that are opt-IN rather than opt-out: their routes are injected only
79
+ // when the site passes `{ <key>: true }`. `courses` needs site-side data
80
+ // (src/data/course-categories.ts), so a theme upgrade alone must not enable it.
81
+ const OPT_IN_SECTIONS = new Set(['courses']);
82
+
70
83
  /**
71
84
  * @param {object} opts
72
85
  * @param {Record<string, any>} opts.glossary — the site's glossary
73
86
  * (`src/data/glossary.mjs`), used by `[[wikilink]]` resolution.
74
87
  * @param {Partial<Record<string, boolean>>} [opts.sections] — optional-section
75
88
  * toggles (`{ slides: false }`); a disabled section's routes are not injected.
89
+ * `courses` is opt-IN (`{ courses: true }`) — it needs site-side course data.
76
90
  * Keep it in sync with `src/data/site.ts` `sections` (which hides the nav item).
77
91
  * @returns {import('astro').AstroIntegration[]}
78
92
  */
@@ -97,6 +111,7 @@ export default function aasTheme({ glossary, sections = {} }) {
97
111
  for (const file of PAGES) {
98
112
  const section = sectionOf(file);
99
113
  if (section && sections[section] === false) continue;
114
+ if (section && OPT_IN_SECTIONS.has(section) && sections[section] !== true) continue;
100
115
  injectRoute({
101
116
  pattern: patternOf(`[...lang]/${file}`),
102
117
  entrypoint: `stack-site-builder/pages/[...lang]/${file}`,
@@ -119,6 +134,15 @@ export default function aasTheme({ glossary, sections = {} }) {
119
134
  defaultLocale,
120
135
  locales: Object.fromEntries(locales.map((l) => [l, l])),
121
136
  },
137
+ // Private entries stay out of the sitemap. PrivateGate records each
138
+ // private pathname (via globalThis — it lives in a different module
139
+ // graph) while pages render; @astrojs/sitemap runs this after.
140
+ filter: (page) => {
141
+ const paths = globalThis.__aasPrivatePaths;
142
+ if (!paths) return true;
143
+ const p = new URL(page).pathname.replace(/\/?$/, '/');
144
+ return !paths.has(p);
145
+ },
122
146
  }),
123
147
  ],
124
148
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stack-site-builder",
3
3
  "type": "module",
4
- "version": "1.13.0",
4
+ "version": "1.15.0",
5
5
  "license": "MIT",
6
6
  "description": "The engine behind the awesome-*-stack catalog sites: an Astro theme with the catalog/concepts/articles/slides/samples routes, components, styles and markdown pipeline. Sites provide content, taxonomy data and config.",
7
7
  "repository": {
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@astrojs/mdx": "^4.2.0",
38
+ "@astrojs/rss": "^4.0.0",
38
39
  "@astrojs/sitemap": "^3.3.0",
39
40
  "@tailwindcss/vite": "^4.1.0",
40
41
  "markdown-it": "^14.2.0",
@@ -16,10 +16,14 @@ interface Props {
16
16
  image?: ImageMetadata;
17
17
  imageAlt?: string;
18
18
  version?: string;
19
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
20
+ isPrivate?: boolean;
21
+ teaser?: string;
19
22
  }
20
23
 
21
- const { slug, lang, title, description, date, tags, image, imageAlt, version } = Astro.props;
24
+ const { slug, lang, title, description, date, tags, image, imageAlt, version, isPrivate = false, teaser } = Astro.props;
22
25
  const t = useTranslations(lang);
26
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
23
27
  const href = getRelativeLocaleUrl(lang, `article/${slug}/`);
24
28
  const dateStr = date.toISOString().slice(0, 10);
25
29
  ---
@@ -36,10 +40,12 @@ const dateStr = date.toISOString().slice(0, 10);
36
40
  <time datetime={dateStr}>{formatDate(date, lang)}</time>
37
41
  {version && <span> · {t('meta.docVersion')} {version}</span>}
38
42
  </p>
39
- <h3 class="mt-1 line-clamp-2 text-base font-semibold text-[var(--aas-text)]">{title}</h3>
43
+ <h3 class="mt-1 line-clamp-2 text-base font-semibold text-[var(--aas-text)]">
44
+ {isPrivate && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{title}
45
+ </h3>
40
46
  <p
41
47
  class="aas-md mt-1.5 line-clamp-2 text-sm leading-relaxed text-[var(--aas-muted)]"
42
- set:html={inlineMd(description)}
48
+ set:html={inlineMd(shownDesc)}
43
49
  />
44
50
  {
45
51
  tags.length > 0 && (
@@ -33,8 +33,13 @@ const target = articles.find((a) => articleSlugOf(a) === slug) as ArticleEntry |
33
33
  <span class="block text-xs font-semibold tracking-wide text-[var(--aas-accent)] uppercase">
34
34
  {t('detail.relatedWriting')}
35
35
  </span>
36
- <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">{target.data.title}</span>
37
- <span class="aas-md mt-1 block text-sm text-[var(--aas-muted)]" set:html={inlineMd(target.data.description)} />
36
+ <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">
37
+ {target.data.private && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{target.data.title}
38
+ </span>
39
+ <span
40
+ class="aas-md mt-1 block text-sm text-[var(--aas-muted)]"
41
+ set:html={inlineMd(target.data.private ? (target.data.teaser ?? '') : target.data.description)}
42
+ />
38
43
  <span class="mt-1.5 block text-xs text-[var(--aas-muted)]">
39
44
  {formatDate(target.data.date, lang)}
40
45
  </span>
@@ -58,6 +58,8 @@ const card = (a: ArticleEntry) => ({
58
58
  tags: a.data.tags,
59
59
  image: a.data.image,
60
60
  imageAlt: a.data.imageAlt,
61
+ isPrivate: a.data.private,
62
+ teaser: a.data.teaser,
61
63
  });
62
64
  ---
63
65
 
@@ -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>
@@ -59,6 +59,8 @@ const cardProps = (e: StackEntry) => ({
59
59
  repo: e.data.repo,
60
60
  version: e.data.version,
61
61
  deprecated: e.data.deprecated,
62
+ isPrivate: e.data.private,
63
+ teaser: e.data.teaser,
62
64
  });
63
65
  ---
64
66
 
@@ -90,7 +90,12 @@ const t = useTranslations(lang);
90
90
  </style>
91
91
 
92
92
  <script>
93
+ import { initOnReady } from '../lib/reinit';
94
+ // Re-runs after private-content decryption; the dataset flag guards each root.
95
+ initOnReady(() =>
93
96
  document.querySelectorAll<HTMLElement>('[data-samples]').forEach((root) => {
97
+ if (root.dataset.aasInit) return;
98
+ root.dataset.aasInit = '1';
94
99
  const dd = root.querySelector<HTMLDetailsElement>('[data-samples-dd]');
95
100
  const current = root.querySelector<HTMLElement>('[data-sample-current]');
96
101
  const options = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-sample-option]'));
@@ -137,5 +142,5 @@ const t = useTranslations(lang);
137
142
  const cur = options.find((o) => o.getAttribute('aria-current') === 'true');
138
143
  if (cur) writeV(cur.dataset.sampleOption!);
139
144
  });
140
- });
145
+ }));
141
146
  </script>
@@ -16,10 +16,14 @@ interface Props {
16
16
  imageAlt?: string;
17
17
  version?: string;
18
18
  updated?: Date;
19
+ /** Private entry: show a lock and the (public) `teaser` instead of the description. */
20
+ isPrivate?: boolean;
21
+ teaser?: string;
19
22
  }
20
23
 
21
- const { lang, slug, title, description, tags, image, imageAlt, version, updated } = Astro.props;
24
+ const { lang, slug, title, description, tags, image, imageAlt, version, updated, isPrivate = false, teaser } = Astro.props;
22
25
  const t = useTranslations(lang);
26
+ const shownDesc = isPrivate ? (teaser ?? '') : description;
23
27
  ---
24
28
 
25
29
  <a
@@ -39,10 +43,12 @@ const t = useTranslations(lang);
39
43
  </p>
40
44
  )
41
45
  }
42
- <h3 class="mt-1 text-lg font-semibold text-[var(--aas-text)]">{title}</h3>
46
+ <h3 class="mt-1 text-lg font-semibold text-[var(--aas-text)]">
47
+ {isPrivate && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{title}
48
+ </h3>
43
49
  <p
44
50
  class="aas-md mt-2 text-sm leading-relaxed text-[var(--aas-muted)]"
45
- set:html={inlineMd(description)}
51
+ set:html={inlineMd(shownDesc)}
46
52
  />
47
53
  {
48
54
  tags.length > 0 && (
@@ -64,6 +64,8 @@ const card = (c: ConceptEntry) => ({
64
64
  imageAlt: c.data.imageAlt,
65
65
  version: c.data.version,
66
66
  updated: c.data.updated,
67
+ isPrivate: c.data.private,
68
+ teaser: c.data.teaser,
67
69
  });
68
70
  ---
69
71
 
@@ -32,8 +32,13 @@ const target = concepts.find((c) => conceptSlugOf(c) === slug) as ConceptEntry |
32
32
  <span class="block text-xs font-semibold tracking-wide text-[var(--aas-accent)] uppercase">
33
33
  {t('concept.learnMore')}
34
34
  </span>
35
- <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">{target.data.title}</span>
36
- <span class="aas-md mt-1 block text-sm text-[var(--aas-muted)]" set:html={inlineMd(target.data.description)} />
35
+ <span class="mt-0.5 block font-semibold text-[var(--aas-text)]">
36
+ {target.data.private && <span aria-label={t('private.badge')} title={t('private.badge')}>🔒 </span>}{target.data.title}
37
+ </span>
38
+ <span
39
+ class="aas-md mt-1 block text-sm text-[var(--aas-muted)]"
40
+ set:html={inlineMd(target.data.private ? (target.data.teaser ?? '') : target.data.description)}
41
+ />
37
42
  </div>
38
43
  <span aria-hidden="true" class="shrink-0 text-xl text-[var(--aas-accent)]">
39
44
  &rarr;
@@ -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>