kizlo 0.8.0 → 0.8.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.
@@ -2,56 +2,64 @@ import { dockerAvailable } from "../wp/docker.js";
2
2
  import { detectPackageManager, getVersion } from "../utils.js";
3
3
  import { printBanner } from "../banner.js";
4
4
  import { getPreset } from "../presets/index.js";
5
+ import { fetchTemplate } from "../presets/source.js";
6
+ import { readManifest } from "../presets/template.js";
5
7
  import { collectConnectionInteractively, nextStepsNote, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
6
8
  import z from "zod/v4";
7
9
  import path from "node:path";
8
10
  import fs from "node:fs";
9
11
  import { defineCommand } from "citty";
10
12
  import * as p from "@clack/prompts";
11
- import { downloadTemplate } from "giget";
12
13
 
13
14
  //#region src/cli/commands/create.ts
14
- /** GitHub repo the full templates are fetched from, matching the release-tag scheme. */
15
- const TEMPLATE_REPO = "github:kizlo-io/kizlo";
16
15
  /**
17
16
  * The templates `create` can scaffold from. Each id is both the `templates/<id>` folder and the
18
- * preset that drives its WordPress setup. When only one exists the template prompt auto-selects it.
17
+ * preset that drives its WordPress setup.
19
18
  */
20
19
  const TEMPLATES = ["nextjs"];
21
20
  function isTemplate(value) {
22
21
  return TEMPLATES.includes(value);
23
22
  }
24
23
  const projectName = validate(z.string().trim().min(1, "Required").refine((value) => !value.startsWith(".") && !value.includes("/") && !value.includes("\\"), "Enter a folder name (letters, numbers, dashes)"));
24
+ /** Directory entries never copied into a scaffolded project — the internal manifest and build junk. */
25
+ const SKIP_COPY = new Set([
26
+ "template.json",
27
+ "node_modules",
28
+ ".next",
29
+ ".turbo",
30
+ ".git"
31
+ ]);
25
32
  /**
26
- * Resolve where the template is fetched from. Defaults to the GitHub repo pinned to the release
27
- * tag matching this CLI version (`kizlo-v<version>`), so the template and the `kizlo` dependency it
28
- * pulls come from the same release. `KIZLO_TEMPLATE_REF` overrides the ref (e.g. `main`) and
29
- * `KIZLO_TEMPLATE_LOCAL_DIR` points at a local `templates/` directory for pre-release development.
30
- */
31
- function templateSource(template) {
32
- const localDir = process.env.KIZLO_TEMPLATE_LOCAL_DIR;
33
- if (localDir) return { local: path.resolve(localDir, template) };
34
- return { remote: `${TEMPLATE_REPO}/templates/${template}#${process.env.KIZLO_TEMPLATE_REF ?? `kizlo-v${getVersion()}`}` };
35
- }
36
- /**
37
- * Copy the template into `dir`, then make it a standalone project: rewrite the `workspace:*` Kizlo
38
- * dependency to this CLI's version and name the package after the project folder. The template's
39
- * `kizlo.template.json` is an internal build input, so it's removed once the copy lands.
33
+ * Copy the template into `dir`, then make it a standalone project: name the package after the
34
+ * project folder and resolve the monorepo-only `workspace:*` Kizlo dependency to the version the
35
+ * template declares (`kizloVersion` in its manifest, stamped at release), falling back to the running
36
+ * CLI's version. The version the project runs is the template's to decide, not a moving `latest` tag.
37
+ * The template's `template.json` is an internal input, so it's left out of the copy — as is
38
+ * local build junk (`node_modules`, `.next`, …) that only exists on the `KIZLO_TEMPLATE_LOCAL_DIR` path.
40
39
  */
41
40
  async function scaffoldTemplate(template, dir, name) {
42
- const source = templateSource(template);
43
- if (source.local) {
44
- if (!fs.existsSync(source.local)) throw new Error(`Local template not found: ${source.local}`);
45
- fs.cpSync(source.local, dir, { recursive: true });
46
- } else await downloadTemplate(source.remote, {
47
- dir,
48
- forceClean: true
49
- });
50
- fs.rmSync(path.join(dir, "kizlo.template.json"), { force: true });
41
+ const { dir: src, cleanup } = await fetchTemplate(template);
42
+ let kizloVersion;
43
+ try {
44
+ kizloVersion = readManifest(src).kizloVersion ?? `^${getVersion()}`;
45
+ fs.cpSync(src, dir, {
46
+ recursive: true,
47
+ filter: (from) => !SKIP_COPY.has(path.basename(from))
48
+ });
49
+ } finally {
50
+ cleanup();
51
+ }
51
52
  const pkgPath = path.join(dir, "package.json");
53
+ if (!fs.existsSync(pkgPath)) {
54
+ fs.rmSync(dir, {
55
+ recursive: true,
56
+ force: true
57
+ });
58
+ throw new Error(`Template "${template}" wasn't found. Check the template name and your network connection, then try again.`);
59
+ }
52
60
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
53
61
  pkg.name = name;
54
- if (pkg.dependencies?.kizlo === "workspace:*") pkg.dependencies.kizlo = `^${getVersion()}`;
62
+ if (pkg.dependencies?.kizlo === "workspace:*") pkg.dependencies.kizlo = kizloVersion;
55
63
  fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
56
64
  }
57
65
  const create = defineCommand({
@@ -83,8 +91,7 @@ const create = defineCommand({
83
91
  process.exit(1);
84
92
  }
85
93
  template = requested;
86
- } else if (TEMPLATES.length === 1) template = TEMPLATES[0];
87
- else template = orCancel(await p.select({
94
+ } else template = orCancel(await p.select({
88
95
  message: "Template",
89
96
  options: TEMPLATES.map((id) => ({
90
97
  value: id,
@@ -2,9 +2,11 @@ import { dockerAvailable } from "../wp/docker.js";
2
2
  import { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, getVersion, loadEnvFiles, resolveModuleImport, runCommand, writeFileIfAbsent } from "../utils.js";
3
3
  import { printBanner } from "../banner.js";
4
4
  import { PRESETS, detectPreset, getPreset } from "../presets/index.js";
5
+ import { fetchTemplate } from "../presets/source.js";
6
+ import { applyPatchToSource, patchChanged, renderPatchCode } from "../presets/patch.js";
7
+ import { adaptOwnFile, findRootLayout, ownEntries, patchEntries, readManifest, resolvePatch } from "../presets/template.js";
5
8
  import { collectConnectionFromEnv, collectConnectionInteractively, dirPath, nextStepsNote, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
6
9
  import { CONTRACT_BARREL } from "../daemon/generate.js";
7
- import { applyPatchToSource, patchChanged, resolvePatch, resolvePatchTargetPath, templatePatches } from "../presets/patch.js";
8
10
  import path from "node:path";
9
11
  import fs from "node:fs";
10
12
  import { defineCommand } from "citty";
@@ -108,6 +110,41 @@ function collectFromEnv(ctx) {
108
110
  function readPkg(pkgPath) {
109
111
  return JSON.parse(fs.readFileSync(pkgPath, "utf8"));
110
112
  }
113
+ function kizloDep(pkg) {
114
+ return pkg.dependencies?.kizlo ?? pkg.devDependencies?.kizlo;
115
+ }
116
+ /** A bare `x.y.z` from a version spec (range prefix and pre-release/build metadata dropped); undefined if none. */
117
+ function coerceVersion(spec) {
118
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(spec);
119
+ return match ? [
120
+ Number(match[1]),
121
+ Number(match[2]),
122
+ Number(match[3])
123
+ ] : void 0;
124
+ }
125
+ /** Whether `have` is a strictly older release than `want`; false when either can't be compared. */
126
+ function isOlder(have, want) {
127
+ const a = coerceVersion(have);
128
+ const b = coerceVersion(want);
129
+ if (!a || !b) return false;
130
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
131
+ return false;
132
+ }
133
+ /**
134
+ * The template is authoritative for the kizlo version: it declares `kizloVersion` in its manifest
135
+ * (stamped at release), falling back to the running CLI's version in-repo before the first stamp.
136
+ * When the project's kizlo is older, upgrade the project up to it and say so — a deliberate pin is
137
+ * never changed silently. Never downgrades.
138
+ */
139
+ function alignKizloVersion(cwd, pm, pkg, manifest) {
140
+ const want = manifest.kizloVersion ?? `^${getVersion()}`;
141
+ const have = kizloDep(pkg);
142
+ if (!have || !isOlder(have, want)) return;
143
+ const s = p.spinner();
144
+ s.start(`Upgrading kizlo from ${have} to ${want}`);
145
+ const ok = runCommand(addDependencyArgs(pm, `kizlo@${want}`), cwd, "ignore");
146
+ s.stop(ok ? `Upgraded kizlo to ${want}` : `Could not upgrade kizlo automatically — install kizlo@${want} yourself`);
147
+ }
111
148
  const init = defineCommand({
112
149
  meta: {
113
150
  name: "init",
@@ -168,6 +205,10 @@ const init = defineCommand({
168
205
  preset = detectPreset(initCtx);
169
206
  if (preset.id !== "base") p.log.success(`${preset.label} detected`);
170
207
  }
208
+ if (preset.template === "nextjs" && !fs.existsSync(path.join(cwd, "app")) && !fs.existsSync(path.join(cwd, "src/app"))) {
209
+ p.cancel("Kizlo needs the Next.js App Router — no `app` or `src/app` directory found. The Pages Router isn't supported.");
210
+ process.exit(1);
211
+ }
171
212
  if (yes) loadEnvFiles(cwd);
172
213
  const setup = yes ? collectFromEnv({
173
214
  cwd,
@@ -185,11 +226,12 @@ const init = defineCommand({
185
226
  process.exit(1);
186
227
  }
187
228
  if (!hasKizlo) {
229
+ const spec = `kizlo@^${getVersion()}`;
188
230
  const s = p.spinner();
189
231
  s.start(`Installing kizlo with ${pm}`);
190
- const ok = runCommand(addDependencyArgs(pm, "kizlo@latest"), cwd, "ignore");
232
+ const ok = runCommand(addDependencyArgs(pm, spec), cwd, "ignore");
191
233
  s.stop(ok ? "Installed kizlo" : "Could not install kizlo automatically");
192
- if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, "kizlo@latest").join(" ")}`);
234
+ if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, spec).join(" ")}`);
193
235
  }
194
236
  if (preset.apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, preset.apiPath);
195
237
  await setupLocalWordPress(cwd, setup);
@@ -214,7 +256,18 @@ const init = defineCommand({
214
256
  label: "Kizlo config",
215
257
  relPath: "kizlo.config.ts",
216
258
  contents: kizloConfigTemplate(dirRel, setup.alias, setup.devPath)
217
- }, ...preset.scaffolds(scaffold)];
259
+ }];
260
+ let manifest;
261
+ if (preset.template) {
262
+ const fetched = await fetchTemplate(preset.template);
263
+ try {
264
+ manifest = readManifest(fetched.dir);
265
+ alignKizloVersion(cwd, pm, pkg, manifest);
266
+ for (const entry of ownEntries(manifest)) files.push(adaptOwnFile(fetched.dir, entry, manifest.tokens, scaffold));
267
+ } finally {
268
+ fetched.cleanup();
269
+ }
270
+ } else if (preset.scaffolds) files.push(...preset.scaffolds(scaffold));
218
271
  const scaffolded = [];
219
272
  for (const file of files) scaffolded.push({
220
273
  file,
@@ -228,42 +281,37 @@ const init = defineCommand({
228
281
  writeFileIfAbsent(path.join(cwd, generatedDirRel, "index.ts"), CONTRACT_BARREL);
229
282
  const gitignore = ensureGitignored(cwd, ".env");
230
283
  for (const { file, result } of scaffolded) reportScaffold(file, result, yes);
231
- for (const patch of templatePatches(preset.id)) {
232
- const resolved = resolvePatch(patch, scaffold);
233
- const abs = resolvePatchTargetPath(cwd, resolved.relPath);
234
- if (!abs) {
235
- p.log.info(`Skipped ${resolved.label} wiring — ${resolved.relPath} not found (add it yourself)`);
284
+ const manualSteps = [];
285
+ for (const entry of manifest ? patchEntries(manifest) : []) {
286
+ const resolved = resolvePatch(entry, manifest.tokens, scaffold);
287
+ const target = findRootLayout(cwd, scaffold.appDir, resolved.relPath);
288
+ if (!target) {
289
+ manualSteps.push(resolved);
290
+ p.log.info(`Couldn't find your ${resolved.label} to wire Kizlo into — see the code to add below`);
236
291
  continue;
237
292
  }
238
- const relTarget = path.relative(cwd, abs);
239
- const src = fs.readFileSync(abs, "utf8");
240
- let text;
241
- let changes;
293
+ const relTarget = path.relative(cwd, target);
294
+ const src = fs.readFileSync(target, "utf8");
295
+ let applied;
242
296
  try {
243
- ({text, changes} = applyPatchToSource(src, resolved, { replaceConflicts: force }));
244
- } catch (err) {
245
- p.log.error(`Could not parse ${resolved.label} (${relTarget}) to wire Kizlo into it: ${err instanceof Error ? err.message : String(err)}`);
246
- throw err;
247
- }
248
- if (!force && !yes && changes.keptExports.length) {
249
- p.log.warn(`${resolved.label} (${relTarget}) already defines ${changes.keptExports.join(", ")}`);
250
- if (orCancel(await p.confirm({
251
- message: "Replace with Kizlo's version?",
252
- initialValue: true
253
- }))) ({text, changes} = applyPatchToSource(src, resolved, { replaceConflicts: true }));
297
+ applied = applyPatchToSource(src, resolved);
298
+ } catch {
299
+ manualSteps.push(resolved);
300
+ p.log.warn(`Couldn't parse your ${resolved.label} (${relTarget}) — see the code to add below`);
301
+ continue;
254
302
  }
303
+ const { text, changes } = applied;
255
304
  if (patchChanged(changes)) {
256
- fs.writeFileSync(abs, text);
305
+ fs.writeFileSync(target, text);
257
306
  const parts = [
258
307
  ...changes.replacedExports.length ? [`replaced ${changes.replacedExports.join(", ")}`] : [],
259
308
  ...changes.addedExports.length ? [`added ${changes.addedExports.join(", ")}`] : [],
260
309
  ...changes.addedImports.length ? ["imports"] : []
261
310
  ];
262
311
  p.log.success(`Wired Kizlo into your ${resolved.label} (${relTarget}) — ${parts.join(", ")}`);
263
- }
264
- if (changes.keptExports.length) p.log.info(`Kept your existing ${changes.keptExports.join(", ")} in ${resolved.label} — pass --force to replace`);
265
- else if (!patchChanged(changes)) p.log.info(`Your ${resolved.label} is already wired (${relTarget})`);
312
+ } else p.log.info(`Your ${resolved.label} is already wired (${relTarget})`);
266
313
  }
314
+ for (const resolved of manualSteps) p.note(renderPatchCode(resolved), `Add these to your ${resolved.label} (the layout that renders <html>)`);
267
315
  if (gitignore !== "present") p.log.success(`${gitignore === "created" ? "Created" : "Updated"} .gitignore (ignoring .env)`);
268
316
  nextStepsNote(setup.mode);
269
317
  p.outro("Kizlo is ready 🎉");
@@ -1,5 +1,3 @@
1
- import { materializeTemplate } from "./template.js";
2
-
3
1
  //#region src/cli/presets/nextjs.ts
4
2
  const nextjs = {
5
3
  id: "nextjs",
@@ -12,9 +10,7 @@ const nextjs = {
12
10
  },
13
11
  baseUrlEnvKey: "NEXT_PUBLIC_KIZLO_BACKEND_URL",
14
12
  apiPath: "/api/kizlo",
15
- scaffolds(ctx) {
16
- return materializeTemplate("nextjs", ctx);
17
- }
13
+ template: "nextjs"
18
14
  };
19
15
 
20
16
  //#endregion
@@ -1,4 +1,3 @@
1
- import { TEMPLATE_PATCHES } from "./generated/templates.js";
2
1
  import path from "node:path";
3
2
  import fs from "node:fs";
4
3
  import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
@@ -28,26 +27,7 @@ function resolvePatchTargetPath(cwd, relPath) {
28
27
  if (fs.existsSync(candidate)) return candidate;
29
28
  }
30
29
  }
31
- /** The Kizlo-wiring patches for a framework (files init merges into rather than writes). */
32
- function templatePatches(framework) {
33
- return TEMPLATE_PATCHES[framework] ?? [];
34
- }
35
- /** Substitute the path/import tokens for a patch against the project's real directories. */
36
- function resolvePatch(patch, ctx) {
37
- const relPath = patch.tokenizedFile.replaceAll("{{kizloDir}}", ctx.kizloDir).replaceAll("{{appDir}}", ctx.appDir);
38
- const fromDir = path.posix.dirname(relPath);
39
- const imports = patch.imports.map((imp) => ({
40
- module: imp.module.replaceAll("{{serverImport}}", ctx.serverImport(fromDir)),
41
- names: imp.names
42
- }));
43
- return {
44
- label: patch.label,
45
- relPath,
46
- imports,
47
- exports: patch.exports
48
- };
49
- }
50
- /** Whether a patch actually modified the file (vs. a no-op / kept outcome). */
30
+ /** Whether a patch actually modified the file (vs. a no-op / already-wired outcome). */
51
31
  function patchChanged(changes) {
52
32
  return Boolean(changes.addedImports.length || changes.addedExports.length || changes.replacedExports.length);
53
33
  }
@@ -69,15 +49,18 @@ function sameExpr(a, b) {
69
49
  }
70
50
  /**
71
51
  * Merge a resolved patch into `source` by parsing it with magicast (a real TS/JS/JSX parser via Babel,
72
- * so declarations are bounded exactly — never by a text heuristic). Imports and absent exports are
73
- * added; an export the file already defines is replaced surgically when `replaceConflicts` is set, else
74
- * kept. A static Next counterpart (`metadata` for `generateMetadata`) is dropped as part of a replace.
75
- * Idempotent: an export whose current value already equals ours is left untouched.
52
+ * so declarations are bounded exactly — never by a text heuristic). Missing imports are added; each
53
+ * export is upserted — set with `builders`, which replaces it if present and adds it if not — and a
54
+ * static Next counterpart (`metadata` for `generateMetadata`) is dropped as part of a replace. The
55
+ * upsert is deliberate replace-always: we cannot distinguish a user's own `generateMetadata` from a
56
+ * stale copy of ours, and those values come from WordPress (edited in wp-admin, not the file), so the
57
+ * export body isn't meant to be hand-customized. Idempotent: an export already equal to ours is left
58
+ * untouched, so re-running never churns the file.
76
59
  *
77
- * Throws if the target cannot be parsed or regenerated. Callers must NOT swallow that — a file we can't
78
- * parse is one we must not guess at, so wiring stops rather than writing a corrupted file.
60
+ * Throws if the target cannot be parsed or regenerated. Callers must NOT swallow that — a file we
61
+ * can't parse is one we must not guess at, so wiring falls back to a printed instruction instead.
79
62
  */
80
- function applyPatchToSource(source, patch, opts) {
63
+ function applyPatchToSource(source, patch) {
81
64
  const format = detectCodeFormat(source);
82
65
  const mod = parseModule(source, {
83
66
  sourceFileName: "layout.tsx",
@@ -86,8 +69,7 @@ function applyPatchToSource(source, patch, opts) {
86
69
  const changes = {
87
70
  addedImports: [],
88
71
  addedExports: [],
89
- replacedExports: [],
90
- keptExports: []
72
+ replacedExports: []
91
73
  };
92
74
  for (const imp of patch.imports) for (const name of imp.names) {
93
75
  if (mod.imports.$items.some((item) => item.from === imp.module && item.imported === name)) continue;
@@ -101,20 +83,12 @@ function applyPatchToSource(source, patch, opts) {
101
83
  const exportNames = Object.keys(mod.exports);
102
84
  if (exportNames.includes(exp.name)) {
103
85
  if (sameExpr(generateCode(mod.exports[exp.name]).code, exp.value)) continue;
104
- if (!opts.replaceConflicts) {
105
- changes.keptExports.push(exp.name);
106
- continue;
107
- }
108
86
  mod.exports[exp.name] = builders.raw(exp.value);
109
87
  changes.replacedExports.push(exp.name);
110
88
  continue;
111
89
  }
112
90
  const counterpart = staticCounterpart(exp.name);
113
91
  if (counterpart && exportNames.includes(counterpart)) {
114
- if (!opts.replaceConflicts) {
115
- changes.keptExports.push(exp.name);
116
- continue;
117
- }
118
92
  delete mod.exports[counterpart];
119
93
  mod.exports[exp.name] = builders.raw(exp.value);
120
94
  changes.replacedExports.push(exp.name);
@@ -128,6 +102,20 @@ function applyPatchToSource(source, patch, opts) {
128
102
  changes
129
103
  };
130
104
  }
105
+ /**
106
+ * Render a resolved patch as source the user can paste in, for the printed fallback when the target
107
+ * can't be resolved or parsed. The same declared payload the confident apply upserts, so what we tell
108
+ * the user to add never drifts from what we would have written.
109
+ */
110
+ function renderPatchCode(patch) {
111
+ const imports = patch.imports.map((imp) => `import { ${imp.names.join(", ")} } from "${imp.module}"`);
112
+ const exports = patch.exports.map((exp) => `export const ${exp.name} = ${exp.value}`);
113
+ return `${[
114
+ ...imports,
115
+ "",
116
+ ...exports
117
+ ].join("\n")}\n`;
118
+ }
131
119
 
132
120
  //#endregion
133
- export { applyPatchToSource, patchChanged, resolvePatch, resolvePatchTargetPath, templatePatches };
121
+ export { applyPatchToSource, patchChanged, renderPatchCode, resolvePatchTargetPath };
@@ -0,0 +1,50 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import { downloadTemplate } from "giget";
5
+
6
+ //#region src/cli/presets/source.ts
7
+ /** GitHub repo the templates are fetched from. */
8
+ const TEMPLATE_REPO = "github:kizlo-io/kizlo";
9
+ /**
10
+ * Resolve where a template is fetched from. Defaults to the templates on the repo's default branch:
11
+ * the template is decoupled from the CLI version — it declares its own `kizlo` dependency — so there
12
+ * is no tag to match and no release to cut. `KIZLO_TEMPLATE_LOCAL_DIR` points at a local `templates/`
13
+ * directory so development (and `create`/`init` tests) never touch the network.
14
+ */
15
+ function templateSource(template) {
16
+ const localDir = process.env.KIZLO_TEMPLATE_LOCAL_DIR;
17
+ if (localDir) return { local: path.resolve(localDir, template) };
18
+ return { remote: `${TEMPLATE_REPO}/templates/${template}` };
19
+ }
20
+ /**
21
+ * Get the template as a readable directory both `create` and `init` can copy from or read. With
22
+ * `KIZLO_TEMPLATE_LOCAL_DIR` set it's the local folder (returned as-is, cleanup is a no-op). Otherwise
23
+ * it's downloaded once into a fresh temp directory the caller cleans up. The single fetch path shared
24
+ * by both commands, so they can never source different bytes.
25
+ */
26
+ async function fetchTemplate(template) {
27
+ const source = templateSource(template);
28
+ if (source.local) {
29
+ if (!fs.existsSync(source.local)) throw new Error(`Local template not found: ${source.local}`);
30
+ return {
31
+ dir: source.local,
32
+ cleanup: () => {}
33
+ };
34
+ }
35
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kizlo-template-"));
36
+ await downloadTemplate(source.remote, {
37
+ dir,
38
+ forceClean: true
39
+ });
40
+ return {
41
+ dir,
42
+ cleanup: () => fs.rmSync(dir, {
43
+ recursive: true,
44
+ force: true
45
+ })
46
+ };
47
+ }
48
+
49
+ //#endregion
50
+ export { fetchTemplate };
@@ -1,8 +1,51 @@
1
- import { TEMPLATE_SUBSETS } from "./generated/templates.js";
1
+ import { resolvePatchTargetPath } from "./patch.js";
2
+ import z from "zod/v4";
2
3
  import path from "node:path";
4
+ import fs from "node:fs";
3
5
 
4
6
  //#region src/cli/presets/template.ts
5
- /** Role → the human label init uses in prompts and logs. Mirrors the old hardcoded labels. */
7
+ /**
8
+ * The template's `template.json`. It is the single declaration of which files are Kizlo wiring
9
+ * and how each is applied. Every entry is one of a small closed set — deliberately no "patch an
10
+ * arbitrary source file" kind, so a dangerous strategy is unrepresentable:
11
+ *
12
+ * - `own` — a whole file Kizlo writes. Its path is authoritative; `create` copies it and `init`
13
+ * adapts + writes it through the overwrite policy.
14
+ * - `patch` — a partial injection into a file the user owns (the root layout's SEO exports). `init`
15
+ * tries a confident apply, otherwise prints the payload with placement instructions. `create` never
16
+ * patches — its own template files already carry the wiring.
17
+ */
18
+ const tokensSchema = z.object({
19
+ kizloDir: z.string(),
20
+ appDir: z.string(),
21
+ alias: z.string()
22
+ });
23
+ const ownSchema = z.object({
24
+ kind: z.literal("own"),
25
+ role: z.string(),
26
+ path: z.string()
27
+ });
28
+ const patchSchema = z.object({
29
+ kind: z.literal("patch"),
30
+ role: z.string(),
31
+ label: z.string(),
32
+ path: z.string(),
33
+ imports: z.array(z.object({
34
+ module: z.string(),
35
+ names: z.array(z.string())
36
+ })),
37
+ exports: z.array(z.object({
38
+ name: z.string(),
39
+ value: z.string()
40
+ }))
41
+ });
42
+ const manifestSchema = z.object({
43
+ framework: z.string(),
44
+ kizloVersion: z.string().optional(),
45
+ tokens: tokensSchema,
46
+ files: z.array(z.discriminatedUnion("kind", [ownSchema, patchSchema]))
47
+ });
48
+ /** Role → the human label init uses in prompts and logs. */
6
49
  const ROLE_LABELS = {
7
50
  "server-entry": "Kizlo server instance",
8
51
  client: "Browser client",
@@ -12,25 +55,102 @@ const ROLE_LABELS = {
12
55
  "sitemap-redirect": "sitemap.xml redirect route",
13
56
  manifest: "web manifest route"
14
57
  };
58
+ /** Read and validate the template's manifest from a fetched template directory. */
59
+ function readManifest(templateDir) {
60
+ const manifestPath = path.join(templateDir, "template.json");
61
+ if (!fs.existsSync(manifestPath)) throw new Error(`Template manifest not found at ${manifestPath}`);
62
+ return manifestSchema.parse(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
63
+ }
64
+ function ownEntries(manifest) {
65
+ return manifest.files.filter((file) => file.kind === "own");
66
+ }
67
+ function patchEntries(manifest) {
68
+ return manifest.files.filter((file) => file.kind === "patch");
69
+ }
70
+ /** The specifier the template's files use to reach the server entry, e.g. `@/lib/kizlo/server`. */
71
+ function serverImportSpecifier(tokens) {
72
+ const withoutSrc = tokens.kizloDir.replace(/^src\//, "");
73
+ return `${tokens.alias.replace(/\/+$/, "")}/${withoutSrc}/server`;
74
+ }
15
75
  /**
16
- * Turn a framework's extracted template subset into the {@link ScaffoldFile}s init writes, by
17
- * substituting the tokens the extractor left behind: the path prefixes (`{{kizloDir}}`, `{{appDir}}`)
18
- * with the project's real directories, and `{{serverImport}}` with the import specifier resolved for
19
- * each file's location (tsconfig alias or relative). The single source of these bodies is the
20
- * template folder, so init and `create` can never drift.
76
+ * Adapt a template path prefix (`src/app/...`, `src/lib/kizlo/...`) to the project's real directories.
77
+ * Only the leading token dir is swapped; the rest of the path is preserved verbatim.
21
78
  */
22
- function materializeTemplate(framework, ctx) {
23
- return (TEMPLATE_SUBSETS[framework] ?? []).map((file) => {
24
- const relPath = file.tokenizedPath.replaceAll("{{kizloDir}}", ctx.kizloDir).replaceAll("{{appDir}}", ctx.appDir);
25
- const fromDir = path.posix.dirname(relPath);
26
- const contents = file.body.replaceAll("{{serverImport}}", ctx.serverImport(fromDir));
27
- return {
28
- label: ROLE_LABELS[file.role] ?? file.role,
29
- relPath,
30
- contents
31
- };
32
- });
79
+ function adaptTemplatePath(relPath, tokens, ctx) {
80
+ const normalized = relPath.split(path.sep).join("/");
81
+ if (normalized === tokens.appDir || normalized.startsWith(`${tokens.appDir}/`)) return `${ctx.appDir}${normalized.slice(tokens.appDir.length)}`;
82
+ if (normalized === tokens.kizloDir || normalized.startsWith(`${tokens.kizloDir}/`)) return `${ctx.kizloDir}${normalized.slice(tokens.kizloDir.length)}`;
83
+ return normalized;
84
+ }
85
+ /**
86
+ * Turn an `own` manifest entry into the {@link ScaffoldFile} init writes: read the template's real
87
+ * file, rewrite its path prefix to the project's directories, and swap the server-entry import
88
+ * specifier for the one resolved at the file's new location (tsconfig alias or relative). The swap is
89
+ * surgical — it replaces the exact specifier the template uses, not a loose directory substring — so
90
+ * an unrelated occurrence can't be corrupted. This is the job the old build-time extractor did; it
91
+ * moves to runtime and stays as precise.
92
+ */
93
+ function adaptOwnFile(templateDir, entry, tokens, ctx) {
94
+ const abs = path.join(templateDir, entry.path);
95
+ if (!fs.existsSync(abs)) throw new Error(`Template file "${entry.path}" is listed in the manifest but does not exist in the template.`);
96
+ const body = fs.readFileSync(abs, "utf8");
97
+ const relPath = adaptTemplatePath(entry.path, tokens, ctx);
98
+ const fromDir = path.posix.dirname(relPath);
99
+ const contents = body.split(serverImportSpecifier(tokens)).join(ctx.serverImport(fromDir));
100
+ return {
101
+ label: ROLE_LABELS[entry.role] ?? entry.role,
102
+ relPath,
103
+ contents
104
+ };
105
+ }
106
+ /** Substitute the path/import tokens for a patch against the project's real directories. */
107
+ function resolvePatch(entry, tokens, ctx) {
108
+ const relPath = adaptTemplatePath(entry.path, tokens, ctx);
109
+ const fromDir = path.posix.dirname(relPath);
110
+ const imports = entry.imports.map((imp) => ({
111
+ module: imp.module.replaceAll("{{serverImport}}", ctx.serverImport(fromDir)),
112
+ names: imp.names
113
+ }));
114
+ return {
115
+ label: entry.label,
116
+ relPath,
117
+ imports,
118
+ exports: entry.exports
119
+ };
120
+ }
121
+ const LAYOUT_FILE = /^layout\.(tsx|jsx|ts|js|mjs|cjs)$/;
122
+ /** A root layout is the one that renders `<html>` — its identity, not its path. */
123
+ const RENDERS_HTML = /<html[\s/>]/;
124
+ /** Every file under `dir`, recursively (files only). Missing dir yields nothing. */
125
+ function walk(dir) {
126
+ if (!fs.existsSync(dir)) return [];
127
+ const out = [];
128
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
129
+ const full = path.join(dir, entry.name);
130
+ if (entry.isDirectory()) out.push(...walk(full));
131
+ else out.push(full);
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * Resolve the root layout by identity, not path. Try the manifest hint first; if it exists and
137
+ * renders `<html>`, use it. Otherwise scan the App Router directory for a layout that renders `<html>`
138
+ * and use it only when there is exactly one. On zero, many, or an unreadable file, return undefined so
139
+ * the caller prints the wiring for the user to place. Never guesses.
140
+ */
141
+ function findRootLayout(cwd, appDir, hintRelPath) {
142
+ const hint = resolvePatchTargetPath(cwd, hintRelPath);
143
+ if (hint && rendersHtml(hint)) return hint;
144
+ const matches = walk(path.join(cwd, appDir)).filter((file) => LAYOUT_FILE.test(path.basename(file)) && rendersHtml(file));
145
+ return matches.length === 1 ? matches[0] : void 0;
146
+ }
147
+ function rendersHtml(file) {
148
+ try {
149
+ return RENDERS_HTML.test(fs.readFileSync(file, "utf8"));
150
+ } catch {
151
+ return false;
152
+ }
33
153
  }
34
154
 
35
155
  //#endregion
36
- export { materializeTemplate };
156
+ export { adaptOwnFile, findRootLayout, ownEntries, patchEntries, readManifest, resolvePatch };
@@ -6,8 +6,8 @@ import { createAdminAppPassword, seedUsers } from "./bootstrap.js";
6
6
  import { prepareByo } from "./byo.js";
7
7
  import { join } from "node:path";
8
8
  import { existsSync, rmSync } from "node:fs";
9
- import { randomBytes } from "node:crypto";
10
9
  import { networkInterfaces } from "node:os";
10
+ import { randomBytes } from "node:crypto";
11
11
 
12
12
  //#region src/cli/wp/dev.ts
13
13
  /** A strong random admin password (144 bits, URL/JSON/CLI-safe characters). */
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.8.0";
2
+ var version = "0.8.2";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/router.d.ts CHANGED
@@ -179,7 +179,7 @@ declare const ROUTER_MAP: {
179
179
  include?: string | number | (string | number)[] | undefined;
180
180
  offset?: string | number | undefined;
181
181
  order?: "asc" | "desc" | undefined;
182
- orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | undefined;
182
+ orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | undefined;
183
183
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
184
184
  slug?: string | string[] | undefined;
185
185
  taxRelation?: "AND" | "OR" | undefined;
@@ -507,7 +507,7 @@ declare const ROUTER_MAP: {
507
507
  include?: string | number | (string | number)[] | undefined;
508
508
  offset?: string | number | undefined;
509
509
  order?: "asc" | "desc" | undefined;
510
- orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | "menu_order" | undefined;
510
+ orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | "menu_order" | undefined;
511
511
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
512
512
  slug?: string | string[] | undefined;
513
513
  parent?: string | number | (string | number)[] | undefined;
@@ -1186,7 +1186,7 @@ declare const ROUTER_MAP: {
1186
1186
  include?: string | number | (string | number)[] | undefined;
1187
1187
  offset?: string | number | undefined;
1188
1188
  order?: "asc" | "desc" | undefined;
1189
- orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
1189
+ orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
1190
1190
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
1191
1191
  slug?: string | string[] | undefined;
1192
1192
  taxRelation?: "AND" | "OR" | undefined;
@@ -1265,7 +1265,7 @@ declare const ROUTER_MAP: {
1265
1265
  include?: string | number | (string | number)[] | undefined;
1266
1266
  offset?: string | number | undefined;
1267
1267
  order?: "asc" | "desc" | undefined;
1268
- orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
1268
+ orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
1269
1269
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
1270
1270
  slug?: string | string[] | undefined;
1271
1271
  taxRelation?: "AND" | "OR" | undefined;
@@ -1414,7 +1414,7 @@ declare const ROUTER_MAP: {
1414
1414
  include?: string | number | (string | number)[] | undefined;
1415
1415
  offset?: string | number | undefined;
1416
1416
  order?: "asc" | "desc" | undefined;
1417
- orderby?: "date" | "id" | "parent" | "post" | "include" | undefined;
1417
+ orderby?: "id" | "date" | "parent" | "post" | "include" | undefined;
1418
1418
  parent?: string | number | (string | number)[] | undefined;
1419
1419
  parentExclude?: string | number | (string | number)[] | undefined;
1420
1420
  post?: string | number | (string | number)[] | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kizlo",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "description": "A headless WordPress framework for TypeScript.",
6
6
  "keywords": [
@@ -1,59 +0,0 @@
1
- //#region src/cli/presets/generated/templates.ts
2
- const TEMPLATE_SUBSETS = { "nextjs": [
3
- {
4
- "role": "server-entry",
5
- "tokenizedPath": "{{kizloDir}}/server/index.ts",
6
- "body": "import { createKizlo } from \"kizlo/nextjs/server\"\n\nexport const { router, client, context, handler } = createKizlo()\n"
7
- },
8
- {
9
- "role": "client",
10
- "tokenizedPath": "{{kizloDir}}/client.ts",
11
- "body": "import { createKizloClient } from \"kizlo/nextjs\"\nimport { contract } from \"./server/generated\"\n\nexport const client = createKizloClient(contract)\n"
12
- },
13
- {
14
- "role": "api-route",
15
- "tokenizedPath": "{{appDir}}/api/kizlo/[[...rest]]/route.ts",
16
- "body": "import { handler } from \"{{serverImport}}\"\n\nexport { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE, handler as OPTIONS }\n"
17
- },
18
- {
19
- "role": "robots",
20
- "tokenizedPath": "{{appDir}}/robots.txt/route.ts",
21
- "body": "import { createRobotsRoute } from \"kizlo/nextjs/server\"\nimport { client } from \"{{serverImport}}\"\n\n// Run on the edge: cheaper and faster than Node for a response this small.\nexport const runtime = \"edge\"\n\n// Vercel treats special metadata files like robots.txt and sitemap.xml as completely static\n// files at build time, bypassing normal Incremental Static Regeneration (ISR) and on-demand\n// revalidatePath rules.\n//\n// While your revalidation logic executes perfectly in a local Node.js server, Vercel uploads\n// these routes directly to its Edge CDN as immutable assets. Subsequent on-demand revalidation\n// triggers will successfully clear the Data Cache but fail to purge the CDN-level Edge cache\n// for that file.\n//\n// force-dynamic opts out of that static treatment so CMS edits show up; the WordPress call is\n// cached, so requests stay cheap.\nexport const dynamic = \"force-dynamic\"\n\nexport const GET = createRobotsRoute(client)\n"
22
- },
23
- {
24
- "role": "sitemap",
25
- "tokenizedPath": "{{appDir}}/sitemaps/[sitemap]/route.ts",
26
- "body": "import { createSitemapRoute } from \"kizlo/nextjs/server\"\nimport { client } from \"{{serverImport}}\"\n\n// Run on the edge: cheaper and faster than Node for a response this small.\nexport const runtime = \"edge\"\n\n// Like robots.txt, Vercel bakes sitemap routes into immutable Edge CDN assets at build time,\n// bypassing ISR and on-demand revalidatePath. The index gets frozen with whatever WordPress\n// returned at build (often nothing, since the self-referential backend isn't reachable yet),\n// and revalidation can't purge that CDN copy.\n//\n// force-dynamic opts out of that static treatment. createSitemapRoute caches the WordPress calls\n// per sitemap and refreshes them on content changes (via revalidateTag), so requests stay cheap.\nexport const dynamic = \"force-dynamic\"\n\nexport const GET = createSitemapRoute(client)\n"
27
- },
28
- {
29
- "role": "sitemap-redirect",
30
- "tokenizedPath": "{{appDir}}/sitemap.xml/route.ts",
31
- "body": "import { createSitemapRedirectRoute } from \"kizlo/nextjs/server\"\n\n// Many crawlers ignore robots.txt and probe the well-known /sitemap.xml directly. This route\n// permanently redirects (308) to the generated index at /sitemaps/index.xml. The response is a\n// fixed constant with no WordPress call or request input, so Next statically generates it at\n// build time (no runtime/edge function invoked per request).\nexport const GET = createSitemapRedirectRoute()\n"
32
- },
33
- {
34
- "role": "manifest",
35
- "tokenizedPath": "{{appDir}}/site.webmanifest/route.ts",
36
- "body": "import { createManifestRoute } from \"kizlo/nextjs/server\"\nimport { client } from \"{{serverImport}}\"\n\n// Run on the edge: cheaper and faster than Node for a response this small.\nexport const runtime = \"edge\"\n\n// Like robots.txt, Vercel bakes this route into an immutable Edge CDN asset at build time,\n// bypassing ISR and on-demand revalidatePath. force-dynamic opts out of that static treatment\n// so brand-settings edits show up; createManifestRoute caches the WordPress call, so requests\n// stay cheap.\nexport const dynamic = \"force-dynamic\"\n\nexport const GET = createManifestRoute(client)\n"
37
- }
38
- ] };
39
- const TEMPLATE_PATCHES = { "nextjs": [{
40
- "label": "root layout",
41
- "tokenizedFile": "{{appDir}}/layout.tsx",
42
- "imports": [{
43
- "module": "kizlo/nextjs/server",
44
- "names": ["createRootMetadata", "createRootViewport"]
45
- }, {
46
- "module": "{{serverImport}}",
47
- "names": ["client"]
48
- }],
49
- "exports": [{
50
- "name": "generateMetadata",
51
- "value": "createRootMetadata(client)"
52
- }, {
53
- "name": "generateViewport",
54
- "value": "createRootViewport(client)"
55
- }]
56
- }] };
57
-
58
- //#endregion
59
- export { TEMPLATE_PATCHES, TEMPLATE_SUBSETS };