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
package/index.mjs ADDED
@@ -0,0 +1,124 @@
1
+ // @ts-check
2
+ // The awesome-*-stack theme. A site's astro.config.mjs stays tiny:
3
+ //
4
+ // import aasTheme from 'stack-site-builder';
5
+ // import { glossary } from './src/data/glossary.mjs';
6
+ // export default defineConfig({
7
+ // site: '…', base: '/…', i18n: { … },
8
+ // integrations: [aasTheme({ glossary })],
9
+ // });
10
+ //
11
+ // The theme injects every route (catalog, concepts, articles, samples, slides,
12
+ // glossary, tags, vendors — in both locales), wires the shared markdown
13
+ // pipeline, tailwind, and the dev-only local-samples middleware, and aliases
14
+ // `@aas-data/*` / `@assets/*` to the SITE's `src/data` / `src/assets` so core
15
+ // components resolve per-site taxonomy (categories, glossary, site identity)
16
+ // at build time.
17
+ import { fileURLToPath } from 'node:url';
18
+ import mdx from '@astrojs/mdx';
19
+ import sitemap from '@astrojs/sitemap';
20
+ import tailwindcss from '@tailwindcss/vite';
21
+ import { aasMarkdown } from './markdown.mjs';
22
+ import { localSamples } from './dev/local-samples.mjs';
23
+
24
+ // Every page the theme provides, as `src/pages/`-relative entrypoints. The
25
+ // same tree is served at `/` (en) and under `/ko/` (physical mirrors, matching
26
+ // prefixDefaultLocale: false).
27
+ const PAGES = [
28
+ 'index.astro',
29
+ 'article/index.astro',
30
+ 'article/[...id].astro',
31
+ 'article/category/[id].astro',
32
+ 'categories/[id].astro',
33
+ 'concept/index.astro',
34
+ 'concept/[...id].astro',
35
+ 'concept/category/[id].astro',
36
+ 'glossary.astro',
37
+ 'sample/index.astro',
38
+ 'sample/[folder].astro',
39
+ 'slides/index.astro',
40
+ 'slides/[deck].astro',
41
+ 'stack/[...id].astro',
42
+ 'tags/[tag].astro',
43
+ 'vendors/[vendor].astro',
44
+ ];
45
+
46
+ /** `article/[...id].astro` → `/article/[...id]`, `index.astro` → `/` */
47
+ /** @param {string} file @param {string} prefix */
48
+ function patternOf(file, prefix) {
49
+ const p = file.replace(/\.astro$/, '').replace(/\/?index$/, '');
50
+ const full = `/${prefix}${p}`.replace(/\/$/, '');
51
+ return full || '/';
52
+ }
53
+
54
+ /**
55
+ * @param {object} opts
56
+ * @param {Record<string, any>} opts.glossary — the site's glossary
57
+ * (`src/data/glossary.mjs`), used by `[[wikilink]]` resolution.
58
+ * @returns {import('astro').AstroIntegration[]}
59
+ */
60
+ export default function aasTheme({ glossary }) {
61
+ /** @type {import('astro').AstroIntegration} */
62
+ const core = {
63
+ name: 'stack-site-builder',
64
+ hooks: {
65
+ 'astro:config:setup': ({ config, injectRoute, updateConfig }) => {
66
+ for (const prefix of ['', 'ko/']) {
67
+ for (const file of PAGES) {
68
+ const entry = prefix === '' ? file : `ko/${file}`;
69
+ injectRoute({
70
+ pattern: patternOf(file, prefix),
71
+ entrypoint: `stack-site-builder/pages/${entry}`,
72
+ });
73
+ }
74
+ }
75
+
76
+ updateConfig({
77
+ markdown: aasMarkdown({ glossary }),
78
+
79
+ // Bind the dev server to 0.0.0.0 so it's reachable from a browser on
80
+ // the host (outside the Docker container).
81
+ server: { host: true },
82
+
83
+ vite: {
84
+ plugins: [tailwindcss(), localSamples()],
85
+
86
+ // Core code reaches the SITE's data and assets through these
87
+ // aliases: `@aas-data/…` → `<site>/src/data/…` (taxonomy, glossary,
88
+ // site identity), `@assets/…` → `<site>/src/assets/…` (in-body
89
+ // images without long relative paths).
90
+ resolve: {
91
+ alias: {
92
+ '@aas-data': fileURLToPath(new URL('./src/data', config.root)),
93
+ '@assets': fileURLToPath(new URL('./src/assets', config.root)),
94
+ },
95
+ },
96
+
97
+ // Developed inside Docker bind-mount devcontainers, where native
98
+ // inotify is unreliable (phantom "config changed" events tear down
99
+ // Vite's module runner). Polling avoids that.
100
+ server: {
101
+ watch: {
102
+ usePolling: true,
103
+ interval: 100,
104
+ },
105
+ },
106
+ },
107
+ });
108
+ },
109
+ },
110
+ };
111
+
112
+ return [
113
+ core,
114
+ mdx(),
115
+ // i18n option emits hreflang alternates so search engines associate each
116
+ // page with its twin in the other locale (/stack/x/ ↔ /ko/stack/x/).
117
+ sitemap({
118
+ i18n: {
119
+ defaultLocale: 'en',
120
+ locales: { en: 'en', ko: 'ko' },
121
+ },
122
+ }),
123
+ ];
124
+ }
package/markdown.mjs ADDED
@@ -0,0 +1,394 @@
1
+ // @ts-check
2
+ // The shared Markdown/MDX pipeline for awesome-*-stack sites: heading ids +
3
+ // copy-link anchors, mermaid fences, slide directives, and [[wikilink]]
4
+ // resolution against the site's glossary. Sites get the whole pipeline from
5
+ // the theme integration (index.mjs); `aasMarkdown({ glossary })` is also
6
+ // exported for direct use.
7
+ import rehypeExternalLinks from 'rehype-external-links';
8
+ import rehypeSlug from 'rehype-slug';
9
+ import remarkDirective from 'remark-directive';
10
+
11
+ // Prepend a "#" copy-link anchor to h2/h3/h4 headings (a global click handler
12
+ // in BaseLayout copies the section URL). The "#" count per level is drawn via
13
+ // CSS (h2 → #, h3 → ##, h4 → ###). The TOC still lists h2/h3 only. Runs after
14
+ // rehype-slug adds the ids.
15
+ function rehypeHeadingAnchors() {
16
+ /** @param {any} node */
17
+ const walk = (node) => {
18
+ if (!node.children) return;
19
+ for (const child of node.children) {
20
+ if (
21
+ child.type === 'element' &&
22
+ (child.tagName === 'h2' || child.tagName === 'h3' || child.tagName === 'h4') &&
23
+ child.properties &&
24
+ child.properties.id
25
+ ) {
26
+ // Empty anchor — the visible "#" is drawn via CSS ::before so it does
27
+ // not leak into Astro's extracted heading text (used by the TOC).
28
+ child.children.unshift({
29
+ type: 'element',
30
+ tagName: 'a',
31
+ properties: {
32
+ className: ['aas-anchor'],
33
+ href: '#' + child.properties.id,
34
+ 'aria-label': 'Copy link to section',
35
+ },
36
+ children: [],
37
+ });
38
+ } else {
39
+ walk(child);
40
+ }
41
+ }
42
+ };
43
+ return (/** @type {any} */ tree) => walk(tree);
44
+ }
45
+
46
+ // Support explicit, stable heading ids written as `## Heading {#custom-id}`. The
47
+ // id is stripped from the visible text and set on the heading, so rehype-slug
48
+ // won't override it. Lets every locale share one anchor and keeps the anchor (and
49
+ // any external link to it) stable even when the heading wording changes.
50
+ function remarkHeadingIds() {
51
+ const re = /\s*\{#([\w-]+)\}\s*$/;
52
+ /** @param {any} node */
53
+ const walk = (node) => {
54
+ if (!node.children) return;
55
+ for (const child of node.children) {
56
+ if (child.type === 'heading' && child.children.length) {
57
+ const last = child.children[child.children.length - 1];
58
+ if (last && last.type === 'text') {
59
+ const m = last.value.match(re);
60
+ if (m) {
61
+ last.value = last.value.replace(re, '');
62
+ child.data = child.data || {};
63
+ child.data.hProperties = { ...(child.data.hProperties || {}), id: m[1] };
64
+ }
65
+ }
66
+ } else {
67
+ walk(child);
68
+ }
69
+ }
70
+ };
71
+ return (/** @type {any} */ tree) => walk(tree);
72
+ }
73
+
74
+ // Turn ```mermaid fenced blocks into <pre class="mermaid"> (raw, un-highlighted)
75
+ // so the client-side mermaid loader can render them. Runs at the remark stage,
76
+ // before syntax highlighting, so Shiki leaves these blocks alone.
77
+ function remarkMermaid() {
78
+ /** @param {string} s */
79
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
80
+ /** @param {any} node */
81
+ const walk = (node) => {
82
+ if (!node.children) return;
83
+ node.children.forEach((/** @type {any} */ child, /** @type {number} */ i) => {
84
+ if (child.type === 'code' && child.lang === 'mermaid') {
85
+ node.children[i] = {
86
+ type: 'html',
87
+ value: `<div class="aas-diagram"><pre class="mermaid">${esc(child.value)}</pre></div>`,
88
+ };
89
+ } else {
90
+ walk(child);
91
+ }
92
+ });
93
+ };
94
+ return (/** @type {any} */ tree) => walk(tree);
95
+ }
96
+
97
+ // Slide directives (needs remarkDirective, which runs first). Two are handled:
98
+ //
99
+ // :::cols columns, separated by `---`:
100
+ // ### Left :::cols
101
+ // --- ### Left
102
+ // ### Right ---
103
+ // ::: ### Right
104
+ // :::
105
+ // ::sub[짧은 부제] a small subtitle line under a slide title
106
+ //
107
+ // `cols` renders as <div class="cols"> (one <div> per column); `sub` renders as
108
+ // <p class="aas-subtitle"> (styled in global.css). Crucially, any OTHER
109
+ // directive is reclaimed back to its literal text — remark-directive parses
110
+ // every `:name` as a directive, so without this a colon in prose ("50:50",
111
+ // "12:00") would be silently eaten. Keeps the source pure Markdown.
112
+ function remarkSlideDirectives() {
113
+ /** @param {any[]} nodes @returns {string} */
114
+ const inlineText = (nodes) =>
115
+ (nodes || []).map((n) => (n.value != null ? n.value : inlineText(n.children))).join('');
116
+
117
+ /** Split a directive's children into groups on `---` (thematic breaks). */
118
+ /** @param {any[]} children @returns {any[][]} */
119
+ const splitOnRule = (children) => {
120
+ /** @type {any[][]} */
121
+ const groups = [[]];
122
+ for (const c of children) {
123
+ if (c.type === 'thematicBreak') groups.push([]);
124
+ else groups[groups.length - 1].push(c);
125
+ }
126
+ return groups;
127
+ };
128
+
129
+ const CALLOUTS = ['note', 'tip', 'warning', 'info'];
130
+
131
+ /** @param {any} node */
132
+ const walk = (node) => {
133
+ if (!node.children) return;
134
+ for (let i = 0; i < node.children.length; i++) {
135
+ const child = node.children[i];
136
+ if (child.type === 'containerDirective' && child.name === 'cols') {
137
+ child.data = { hName: 'div', hProperties: { className: ['cols'] } };
138
+ child.children = splitOnRule(child.children).map((g) => ({
139
+ type: 'columnGroup',
140
+ data: { hName: 'div' },
141
+ children: g,
142
+ }));
143
+ walk(child);
144
+ } else if (child.type === 'containerDirective' && child.name === 'stats') {
145
+ // Big-number cards. Each heading starts a new card (its number), and the
146
+ // following lines are its label — no `---` needed (and a `---` right under
147
+ // a label line would be eaten as a Setext underline anyway).
148
+ /** @type {any[][]} */
149
+ const groups = [];
150
+ for (const c of child.children) {
151
+ if (c.type === 'heading' || groups.length === 0) groups.push([c]);
152
+ else groups[groups.length - 1].push(c);
153
+ }
154
+ child.data = { hName: 'div', hProperties: { className: ['aas-stats'] } };
155
+ child.children = groups.map((g) => ({
156
+ type: 'statCard',
157
+ data: { hName: 'div', hProperties: { className: ['aas-stat'] } },
158
+ children: g,
159
+ }));
160
+ walk(child);
161
+ } else if (child.type === 'containerDirective' && child.name === 'compare') {
162
+ // Side-by-side comparison cards, split by `---`.
163
+ child.data = { hName: 'div', hProperties: { className: ['aas-compare'] } };
164
+ child.children = splitOnRule(child.children).map((g) => ({
165
+ type: 'compareCol',
166
+ data: { hName: 'div', hProperties: { className: ['aas-compare-col'] } },
167
+ children: g,
168
+ }));
169
+ walk(child);
170
+ } else if (child.type === 'containerDirective' && child.name === 'steps') {
171
+ // Step-reveal container: each direct block becomes a step; a list inside
172
+ // makes each <li> a step, so bullets reveal one at a time.
173
+ child.data = { hName: 'div', hProperties: { className: ['aas-steps'] } };
174
+ /** @type {any[]} */
175
+ const out = [];
176
+ for (const c of child.children) {
177
+ if (c.type === 'list') {
178
+ for (const li of c.children) {
179
+ li.data = { hProperties: { className: ['aas-step'] } };
180
+ }
181
+ out.push(c);
182
+ } else {
183
+ out.push({
184
+ type: 'stepItem',
185
+ data: { hName: 'div', hProperties: { className: ['aas-step'] } },
186
+ children: [c],
187
+ });
188
+ }
189
+ }
190
+ child.children = out;
191
+ walk(child);
192
+ } else if (child.type === 'containerDirective' && child.name === 'step') {
193
+ // A single reveal step for scroll+text walkthroughs. Optionally tied to a
194
+ // scroll position (`:::step{scroll=40}` = 40%, or `scroll=120px`) or, for
195
+ // a code walkthrough, a line range to highlight (`:::step{lines="5-9"}`).
196
+ /** @type {Record<string, any>} */
197
+ const props = { className: ['aas-step'] };
198
+ const attrs = child.attributes || {};
199
+ if (attrs.scroll != null && attrs.scroll !== '') props['data-scroll'] = String(attrs.scroll);
200
+ if (attrs.lines != null && attrs.lines !== '') props['data-lines'] = String(attrs.lines);
201
+ child.data = { hName: 'div', hProperties: props };
202
+ walk(child);
203
+ } else if (child.type === 'containerDirective' && CALLOUTS.includes(child.name)) {
204
+ // Callout box (note / tip / warning / info).
205
+ child.data = {
206
+ hName: 'div',
207
+ hProperties: { className: ['aas-callout', `aas-callout-${child.name}`] },
208
+ };
209
+ walk(child);
210
+ } else if (child.type === 'leafDirective' && child.name === 'sub') {
211
+ child.data = { hName: 'p', hProperties: { className: ['aas-subtitle'] } };
212
+ walk(child);
213
+ } else if (
214
+ child.type === 'textDirective' ||
215
+ child.type === 'leafDirective' ||
216
+ child.type === 'containerDirective'
217
+ ) {
218
+ // Unintended directive (e.g. `:50` inside "50:50") — restore its source.
219
+ const marker =
220
+ child.type === 'containerDirective' ? ':::' : child.type === 'leafDirective' ? '::' : ':';
221
+ const label = child.children && child.children.length ? `[${inlineText(child.children)}]` : '';
222
+ node.children[i] = { type: 'text', value: `${marker}${child.name || ''}${label}` };
223
+ } else {
224
+ walk(child);
225
+ }
226
+ }
227
+ };
228
+ return (/** @type {any} */ tree) => walk(tree);
229
+ }
230
+
231
+ // Turn `[[Term]]` (and `[[Term|display text]]`) wikilinks into links, resolving
232
+ // each against the site's central glossary (passed as an option). Internal targets
233
+ // emit the `../../stack|concept/<slug>/` relative form (locale- and base-agnostic
234
+ // on the depth-3 detail routes); external `href` entries pass through and get
235
+ // target="_blank" from rehype-external-links downstream. An unknown term throws,
236
+ // failing the build so a typo can't silently degrade to plain text. Code spans
237
+ // and fenced blocks are untouched (mdast `inlineCode`/`code` carry no children).
238
+ function remarkGlossary({ glossary }) {
239
+ const RE = /\[\[\s*([^\]|]+?)\s*(?:\|\s*([^\]]+?)\s*)?\]\]/g;
240
+ /** @param {string} s */
241
+ const norm = (s) => s.trim().toLowerCase().replace(/\s+/g, '-');
242
+ // Reverse index: an entry's id and each of its labels (any locale) all resolve
243
+ // to that entry, so authors can write the natural word in either language —
244
+ // [[도구]] / [[Tools]] — or the id ([[agent-tools]]). Ambiguity fails the build.
245
+ /** @type {Record<string, string>} */
246
+ const lookup = {};
247
+ /** @param {string} name @param {string} id */
248
+ const register = (name, id) => {
249
+ const k = norm(name);
250
+ if (lookup[k] && lookup[k] !== id)
251
+ throw new Error(`[glossary] ambiguous term "${k}" maps to both "${lookup[k]}" and "${id}"`);
252
+ lookup[k] = id;
253
+ };
254
+ for (const [id, e] of Object.entries(glossary)) {
255
+ register(id, id);
256
+ if (typeof e.label === 'string') register(e.label, id);
257
+ else {
258
+ register(e.label.ko, id);
259
+ register(e.label.en, id);
260
+ }
261
+ }
262
+ /** @param {any} tree @param {any} file */
263
+ return (tree, file) => {
264
+ const path = (file && (file.path || (file.history && file.history[0]))) || '';
265
+ const lang = /[/\\]ko[/\\]/.test(path) ? 'ko' : 'en';
266
+ /** @param {any} l */
267
+ const labelOf = (l) => (typeof l === 'string' ? l : l[lang]);
268
+ // Same-document section links ([[#anchor]]) resolve their display text to
269
+ // the target heading's own title. Collect id -> title up front from this
270
+ // file's tree. remarkHeadingIds ran earlier, so explicit \{#id} headings
271
+ // already carry their id on data.hProperties and have the \{#id} stripped
272
+ // from the visible text. Auto-slugged headings (no explicit id) have no id
273
+ // at this stage and are intentionally not indexed — an unknown anchor fails
274
+ // the build, nudging authors to add an explicit \{#id}.
275
+ /** @param {any} node @returns {string} */
276
+ const headingText = (node) =>
277
+ (node.children || [])
278
+ .map((/** @type {any} */ c) => (c.value != null ? c.value : headingText(c)))
279
+ .join('');
280
+ /** @type {Record<string, string>} */
281
+ const headingTitles = {};
282
+ /** @param {any} node */
283
+ const collectHeadings = (node) => {
284
+ if (!node.children) return;
285
+ for (const c of node.children) {
286
+ if (c.type === 'heading') {
287
+ const hid = c.data && c.data.hProperties && c.data.hProperties.id;
288
+ if (hid) headingTitles[hid] = headingText(c);
289
+ } else collectHeadings(c);
290
+ }
291
+ };
292
+ collectHeadings(tree);
293
+ /** @param {any} node */
294
+ const walk = (node) => {
295
+ if (!node.children) return;
296
+ /** @type {any[]} */
297
+ const out = [];
298
+ for (const child of node.children) {
299
+ if (child.type === 'text' && child.value.includes('[[')) {
300
+ let last = 0;
301
+ let m;
302
+ RE.lastIndex = 0;
303
+ while ((m = RE.exec(child.value))) {
304
+ if (m.index > last) out.push({ type: 'text', value: child.value.slice(last, m.index) });
305
+ // Obsidian-style section link: [[term#anchor|text]] targets a
306
+ // heading id on the term's page (anchors are the stable \{#id}s,
307
+ // shared across locales).
308
+ const hashAt = m[1].indexOf('#');
309
+ const name = hashAt === -1 ? m[1] : m[1].slice(0, hashAt);
310
+ const anchor = hashAt === -1 ? '' : m[1].slice(hashAt + 1).trim();
311
+ // Same-document section link: [[#anchor]] / [[#anchor|text]] points
312
+ // at a heading in THIS file (no glossary term before the #). The
313
+ // display text defaults to the heading's own title.
314
+ if (name === '') {
315
+ if (!anchor) throw new Error(`[glossary] empty wikilink "[[${m[1]}]]" in ${path}`);
316
+ const title = headingTitles[anchor];
317
+ if (!title)
318
+ throw new Error(
319
+ `[glossary] "[[${m[1]}]]" — no heading with an explicit \{#${anchor}} in ${path}`,
320
+ );
321
+ const text = m[2] ? m[2].trim() : title;
322
+ out.push({ type: 'link', url: `#${anchor}`, children: [{ type: 'text', value: text }] });
323
+ last = m.index + m[0].length;
324
+ continue;
325
+ }
326
+ const id = lookup[norm(name)];
327
+ const entry = glossary[id];
328
+ if (!entry) throw new Error(`[glossary] unknown term "[[${m[1]}]]" in ${path}`);
329
+ const def = entry.def ? labelOf(entry.def) : undefined;
330
+ // A def-only term (no page) links to its entry on the glossary page.
331
+ let url = entry.stack
332
+ ? `../../stack/${entry.stack}/`
333
+ : entry.concept
334
+ ? `../../concept/${entry.concept}/`
335
+ : entry.article
336
+ ? `../../article/${entry.article}/`
337
+ : entry.href
338
+ ? entry.href
339
+ : def
340
+ ? `../../glossary/#${id}`
341
+ : null;
342
+ if (!url)
343
+ throw new Error(
344
+ `[glossary] term "[[${m[1]}]]" needs one of stack/concept/article/href/def`,
345
+ );
346
+ if (anchor) {
347
+ // A def-only target already carries its own hash — an extra
348
+ // anchor is a mistake, so fail the build like an unknown term.
349
+ if (!entry.stack && !entry.concept && !entry.article && !entry.href)
350
+ throw new Error(
351
+ `[glossary] "[[${m[1]}]]" — a definition-only term can't take a #anchor`,
352
+ );
353
+ url += `#${anchor}`;
354
+ }
355
+ const text = m[2] ? m[2].trim() : labelOf(entry.label);
356
+ /** @type {any} */
357
+ const link = { type: 'link', url, children: [{ type: 'text', value: text }] };
358
+ if (def) link.data = { hProperties: { title: def } };
359
+ out.push(link);
360
+ last = m.index + m[0].length;
361
+ }
362
+ if (last < child.value.length) out.push({ type: 'text', value: child.value.slice(last) });
363
+ } else {
364
+ walk(child);
365
+ out.push(child);
366
+ }
367
+ }
368
+ node.children = out;
369
+ };
370
+ walk(tree);
371
+ };
372
+ }
373
+
374
+ /**
375
+ * The full markdown config for `defineConfig({ markdown })`.
376
+ * @param {{ glossary: Record<string, any> }} opts — the site's glossary
377
+ * (wikilink targets); pass `{}` for a site without wikilinks.
378
+ */
379
+ export function aasMarkdown({ glossary }) {
380
+ return {
381
+ remarkPlugins: [
382
+ remarkHeadingIds,
383
+ remarkMermaid,
384
+ remarkDirective,
385
+ remarkSlideDirectives,
386
+ [remarkGlossary, { glossary }],
387
+ ],
388
+ rehypePlugins: [
389
+ rehypeSlug,
390
+ rehypeHeadingAnchors,
391
+ [rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
392
+ ],
393
+ };
394
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "stack-site-builder",
3
+ "type": "module",
4
+ "version": "1.10.0",
5
+ "license": "MIT",
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
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/CodeCompose7/stack-site-builder.git"
10
+ },
11
+ "files": [
12
+ "index.mjs",
13
+ "index.d.ts",
14
+ "markdown.mjs",
15
+ "src",
16
+ "dev"
17
+ ],
18
+ "exports": {
19
+ ".": {
20
+ "types": "./index.d.ts",
21
+ "default": "./index.mjs"
22
+ },
23
+ "./markdown": "./markdown.mjs",
24
+ "./content": "./src/content.ts",
25
+ "./pages/*": "./src/pages/*",
26
+ "./components/*": "./src/components/*",
27
+ "./layouts/*": "./src/layouts/*",
28
+ "./lib/*": "./src/lib/*.ts",
29
+ "./i18n/*": "./src/i18n/*.ts",
30
+ "./styles/*": "./src/styles/*"
31
+ },
32
+ "peerDependencies": {
33
+ "astro": "^5.7.0"
34
+ },
35
+ "dependencies": {
36
+ "@astrojs/mdx": "^4.2.0",
37
+ "@astrojs/sitemap": "^3.3.0",
38
+ "@tailwindcss/vite": "^4.1.0",
39
+ "markdown-it": "^14.2.0",
40
+ "mermaid": "^11.15.0",
41
+ "rehype-external-links": "^3.0.0",
42
+ "rehype-slug": "^6.0.0",
43
+ "remark-directive": "^4.0.0",
44
+ "shiki": "^4.2.0",
45
+ "tailwindcss": "^4.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/markdown-it": "^14.1.2",
49
+ "astro": "^5.7.0",
50
+ "typescript": "^6.0.3"
51
+ }
52
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ interface Props {
3
+ size?: number;
4
+ class?: string;
5
+ }
6
+
7
+ const { size = 16, class: className } = Astro.props;
8
+ ---
9
+
10
+ <svg
11
+ width={size}
12
+ height={size}
13
+ viewBox="0 0 24 24"
14
+ fill="none"
15
+ stroke="currentColor"
16
+ stroke-width="2.25"
17
+ stroke-linecap="round"
18
+ stroke-linejoin="round"
19
+ aria-hidden="true"
20
+ class:list={['inline-block shrink-0 align-middle', className]}
21
+ >
22
+ <path d="M7 17 17 7"></path>
23
+ <path d="M8 7h9v9"></path>
24
+ </svg>
@@ -0,0 +1,56 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+ import { getRelativeLocaleUrl } from 'astro:i18n';
4
+ import { useTranslations, type Lang } from '../i18n/ui';
5
+ import { formatDate } from '../lib/dates';
6
+ import { inlineMd } from '../lib/inline-md';
7
+ import type { ImageMetadata } from 'astro';
8
+
9
+ interface Props {
10
+ slug: string;
11
+ lang: Lang;
12
+ title: string;
13
+ description: string;
14
+ date: Date;
15
+ tags: string[];
16
+ image?: ImageMetadata;
17
+ imageAlt?: string;
18
+ version?: string;
19
+ }
20
+
21
+ const { slug, lang, title, description, date, tags, image, imageAlt, version } = Astro.props;
22
+ const t = useTranslations(lang);
23
+ const href = getRelativeLocaleUrl(lang, `article/${slug}/`);
24
+ const dateStr = date.toISOString().slice(0, 10);
25
+ ---
26
+
27
+ <a
28
+ href={href}
29
+ data-name={title.toLowerCase()}
30
+ data-date={date.toISOString()}
31
+ class="aas-lift flex items-stretch overflow-hidden rounded-2xl border border-[var(--aas-border)] bg-[var(--aas-panel)] no-underline"
32
+ >
33
+ {image && <Image src={image} alt={imageAlt ?? title} class="w-32 shrink-0 self-stretch object-cover sm:w-40" />}
34
+ <div class="min-w-0 flex-1 p-4 sm:p-5">
35
+ <p class="text-xs text-[var(--aas-muted)]">
36
+ <time datetime={dateStr}>{formatDate(date, lang)}</time>
37
+ {version && <span> · {t('meta.docVersion')} {version}</span>}
38
+ </p>
39
+ <h3 class="mt-1 line-clamp-2 text-base font-semibold text-[var(--aas-text)]">{title}</h3>
40
+ <p
41
+ class="aas-md mt-1.5 line-clamp-2 text-sm leading-relaxed text-[var(--aas-muted)]"
42
+ set:html={inlineMd(description)}
43
+ />
44
+ {
45
+ tags.length > 0 && (
46
+ <div class="mt-2.5 flex flex-wrap gap-1.5">
47
+ {tags.slice(0, 4).map((tag) => (
48
+ <span class="rounded-full border border-[var(--aas-border)] px-2 py-0.5 text-xs text-[var(--aas-muted)]">
49
+ {tag}
50
+ </span>
51
+ ))}
52
+ </div>
53
+ )
54
+ }
55
+ </div>
56
+ </a>