@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/LICENSE +201 -0
- package/README.md +98 -0
- package/package.json +59 -0
- package/src/emit-guard.mjs +98 -0
- package/src/emit.mjs +292 -0
- package/src/index.mjs +88 -0
- package/src/published.mjs +67 -0
- package/src/scaffold.mjs +185 -0
- package/template/.env.example +15 -0
- package/template/README.md +108 -0
- package/template/app/[...slug]/page.tsx +110 -0
- package/template/app/api/preview/route.ts +65 -0
- package/template/app/api/revalidate/route.ts +96 -0
- package/template/app/layout.tsx +25 -0
- package/template/app/page.tsx +72 -0
- package/template/gitignore +26 -0
- package/template/lib/shio.ts +70 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.mjs +12 -0
- package/template/package.json +25 -0
- package/template/tsconfig.json +21 -0
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
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -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=
|