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
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
|
-
|
|
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}`,
|
|
@@ -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.
|
|
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)]">
|
|
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(
|
|
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)]">
|
|
37
|
-
|
|
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>
|
|
@@ -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>
|
|
@@ -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)]">
|
|
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(
|
|
51
|
+
set:html={inlineMd(shownDesc)}
|
|
46
52
|
/>
|
|
47
53
|
{
|
|
48
54
|
tags.length > 0 && (
|
|
@@ -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)]">
|
|
36
|
-
|
|
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
|
→
|
|
@@ -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>
|