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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dev/local-samples-client.js +284 -0
- package/dev/local-samples.mjs +594 -0
- package/index.d.ts +12 -0
- package/index.mjs +124 -0
- package/markdown.mjs +394 -0
- package/package.json +52 -0
- package/src/components/ArrowUpRight.astro +24 -0
- package/src/components/ArticleCard.astro +56 -0
- package/src/components/ArticleDetail.astro +100 -0
- package/src/components/ArticleLink.astro +45 -0
- package/src/components/BackToTop.astro +47 -0
- package/src/components/BlogIndex.astro +137 -0
- package/src/components/Breadcrumb.astro +37 -0
- package/src/components/CategoryIndex.astro +115 -0
- package/src/components/ChevronDown.astro +23 -0
- package/src/components/CodeSamples.astro +141 -0
- package/src/components/CollapsibleGrid.astro +50 -0
- package/src/components/ConceptCard.astro +59 -0
- package/src/components/ConceptDetail.astro +102 -0
- package/src/components/ConceptIndex.astro +143 -0
- package/src/components/ConceptLink.astro +43 -0
- package/src/components/ConceptTools.astro +49 -0
- package/src/components/DeckView.astro +772 -0
- package/src/components/DetailTabs.astro +219 -0
- package/src/components/FilterControls.astro +407 -0
- package/src/components/Glossary.astro +264 -0
- package/src/components/Home.astro +90 -0
- package/src/components/LanguageSwitcher.astro +86 -0
- package/src/components/ListControls.astro +182 -0
- package/src/components/MermaidLoader.astro +96 -0
- package/src/components/PricingSection.astro +100 -0
- package/src/components/ProjectFileList.astro +68 -0
- package/src/components/ProjectFileTree.astro +95 -0
- package/src/components/ProjectRelatedTools.astro +70 -0
- package/src/components/ProjectViewer.astro +431 -0
- package/src/components/RelatedArticles.astro +52 -0
- package/src/components/RelatedConcepts.astro +60 -0
- package/src/components/RelatedTools.astro +55 -0
- package/src/components/SampleIndex.astro +464 -0
- package/src/components/SamplePage.astro +95 -0
- package/src/components/SampleProject.astro +56 -0
- package/src/components/Slide.astro +36 -0
- package/src/components/SlidesIndex.astro +60 -0
- package/src/components/StackCard.astro +183 -0
- package/src/components/StackDetail.astro +507 -0
- package/src/components/StarIcon.astro +21 -0
- package/src/components/TagIndex.astro +51 -0
- package/src/components/ThemeToggle.astro +106 -0
- package/src/components/Toc.astro +39 -0
- package/src/components/TocRail.astro +74 -0
- package/src/components/ToolAvatar.astro +133 -0
- package/src/components/ToolMeta.astro +95 -0
- package/src/components/VendorIndex.astro +51 -0
- package/src/content.ts +228 -0
- package/src/i18n/ui.ts +337 -0
- package/src/layouts/BaseLayout.astro +287 -0
- package/src/lib/articles.ts +27 -0
- package/src/lib/assets.ts +19 -0
- package/src/lib/category-tree.ts +64 -0
- package/src/lib/concepts.ts +35 -0
- package/src/lib/dates.ts +21 -0
- package/src/lib/facets.ts +57 -0
- package/src/lib/github.ts +186 -0
- package/src/lib/inline-md.ts +30 -0
- package/src/lib/pricing.ts +22 -0
- package/src/lib/project.ts +309 -0
- package/src/lib/samples.ts +88 -0
- package/src/lib/slides.ts +25 -0
- package/src/lib/stacks.ts +129 -0
- package/src/lib/toc-rail-client.ts +79 -0
- package/src/pages/article/[...id].astro +24 -0
- package/src/pages/article/category/[id].astro +17 -0
- package/src/pages/article/index.astro +12 -0
- package/src/pages/categories/[id].astro +25 -0
- package/src/pages/concept/[...id].astro +24 -0
- package/src/pages/concept/category/[id].astro +17 -0
- package/src/pages/concept/index.astro +12 -0
- package/src/pages/glossary.astro +12 -0
- package/src/pages/index.astro +9 -0
- package/src/pages/ko/article/[...id].astro +24 -0
- package/src/pages/ko/article/category/[id].astro +17 -0
- package/src/pages/ko/article/index.astro +12 -0
- package/src/pages/ko/categories/[id].astro +24 -0
- package/src/pages/ko/concept/[...id].astro +24 -0
- package/src/pages/ko/concept/category/[id].astro +17 -0
- package/src/pages/ko/concept/index.astro +12 -0
- package/src/pages/ko/glossary.astro +12 -0
- package/src/pages/ko/index.astro +9 -0
- package/src/pages/ko/sample/[folder].astro +16 -0
- package/src/pages/ko/sample/index.astro +12 -0
- package/src/pages/ko/slides/[deck].astro +14 -0
- package/src/pages/ko/slides/index.astro +12 -0
- package/src/pages/ko/stack/[...id].astro +51 -0
- package/src/pages/ko/tags/[tag].astro +18 -0
- package/src/pages/ko/vendors/[vendor].astro +18 -0
- package/src/pages/sample/[folder].astro +16 -0
- package/src/pages/sample/index.astro +12 -0
- package/src/pages/slides/[deck].astro +14 -0
- package/src/pages/slides/index.astro +12 -0
- package/src/pages/stack/[...id].astro +51 -0
- package/src/pages/tags/[tag].astro +18 -0
- package/src/pages/vendors/[vendor].astro +18 -0
- package/src/styles/global.css +2350 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Dev-only Vite middleware. Serves samples/<folder>/ at /local-samples/ so the
|
|
3
|
+
// project viewer can offer a "view locally" link (the GitHub link only works
|
|
4
|
+
// once the branch is pushed). Directories render as a styled listing; files as
|
|
5
|
+
// Shiki-highlighted pages (?raw for plain text). A runnable sample folder also
|
|
6
|
+
// gets a "run" wizard: edit .env, set the task, then docker build + run with
|
|
7
|
+
// the output streamed back. Never active in `astro build` output.
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join, resolve, sep } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const CLIENT_JS = readFileSync(new URL('./local-samples-client.js', import.meta.url), 'utf8');
|
|
14
|
+
|
|
15
|
+
// Shared page chrome (theme tokens + breadcrumb), reused by every rendered page.
|
|
16
|
+
const BASE_CSS = `
|
|
17
|
+
:root { --bg:#f8f8f7; --panel:#fff; --border:#e4e4e2; --text:#1c1c1a; --muted:#78786f; --tint:#f0f0ee; --accent:#5b57e0; }
|
|
18
|
+
@media (prefers-color-scheme: dark) {
|
|
19
|
+
:root { --bg:#121212; --panel:#1c1c1c; --border:#2e2e2e; --text:#edede8; --muted:#9a9a90; --tint:#242424; --accent:#8f8bf5; }
|
|
20
|
+
}
|
|
21
|
+
* { box-sizing: border-box; margin: 0 }
|
|
22
|
+
body { background: var(--bg); color: var(--text); min-height: 100vh; padding: 3rem 1.25rem;
|
|
23
|
+
font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
24
|
+
main { max-width: 40rem; margin: 0 auto }
|
|
25
|
+
.crumbs { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95rem;
|
|
26
|
+
color: var(--muted); display: flex; flex-wrap: wrap; gap: .4rem; align-items: center }
|
|
27
|
+
.crumbs a { color: var(--muted); text-decoration: none }
|
|
28
|
+
.crumbs a:hover { color: var(--text); text-decoration: underline }
|
|
29
|
+
.crumbs b { color: var(--text) }
|
|
30
|
+
.badge { font-size: .7rem; letter-spacing: .05em; text-transform: uppercase; color: var(--muted);
|
|
31
|
+
border: 1px dashed var(--border); border-radius: .4rem; padding: .15rem .5rem }
|
|
32
|
+
.spacer { margin-left: auto }
|
|
33
|
+
.runbtn { display: inline-block; font: inherit; font-size: .85rem; cursor: pointer; color: #fff;
|
|
34
|
+
background: var(--accent); border: 0; border-radius: .5rem; padding: .45rem .85rem; text-decoration: none }
|
|
35
|
+
.runbtn:hover { filter: brightness(1.08) }`;
|
|
36
|
+
|
|
37
|
+
// The run wizard, rendered as a full page (own URL) rather than a modal.
|
|
38
|
+
const WIZARD_CSS = `
|
|
39
|
+
.runbar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap }
|
|
40
|
+
.runbar-lbl { font-size: .8rem; color: var(--muted) }
|
|
41
|
+
.runbar select { font: inherit; font-size: .85rem; padding: .4rem .6rem; border: 1px solid var(--border);
|
|
42
|
+
border-radius: .5rem; background: var(--panel); color: var(--text) }
|
|
43
|
+
.runbar select:disabled { opacity: .55; cursor: default }
|
|
44
|
+
.recipe-desc { margin-top: .45rem; font-size: .8rem; color: var(--muted) }
|
|
45
|
+
.wizard { margin-top: 1.1rem; border: 1px solid var(--border); border-radius: .9rem; padding: 1.25rem; background: var(--panel) }
|
|
46
|
+
.stepper { display: flex; gap: .5rem; font-size: .78rem; color: var(--muted); margin-bottom: 1rem; flex-wrap: wrap }
|
|
47
|
+
.stepper span { border: 1px solid var(--border); border-radius: 999px; padding: .2rem .7rem }
|
|
48
|
+
.stepper span.active { color: #fff; background: var(--accent); border-color: var(--accent) }
|
|
49
|
+
.desc, .lbl { font-size: .85rem; color: var(--muted) }
|
|
50
|
+
.lbl { display: block; margin: .85rem 0 .35rem }
|
|
51
|
+
.field { margin-top: .8rem }
|
|
52
|
+
.field label { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; font-weight: 600 }
|
|
53
|
+
.field .hint { font-size: .74rem; color: var(--muted); white-space: pre-line; margin-top: .15rem }
|
|
54
|
+
.inrow { display: flex; gap: .4rem; margin-top: .3rem }
|
|
55
|
+
.inrow input { flex: 1; min-width: 0; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
56
|
+
font-size: .85rem; padding: .45rem .6rem; border: 1px solid var(--border); border-radius: .45rem;
|
|
57
|
+
background: var(--bg); color: var(--text) }
|
|
58
|
+
.reveal { font: inherit; font-size: .72rem; cursor: pointer; color: var(--muted); background: var(--tint);
|
|
59
|
+
border: 1px solid var(--border); border-radius: .45rem; padding: 0 .6rem }
|
|
60
|
+
#task { width: 100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem;
|
|
61
|
+
line-height: 1.5; padding: .5rem .6rem; border: 1px solid var(--border); border-radius: .45rem;
|
|
62
|
+
background: var(--bg); color: var(--text); resize: none; overflow: hidden; min-height: 2.6rem }
|
|
63
|
+
.run-status { font-size: .8rem; margin-bottom: .5rem; min-height: 1.15em; color: var(--muted) }
|
|
64
|
+
.run-status.busy { color: var(--accent); animation: aas-pulse 1.1s ease-in-out infinite }
|
|
65
|
+
@keyframes aas-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .4 } }
|
|
66
|
+
.cmd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; line-height: 1.5;
|
|
67
|
+
background: var(--tint); border: 1px solid var(--border); border-radius: .5rem; padding: .7rem .8rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all }
|
|
68
|
+
.terminal { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; line-height: 1.5;
|
|
69
|
+
background: #0d1117; color: #d1d5da; border-radius: .5rem; padding: .9rem 1rem; min-height: 16rem; max-height: 55vh; overflow: auto; white-space: pre-wrap; word-break: break-word }
|
|
70
|
+
.actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: 1.1rem }
|
|
71
|
+
.actions .primary { font: inherit; font-size: .85rem; cursor: pointer; color: #fff; background: var(--accent); border: 0; border-radius: .5rem; padding: .45rem .9rem }
|
|
72
|
+
.actions .ghost { font: inherit; font-size: .85rem; cursor: pointer; color: var(--text); background: transparent; border: 1px solid var(--border); border-radius: .5rem; padding: .45rem .9rem }
|
|
73
|
+
.actions button:disabled { opacity: .5; cursor: default }`;
|
|
74
|
+
|
|
75
|
+
const FILE_ICON =
|
|
76
|
+
'<svg viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>';
|
|
77
|
+
const DIR_ICON =
|
|
78
|
+
'<svg viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>';
|
|
79
|
+
|
|
80
|
+
export function localSamples() {
|
|
81
|
+
const root = resolve('samples');
|
|
82
|
+
|
|
83
|
+
/** @param {string} name */
|
|
84
|
+
const hidden = (name) =>
|
|
85
|
+
name === '.env' || name === '__pycache__' || name === '.venv' || name.endsWith('.pyc');
|
|
86
|
+
|
|
87
|
+
/** @type {Promise<import('shiki').Highlighter> | null} */
|
|
88
|
+
let hlPromise = null;
|
|
89
|
+
const getHighlighter = async () => {
|
|
90
|
+
const { createHighlighter } = await import('shiki');
|
|
91
|
+
if (!hlPromise)
|
|
92
|
+
hlPromise = createHighlighter({
|
|
93
|
+
themes: ['github-dark'],
|
|
94
|
+
langs: ['python', 'typescript', 'javascript', 'bash', 'docker', 'markdown', 'json', 'yaml', 'toml'],
|
|
95
|
+
});
|
|
96
|
+
return hlPromise;
|
|
97
|
+
};
|
|
98
|
+
/** @param {string} name */
|
|
99
|
+
const langFor = (name) => {
|
|
100
|
+
if (name === 'Dockerfile' || name.endsWith('.dockerfile')) return 'docker';
|
|
101
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
102
|
+
return (
|
|
103
|
+
{ py: 'python', ts: 'typescript', js: 'javascript', md: 'markdown', yml: 'yaml', yaml: 'yaml', json: 'json', toml: 'toml', sh: 'bash', bash: 'bash', env: 'bash', sample: 'bash', gitignore: 'bash', txt: 'text' }[ext] ?? 'text'
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Run recipes offered for a sample: the built-in "default" (docker build +
|
|
108
|
+
// run) when there's a Dockerfile, plus one per run/<id>.sh — each script
|
|
109
|
+
// declaring its own display name/description via `# @name` / `# @desc` header
|
|
110
|
+
// comments. A script recipe can do anything (compose, multi-step, …).
|
|
111
|
+
/** @param {string} dir */
|
|
112
|
+
const listRecipes = (dir) => {
|
|
113
|
+
/** @type {{id:string,name:string,desc:string}[]} */
|
|
114
|
+
const recipes = [];
|
|
115
|
+
if (existsSync(join(dir, 'Dockerfile')))
|
|
116
|
+
recipes.push({ id: '__default', name: '기본 · docker build + run', desc: 'Dockerfile을 빌드해 detached로 실행하고 로그를 따라갑니다.' });
|
|
117
|
+
const runDir = join(dir, 'run');
|
|
118
|
+
if (existsSync(runDir) && statSync(runDir).isDirectory()) {
|
|
119
|
+
for (const f of readdirSync(runDir).filter((n) => n.endsWith('.sh')).sort()) {
|
|
120
|
+
const src = readFileSync(join(runDir, f), 'utf8');
|
|
121
|
+
const id = f.replace(/\.sh$/, '');
|
|
122
|
+
recipes.push({
|
|
123
|
+
id,
|
|
124
|
+
name: (src.match(/^#\s*@name\s+(.+)$/m)?.[1] ?? id).trim(),
|
|
125
|
+
desc: (src.match(/^#\s*@desc\s+(.+)$/m)?.[1] ?? '').trim(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return recipes;
|
|
130
|
+
};
|
|
131
|
+
// A runnable sample offers at least one recipe.
|
|
132
|
+
/** @param {string} dir */
|
|
133
|
+
const runnable = (dir) => listRecipes(dir).length > 0;
|
|
134
|
+
// Docker-out-of-Docker samples mount the host socket (the agent spawns sibling
|
|
135
|
+
// containers); detected from their own committed files, not hard-coded.
|
|
136
|
+
/** @param {string} dir */
|
|
137
|
+
const isDooD = (dir) =>
|
|
138
|
+
['Dockerfile', 'README.md'].some(
|
|
139
|
+
(f) => existsSync(join(dir, f)) && readFileSync(join(dir, f), 'utf8').includes('docker.sock'),
|
|
140
|
+
);
|
|
141
|
+
// A valid Docker image tag: lowercase, alphanumerics separated by single '-'
|
|
142
|
+
// (collapse any other run and trim edges, so odd folder names can't produce
|
|
143
|
+
// an invalid reference like a component starting with '_').
|
|
144
|
+
/** @param {string} folder */
|
|
145
|
+
const imageName = (folder) =>
|
|
146
|
+
('aas-sample-' + folder).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
147
|
+
|
|
148
|
+
// The .env.sample as an editable schema: one entry per KEY=value line, with
|
|
149
|
+
// the preceding comment block as a hint and secret keys flagged for masking.
|
|
150
|
+
/** @param {string} dir */
|
|
151
|
+
const parseEnvSample = (dir) => {
|
|
152
|
+
const p = join(dir, '.env.sample');
|
|
153
|
+
if (!existsSync(p)) return [];
|
|
154
|
+
/** @type {{key:string,value:string,comment:string,secret:boolean}[]} */
|
|
155
|
+
const out = [];
|
|
156
|
+
let comment = [];
|
|
157
|
+
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
|
158
|
+
const t = line.trim();
|
|
159
|
+
if (t.startsWith('#')) {
|
|
160
|
+
comment.push(t.replace(/^#\s?/, ''));
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const eq = t.indexOf('=');
|
|
164
|
+
if (!t || eq === -1) {
|
|
165
|
+
comment = [];
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const key = t.slice(0, eq).trim();
|
|
169
|
+
out.push({
|
|
170
|
+
key,
|
|
171
|
+
value: t.slice(eq + 1).trim(),
|
|
172
|
+
comment: comment.join('\n'),
|
|
173
|
+
secret: /(_KEY|TOKEN|SECRET|PASSWORD)$/i.test(key),
|
|
174
|
+
});
|
|
175
|
+
comment = [];
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
};
|
|
179
|
+
// Current .env values (for prefilling the form). {} when there's no .env yet.
|
|
180
|
+
/** @param {string} dir */
|
|
181
|
+
const readEnv = (dir) => {
|
|
182
|
+
const p = join(dir, '.env');
|
|
183
|
+
/** @type {Record<string,string>} */
|
|
184
|
+
const out = {};
|
|
185
|
+
if (!existsSync(p)) return out;
|
|
186
|
+
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
|
187
|
+
const t = line.trim();
|
|
188
|
+
const eq = t.indexOf('=');
|
|
189
|
+
if (!t || t.startsWith('#') || eq === -1) continue;
|
|
190
|
+
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
};
|
|
194
|
+
// Write .env by filling the .env.sample template with submitted values, so the
|
|
195
|
+
// sample's comments and structure carry over.
|
|
196
|
+
/** @param {string} dir @param {Record<string,string>} values */
|
|
197
|
+
const writeEnv = (dir, values) => {
|
|
198
|
+
const sample = join(dir, '.env.sample');
|
|
199
|
+
const src = existsSync(sample)
|
|
200
|
+
? readFileSync(sample, 'utf8')
|
|
201
|
+
: Object.keys(values).map((k) => k + '=').join('\n');
|
|
202
|
+
const out = src
|
|
203
|
+
.split('\n')
|
|
204
|
+
.map((line) => {
|
|
205
|
+
const t = line.trim();
|
|
206
|
+
const eq = t.indexOf('=');
|
|
207
|
+
if (!t || t.startsWith('#') || eq === -1) return line;
|
|
208
|
+
const key = t.slice(0, eq).trim();
|
|
209
|
+
return key in values ? key + '=' + (values[key] ?? '') : line;
|
|
210
|
+
})
|
|
211
|
+
.join('\n');
|
|
212
|
+
writeFileSync(join(dir, '.env'), out);
|
|
213
|
+
};
|
|
214
|
+
// The example task from the README's `docker run … <image> "<task>"` line.
|
|
215
|
+
/** @param {string} dir */
|
|
216
|
+
const defaultTask = (dir) => {
|
|
217
|
+
const p = join(dir, 'README.md');
|
|
218
|
+
if (!existsSync(p)) return '';
|
|
219
|
+
const m = readFileSync(p, 'utf8').match(/aas-[a-z0-9-]+\s*\\?\s*"([^"]+)"/);
|
|
220
|
+
return m ? m[1] : '';
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** @param {any} req */
|
|
224
|
+
const readBody = (req) =>
|
|
225
|
+
new Promise((res) => {
|
|
226
|
+
let d = '';
|
|
227
|
+
req.on('data', (/** @type {any} */ c) => (d += c));
|
|
228
|
+
req.on('end', () => res(d));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Stream one child process's command line + output to the response.
|
|
232
|
+
// The task/args are passed to spawn as an argv array (no shell), so the
|
|
233
|
+
// command line we echo is display-only. Quote args with spaces so the echoed
|
|
234
|
+
// line matches the preview and is copy-paste-safe into a shell.
|
|
235
|
+
/** @param {string} a */
|
|
236
|
+
const shellish = (a) => (a === '' || /[\s"]/.test(a) ? '"' + a.replace(/"/g, '\\"') + '"' : a);
|
|
237
|
+
// A run job: the process output accumulates in a buffer that outlives any
|
|
238
|
+
// single HTTP request. The client polls __run-log?job=<id>&from=<offset> for
|
|
239
|
+
// new output — the child keeps running regardless of who's polling, so a page
|
|
240
|
+
// refresh just resumes polling.
|
|
241
|
+
/** @type {Map<string, { id:string, buffer:string, done:boolean, write:(d:any)=>void, finish:()=>void }>} */
|
|
242
|
+
const jobs = new Map();
|
|
243
|
+
const createJob = () => {
|
|
244
|
+
const id = randomUUID().slice(0, 12);
|
|
245
|
+
const job = {
|
|
246
|
+
id,
|
|
247
|
+
buffer: '',
|
|
248
|
+
done: false,
|
|
249
|
+
/** @param {any} d */
|
|
250
|
+
write(d) {
|
|
251
|
+
job.buffer += typeof d === 'string' ? d : d.toString();
|
|
252
|
+
},
|
|
253
|
+
finish() {
|
|
254
|
+
job.done = true;
|
|
255
|
+
// Keep the finished job around briefly so a client coming back can still
|
|
256
|
+
// fetch the full log, then evict.
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
if (jobs.get(id) === job) jobs.delete(id);
|
|
259
|
+
}, 5 * 60 * 1000);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
jobs.set(id, job);
|
|
263
|
+
return job;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** @param {{write:(d:any)=>void}} sink @param {string} cmd @param {string[]} args @param {{cwd?:string}} [opts] */
|
|
267
|
+
const runStep = (sink, cmd, args, opts = {}) =>
|
|
268
|
+
new Promise((resolve) => {
|
|
269
|
+
sink.write('$ ' + cmd + ' ' + args.map(shellish).join(' ') + '\n');
|
|
270
|
+
let child;
|
|
271
|
+
try {
|
|
272
|
+
child = spawn(cmd, args, opts);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
sink.write('[spawn error] ' + (/** @type {Error} */ (e)).message + '\n');
|
|
275
|
+
return resolve(1);
|
|
276
|
+
}
|
|
277
|
+
child.stdout.on('data', (d) => sink.write(d));
|
|
278
|
+
child.stderr.on('data', (d) => sink.write(d));
|
|
279
|
+
child.on('error', (e) => {
|
|
280
|
+
sink.write('\n[error] ' + e.message + '\n');
|
|
281
|
+
resolve(1);
|
|
282
|
+
});
|
|
283
|
+
child.on('close', (code) => {
|
|
284
|
+
sink.write('\n[exit ' + code + ']\n\n');
|
|
285
|
+
resolve(code);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Detached run: under nested Docker-outside-of-Docker (this repo's
|
|
290
|
+
// devcontainer) a foreground `docker run` often prints nothing and still
|
|
291
|
+
// exits 0 — the attach stream drops the output at the VM boundary. So, as the
|
|
292
|
+
// sample READMEs prescribe, run every sample detached, follow `docker logs
|
|
293
|
+
// -f`, then remove the container. `--rm` is dropped so the log survives to be
|
|
294
|
+
// read. DooD samples additionally mount the host Docker socket so the agent
|
|
295
|
+
// can spawn sibling containers.
|
|
296
|
+
/** @param {{write:(d:any)=>void}} sink @param {string} image @param {string} envPath @param {string} task @param {boolean} mountSock */
|
|
297
|
+
const runDetached = (sink, image, envPath, task, mountSock) =>
|
|
298
|
+
new Promise((resolve) => {
|
|
299
|
+
const args = ['run', '-d', '--env-file', envPath];
|
|
300
|
+
if (mountSock) args.push('-v', '/var/run/docker.sock:/var/run/docker.sock');
|
|
301
|
+
args.push(image);
|
|
302
|
+
if (task) args.push(task);
|
|
303
|
+
sink.write('$ ' + ['docker', ...args].map(shellish).join(' ') + '\n');
|
|
304
|
+
let id = '';
|
|
305
|
+
const run = spawn('docker', args);
|
|
306
|
+
run.stdout.on('data', (d) => (id += d.toString()));
|
|
307
|
+
run.stderr.on('data', (d) => sink.write(d));
|
|
308
|
+
run.on('error', (e) => {
|
|
309
|
+
sink.write('\n[error] ' + e.message + '\n');
|
|
310
|
+
resolve(1);
|
|
311
|
+
});
|
|
312
|
+
run.on('close', (code) => {
|
|
313
|
+
id = id.trim();
|
|
314
|
+
if (code !== 0 || !id) {
|
|
315
|
+
sink.write('\n[exit ' + code + ']\n\n');
|
|
316
|
+
return resolve(code || 1);
|
|
317
|
+
}
|
|
318
|
+
sink.write('컨테이너 ' + id.slice(0, 12) + ' — 로그를 따라갑니다\n\n');
|
|
319
|
+
runStep(sink, 'docker', ['logs', '-f', id]).then((lc) => {
|
|
320
|
+
const rm = spawn('docker', ['rm', '-f', id]);
|
|
321
|
+
rm.on('close', () => resolve(lc));
|
|
322
|
+
rm.on('error', () => resolve(lc));
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/** @param {number} n */
|
|
328
|
+
const fmtSize = (n) =>
|
|
329
|
+
n < 1024 ? n + ' B' : n < 1024 * 1024 ? (n / 1024).toFixed(1) + ' KB' : (n / 1024 / 1024).toFixed(1) + ' MB';
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
name: 'aas-local-samples',
|
|
333
|
+
apply: /** @type {const} */ ('serve'),
|
|
334
|
+
/** @param {any} server */
|
|
335
|
+
configureServer(server) {
|
|
336
|
+
server.middlewares.use(async (/** @type {any} */ req, /** @type {any} */ res, /** @type {any} */ next) => {
|
|
337
|
+
const m = (req.url || '').match(/\/local-samples\/(.*)$/);
|
|
338
|
+
if (!m) return next();
|
|
339
|
+
const rel = decodeURIComponent(m[1].split('?')[0]);
|
|
340
|
+
|
|
341
|
+
// Dedicated run page: /local-samples/<folder>/__run-ui (own URL, so a
|
|
342
|
+
// refresh naturally reloads the wizard and it can open in a new tab).
|
|
343
|
+
const uiMatch = rel.match(/^(.+)\/__run-ui$/);
|
|
344
|
+
if (uiMatch) {
|
|
345
|
+
const dir = resolve(root, uiMatch[1]);
|
|
346
|
+
if (
|
|
347
|
+
(dir !== root && !dir.startsWith(root + sep)) ||
|
|
348
|
+
!existsSync(dir) ||
|
|
349
|
+
!statSync(dir).isDirectory() ||
|
|
350
|
+
!runnable(dir)
|
|
351
|
+
) {
|
|
352
|
+
res.statusCode = 404;
|
|
353
|
+
return res.end('not found');
|
|
354
|
+
}
|
|
355
|
+
const folder = uiMatch[1].split('/').pop() || uiMatch[1];
|
|
356
|
+
const uiParts = uiMatch[1].split('/').filter(Boolean);
|
|
357
|
+
const recipes = listRecipes(dir);
|
|
358
|
+
const params = new URLSearchParams((req.url || '').split('?')[1] || '');
|
|
359
|
+
const wantRecipe = params.get('recipe');
|
|
360
|
+
const selected = recipes.some((r) => r.id === wantRecipe) ? wantRecipe : recipes[0].id;
|
|
361
|
+
const runData = {
|
|
362
|
+
folder,
|
|
363
|
+
image: imageName(folder),
|
|
364
|
+
dood: isDooD(dir),
|
|
365
|
+
defaultTask: defaultTask(dir),
|
|
366
|
+
env: parseEnvSample(dir),
|
|
367
|
+
values: readEnv(dir),
|
|
368
|
+
recipes,
|
|
369
|
+
};
|
|
370
|
+
const dataJson = JSON.stringify(runData).replace(/</g, '\\u003c');
|
|
371
|
+
const esc = (/** @type {string} */ s) =>
|
|
372
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
373
|
+
const options = recipes
|
|
374
|
+
.map((r) => `<option value="${esc(r.id)}"${r.id === selected ? ' selected' : ''}>${esc(r.name)}</option>`)
|
|
375
|
+
.join('');
|
|
376
|
+
const crumbs = [
|
|
377
|
+
`<a href="${'../'.repeat(uiParts.length)}">samples</a>`,
|
|
378
|
+
...uiParts.map((p, i) => {
|
|
379
|
+
const up = uiParts.length - 1 - i;
|
|
380
|
+
return `<a href="${up === 0 ? './' : '../'.repeat(up)}">${p}</a>`;
|
|
381
|
+
}),
|
|
382
|
+
`<b>실행</b>`,
|
|
383
|
+
].join('<span>/</span>');
|
|
384
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
385
|
+
return res.end(`<!doctype html><html><head><meta charset="utf-8">
|
|
386
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
387
|
+
<title>samples/${folder} · 실행</title>
|
|
388
|
+
<style>${BASE_CSS}${WIZARD_CSS}
|
|
389
|
+
main { max-width: 48rem }
|
|
390
|
+
</style></head><body><main>
|
|
391
|
+
<div class="crumbs">${crumbs}<span class="spacer"></span><a href="./">← 목록</a><span class="badge">dev only</span></div>
|
|
392
|
+
<div class="runbar"><label class="runbar-lbl">레시피</label><select id="recipe">${options}</select></div>
|
|
393
|
+
<p id="recipe-desc" class="recipe-desc"></p>
|
|
394
|
+
<div class="wizard">
|
|
395
|
+
<div class="stepper"><span data-step="1" class="active">1 · 환경 변수</span><span data-step="2">2 · 실행 인자</span><span data-step="3">3 · 실행 · 결과</span></div>
|
|
396
|
+
<section data-panel="1"><p class="desc">컨테이너에 넘길 <code>.env</code> 값입니다. 저장하면 <code>samples/${folder}/.env</code> 에 기록됩니다.</p><div id="env-fields"></div><div class="actions"><button data-next class="primary">저장하고 다음</button></div></section>
|
|
397
|
+
<section data-panel="2" hidden><label class="lbl">실행 인자 (task)</label><textarea id="task" rows="2"></textarea><label class="lbl">실행될 명령</label><pre id="cmd-preview" class="cmd"></pre><div class="actions"><button data-back1 class="ghost">이전</button><button data-run class="primary">실행 ▶</button></div></section>
|
|
398
|
+
<section data-panel="3" hidden><div id="run-status" class="run-status"></div><pre id="run-log" class="terminal"></pre><div class="actions"><button data-back2 class="ghost">이전</button><button data-run class="primary">다시 실행</button></div></section>
|
|
399
|
+
</div>
|
|
400
|
+
<script type="application/json" id="run-data">${dataJson}</script><script>${CLIENT_JS}</script>
|
|
401
|
+
</main></body></html>`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Run-wizard endpoints: /local-samples/<folder>/__env|__run (POST).
|
|
405
|
+
const action = rel.match(/^(.+)\/__(env|run)$/);
|
|
406
|
+
if (action) {
|
|
407
|
+
const dir = resolve(root, action[1]);
|
|
408
|
+
if (
|
|
409
|
+
(dir !== root && !dir.startsWith(root + sep)) ||
|
|
410
|
+
!existsSync(dir) ||
|
|
411
|
+
!statSync(dir).isDirectory() ||
|
|
412
|
+
req.method !== 'POST'
|
|
413
|
+
) {
|
|
414
|
+
res.statusCode = 404;
|
|
415
|
+
return res.end('not found');
|
|
416
|
+
}
|
|
417
|
+
const body = await readBody(req);
|
|
418
|
+
if (action[2] === 'env') {
|
|
419
|
+
try {
|
|
420
|
+
writeEnv(dir, JSON.parse(String(body) || '{}'));
|
|
421
|
+
res.setHeader('Content-Type', 'application/json');
|
|
422
|
+
return res.end('{"ok":true}');
|
|
423
|
+
} catch {
|
|
424
|
+
res.statusCode = 400;
|
|
425
|
+
return res.end('{"ok":false}');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// __run: start the chosen recipe as a background job and return its id.
|
|
429
|
+
// The client then POLLS __run-log for output. Polling (short requests)
|
|
430
|
+
// survives a flaky proxy / port forward far better than one long-open
|
|
431
|
+
// streaming response, and makes reconnect-after-refresh trivial.
|
|
432
|
+
let task = '';
|
|
433
|
+
let recipe = '__default';
|
|
434
|
+
try {
|
|
435
|
+
const b = JSON.parse(String(body) || '{}');
|
|
436
|
+
task = String(b.task || '');
|
|
437
|
+
recipe = String(b.recipe || '__default');
|
|
438
|
+
} catch {}
|
|
439
|
+
|
|
440
|
+
const job = createJob();
|
|
441
|
+
const folder = action[1].split('/').pop() || action[1];
|
|
442
|
+
// Run in the background; the handler returns immediately with the id.
|
|
443
|
+
(async () => {
|
|
444
|
+
try {
|
|
445
|
+
if (recipe !== '__default') {
|
|
446
|
+
// Custom recipe run/<id>.sh, cwd = sample dir so relative paths
|
|
447
|
+
// (.env, compose.yml, …) resolve. Id validated — no traversal.
|
|
448
|
+
if (!/^[a-z0-9_-]+$/i.test(recipe) || !existsSync(join(dir, 'run', recipe + '.sh'))) {
|
|
449
|
+
job.write('[error] 알 수 없는 레시피: ' + recipe + '\n');
|
|
450
|
+
} else {
|
|
451
|
+
const args = ['run/' + recipe + '.sh'];
|
|
452
|
+
if (task) args.push(task);
|
|
453
|
+
await runStep(job, 'bash', args, { cwd: dir });
|
|
454
|
+
}
|
|
455
|
+
} else if (!existsSync(join(dir, '.env'))) {
|
|
456
|
+
job.write('[error] .env가 없습니다. 먼저 환경 변수를 저장하세요.\n');
|
|
457
|
+
} else {
|
|
458
|
+
const image = imageName(folder);
|
|
459
|
+
const envPath = join(dir, '.env');
|
|
460
|
+
const buildCode = await runStep(job, 'docker', ['build', '-t', image, dir]);
|
|
461
|
+
// Always run detached + `docker logs -f`: a foreground run loses
|
|
462
|
+
// its output under this repo's nested Docker (see runDetached).
|
|
463
|
+
// Only DooD samples also mount the host socket.
|
|
464
|
+
if (buildCode === 0) await runDetached(job, image, envPath, task, isDooD(dir));
|
|
465
|
+
}
|
|
466
|
+
} catch (e) {
|
|
467
|
+
job.write('\n[error] ' + (/** @type {Error} */ (e)).message + '\n');
|
|
468
|
+
}
|
|
469
|
+
job.finish();
|
|
470
|
+
})();
|
|
471
|
+
|
|
472
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
473
|
+
res.setHeader('X-Run-Job', job.id);
|
|
474
|
+
return res.end(JSON.stringify({ job: job.id }));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Poll a job's output: returns the buffer from ?from=<offset> plus the
|
|
478
|
+
// total length and done flag. Short requests → robust through a proxy.
|
|
479
|
+
const logMatch = rel.match(/^(.+)\/__run-log$/);
|
|
480
|
+
if (logMatch) {
|
|
481
|
+
const params = new URLSearchParams((req.url || '').split('?')[1] || '');
|
|
482
|
+
const id = params.get('job') || '';
|
|
483
|
+
const from = Number.parseInt(params.get('from') || '0', 10) || 0;
|
|
484
|
+
const job = jobs.get(id);
|
|
485
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
486
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
487
|
+
if (!job) {
|
|
488
|
+
res.statusCode = 404;
|
|
489
|
+
return res.end(JSON.stringify({ error: 'gone' }));
|
|
490
|
+
}
|
|
491
|
+
return res.end(
|
|
492
|
+
JSON.stringify({ text: job.buffer.slice(from), len: job.buffer.length, done: job.done }),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const target = resolve(root, rel);
|
|
497
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
498
|
+
res.statusCode = 403;
|
|
499
|
+
return res.end('forbidden');
|
|
500
|
+
}
|
|
501
|
+
if (rel.split('/').some((part) => part && hidden(part)) || !existsSync(target)) {
|
|
502
|
+
res.statusCode = 404;
|
|
503
|
+
return res.end('not found');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const parts = rel.split('/').filter(Boolean);
|
|
507
|
+
|
|
508
|
+
if (statSync(target).isDirectory()) {
|
|
509
|
+
if (!req.url?.split('?')[0].endsWith('/')) {
|
|
510
|
+
res.statusCode = 301;
|
|
511
|
+
res.setHeader('Location', req.url?.split('?')[0] + '/');
|
|
512
|
+
return res.end();
|
|
513
|
+
}
|
|
514
|
+
const entries = readdirSync(target, { withFileTypes: true })
|
|
515
|
+
.filter((e) => !hidden(e.name))
|
|
516
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
517
|
+
const rows = entries
|
|
518
|
+
.map((e) => {
|
|
519
|
+
const dir = e.isDirectory();
|
|
520
|
+
const href = encodeURIComponent(e.name) + (dir ? '/' : '');
|
|
521
|
+
const size = dir ? '' : fmtSize(statSync(resolve(target, e.name)).size);
|
|
522
|
+
return `<li><a href="${href}">${dir ? DIR_ICON : FILE_ICON}<span class="name">${e.name}${dir ? '/' : ''}</span><span class="size">${size}</span></a></li>`;
|
|
523
|
+
})
|
|
524
|
+
.join('');
|
|
525
|
+
const crumbs = [
|
|
526
|
+
`<a href="${'../'.repeat(parts.length)}">samples</a>`,
|
|
527
|
+
...parts.map((p, i) =>
|
|
528
|
+
i === parts.length - 1 ? `<b>${p}</b>` : `<a href="${'../'.repeat(parts.length - 1 - i)}">${p}</a>`,
|
|
529
|
+
),
|
|
530
|
+
].join('<span>/</span>');
|
|
531
|
+
|
|
532
|
+
// A runnable sample folder gets a link to its dedicated run page,
|
|
533
|
+
// opened in a new tab (rel=noopener → fresh sessionStorage, so each
|
|
534
|
+
// run starts clean and doesn't inherit a prior tab's finished state).
|
|
535
|
+
const runLink = runnable(target)
|
|
536
|
+
? `<div style="margin-top:1rem"><a class="runbtn" href="__run-ui" target="_blank" rel="noopener">▶ 실행 ↗</a></div>`
|
|
537
|
+
: '';
|
|
538
|
+
|
|
539
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
540
|
+
return res.end(`<!doctype html><html><head><meta charset="utf-8">
|
|
541
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
542
|
+
<title>samples/${rel}</title>
|
|
543
|
+
<style>${BASE_CSS}
|
|
544
|
+
ul { list-style: none; margin-top: 1rem; border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); overflow: hidden; padding: .3rem }
|
|
545
|
+
li + li { margin-top: 2px }
|
|
546
|
+
li a { display: flex; align-items: center; gap: .65rem; padding: .55rem .75rem; border-radius: .5rem; color: var(--text); text-decoration: none; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem }
|
|
547
|
+
li a:hover { background: var(--tint) }
|
|
548
|
+
li svg { width: 15px; height: 15px; flex: none; fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round }
|
|
549
|
+
.name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
550
|
+
.size { margin-left: auto; flex: none; font-size: .75rem; color: var(--muted) }
|
|
551
|
+
</style></head><body><main>
|
|
552
|
+
<div class="crumbs">${crumbs}<span class="spacer"></span><span class="badge">dev only</span></div>
|
|
553
|
+
${runLink}
|
|
554
|
+
<ul>${rows}</ul>
|
|
555
|
+
</main></body></html>`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// File view: ?raw serves the bytes, otherwise a Shiki-highlighted page.
|
|
559
|
+
if ((req.url || '').includes('?raw')) {
|
|
560
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
561
|
+
return res.end(readFileSync(target));
|
|
562
|
+
}
|
|
563
|
+
const name = parts[parts.length - 1] ?? rel;
|
|
564
|
+
const hl = await getHighlighter();
|
|
565
|
+
const lang = langFor(name);
|
|
566
|
+
const safeLang = hl.getLoadedLanguages().includes(lang) ? lang : 'text';
|
|
567
|
+
const codeHtml = hl.codeToHtml(readFileSync(target, 'utf8'), { lang: safeLang, theme: 'github-dark' });
|
|
568
|
+
// File URLs have no trailing slash, so hrefs resolve against the parent.
|
|
569
|
+
const up = (/** @type {number} */ n) => (n <= 0 ? './' : '../'.repeat(n));
|
|
570
|
+
const crumbs = [
|
|
571
|
+
`<a href="${up(parts.length - 1)}">samples</a>`,
|
|
572
|
+
...parts.map((p, i) =>
|
|
573
|
+
i === parts.length - 1 ? `<b>${p}</b>` : `<a href="${up(parts.length - 2 - i)}">${p}</a>`,
|
|
574
|
+
),
|
|
575
|
+
].join('<span>/</span>');
|
|
576
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
577
|
+
return res.end(`<!doctype html><html><head><meta charset="utf-8">
|
|
578
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
579
|
+
<title>samples/${rel}</title>
|
|
580
|
+
<style>${BASE_CSS}
|
|
581
|
+
main { max-width: 56rem }
|
|
582
|
+
.panel { margin-top: 1rem; border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); overflow: hidden }
|
|
583
|
+
.head { display: flex; align-items: center; gap: .75rem; padding: .6rem .9rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--muted) }
|
|
584
|
+
.head a { margin-left: auto; color: var(--muted); text-decoration: none; border: 1px solid var(--border); border-radius: .4rem; padding: .1rem .5rem }
|
|
585
|
+
.head a:hover { color: var(--text) }
|
|
586
|
+
pre.shiki { margin: 0; padding: 1rem 1.1rem; overflow-x: auto; font-size: .875rem; line-height: 1.6 }
|
|
587
|
+
</style></head><body><main>
|
|
588
|
+
<div class="crumbs">${crumbs}<span class="spacer"></span><span class="badge">dev only</span></div>
|
|
589
|
+
<div class="panel"><div class="head">${name}<a href="?raw">raw</a></div>${codeHtml}</div>
|
|
590
|
+
</main></body></html>`);
|
|
591
|
+
});
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro';
|
|
2
|
+
|
|
3
|
+
export interface AasThemeOptions {
|
|
4
|
+
/** The site's glossary (`src/data/glossary.mjs`) — `[[wikilink]]` targets. */
|
|
5
|
+
glossary: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The awesome-*-stack theme: injects every route in both locales and wires
|
|
10
|
+
* the markdown pipeline, tailwind and dev middleware. See index.mjs.
|
|
11
|
+
*/
|
|
12
|
+
export default function aasTheme(opts: AasThemeOptions): AstroIntegration[];
|