@viglet/create-shio-app 2026.3.2

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/src/emit.mjs ADDED
@@ -0,0 +1,292 @@
1
+ /*
2
+ * Copyright (C) 2016-2026 the original author or authors.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0.
5
+ *
6
+ * SH119 — generate the `nextjs-starter` blueprint package from this package's
7
+ * `template/` tree, so there is one starter with two ways to get it: offline
8
+ * (`npm create shio-app`, which copies the tree) and from an instance
9
+ * (`shio init --blueprint nextjs-starter`, which downloads the package's `files`).
10
+ *
11
+ * SH156 — and generate the section-vocabulary packages' front-end half the same way, as
12
+ * **the same tree plus an overlay**. That is the answer to "how do packages share a tree
13
+ * instead of each carrying a copy": they each carry a copy and *nobody writes one*. A
14
+ * hand-maintained second starter would drift within a release; a generated one cannot,
15
+ * because `test/emit.test.mjs` fails the build the moment it disagrees with its sources.
16
+ *
17
+ * Why generate rather than share. A running Shio reads blueprints off the classpath
18
+ * and cannot run a JS build, so the bytes have to exist under
19
+ * `shio-app/src/main/resources/blueprints/`. That is a second copy by necessity, which
20
+ * is precisely why the drift test exists — the same discipline `shio-sections`' `emit`
21
+ * uses for post-type defs.
22
+ *
23
+ * Run: `pnpm -C create-shio-app run emit`
24
+ */
25
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
26
+ import { existsSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+
30
+ import { rangeFor, unpublished } from "./published.mjs";
31
+ import { readTemplateFiles } from "./scaffold.mjs";
32
+
33
+ /** Where the blueprint packages live, relative to this file. */
34
+ const BLUEPRINTS = fileURLToPath(
35
+ new URL("../../shio-app/src/main/resources/blueprints/", import.meta.url)
36
+ );
37
+
38
+ /**
39
+ * The section vocabulary's front-end half, authored once (SH156).
40
+ *
41
+ * <p>It lives in `shio-sections` rather than here because the vocabulary owns how it
42
+ * renders — the same argument that puts its post-type defs in `src/post-types.ts`. This
43
+ * package owns the *starter*, that one owns the *sections*, and `emit` is where they
44
+ * meet.</p>
45
+ */
46
+ const SECTIONS_OVERLAY = fileURLToPath(new URL("../../shio-sections/front-end/", import.meta.url));
47
+
48
+ /**
49
+ * A package's own index page, one directory per package (SH216).
50
+ *
51
+ * <h2>Why these live here and the section overlay does not</h2>
52
+ *
53
+ * <p>The section overlay lives in `shio-sections` because <b>the vocabulary owns how it
54
+ * renders</b> — the same argument that puts its post-type defs in `src/post-types.ts`. These have
55
+ * no vocabulary. `blog`, `docs` and `product-catalog` are ordinary posts with an HTML field, and
56
+ * what each is missing is one page the starter cannot provide because it is about the *model*
57
+ * rather than about any page: the posts newest first, the documentation tree, the catalogue grid.
58
+ * That belongs to whoever owns the starter, which is this package.</p>
59
+ *
60
+ * <p><b>Not beside the blueprint</b>, which was the tempting answer: `blueprints/<name>/files/` is
61
+ * the tree `emit` <em>writes</em>, so a source overlay in the same directory would put input and
62
+ * output in one place and make the drift test compare a thing with itself.</p>
63
+ */
64
+ const packageOverlay = (name) =>
65
+ fileURLToPath(new URL(`../overlays/${name}/`, import.meta.url));
66
+
67
+ /** `@viglet/shio-sections`' own package.json — the version the overlay depends on. */
68
+ const SECTIONS_MANIFEST = fileURLToPath(
69
+ new URL("../../shio-sections/package.json", import.meta.url)
70
+ );
71
+
72
+ /**
73
+ * Every package generated from the starter tree.
74
+ *
75
+ * <p>Named explicitly rather than globbed, for the same reason `shio-sections`' target
76
+ * list is: a package that stops carrying a front end is a decision, and it should look
77
+ * like one here.</p>
78
+ *
79
+ * <p><b>`blog`, `docs` and `product-catalog` carry an index page instead of the section
80
+ * overlay (SH216).</b> They do not compose sections — their models are ordinary posts with an
81
+ * HTML field, which the starter's catch-all already renders — so the section tree would ship a
82
+ * dependency they never import and let three packages claim a front end that renders their pages
83
+ * no better than the plain starter does. What each was actually missing is one page the starter
84
+ * cannot provide because it is about the <em>model</em>: a post list at `/blog`, a navigation tree
85
+ * at `/docs`, a grid at `/products`. Three shapes, which is exactly why it is three overlays and
86
+ * not one.</p>
87
+ */
88
+ export const PACKAGES = [
89
+ { name: "nextjs-starter", overlay: null },
90
+ { name: "marketing-site", overlay: SECTIONS_OVERLAY, needs: ["@viglet/shio-sections"] },
91
+ { name: "landing", overlay: SECTIONS_OVERLAY, needs: ["@viglet/shio-sections"] },
92
+ // No `needs`: an index page uses the SDK the starter already depends on. That is the whole
93
+ // distinction from the section overlay, which brings a vocabulary with it.
94
+ { name: "blog", overlay: packageOverlay("blog") },
95
+ { name: "docs", overlay: packageOverlay("docs") },
96
+ { name: "product-catalog", overlay: packageOverlay("product-catalog") },
97
+ ];
98
+
99
+ /** Where the carried tree lives inside a package. */
100
+ const FILES_SUBDIR = "files";
101
+
102
+ /** The starter's package directory — kept as a named export for SH119's callers. */
103
+ export const PACKAGE_DIR = join(BLUEPRINTS, "nextjs-starter/");
104
+
105
+ /**
106
+ * The manifest's `files` refs for a carried tree.
107
+ *
108
+ * <p>`path` is where the file goes in the caller's project; `from` is where its bytes
109
+ * live in the package. Both are always stated: the tree lives under `files/` so the
110
+ * package root stays readable (manifest, model, content, tree), and the two names differ
111
+ * for dotfiles anyway — a leading dot is stripped inside the package, which is the same
112
+ * dodge the npm template uses for `gitignore` and which the `path` now undoes *in the
113
+ * format* rather than in each consumer's code.</p>
114
+ *
115
+ * @param {Array<{path: string, text: string}>} files
116
+ */
117
+ export function fileRefs(files) {
118
+ return files.map((file) => ({ path: file.path, from: sourcePath(file) }));
119
+ }
120
+
121
+ /** Where a row's bytes are written inside the package. */
122
+ export function sourcePath(file) {
123
+ return `${FILES_SUBDIR}/${file.path.replace(/^\./, "")}`;
124
+ }
125
+
126
+ /** Read an overlay directory as the same `{path, text}` rows the template produces. */
127
+ export async function readOverlay(dir) {
128
+ if (!dir || !existsSync(dir)) return [];
129
+ const rows = [];
130
+ for (const rel of await listFiles(dir)) {
131
+ rows.push({ path: rel, text: await readFile(join(dir, rel), "utf8") });
132
+ }
133
+ return rows.sort((a, b) => a.path.localeCompare(b.path));
134
+ }
135
+
136
+ async function listFiles(dir, prefix = "") {
137
+ const out = [];
138
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
139
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
140
+ if (entry.isDirectory()) {
141
+ out.push(...(await listFiles(join(dir, entry.name), rel)));
142
+ } else {
143
+ out.push(rel);
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * The tree one package carries: the starter, with the overlay's files replacing or
151
+ * joining it, and the dependencies the overlay needs added to `package.json`.
152
+ *
153
+ * <p>Replace-by-path is what keeps the overlay small. The section-aware catch-all route
154
+ * is the starter's route plus one branch, so it <em>is</em> `app/[...slug]/page.tsx`
155
+ * rather than a second route the project would have to be told about.</p>
156
+ *
157
+ * @param {Array<{path: string, text: string}>} base the starter tree
158
+ * @param {Array<{path: string, text: string}>} overlay files that replace or join it
159
+ * @param {Record<string, string>} dependencies added to the project's package.json
160
+ */
161
+ export function compose(base, overlay, dependencies = {}) {
162
+ const byPath = new Map(base.map((file) => [file.path, file]));
163
+ for (const file of overlay) {
164
+ byPath.set(file.path, file);
165
+ }
166
+ if (Object.keys(dependencies).length > 0) {
167
+ const manifest = byPath.get("package.json");
168
+ if (!manifest) {
169
+ throw new Error("emit: the starter tree has no package.json to add dependencies to");
170
+ }
171
+ const parsed = JSON.parse(manifest.text);
172
+ // Sorted, so adding one dependency does not reorder the ones already there and the
173
+ // generated diff is the change and nothing else (P5).
174
+ parsed.dependencies = Object.fromEntries(
175
+ Object.entries({ ...parsed.dependencies, ...dependencies }).sort(([a], [b]) =>
176
+ a.localeCompare(b)
177
+ )
178
+ );
179
+ byPath.set("package.json", {
180
+ path: "package.json",
181
+ text: `${JSON.stringify(parsed, null, 2)}\n`,
182
+ });
183
+ }
184
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
185
+ }
186
+
187
+ /**
188
+ * The dependency ranges an overlay's `needs` resolve to.
189
+ *
190
+ * <h3>The comment that used to be here was exactly backwards (SH446)</h3>
191
+ *
192
+ * <p>It read: "read from the workspace rather than written down here, so a release that bumps
193
+ * `@viglet/shio-sections` cannot leave the generated packages pointing at a version nobody
194
+ * published." Reading it from the workspace is what <em>guarantees</em> pointing at a version nobody
195
+ * published — the workspace is the version being <em>built</em>, and it is installable only in the
196
+ * instant after a release. Measured on `@viglet/shio-client`, whose template range was derived the
197
+ * same way: `^2026.3.1` against a registry that had `2026.2.1`, so `npm install` in a fresh scaffold
198
+ * failed `ETARGET` and nothing in the repository noticed, because nothing installs from the
199
+ * registry.</p>
200
+ *
201
+ * <p>So the published range wins when there is one ({@link rangeFor}), and the workspace's is the
202
+ * fallback for a package that has never been published at all — where no range is correct and
203
+ * writing *something* at least leaves a project that is right the day the release runs.
204
+ * {@link unpublishedNeeds} is what reports that state, and `emit` prints it.</p>
205
+ */
206
+ export async function resolveNeeds(needs) {
207
+ if (!needs || needs.length === 0) return {};
208
+ const { version } = JSON.parse(await readFile(SECTIONS_MANIFEST, "utf8"));
209
+ return Object.fromEntries(needs.map((name) => [name, rangeFor(name) ?? `^${version}`]));
210
+ }
211
+
212
+ /** The `needs` across every package that name something the registry does not have (SH446). */
213
+ export function unpublishedNeeds() {
214
+ return unpublished([...new Set(PACKAGES.flatMap((pkg) => pkg.needs ?? []))]);
215
+ }
216
+
217
+ /** The whole tree one entry of {@link PACKAGES} should carry. */
218
+ export async function filesFor(pkg, base) {
219
+ const template = base ?? (await readTemplateFiles());
220
+ if (!pkg.overlay) return template;
221
+ return compose(template, await readOverlay(pkg.overlay), await resolveNeeds(pkg.needs));
222
+ }
223
+
224
+ /**
225
+ * Read a package's manifest with its `files` list replaced by what its tree currently
226
+ * holds — the shape `emit` would write. Pure, so the drift test can compare it against
227
+ * what is on disk without touching the filesystem twice.
228
+ */
229
+ export async function expectedManifest(files, name = "nextjs-starter") {
230
+ const manifest = JSON.parse(await readFile(join(BLUEPRINTS, name, "blueprint.json"), "utf8"));
231
+ return { ...manifest, files: fileRefs(files) };
232
+ }
233
+
234
+ /** The bytes a package should carry, keyed by package-relative path. */
235
+ export function expectedSources(files) {
236
+ return new Map(files.map((file) => [sourcePath(file), file.text]));
237
+ }
238
+
239
+ export async function emit({ log = console.log } = {}) {
240
+ const base = await readTemplateFiles();
241
+ const emitted = {};
242
+
243
+ for (const pkg of PACKAGES) {
244
+ const dir = join(BLUEPRINTS, pkg.name);
245
+ if (!existsSync(dir)) {
246
+ throw new Error(
247
+ `emit: the ${pkg.name} blueprint package is missing at ${dir}. ` +
248
+ "The manifest is hand-authored (it declares the params and the promise); only " +
249
+ "`files` and the carried tree are generated."
250
+ );
251
+ }
252
+ const files = await filesFor(pkg, base);
253
+
254
+ // Rewritten wholesale so a file deleted from the template disappears here too — a
255
+ // stale leftover would be shipped to every project that applies the package.
256
+ await rm(join(dir, FILES_SUBDIR), { recursive: true, force: true });
257
+ for (const file of files) {
258
+ const target = join(dir, sourcePath(file));
259
+ await mkdir(dirname(target), { recursive: true });
260
+ await writeFile(target, file.text, "utf8");
261
+ }
262
+
263
+ const manifest = await expectedManifest(files, pkg.name);
264
+ await writeFile(join(dir, "blueprint.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
265
+
266
+ log(`✓ emitted ${files.length} file(s) into blueprints/${pkg.name}`);
267
+ emitted[pkg.name] = files.map((file) => file.path);
268
+ }
269
+
270
+ // SH446 — said at the moment the file is written, because the alternative is a stranger's
271
+ // `npm install` saying it, and by then the scaffold is on their disk and this repository is not.
272
+ const missing = unpublishedNeeds();
273
+ if (missing.length) {
274
+ log(
275
+ `\n! ${missing.join(" ")} ${missing.length === 1 ? "is" : "are"} NOT on the registry, so the `
276
+ + `starter${missing.length === 1 ? "" : "s"} carrying the section overlay cannot `
277
+ + `npm install outside this monorepo.\n`
278
+ + ` No range fixes it — publish them, then record the range in src/published.mjs.\n`
279
+ + ` \`shio init\` (no --blueprint) is unaffected: it carries only published packages.\n`,
280
+ );
281
+ }
282
+
283
+ return { files: emitted["nextjs-starter"], packages: emitted, unpublished: missing };
284
+ }
285
+
286
+ // `pathToFileURL`, not a hand-built `file://` string: on Windows `process.argv[1]` is
287
+ // `D:\...` while `import.meta.url` is `file:///D:/...`, so the concatenated form never
288
+ // matched and running the script did nothing at all — silently, which is the worst way for
289
+ // a generator to fail.
290
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
291
+ await emit();
292
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Copyright (C) 2016-2026 the original author or authors.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0.
6
+ *
7
+ * create-shio-app — scaffold a Next.js App Router site wired to the Shio CDA.
8
+ *
9
+ * npm create shio-app@latest my-site
10
+ * npx create-shio-app my-site --url https://cms.example.com --site <id> --token <key>
11
+ */
12
+ import { resolve } from "node:path";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { stdin, stdout, argv, env, exit } from "node:process";
15
+
16
+ import { scaffold } from "./scaffold.mjs";
17
+
18
+ function parseArgs(args) {
19
+ const opts = { yes: false };
20
+ const positionals = [];
21
+ for (let i = 0; i < args.length; i++) {
22
+ const a = args[i];
23
+ if (a === "--yes" || a === "-y") opts.yes = true;
24
+ else if (a === "--url") opts.url = args[++i];
25
+ else if (a === "--site") opts.site = args[++i];
26
+ else if (a === "--token") opts.token = args[++i];
27
+ else if (a.startsWith("--url=")) opts.url = a.slice(6);
28
+ else if (a.startsWith("--site=")) opts.site = a.slice(7);
29
+ else if (a.startsWith("--token=")) opts.token = a.slice(8);
30
+ else if (!a.startsWith("-")) positionals.push(a);
31
+ }
32
+ opts.projectName = positionals[0];
33
+ return opts;
34
+ }
35
+
36
+ async function prompt(rl, question, fallback) {
37
+ const answer = (await rl.question(`${question}${fallback ? ` (${fallback})` : ""}: `)).trim();
38
+ return answer || fallback || "";
39
+ }
40
+
41
+ async function main() {
42
+ const opts = parseArgs(argv.slice(2));
43
+ const interactive = stdin.isTTY && !opts.yes;
44
+
45
+ let { projectName, url, site, token } = opts;
46
+
47
+ if (interactive) {
48
+ const rl = createInterface({ input: stdin, output: stdout });
49
+ try {
50
+ projectName = projectName || (await prompt(rl, "Project name", "my-shio-site"));
51
+ url = url ?? (await prompt(rl, "Shio base URL", "http://localhost:2710"));
52
+ site = site ?? (await prompt(rl, "Shio site id", ""));
53
+ token = token ?? (await prompt(rl, "Shio CDA token", ""));
54
+ } finally {
55
+ rl.close();
56
+ }
57
+ }
58
+
59
+ projectName = projectName || "my-shio-site";
60
+ const targetDir = resolve(process.cwd(), projectName);
61
+
62
+ try {
63
+ const { files } = await scaffold({ targetDir, projectName, url, site, token });
64
+ const pm = detectPackageManager();
65
+ stdout.write(
66
+ `\n✓ Created ${projectName} (${files.length} files) at ${targetDir}\n\n` +
67
+ `Next steps:\n` +
68
+ ` cd ${projectName}\n` +
69
+ ` ${pm.install}\n` +
70
+ ` ${pm.dev}\n\n` +
71
+ `Edit .env.local to point at your Shio instance.\n` +
72
+ `Open http://localhost:3000 — the catch-all route resolves CDA paths to posts.\n`
73
+ );
74
+ } catch (err) {
75
+ stdout.write(`\n✗ ${err.message}\n`);
76
+ exit(1);
77
+ }
78
+ }
79
+
80
+ function detectPackageManager() {
81
+ const ua = env.npm_config_user_agent || "";
82
+ if (ua.startsWith("pnpm")) return { install: "pnpm install", dev: "pnpm dev" };
83
+ if (ua.startsWith("yarn")) return { install: "yarn", dev: "yarn dev" };
84
+ if (ua.startsWith("bun")) return { install: "bun install", dev: "bun dev" };
85
+ return { install: "npm install", dev: "npm run dev" };
86
+ }
87
+
88
+ main();
@@ -0,0 +1,67 @@
1
+ /*
2
+ * Copyright (C) 2016-2026 the original author or authors.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0.
5
+ *
6
+ * SH446 — the versions a scaffold may declare, which are the ones the registry has.
7
+ *
8
+ * ## The defect this exists to make impossible
9
+ *
10
+ * The replication chain ran green to its last step and the last step did not install. `shio init`
11
+ * scaffolds the Next.js app that serves a converted site, prints `npm install` as the next command,
12
+ * and that command failed: `ETARGET — No matching version found for @viglet/shio-client@^2026.3.1`.
13
+ * The range came from **the workspace**, which is at `2026.3.1` and unpublished, so every release
14
+ * cycle re-broke every scaffold the moment the version was bumped and nothing said so until a human
15
+ * ran `npm install` by hand.
16
+ *
17
+ * That is worth stating as the end of the chain rather than a packaging chore: `clone`, `propose`,
18
+ * `convert` and `verify` all reported success, the gate said `ok — 0 error(s)`, and the replica was
19
+ * viewable by nobody, because the one artifact that would serve it could not be installed outside
20
+ * this monorepo. P9 failing at the last hop — every cheap textual proof passes and the perception
21
+ * loop never closes.
22
+ *
23
+ * ## Why a declared range and not the workspace's version
24
+ *
25
+ * A scaffold is consumed **outside** this repository, so the only version it may name is one the
26
+ * registry can serve. Reading it from the workspace answers a different question — "what is this
27
+ * checkout building" — and the two are equal only in the instant after a release. So the ranges
28
+ * live here, they are **caret** ranges on the latest *published* version, and they therefore pick
29
+ * up the new one automatically the day it ships: `^2026.2.1` resolves today and resolves to
30
+ * `2026.3.1` the moment that exists.
31
+ *
32
+ * `scaffold-versions.test.mjs` fails the build when the template disagrees with this file, and the
33
+ * SH448 conformance suite hands the result to **npm**, which is the only thing that can say whether
34
+ * a range is real.
35
+ */
36
+
37
+ /**
38
+ * Package → the range a scaffold declares, or `null` for "never published".
39
+ *
40
+ * A `null` is not a placeholder to fill in with a guess: it means the package has no version on the
41
+ * registry at all, so **no range is correct** and any scaffold carrying it cannot install. It is
42
+ * recorded rather than hidden so the failure has a name, and `unpublished()` is what reports it.
43
+ */
44
+ export const PUBLISHED = {
45
+ "@viglet/shio-client": "^2026.2.1",
46
+ // SH156 made these publishable and no release has run, so both answer E404. The section-carrying
47
+ // starters (`marketing-site`, `landing`) import the vocabulary, so they cannot be made installable
48
+ // by choosing a different range — only by publishing. Left declared so the gap is a row rather
49
+ // than an absence.
50
+ "@viglet/shio-model": null,
51
+ "@viglet/shio-sections": null,
52
+ };
53
+
54
+ /** The packages a scaffold would need and the registry does not have. */
55
+ export function unpublished(names = Object.keys(PUBLISHED)) {
56
+ return names.filter((name) => PUBLISHED[name] === null);
57
+ }
58
+
59
+ /**
60
+ * The range to declare for a dependency, or `null` when there is none to declare.
61
+ *
62
+ * Falls through for anything not in this table: `next`, `react` and the rest are somebody else's
63
+ * packages on their own release cycle, and this file has no business having an opinion about them.
64
+ */
65
+ export function rangeFor(name) {
66
+ return Object.hasOwn(PUBLISHED, name) ? PUBLISHED[name] : undefined;
67
+ }
@@ -0,0 +1,185 @@
1
+ /*
2
+ * Copyright (C) 2016-2026 the original author or authors.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0.
5
+ *
6
+ * Pure file-operations core for create-shio-app — no prompts, no process.exit,
7
+ * so it can be unit-tested directly.
8
+ *
9
+ * SH119 folded the starter into the blueprint format, which split this module in
10
+ * two. `readTemplateFiles()` reads the tree that ships in this package; `writeProject()`
11
+ * writes *a* tree — the local one, or the `files` a blueprint carried — substitutes
12
+ * parameters and writes `.env.local`. `scaffold()` is the first composed with the second,
13
+ * and `shio init --blueprint` is the second on its own.
14
+ *
15
+ * The template tree here stays the single source of truth: `npm run emit` generates the
16
+ * `nextjs-starter` blueprint package from it, and `test/emit.test.mjs` fails the build
17
+ * when the two drift. One starter, two ways to get it.
18
+ */
19
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
20
+ import { existsSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const TEMPLATE_DIR = fileURLToPath(new URL("../template/", import.meta.url));
25
+
26
+ /**
27
+ * npm strips or renames dotfiles in published packages, so the template ships
28
+ * `gitignore` and every consumer restores the leading dot. Keeping the mapping here
29
+ * means the blueprint package and the local scaffold cannot disagree about it.
30
+ */
31
+ const DOTFILE_RENAMES = { gitignore: ".gitignore" };
32
+
33
+ /**
34
+ * `{{name}}` and nothing else — character-for-character the rule `ShBlueprintParams`
35
+ * applies server-side, so a package cannot have a parameter one half substitutes and the
36
+ * other leaves standing. Narrow enough that JSX (`style={{ color: "#666" }}`) is not a
37
+ * placeholder, which is what lets a starter tree carry both.
38
+ */
39
+ const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}/g;
40
+
41
+ /**
42
+ * Substitute `{{name}}` placeholders from `params`. An unknown placeholder is left
43
+ * alone rather than blanked: a starter file is source the developer will read, and a
44
+ * silently emptied string is harder to notice than a visible `{{typo}}`.
45
+ *
46
+ * <p>Deliberately the *client's* job for carried files, even though the server
47
+ * substitutes a blueprint's content. The server never writes a project file (it cannot
48
+ * see the working tree), and the one parameter a starter needs — the project's own name —
49
+ * is local identity the server has no business being told.</p>
50
+ *
51
+ * @param {string} text
52
+ * @param {Record<string, string>} params
53
+ */
54
+ export function substituteParams(text, params) {
55
+ if (!text || !params) return text;
56
+ return text.replace(PLACEHOLDER, (match, name) =>
57
+ Object.hasOwn(params, name) && params[name] != null ? String(params[name]) : match
58
+ );
59
+ }
60
+
61
+ /**
62
+ * The starter tree that ships in this package, as `{ path, text }` rows — the same
63
+ * shape a blueprint's `files` come back in, so both paths write through one function.
64
+ *
65
+ * @returns {Promise<Array<{path: string, text: string}>>}
66
+ */
67
+ export async function readTemplateFiles() {
68
+ const rows = [];
69
+ for (const rel of await listFiles(TEMPLATE_DIR)) {
70
+ rows.push({
71
+ path: DOTFILE_RENAMES[rel] ?? rel,
72
+ text: await readFile(join(TEMPLATE_DIR, rel), "utf8"),
73
+ });
74
+ }
75
+ return rows.sort((a, b) => a.path.localeCompare(b.path));
76
+ }
77
+
78
+ /**
79
+ * Write a starter tree into `targetDir`.
80
+ *
81
+ * @param {object} options
82
+ * @param {string} options.targetDir Absolute path of the project directory.
83
+ * @param {Array<{path: string, text: string}>} options.files The tree to write.
84
+ * @param {Record<string, string>} [options.params] `{{name}}` substitutions.
85
+ * @param {object} [options.env] Connection details for `.env.local`; omit to skip it.
86
+ * @param {boolean} [options.force] Overwrite existing files instead of skipping them.
87
+ * @param {boolean} [options.requireEmpty] Refuse a non-empty target (the `create` default).
88
+ * @returns {Promise<{targetDir: string, files: string[], skipped: string[]}>}
89
+ */
90
+ export async function writeProject({
91
+ targetDir,
92
+ files,
93
+ params = {},
94
+ env,
95
+ force = false,
96
+ requireEmpty = false,
97
+ }) {
98
+ if (!targetDir) throw new Error("writeProject: targetDir is required");
99
+ if (!Array.isArray(files) || files.length === 0) {
100
+ throw new Error("writeProject: files is required and must not be empty");
101
+ }
102
+
103
+ if (requireEmpty && existsSync(targetDir)) {
104
+ const entries = await readdir(targetDir);
105
+ if (entries.length > 0) {
106
+ throw new Error(`Target directory is not empty: ${targetDir}`);
107
+ }
108
+ }
109
+ await mkdir(targetDir, { recursive: true });
110
+
111
+ const written = [];
112
+ const skipped = [];
113
+ for (const file of files) {
114
+ const target = join(targetDir, file.path);
115
+ if (!force && existsSync(target)) {
116
+ // A starter written over a project somebody has since edited is the one
117
+ // irreversible thing this module could do (P7), so it is opt-in.
118
+ skipped.push(file.path);
119
+ continue;
120
+ }
121
+ await mkdir(dirname(target), { recursive: true });
122
+ await writeFile(target, substituteParams(file.text, params), "utf8");
123
+ written.push(file.path);
124
+ }
125
+
126
+ if (env) {
127
+ await writeFile(join(targetDir, ".env.local"), envFile(env), "utf8");
128
+ written.push(".env.local");
129
+ }
130
+ return { targetDir, files: written.sort(), skipped: skipped.sort() };
131
+ }
132
+
133
+ /**
134
+ * @param {object} options
135
+ * @param {string} options.targetDir Absolute path of the project directory to create.
136
+ * @param {string} options.projectName Package name written into the generated package.json.
137
+ * @param {string} [options.url] Shio base URL → SHIO_URL.
138
+ * @param {string} [options.site] Shio site id → SHIO_SITE_ID.
139
+ * @param {string} [options.token] CDA token → SHIO_CDA_TOKEN.
140
+ * @returns {Promise<{targetDir: string, files: string[]}>}
141
+ */
142
+ export async function scaffold({ targetDir, projectName, url, site, token }) {
143
+ if (!targetDir) throw new Error("scaffold: targetDir is required");
144
+ if (!projectName) throw new Error("scaffold: projectName is required");
145
+
146
+ const { files } = await writeProject({
147
+ targetDir,
148
+ files: await readTemplateFiles(),
149
+ params: { projectName },
150
+ env: { url, site, token },
151
+ requireEmpty: true,
152
+ });
153
+ return { targetDir, files };
154
+ }
155
+
156
+ export { TEMPLATE_DIR, DOTFILE_RENAMES };
157
+
158
+ /**
159
+ * `.env.local`. The preview / webhook keys (SH9) are optional and start empty — see
160
+ * `.env.example`. Never carried by a blueprint: it holds a token, and a token has no
161
+ * business making a round trip through the CMS to get back to the machine that typed it.
162
+ */
163
+ function envFile({ url, site, token } = {}) {
164
+ return (
165
+ `SHIO_URL=${url ?? "http://localhost:2710"}\n` +
166
+ `SHIO_SITE_ID=${site ?? ""}\n` +
167
+ `SHIO_CDA_TOKEN=${token ?? ""}\n` +
168
+ `SHIO_PREVIEW_TOKEN=\n` +
169
+ `SHIO_PREVIEW_SECRET=\n` +
170
+ `SHIO_WEBHOOK_SECRET=\n`
171
+ );
172
+ }
173
+
174
+ async function listFiles(dir, prefix = "") {
175
+ const out = [];
176
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
177
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
178
+ if (entry.isDirectory()) {
179
+ out.push(...(await listFiles(join(dir, entry.name), rel)));
180
+ } else {
181
+ out.push(rel);
182
+ }
183
+ }
184
+ return out;
185
+ }
@@ -0,0 +1,15 @@
1
+ # Shio Content Delivery API connection
2
+ SHIO_URL=http://localhost:2710
3
+ SHIO_SITE_ID=
4
+ # PROD-env CDA token (serves PUBLISHED content)
5
+ SHIO_CDA_TOKEN=
6
+
7
+ # --- Optional: draft preview (SH9) ---
8
+ # PREVIEW-env CDA token (serves draft-preferred content). Leave empty to disable.
9
+ SHIO_PREVIEW_TOKEN=
10
+ # Secret that gates the /api/preview entry point.
11
+ SHIO_PREVIEW_SECRET=
12
+
13
+ # --- Optional: on-demand revalidation (SH9) ---
14
+ # Shared secret the publish webhook signs its body with (HMAC-SHA256).
15
+ SHIO_WEBHOOK_SECRET=