stack-site-builder 1.10.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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/dev/local-samples-client.js +284 -0
  4. package/dev/local-samples.mjs +594 -0
  5. package/index.d.ts +12 -0
  6. package/index.mjs +124 -0
  7. package/markdown.mjs +394 -0
  8. package/package.json +52 -0
  9. package/src/components/ArrowUpRight.astro +24 -0
  10. package/src/components/ArticleCard.astro +56 -0
  11. package/src/components/ArticleDetail.astro +100 -0
  12. package/src/components/ArticleLink.astro +45 -0
  13. package/src/components/BackToTop.astro +47 -0
  14. package/src/components/BlogIndex.astro +137 -0
  15. package/src/components/Breadcrumb.astro +37 -0
  16. package/src/components/CategoryIndex.astro +115 -0
  17. package/src/components/ChevronDown.astro +23 -0
  18. package/src/components/CodeSamples.astro +141 -0
  19. package/src/components/CollapsibleGrid.astro +50 -0
  20. package/src/components/ConceptCard.astro +59 -0
  21. package/src/components/ConceptDetail.astro +102 -0
  22. package/src/components/ConceptIndex.astro +143 -0
  23. package/src/components/ConceptLink.astro +43 -0
  24. package/src/components/ConceptTools.astro +49 -0
  25. package/src/components/DeckView.astro +772 -0
  26. package/src/components/DetailTabs.astro +219 -0
  27. package/src/components/FilterControls.astro +407 -0
  28. package/src/components/Glossary.astro +264 -0
  29. package/src/components/Home.astro +90 -0
  30. package/src/components/LanguageSwitcher.astro +86 -0
  31. package/src/components/ListControls.astro +182 -0
  32. package/src/components/MermaidLoader.astro +96 -0
  33. package/src/components/PricingSection.astro +100 -0
  34. package/src/components/ProjectFileList.astro +68 -0
  35. package/src/components/ProjectFileTree.astro +95 -0
  36. package/src/components/ProjectRelatedTools.astro +70 -0
  37. package/src/components/ProjectViewer.astro +431 -0
  38. package/src/components/RelatedArticles.astro +52 -0
  39. package/src/components/RelatedConcepts.astro +60 -0
  40. package/src/components/RelatedTools.astro +55 -0
  41. package/src/components/SampleIndex.astro +464 -0
  42. package/src/components/SamplePage.astro +95 -0
  43. package/src/components/SampleProject.astro +56 -0
  44. package/src/components/Slide.astro +36 -0
  45. package/src/components/SlidesIndex.astro +60 -0
  46. package/src/components/StackCard.astro +183 -0
  47. package/src/components/StackDetail.astro +507 -0
  48. package/src/components/StarIcon.astro +21 -0
  49. package/src/components/TagIndex.astro +51 -0
  50. package/src/components/ThemeToggle.astro +106 -0
  51. package/src/components/Toc.astro +39 -0
  52. package/src/components/TocRail.astro +74 -0
  53. package/src/components/ToolAvatar.astro +133 -0
  54. package/src/components/ToolMeta.astro +95 -0
  55. package/src/components/VendorIndex.astro +51 -0
  56. package/src/content.ts +228 -0
  57. package/src/i18n/ui.ts +337 -0
  58. package/src/layouts/BaseLayout.astro +287 -0
  59. package/src/lib/articles.ts +27 -0
  60. package/src/lib/assets.ts +19 -0
  61. package/src/lib/category-tree.ts +64 -0
  62. package/src/lib/concepts.ts +35 -0
  63. package/src/lib/dates.ts +21 -0
  64. package/src/lib/facets.ts +57 -0
  65. package/src/lib/github.ts +186 -0
  66. package/src/lib/inline-md.ts +30 -0
  67. package/src/lib/pricing.ts +22 -0
  68. package/src/lib/project.ts +309 -0
  69. package/src/lib/samples.ts +88 -0
  70. package/src/lib/slides.ts +25 -0
  71. package/src/lib/stacks.ts +129 -0
  72. package/src/lib/toc-rail-client.ts +79 -0
  73. package/src/pages/article/[...id].astro +24 -0
  74. package/src/pages/article/category/[id].astro +17 -0
  75. package/src/pages/article/index.astro +12 -0
  76. package/src/pages/categories/[id].astro +25 -0
  77. package/src/pages/concept/[...id].astro +24 -0
  78. package/src/pages/concept/category/[id].astro +17 -0
  79. package/src/pages/concept/index.astro +12 -0
  80. package/src/pages/glossary.astro +12 -0
  81. package/src/pages/index.astro +9 -0
  82. package/src/pages/ko/article/[...id].astro +24 -0
  83. package/src/pages/ko/article/category/[id].astro +17 -0
  84. package/src/pages/ko/article/index.astro +12 -0
  85. package/src/pages/ko/categories/[id].astro +24 -0
  86. package/src/pages/ko/concept/[...id].astro +24 -0
  87. package/src/pages/ko/concept/category/[id].astro +17 -0
  88. package/src/pages/ko/concept/index.astro +12 -0
  89. package/src/pages/ko/glossary.astro +12 -0
  90. package/src/pages/ko/index.astro +9 -0
  91. package/src/pages/ko/sample/[folder].astro +16 -0
  92. package/src/pages/ko/sample/index.astro +12 -0
  93. package/src/pages/ko/slides/[deck].astro +14 -0
  94. package/src/pages/ko/slides/index.astro +12 -0
  95. package/src/pages/ko/stack/[...id].astro +51 -0
  96. package/src/pages/ko/tags/[tag].astro +18 -0
  97. package/src/pages/ko/vendors/[vendor].astro +18 -0
  98. package/src/pages/sample/[folder].astro +16 -0
  99. package/src/pages/sample/index.astro +12 -0
  100. package/src/pages/slides/[deck].astro +14 -0
  101. package/src/pages/slides/index.astro +12 -0
  102. package/src/pages/stack/[...id].astro +51 -0
  103. package/src/pages/tags/[tag].astro +18 -0
  104. package/src/pages/vendors/[vendor].astro +18 -0
  105. package/src/styles/global.css +2350 -0
@@ -0,0 +1,309 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { createHighlighter, type Highlighter } from 'shiki';
5
+ import MarkdownIt from 'markdown-it';
6
+ import { site } from '@aas-data/site';
7
+
8
+ const SAMPLES_DIR = 'samples';
9
+ const REPO = site.repoUrl;
10
+ const THEME = 'github-dark';
11
+ const LANGS = [
12
+ 'python',
13
+ 'typescript',
14
+ 'tsx',
15
+ 'javascript',
16
+ 'jsx',
17
+ 'bash',
18
+ 'docker',
19
+ 'markdown',
20
+ 'json',
21
+ 'yaml',
22
+ 'toml',
23
+ 'sql',
24
+ 'go',
25
+ 'rust',
26
+ ];
27
+
28
+ let hlPromise: Promise<Highlighter> | null = null;
29
+ function getHighlighter() {
30
+ if (!hlPromise) hlPromise = createHighlighter({ themes: [THEME], langs: LANGS });
31
+ return hlPromise;
32
+ }
33
+ function highlight(hl: Highlighter, code: string, lang: string): string {
34
+ const l = hl.getLoadedLanguages().includes(lang) ? lang : 'text';
35
+ // Tag the <pre> with its language so CSS can soft-wrap shell/output blocks
36
+ // (bash, text…) while leaving real source code horizontally scrollable.
37
+ return hl
38
+ .codeToHtml(code, { lang: l, theme: THEME })
39
+ .replace(/^<pre/, `<pre data-lang="${l}"`);
40
+ }
41
+
42
+ export interface ProjectFile {
43
+ path: string; // relative to the project folder, e.g. "app.py"
44
+ html: string; // Shiki-highlighted
45
+ }
46
+ export interface ProjectHeading {
47
+ slug: string;
48
+ text: string;
49
+ depth: number;
50
+ }
51
+ export interface RenderedProject {
52
+ folder: string;
53
+ name: string;
54
+ date: string; // when the example was written (YYYY-MM-DD)
55
+ readmeHtml?: string;
56
+ headings: ProjectHeading[]; // README h2/h3, for the right-rail TOC
57
+ files: ProjectFile[];
58
+ folderUrl: string;
59
+ }
60
+
61
+ // When each sample was authored. Per-folder overrides go here; anything not
62
+ // listed falls back to DEFAULT_SAMPLE_DATE.
63
+ const DEFAULT_SAMPLE_DATE = '2026-06-28';
64
+ const SAMPLE_DATES: Record<string, string> = {};
65
+
66
+ function slugify(s: string): string {
67
+ return s
68
+ .toLowerCase()
69
+ .trim()
70
+ .replace(/[^\p{L}\p{N} -]/gu, '')
71
+ .replace(/\s+/g, '-');
72
+ }
73
+
74
+ /**
75
+ * Render a project README to HTML, giving its h2/h3 stable ids (prefixed with
76
+ * the folder so headings stay unique across projects) plus a copy-link anchor,
77
+ * and collecting those headings for the right-rail TOC.
78
+ */
79
+ function renderReadme(
80
+ md: MarkdownIt,
81
+ content: string,
82
+ folder: string,
83
+ ): { html: string; headings: ProjectHeading[]; title?: string } {
84
+ const tokens = md.parse(content, {});
85
+ const headings: ProjectHeading[] = [];
86
+ // The first real h1 is the project's display name. Taken from the parsed
87
+ // tokens (not a regex over the source) so a `# comment` line inside a fenced
88
+ // shell block can't be mistaken for it.
89
+ let title: string | undefined;
90
+ for (let i = 0; i < tokens.length; i++) {
91
+ const tk = tokens[i];
92
+ if (tk.type === 'heading_open' && tk.tag === 'h1' && title === undefined) {
93
+ title = tokens[i + 1]?.content ?? '';
94
+ }
95
+ if (tk.type === 'heading_open' && (tk.tag === 'h2' || tk.tag === 'h3' || tk.tag === 'h4')) {
96
+ const text = tokens[i + 1]?.content ?? '';
97
+ const slug = `${folder}-${slugify(text)}`;
98
+ tk.attrSet('id', slug);
99
+ // Anchor every level up to h4; the TOC still lists h2/h3 only.
100
+ if (tk.tag !== 'h4') headings.push({ slug, text, depth: tk.tag === 'h2' ? 2 : 3 });
101
+ }
102
+ }
103
+ const html = md.renderer.render(tokens, md.options, {}).replace(
104
+ /<(h[234]) id="([^"]+)">([\s\S]*?)<\/\1>/g,
105
+ (_m, tag, id, inner) =>
106
+ `<${tag} id="${id}">` +
107
+ `<a class="aas-anchor" href="#${id}" aria-label="Copy link to section"></a>${inner}</${tag}>`,
108
+ );
109
+ return { html, headings, title };
110
+ }
111
+
112
+ function langFor(name: string): string {
113
+ if (name === 'Dockerfile' || name.endsWith('.dockerfile')) return 'docker';
114
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
115
+ const map: Record<string, string> = {
116
+ py: 'python',
117
+ ts: 'typescript',
118
+ tsx: 'tsx',
119
+ js: 'javascript',
120
+ jsx: 'jsx',
121
+ md: 'markdown',
122
+ toml: 'toml',
123
+ yml: 'yaml',
124
+ yaml: 'yaml',
125
+ json: 'json',
126
+ sh: 'bash',
127
+ bash: 'bash',
128
+ env: 'bash',
129
+ sql: 'sql',
130
+ go: 'go',
131
+ rs: 'rust',
132
+ };
133
+ return map[ext] ?? 'text';
134
+ }
135
+
136
+ // In the file tree: source first, then Dockerfile, then dependency/config files.
137
+ function priority(path: string): number {
138
+ const name = path.split('/').pop() ?? path;
139
+ if (name === 'Dockerfile' || name.endsWith('.dockerfile')) return 1;
140
+ if (/^requirements|^package\.json$|^pyproject|^package-lock|lock$/i.test(name)) return 3;
141
+ return 0;
142
+ }
143
+
144
+ // The viewer renders text only — skip images/archives/etc. by extension, and
145
+ // anything else that carries NUL bytes (binary content decoded as UTF-8 would
146
+ // render as mojibake in the file tree).
147
+ const BINARY_RE =
148
+ /\.(png|jpe?g|gif|webp|avif|ico|pdf|zip|gz|tgz|tar|db|sqlite3?|woff2?|ttf|eot|otf|mp[34]|wav|bin|exe|so|dylib|pyc|wasm)$/i;
149
+
150
+ function walk(dir: string, base: string, out: { path: string; name: string; content: string }[]): void {
151
+ for (const name of readdirSync(dir)) {
152
+ const full = join(dir, name);
153
+ if (statSync(full).isDirectory()) walk(full, base, out);
154
+ else if (!BINARY_RE.test(name)) {
155
+ const buf = readFileSync(full);
156
+ if (buf.subarray(0, 8192).includes(0)) continue;
157
+ out.push({ path: relative(base, full), name, content: buf.toString('utf8') });
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Subset of `paths` (relative to the repo root) that git would ignore, so files
164
+ * like `.env` never leak into the rendered file tree. Respects nested .gitignore
165
+ * files and negations. Falls back to ignoring nothing if git isn't available.
166
+ */
167
+ function gitIgnored(paths: string[]): Set<string> {
168
+ if (!paths.length) return new Set();
169
+ try {
170
+ const out = execFileSync('git', ['check-ignore', '--stdin'], {
171
+ input: paths.join('\n'),
172
+ encoding: 'utf8',
173
+ });
174
+ return new Set(out.split('\n').map((s) => s.trim()).filter(Boolean));
175
+ } catch (err) {
176
+ // Exit code 1 ("nothing ignored") still throws; its stdout holds any matches.
177
+ const out = (err as { stdout?: string }).stdout ?? '';
178
+ return new Set(out.split('\n').map((s) => s.trim()).filter(Boolean));
179
+ }
180
+ }
181
+
182
+ /** A README file in the project, with an optional locale suffix:
183
+ * README.md (default) or README.<lang>.md (e.g. README.ko.md). */
184
+ const README_RE = /^readme(?:\.([a-z]{2}))?\.md$/i;
185
+
186
+ /** All sample folders under samples/, sorted by name. */
187
+ export function listSampleFolders(): string[] {
188
+ return readdirSync(SAMPLES_DIR, { withFileTypes: true })
189
+ .filter((e) => e.isDirectory())
190
+ .map((e) => e.name)
191
+ .sort();
192
+ }
193
+
194
+ export interface ProjectSummary {
195
+ folder: string;
196
+ name: string; // README h1 in the requested locale (folder as fallback)
197
+ excerpt: string; // first README paragraph, plain text
198
+ date: string; // when the sample was authored (YYYY-MM-DD)
199
+ }
200
+
201
+ /**
202
+ * Light per-folder metadata for the samples index: the locale-appropriate
203
+ * README's title and first paragraph — parsed, not rendered, and nothing is
204
+ * highlighted, so listing every sample stays cheap.
205
+ */
206
+ export function listProjects(lang: string): ProjectSummary[] {
207
+ const md = new MarkdownIt();
208
+ // Markdown inline syntax the excerpt should not carry into a card.
209
+ const plain = (s: string) =>
210
+ s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`]/g, '');
211
+ return listSampleFolders().map((folder) => {
212
+ const dir = join(SAMPLES_DIR, folder);
213
+ const readmes = readdirSync(dir).filter((n) => README_RE.test(n));
214
+ const pick =
215
+ readmes.find((n) => n.toLowerCase() === `readme.${lang}.md`) ??
216
+ readmes.find((n) => n.toLowerCase() === 'readme.md') ??
217
+ readmes[0];
218
+ let name = folder;
219
+ let excerpt = '';
220
+ if (pick) {
221
+ const tokens = md.parse(readFileSync(join(dir, pick), 'utf8'), {});
222
+ for (let i = 0; i < tokens.length; i++) {
223
+ if (tokens[i].type === 'heading_open' && tokens[i].tag === 'h1' && name === folder)
224
+ name = tokens[i + 1]?.content ?? folder;
225
+ else if (tokens[i].type === 'paragraph_open' && !excerpt)
226
+ excerpt = plain(tokens[i + 1]?.content ?? '').replace(/\s+/g, ' ');
227
+ if (name !== folder && excerpt) break;
228
+ }
229
+ }
230
+ return { folder, name, excerpt, date: SAMPLE_DATES[folder] ?? DEFAULT_SAMPLE_DATE };
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Read + render the sample projects in the given `samples/<folder>/` list.
236
+ * For each project the README is localized: `README.<lang>.md` is used when it
237
+ * exists, otherwise the plain `README.md` is the fallback.
238
+ */
239
+ export async function renderProjects(folders: string[], lang: string): Promise<RenderedProject[]> {
240
+ const hl = await getHighlighter();
241
+ const escapeHtml = (s: string) =>
242
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
243
+ const md = new MarkdownIt({
244
+ html: false,
245
+ linkify: true,
246
+ highlight: (code, info) => {
247
+ const lang = (info || '').trim() || 'text';
248
+ // Hand mermaid blocks to the client-side MermaidLoader instead of Shiki,
249
+ // so README diagrams render as graphics rather than highlighted text.
250
+ if (lang === 'mermaid') return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
251
+ return highlight(hl, code, lang);
252
+ },
253
+ });
254
+ // README links open in a new tab.
255
+ const baseLink = md.renderer.rules.link_open ?? ((t, i, o, _e, s) => s.renderToken(t, i, o));
256
+ md.renderer.rules.link_open = (tokens, idx, opts, env, self) => {
257
+ tokens[idx].attrSet('target', '_blank');
258
+ tokens[idx].attrSet('rel', 'noopener noreferrer');
259
+ return baseLink(tokens, idx, opts, env, self);
260
+ };
261
+
262
+ const out: RenderedProject[] = [];
263
+ for (const folder of folders) {
264
+ const dir = join(SAMPLES_DIR, folder);
265
+ if (!existsSync(dir)) continue;
266
+ const raw: { path: string; name: string; content: string }[] = [];
267
+ walk(dir, dir, raw);
268
+ if (!raw.length) continue;
269
+
270
+ // Hide gitignored files (e.g. a local .env) from the file tree.
271
+ const ignored = gitIgnored(raw.map((f) => join(dir, f.path)));
272
+ const shown = raw.filter((f) => !ignored.has(join(dir, f.path)));
273
+ if (!shown.length) continue;
274
+
275
+ // Pick the README for the current language: README.<lang>.md, else the
276
+ // plain README.md. All README variants are kept out of the file tree.
277
+ const readmes = shown.filter((f) => README_RE.test(f.path));
278
+ const readme =
279
+ readmes.find((f) => f.path.toLowerCase() === `readme.${lang}.md`) ??
280
+ readmes.find((f) => f.path.toLowerCase() === 'readme.md') ??
281
+ readmes[0];
282
+
283
+ let readmeHtml: string | undefined;
284
+ let headings: ProjectHeading[] = [];
285
+ let name = folder;
286
+ if (readme) {
287
+ let title: string | undefined;
288
+ ({ html: readmeHtml, headings, title } = renderReadme(md, readme.content, folder));
289
+ name = title || folder;
290
+ }
291
+
292
+ const files: ProjectFile[] = [];
293
+ for (const f of shown
294
+ .filter((f) => !README_RE.test(f.path))
295
+ .sort((a, b) => priority(a.path) - priority(b.path) || a.path.localeCompare(b.path))) {
296
+ files.push({ path: f.path, html: highlight(hl, f.content, langFor(f.name)) });
297
+ }
298
+ out.push({
299
+ folder,
300
+ name,
301
+ date: SAMPLE_DATES[folder] ?? DEFAULT_SAMPLE_DATE,
302
+ readmeHtml,
303
+ headings,
304
+ files,
305
+ folderUrl: `${REPO}/tree/main/samples/${folder}`,
306
+ });
307
+ }
308
+ return out;
309
+ }
@@ -0,0 +1,88 @@
1
+ import { codeToHtml } from 'shiki';
2
+ import MarkdownIt from 'markdown-it';
3
+
4
+ const md = new MarkdownIt({ html: false, linkify: true });
5
+
6
+ export interface SampleHeading {
7
+ slug: string;
8
+ text: string;
9
+ depth: number;
10
+ }
11
+
12
+ export interface RenderedSample {
13
+ version: string;
14
+ lang: string;
15
+ codeHtml: string;
16
+ descHtml?: string;
17
+ diagram?: string;
18
+ note?: string;
19
+ headings: SampleHeading[];
20
+ }
21
+
22
+ interface RawSample {
23
+ version: string;
24
+ lang?: string;
25
+ description?: string;
26
+ diagram?: string;
27
+ code: string;
28
+ note?: string;
29
+ }
30
+
31
+ function slugify(s: string): string {
32
+ return s
33
+ .toLowerCase()
34
+ .trim()
35
+ .replace(/[^\p{L}\p{N} -]/gu, '')
36
+ .replace(/\s+/g, '-');
37
+ }
38
+
39
+ /**
40
+ * Render code samples for the detail "Code" tab: highlight code with Shiki,
41
+ * render each Markdown `description` to HTML (giving its h2/h3 stable ids), and
42
+ * return the per-sample headings so the right-rail TOC can list a version's
43
+ * internal sections (not the version itself).
44
+ */
45
+ export async function renderSamples(samples: RawSample[]): Promise<RenderedSample[]> {
46
+ return Promise.all(
47
+ samples.map(async (s, idx) => {
48
+ let codeHtml: string;
49
+ try {
50
+ codeHtml = await codeToHtml(s.code, { lang: s.lang ?? 'ts', theme: 'github-dark' });
51
+ } catch {
52
+ codeHtml = await codeToHtml(s.code, { lang: 'text', theme: 'github-dark' });
53
+ }
54
+
55
+ let descHtml: string | undefined;
56
+ const headings: SampleHeading[] = [];
57
+ if (s.description) {
58
+ const tokens = md.parse(s.description, {});
59
+ for (let i = 0; i < tokens.length; i++) {
60
+ const tk = tokens[i];
61
+ if (tk.type === 'heading_open' && (tk.tag === 'h2' || tk.tag === 'h3' || tk.tag === 'h4')) {
62
+ const text = tokens[i + 1]?.content ?? '';
63
+ const slug = `s${idx}-${slugify(text)}`;
64
+ tk.attrSet('id', slug);
65
+ // Anchor every level up to h4; the TOC still lists h2/h3 only.
66
+ if (tk.tag !== 'h4') headings.push({ slug, text, depth: tk.tag === 'h2' ? 2 : 3 });
67
+ }
68
+ }
69
+ descHtml = md.renderer.render(tokens, md.options, {}).replace(
70
+ /<(h[234]) id="([^"]+)">([\s\S]*?)<\/\1>/g,
71
+ (_m, tag, id, inner) =>
72
+ `<${tag} id="${id}">` +
73
+ `<a class="aas-anchor" href="#${id}" aria-label="Copy link to section"></a>${inner}</${tag}>`,
74
+ );
75
+ }
76
+
77
+ return {
78
+ version: s.version,
79
+ lang: s.lang ?? 'ts',
80
+ codeHtml,
81
+ descHtml,
82
+ diagram: s.diagram,
83
+ note: s.note,
84
+ headings,
85
+ };
86
+ }),
87
+ );
88
+ }
@@ -0,0 +1,25 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import type { Lang } from '../i18n/ui';
3
+
4
+ export type SlideDeck = CollectionEntry<'slides'>;
5
+
6
+ /**
7
+ * The url slug of a deck: its id with the `<lang>/` prefix removed, plus a
8
+ * trailing `/index` dropped so a folder deck (`<name>/index.mdx`, used when a
9
+ * deck co-locates images) shares the same `<name>` slug as a flat `<name>.mdx`.
10
+ */
11
+ export function deckSlugOf(entry: SlideDeck): string {
12
+ return entry.id.replace(/^[a-z]{2}\//, '').replace(/\/index$/, '');
13
+ }
14
+
15
+ /** Published slide decks for one locale, by `order` then title. */
16
+ export async function getDecks(lang: Lang): Promise<SlideDeck[]> {
17
+ const all = await getCollection('slides');
18
+ return all
19
+ .filter((e) => e.id.startsWith(`${lang}/`) && !e.data.draft)
20
+ .sort(
21
+ (a, b) =>
22
+ (a.data.order ?? 999) - (b.data.order ?? 999) ||
23
+ a.data.title.localeCompare(b.data.title),
24
+ );
25
+ }
@@ -0,0 +1,129 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import type { Lang } from '../i18n/ui';
3
+ import { descendantIds } from '@aas-data/categories';
4
+
5
+ export type StackEntry = CollectionEntry<'stacks'>;
6
+
7
+ /** The url slug of an entry, i.e. its id with the `<lang>/` prefix removed. */
8
+ export function slugOf(entry: StackEntry): string {
9
+ return entry.id.replace(/^[a-z]{2}\//, '');
10
+ }
11
+
12
+ /** A name → url-safe slug, e.g. "Roo Code" → "roo-code". */
13
+ export function slugifyName(name: string): string {
14
+ return name
15
+ .toLowerCase()
16
+ .trim()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-+|-+$/g, '');
19
+ }
20
+
21
+ /**
22
+ * Redirect aliases for a locale's stacks: each former name (after a rename)
23
+ * slugifies to an alias that should redirect to the entry's canonical slug.
24
+ * Aliases that collide with a real slug or each other are dropped.
25
+ */
26
+ export async function getStackAliases(lang: Lang): Promise<{ alias: string; target: string }[]> {
27
+ const stacks = await getStacks(lang);
28
+ const canonical = new Set(stacks.map(slugOf));
29
+ const seen = new Set<string>();
30
+ const out: { alias: string; target: string }[] = [];
31
+ for (const s of stacks) {
32
+ const target = slugOf(s);
33
+ for (const former of s.data.formerNames) {
34
+ const alias = slugifyName(former);
35
+ if (!alias || alias === target || canonical.has(alias) || seen.has(alias)) continue;
36
+ seen.add(alias);
37
+ out.push({ alias, target });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** All stack entries for one locale (entries live in `stacks/<lang>/*.mdx`). */
44
+ export async function getStacks(lang: Lang): Promise<StackEntry[]> {
45
+ const all = await getCollection('stacks');
46
+ return all.filter((e) => e.id.startsWith(`${lang}/`));
47
+ }
48
+
49
+ /** Unique, sorted list of tags used by entries in a locale. */
50
+ export async function getAllTags(lang: Lang): Promise<string[]> {
51
+ const stacks = await getStacks(lang);
52
+ const set = new Set<string>();
53
+ for (const s of stacks) for (const tag of s.data.tags) set.add(tag);
54
+ return [...set].sort();
55
+ }
56
+
57
+ /** Entries in a locale that carry a given tag. */
58
+ export async function getStacksByTag(lang: Lang, tag: string): Promise<StackEntry[]> {
59
+ const stacks = await getStacks(lang);
60
+ return stacks.filter((s) => s.data.tags.includes(tag));
61
+ }
62
+
63
+ /** Unique vendor url-slugs across a locale's stacks (for /vendors/<slug>). */
64
+ export async function getAllVendors(lang: Lang): Promise<string[]> {
65
+ const stacks = await getStacks(lang);
66
+ const set = new Set<string>();
67
+ for (const s of stacks) {
68
+ // A vendor with no Latin/digit characters (e.g. a Korean-only name)
69
+ // slugifies to '' — skip it rather than emit a broken /vendors/ route.
70
+ const slug = s.data.vendor ? slugifyName(s.data.vendor) : '';
71
+ if (slug) set.add(slug);
72
+ }
73
+ return [...set].sort();
74
+ }
75
+
76
+ /** Entries in a locale whose vendor slugifies to `slug`, sorted by name. */
77
+ export async function getStacksByVendor(lang: Lang, slug: string): Promise<StackEntry[]> {
78
+ const stacks = await getStacks(lang);
79
+ return stacks
80
+ .filter((s) => s.data.vendor && slugifyName(s.data.vendor) === slug)
81
+ .sort((a, b) => a.data.name.localeCompare(b.data.name));
82
+ }
83
+
84
+ /**
85
+ * A tool referenced by slug, resolved for display (avatar + link + flags).
86
+ * Unknown slugs — no matching mdx — carry `registered: false` and only a
87
+ * display name, so the UI can render them as "not in our catalog".
88
+ */
89
+ export interface ToolRef {
90
+ slug: string;
91
+ name: string;
92
+ registered: boolean;
93
+ logo?: string;
94
+ logoDark?: string;
95
+ category?: string;
96
+ deprecated?: boolean;
97
+ }
98
+
99
+ /** Build a slug → ToolRef resolver over a locale's stacks (one lookup map,
100
+ * reused across many slugs — e.g. a project's related-tools lists). */
101
+ export async function toolResolver(lang: Lang): Promise<(slug: string) => ToolRef> {
102
+ const stacks = await getStacks(lang);
103
+ const bySlug = new Map(stacks.map((s) => [slugOf(s), s]));
104
+ return (slug) => {
105
+ const e = bySlug.get(slug);
106
+ if (!e) return { slug, name: slug, registered: false };
107
+ return {
108
+ slug,
109
+ name: e.data.name,
110
+ registered: true,
111
+ logo: e.data.logo,
112
+ logoDark: e.data.logoDark,
113
+ category: e.data.category,
114
+ deprecated: e.data.deprecated,
115
+ };
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Entries in a locale that belong to a category node or any of its
121
+ * subcategories (subtree roll-up), sorted alphabetically by name.
122
+ */
123
+ export async function getStacksByCategory(lang: Lang, id: string): Promise<StackEntry[]> {
124
+ const ids = new Set(descendantIds(id));
125
+ const stacks = await getStacks(lang);
126
+ return stacks
127
+ .filter((s) => ids.has(s.data.category))
128
+ .sort((a, b) => a.data.name.localeCompare(b.data.name));
129
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Shared CLIENT-SIDE logic for the fixed right-rail TOC — imported by the
3
+ * <script> blocks of both TocRail.astro (single-column pages: concepts,
4
+ * articles) and StackDetail.astro (tab-aware rail). Everything here runs in
5
+ * the browser, not at build time.
6
+ */
7
+
8
+ /**
9
+ * Start the scroll-spy: highlight the rail link whose section is nearest above
10
+ * the viewport top. Headings that are hidden — inside an inactive tab panel or
11
+ * a collapsed <details> (offsetParent === null) — are skipped. Returns the spy
12
+ * so tab/version/example switches can re-run it after changing visibility.
13
+ */
14
+ export function startScrollSpy(rail: HTMLElement): () => void {
15
+ const spy = () => {
16
+ const links = Array.from(rail.querySelectorAll<HTMLAnchorElement>('a[href^="#"]'));
17
+ let activeId: string | null = null;
18
+ for (const a of links) {
19
+ const id = a.getAttribute('href')!.slice(1);
20
+ const h = document.getElementById(id);
21
+ if (h && h.offsetParent !== null && h.getBoundingClientRect().top <= 140) activeId = id;
22
+ }
23
+ links.forEach((a) =>
24
+ a.setAttribute('aria-current', String(a.getAttribute('href')!.slice(1) === activeId)),
25
+ );
26
+ };
27
+ window.addEventListener('scroll', spy, { passive: true });
28
+ spy();
29
+ return spy;
30
+ }
31
+
32
+ /** Wire the rail's back-to-top button. */
33
+ export function initBackToTop(rail: HTMLElement): void {
34
+ rail
35
+ .querySelector('[data-back-to-top]')
36
+ ?.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
37
+ }
38
+
39
+ /**
40
+ * Prose reveals (<details> in the body, e.g. "구체적인 예시"): open by default,
41
+ * remembered per page in localStorage, with a "collapse / expand all" toggle in
42
+ * the rail foot. Storage access is guarded throughout — a browser that blocks
43
+ * storage just loses persistence, not the whole feature.
44
+ */
45
+ export function initReveals(rail: HTMLElement): void {
46
+ const reveals = Array.from(
47
+ document.querySelectorAll<HTMLDetailsElement>('.prose details:not(.aas-tree-dir)'),
48
+ );
49
+ if (!reveals.length) return;
50
+ const allBtn = rail.querySelector<HTMLButtonElement>('[data-toggle-all]');
51
+ const key = (i: number) => `aas:reveal:${location.pathname}:${i}`;
52
+ const syncAll = () => {
53
+ if (!allBtn) return;
54
+ const anyOpen = reveals.some((d) => d.open);
55
+ allBtn.textContent = anyOpen ? allBtn.dataset.collapse! : allBtn.dataset.expand!;
56
+ };
57
+ reveals.forEach((d, i) => {
58
+ let saved: string | null = null;
59
+ try {
60
+ saved = localStorage.getItem(key(i));
61
+ } catch {}
62
+ if (saved === '0') d.open = false;
63
+ else if (saved === '1') d.open = true;
64
+ d.addEventListener('toggle', () => {
65
+ try {
66
+ localStorage.setItem(key(i), d.open ? '1' : '0');
67
+ } catch {}
68
+ syncAll();
69
+ });
70
+ });
71
+ if (allBtn) {
72
+ allBtn.hidden = false;
73
+ allBtn.addEventListener('click', () => {
74
+ const open = !reveals.some((d) => d.open);
75
+ reveals.forEach((d) => (d.open = open));
76
+ });
77
+ syncAll();
78
+ }
79
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import type { GetStaticPaths } from 'astro';
4
+ import BaseLayout from '../../layouts/BaseLayout.astro';
5
+ import ArticleDetail from '../../components/ArticleDetail.astro';
6
+ import { getArticles, articleSlugOf } from '../../lib/articles';
7
+
8
+ export const getStaticPaths = (async () => {
9
+ const entries = await getArticles('en');
10
+ return entries.map((entry) => ({ params: { id: articleSlugOf(entry) }, props: { entry } }));
11
+ }) satisfies GetStaticPaths;
12
+
13
+ const { entry } = Astro.props;
14
+ const slug = articleSlugOf(entry);
15
+ ---
16
+
17
+ <BaseLayout
18
+ title={`${entry.data.title} — ${site.name}`}
19
+ description={entry.data.description}
20
+ lang="en"
21
+ path={`article/${slug}/`}
22
+ >
23
+ <ArticleDetail entry={entry} lang="en" />
24
+ </BaseLayout>
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import type { GetStaticPaths } from 'astro';
4
+ import BaseLayout from '../../../layouts/BaseLayout.astro';
5
+ import BlogIndex from '../../../components/BlogIndex.astro';
6
+ import { articleTree } from '@aas-data/article-categories';
7
+
8
+ export const getStaticPaths = (() =>
9
+ articleTree.allIds.map((id) => ({ params: { id }, props: { id } }))) satisfies GetStaticPaths;
10
+
11
+ const { id } = Astro.props as { id: string };
12
+ const node = articleTree.map.get(id)!;
13
+ ---
14
+
15
+ <BaseLayout title={`${node.label.en} — ${site.name}`} lang="en" path={`article/category/${id}/`}>
16
+ <BlogIndex lang="en" categoryId={id} />
17
+ </BaseLayout>
@@ -0,0 +1,12 @@
1
+ ---
2
+ import { site } from '@aas-data/site';
3
+ import BaseLayout from '../../layouts/BaseLayout.astro';
4
+ import BlogIndex from '../../components/BlogIndex.astro';
5
+ import { useTranslations } from '../../i18n/ui';
6
+
7
+ const t = useTranslations('en');
8
+ ---
9
+
10
+ <BaseLayout title={`${t('blog.title')} — ${site.name}`} lang="en" path="article/">
11
+ <BlogIndex lang="en" />
12
+ </BaseLayout>