create-kywi-app 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/eject.mjs ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * `create-kywi-app eject <artifact>` — the mechanics behind the subcommand.
3
+ *
4
+ * WHAT EJECTING IS. Core ships four render-path client components as readable
5
+ * `.tsx` under `@kywi-software/core/eject/` (built by core's
6
+ * `scripts/build-eject.mjs`, described by `eject/manifest.json`). This copies
7
+ * ONE of them into a project's layer — `sites/<site>/themes/<theme>/` by
8
+ * default — and wires it into that layer's `templates` export, so the layer
9
+ * chain renders the project's copy instead of core's. From then on the file is
10
+ * the project's: it stops following core upgrades, which is the whole point and
11
+ * also the cost, so the report says so out loud.
12
+ *
13
+ * WHY THE STAMP IS TWO LINES AND IN THAT ORDER. Line 1 is `'use client'` and
14
+ * nothing else may precede it: a directive that follows a comment is inert, and
15
+ * the copied file would silently become a Server Component — `useState` would
16
+ * throw at render time, far from the CLI that caused it. The provenance comment
17
+ * therefore goes on line 2, where `upgrade` can still find it.
18
+ *
19
+ * WHY THE WIRING IS TEXT SURGERY. `create-kywi-app` is dependency-free (Node
20
+ * builtins only; it runs through `npx` before the project exists), so there is
21
+ * no parser available — the same trade `lib/config-scan.mjs` documents. The
22
+ * answer is the same too: recognise ONLY the shapes this package generates, and
23
+ * when a file does not match one of them, refuse to touch it and print the two
24
+ * lines the owner should add. A hand-edited layer index is a file the CLI has no
25
+ * business rewriting.
26
+ */
27
+
28
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
29
+ import { createRequire } from 'node:module'
30
+ import { dirname, join } from 'node:path'
31
+ import { scanKywiConfig } from './config-scan.mjs'
32
+
33
+ /** The first core release that ships `eject/` (plan E3). Named in the "too old" error. */
34
+ export const MIN_EJECT_CORE_VERSION = '0.20.0'
35
+
36
+ /** The two layers a project can own an artifact in, nearest-wins last. */
37
+ export const EJECT_LAYERS = ['site', 'theme']
38
+
39
+ /**
40
+ * A failure the user caused and can fix. `exitCode` 1 is "bad request / bad
41
+ * project"; 2 is reserved for "the target already exists", which `--force`
42
+ * overrides — a distinct code so a script can tell "already ejected" from
43
+ * "wrong arguments".
44
+ */
45
+ export class EjectError extends Error {
46
+ constructor(message, exitCode = 1) {
47
+ super(message)
48
+ this.name = 'EjectError'
49
+ this.exitCode = exitCode
50
+ this.userFacing = true
51
+ }
52
+ }
53
+
54
+ // ── locating core ─────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * The installed `@kywi-software/core` package DIRECTORY, as the PROJECT resolves
58
+ * it — not as this CLI does. `npx create-kywi-app` runs from a throwaway npm
59
+ * cache directory, so resolving from `import.meta.url` would find nothing (or,
60
+ * worse, some unrelated hoisted copy).
61
+ *
62
+ * Two strategies, because module resolution alone cannot answer this question
63
+ * for core. Core's `exports` map declares no `"./package.json"` subpath, and its
64
+ * `"."` entry has only an `import` condition, so BOTH
65
+ * `require.resolve('@kywi-software/core/package.json')` and
66
+ * `require.resolve('@kywi-software/core')` come back
67
+ * ERR_PACKAGE_PATH_NOT_EXPORTED — an `exports` map is a public API surface, and
68
+ * a package directory is not on it. (`import.meta.resolve` cannot stand in: its
69
+ * second `parent` argument is flag-gated, so it would resolve from the CLI's
70
+ * location rather than the project's.)
71
+ *
72
+ * So: try the manifest subpath first, which works for any package that does
73
+ * export it, then do what Node's resolver does and walk `node_modules` up from
74
+ * the project — the directory is what is wanted, and the map has no say over it.
75
+ * @param {string} projectDir
76
+ * @returns {string|null} absolute package directory, or null when not installed
77
+ */
78
+ export function findCoreDir(projectDir) {
79
+ try {
80
+ return dirname(createRequire(join(projectDir, 'package.json')).resolve('@kywi-software/core/package.json'))
81
+ } catch {
82
+ /* the package's `exports` map does not publish it — walk for the directory */
83
+ }
84
+
85
+ let dir = projectDir
86
+ while (true) {
87
+ const candidate = join(dir, 'node_modules', '@kywi-software', 'core')
88
+ if (existsSync(join(candidate, 'package.json'))) return candidate
89
+ const parent = dirname(dir)
90
+ if (parent === dir) return null
91
+ dir = parent
92
+ }
93
+ }
94
+
95
+ /** @param {string} coreDir */
96
+ function installedCoreVersion(coreDir) {
97
+ try {
98
+ return JSON.parse(readFileSync(join(coreDir, 'package.json'), 'utf8')).version ?? 'unknown'
99
+ } catch {
100
+ return 'unknown'
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Locate the eject source for one artifact in the core the PROJECT has installed.
106
+ *
107
+ * The artifact names are read from the manifest rather than hard-coded here: a
108
+ * core that grows a fifth ejectable artifact must be ejectable with the CLI the
109
+ * project already has.
110
+ * @param {string} projectDir
111
+ * @param {string|undefined} artifact
112
+ * @returns {{ coreDir: string, coreVersion: string, artifact: string, file: string, exportName: string, sourcePath: string, sourceText: string }}
113
+ */
114
+ export function resolveEjectSource(projectDir, artifact) {
115
+ const coreDir = findCoreDir(projectDir)
116
+ if (!coreDir) {
117
+ throw new EjectError('Install dependencies first (@kywi-software/core is not resolvable from this project).')
118
+ }
119
+
120
+ const manifestPath = join(coreDir, 'eject', 'manifest.json')
121
+ if (!existsSync(manifestPath)) {
122
+ throw new EjectError(
123
+ `@kywi-software/core ${installedCoreVersion(coreDir)} ships no eject/ sources — ` +
124
+ `\`create-kywi-app eject\` needs @kywi-software/core >= ${MIN_EJECT_CORE_VERSION}. Upgrade core first.`,
125
+ )
126
+ }
127
+
128
+ let manifest
129
+ try {
130
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
131
+ } catch (err) {
132
+ throw new EjectError(`@kywi-software/core's eject/manifest.json could not be read: ${err?.message ?? err}`)
133
+ }
134
+
135
+ const artifacts = manifest?.artifacts ?? {}
136
+ const names = Object.keys(artifacts)
137
+ const entry = artifact && Object.prototype.hasOwnProperty.call(artifacts, artifact) ? artifacts[artifact] : null
138
+ if (!entry) {
139
+ const lead = artifact ? `Unknown artifact "${artifact}".` : 'Which artifact? Name one.'
140
+ throw new EjectError(
141
+ `${lead}\n Choose one of: ${names.join(', ')}\n Usage: create-kywi-app eject <artifact> [--site <id>] [--theme <name>] [--layer site|theme] [--force]`,
142
+ )
143
+ }
144
+
145
+ const sourcePath = join(coreDir, 'eject', entry.file)
146
+ if (!existsSync(sourcePath)) {
147
+ throw new EjectError(
148
+ `@kywi-software/core's manifest names eject/${entry.file} for "${artifact}", but that file is not installed. Reinstall @kywi-software/core.`,
149
+ )
150
+ }
151
+
152
+ return {
153
+ coreDir,
154
+ coreVersion: manifest.coreVersion ?? installedCoreVersion(coreDir),
155
+ artifact,
156
+ file: entry.file,
157
+ exportName: entry.exportName,
158
+ sourcePath,
159
+ sourceText: readFileSync(sourcePath, 'utf8'),
160
+ }
161
+ }
162
+
163
+ // ── the copied file ───────────────────────────────────────────────────────────
164
+
165
+ const USE_CLIENT_RE = /^\s*(['"])use client\1\s*;?\s*$/
166
+
167
+ /**
168
+ * Core's file, restamped for the project: `'use client'` first (see the header
169
+ * note — order is load-bearing), then one provenance line naming the artifact
170
+ * and the core version it was taken from, which is what `upgrade` reads to warn
171
+ * when core's copy moves on.
172
+ * @param {string} sourceText
173
+ * @param {string} artifact
174
+ * @param {string} coreVersion
175
+ */
176
+ export function stampEjectedSource(sourceText, artifact, coreVersion) {
177
+ const lines = sourceText.split('\n')
178
+ const body = USE_CLIENT_RE.test(lines[0] ?? '') ? lines.slice(1) : lines
179
+ return ["'use client'", `// kywi-eject ${artifact} (@kywi-software/core ${coreVersion})`, ...body].join('\n')
180
+ }
181
+
182
+ // ── wiring the layer index ────────────────────────────────────────────────────
183
+
184
+ const TEMPLATES_ANCHOR = 'export const templates = '
185
+ const TEMPLATES_TYPE_IMPORT = "import type { KywiLayerModule } from '@kywi-software/core/next'"
186
+ /** The generated `sites/<id>/index.ts` landmark — the shape with no templates export yet. */
187
+ const SITE_INDEX_RE = /export\s*\{\s*moduleComponents\s+as\s+modules\s*\}/
188
+
189
+ /** `site-nav.tsx` → `./site-nav`. Extensionless: the generated tsconfig uses `moduleResolution: Bundler`. */
190
+ function importSpecifier(file) {
191
+ return `./${file.replace(/\.(tsx|ts|jsx|js)$/, '')}`
192
+ }
193
+
194
+ /** The two lines an owner must add by hand when the index is not a shape we generate. */
195
+ export function wiringHints({ artifact, exportName, file }) {
196
+ return [`import { ${exportName} } from '${importSpecifier(file)}'`, `${artifact}: ${exportName},`]
197
+ }
198
+
199
+ /** Local bindings an import clause introduces (`{ A, B as C }`, `D`, `* as N`). */
200
+ function importBindings(clause) {
201
+ const names = []
202
+ const braced = /\{([^}]*)\}/.exec(clause)
203
+ if (braced) {
204
+ for (const part of braced[1].split(',')) {
205
+ const t = part.trim()
206
+ if (!t) continue
207
+ const aliased = /^(?:type\s+)?[A-Za-z_$][\w$]*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(t)
208
+ const plain = /^(?:type\s+)?([A-Za-z_$][\w$]*)$/.exec(t)
209
+ if (aliased) names.push(aliased[1])
210
+ else if (plain) names.push(plain[1])
211
+ }
212
+ }
213
+ const outside = clause.replace(/\{[^}]*\}/, '').replace(/,/g, ' ').trim()
214
+ const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(outside)
215
+ if (namespace) names.push(namespace[1])
216
+ else if (/^[A-Za-z_$][\w$]*$/.test(outside)) names.push(outside)
217
+ return names
218
+ }
219
+
220
+ /** Does this line import `name`? (Single-line imports only — all this package generates.) */
221
+ function importsBinding(line, name) {
222
+ const m = /^import\s+(.+?)\s+from\s+['"][^'"]+['"];?\s*$/.exec(line)
223
+ return m ? importBindings(m[1]).includes(name) : false
224
+ }
225
+
226
+ /** Index of the `}` matching the `{` at `open`, or -1. Braces only; enough for a templates literal. */
227
+ function matchingBrace(text, open) {
228
+ let depth = 0
229
+ for (let i = open; i < text.length; i++) {
230
+ if (text[i] === '{') depth++
231
+ else if (text[i] === '}' && --depth === 0) return i
232
+ }
233
+ return -1
234
+ }
235
+
236
+ /**
237
+ * Insert `line` after the file's last single-line `import` statement, or at the
238
+ * top when it has none.
239
+ */
240
+ function insertImport(lines, line) {
241
+ let last = -1
242
+ for (let i = 0; i < lines.length; i++) if (/^import\s.+\sfrom\s+['"]/.test(lines[i])) last = i
243
+ const out = lines.slice()
244
+ out.splice(last + 1, 0, line)
245
+ return out
246
+ }
247
+
248
+ /**
249
+ * Wire `templates.<artifact>` in a layer index to the ejected file. PURE: takes
250
+ * and returns text.
251
+ *
252
+ * Three recognised shapes, and one deliberate refusal:
253
+ * - a `templates` object (populated or `{}`): the entry goes in FIRST, and any
254
+ * import that bound the entry's previous component is dropped — the scaffold's
255
+ * theme index points `nav` at `components/site-nav`, and leaving that import
256
+ * behind would declare `SiteNav` twice and fail to compile;
257
+ * - the generated site index, which has no `templates` export: one is appended
258
+ * in the same `satisfies` form the theme index uses;
259
+ * - anything else, including a templates object whose entries are not one
260
+ * `key: value,` per line: returned unchanged with `wired: false`.
261
+ * @param {string} indexText
262
+ * @param {{ artifact: string, exportName: string, file: string }} target
263
+ * @returns {{ text: string, wired: boolean }}
264
+ */
265
+ export function wireLayerIndex(indexText, { artifact, exportName, file }) {
266
+ const importLine = `import { ${exportName} } from '${importSpecifier(file)}'`
267
+ const anchor = indexText.indexOf(TEMPLATES_ANCHOR)
268
+
269
+ // ── shape 3: the generated site index, no templates export yet ──
270
+ if (anchor === -1) {
271
+ if (!SITE_INDEX_RE.test(indexText)) return { text: indexText, wired: false }
272
+ const body = indexText.endsWith('\n') ? indexText : `${indexText}\n`
273
+ return {
274
+ text:
275
+ `${body}\n${importLine}\n${TEMPLATES_TYPE_IMPORT}\n\n` +
276
+ `export const templates = {\n ${artifact}: ${exportName},\n} satisfies NonNullable<KywiLayerModule['templates']>\n`,
277
+ wired: true,
278
+ }
279
+ }
280
+
281
+ // ── shapes 1 + 2: an existing templates object ──
282
+ const open = indexText.indexOf('{', anchor + TEMPLATES_ANCHOR.length)
283
+ if (open === -1) return { text: indexText, wired: false }
284
+ const close = matchingBrace(indexText, open)
285
+ if (close === -1 || !indexText.slice(close).startsWith('} satisfies')) return { text: indexText, wired: false }
286
+
287
+ const inner = indexText.slice(open + 1, close)
288
+ /** @type {string[]} */
289
+ const kept = []
290
+ /** @type {string|null} */
291
+ let replacedBinding = null
292
+ for (const line of inner.split('\n')) {
293
+ if (line.trim() === '') continue
294
+ const m = /^\s*([A-Za-z_$][\w$]*)\s*:\s*([^,]+?),?\s*$/.exec(line)
295
+ // A comment, a multi-line value, a spread — not a shape we generate, so not
296
+ // one we rewrite.
297
+ if (!m) return { text: indexText, wired: false }
298
+ if (m[1] !== artifact) {
299
+ kept.push(line.trimEnd())
300
+ continue
301
+ }
302
+ const binding = /^([A-Za-z_$][\w$]*)/.exec(m[2].trim())
303
+ replacedBinding = binding ? binding[1] : null
304
+ }
305
+
306
+ const newInner = ['', ` ${artifact}: ${exportName},`, ...kept, ''].join('\n')
307
+ const rewritten = indexText.slice(0, open + 1) + newInner + indexText.slice(close)
308
+
309
+ let lines = rewritten.split('\n')
310
+ // Drop the import the replaced entry pointed at — including our own, on a
311
+ // re-eject, so `--force` stays idempotent instead of stacking imports.
312
+ if (replacedBinding) lines = lines.filter((line) => !importsBinding(line, replacedBinding))
313
+ if (!lines.some((line) => line === importLine)) lines = insertImport(lines, importLine)
314
+
315
+ return { text: lines.join('\n'), wired: true }
316
+ }
317
+
318
+ // ── plan + apply ──────────────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Work out exactly which file this eject would write and which index it would
322
+ * wire, WITHOUT touching the disk. Everything that can be rejected — no Kywi
323
+ * project, no core, an unknown artifact, a site or theme the config does not
324
+ * declare — is rejected here, so `applyEject` only has to deal with the one
325
+ * question it can answer: does the target already exist.
326
+ * @param {string} projectDir
327
+ * @param {{ artifact?: string, site?: string, theme?: string, layer?: string }} options
328
+ */
329
+ export function planEject(projectDir, { artifact, site, theme, layer } = {}) {
330
+ const configPath = join(projectDir, 'kywi.config.ts')
331
+ if (!existsSync(configPath)) {
332
+ throw new EjectError('No kywi.config.ts here — run `create-kywi-app eject` inside a Kywi project (its root).')
333
+ }
334
+
335
+ const layerName = layer ?? 'theme'
336
+ if (!EJECT_LAYERS.includes(layerName)) {
337
+ throw new EjectError(`Unknown --layer "${layer}". Choose one of: ${EJECT_LAYERS.join(', ')}.`)
338
+ }
339
+ if (layerName === 'site' && theme) {
340
+ throw new EjectError('--theme has no meaning with --layer site: a site layer is not per-theme. Drop one of them.')
341
+ }
342
+
343
+ const source = resolveEjectSource(projectDir, artifact)
344
+
345
+ const scan = scanKywiConfig(readFileSync(configPath, 'utf8'))
346
+ if (scan.sites.length === 0) {
347
+ const why = scan.problems.length ? `\n - ${scan.problems.join('\n - ')}` : ''
348
+ throw new EjectError(`No site could be read out of kywi.config.ts, so there is no layer to eject into.${why}`)
349
+ }
350
+
351
+ const siteId = site ?? scan.sites[0].id
352
+ const siteEntry = scan.sites.find((s) => s.id === siteId)
353
+ if (!siteEntry) {
354
+ throw new EjectError(
355
+ `Unknown site "${siteId}". kywi.config.ts declares: ${scan.sites.map((s) => s.id).join(', ')}.`,
356
+ )
357
+ }
358
+
359
+ let themeName = null
360
+ let dir = `sites/${siteId}`
361
+ let indexRel = `sites/${siteId}/index.ts`
362
+ if (layerName === 'theme') {
363
+ themeName = theme ?? siteEntry.theme
364
+ if (!themeName) {
365
+ throw new EjectError(
366
+ `site "${siteId}" declares no theme in kywi.config.ts — pass --theme <name>, or eject into the site layer with --layer site.`,
367
+ )
368
+ }
369
+ const declared = new Set([
370
+ ...scan.themes.map((t) => t.name),
371
+ ...scan.sites.map((s) => s.theme).filter(Boolean),
372
+ ])
373
+ if (!declared.has(themeName)) {
374
+ throw new EjectError(`Unknown theme "${themeName}". kywi.config.ts declares: ${[...declared].join(', ')}.`)
375
+ }
376
+ dir = `sites/${siteId}/themes/${themeName}`
377
+ indexRel = `${dir}/index.tsx`
378
+ }
379
+
380
+ const targetRel = `${dir}/${source.file}`
381
+ return {
382
+ projectDir,
383
+ artifact: source.artifact,
384
+ exportName: source.exportName,
385
+ file: source.file,
386
+ coreVersion: source.coreVersion,
387
+ layer: layerName,
388
+ site: siteId,
389
+ theme: themeName,
390
+ targetRel,
391
+ targetPath: join(projectDir, targetRel),
392
+ indexRel,
393
+ indexPath: join(projectDir, indexRel),
394
+ content: stampEjectedSource(source.sourceText, source.artifact, source.coreVersion),
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Execute a plan: write the override, then wire the layer index if it is a shape
400
+ * this package generated. The index is only rewritten when the wiring succeeded,
401
+ * so a refusal is genuinely a no-op on that file.
402
+ * @param {ReturnType<typeof planEject>} plan
403
+ * @param {{ force?: boolean }} options
404
+ * @returns {{ overwrote: boolean, wired: boolean }}
405
+ */
406
+ export function applyEject(plan, { force = false } = {}) {
407
+ const existed = existsSync(plan.targetPath)
408
+ if (existed && !force) {
409
+ throw new EjectError(`${plan.targetRel} already exists. Re-run with --force to overwrite it.`, 2)
410
+ }
411
+
412
+ mkdirSync(dirname(plan.targetPath), { recursive: true })
413
+ writeFileSync(plan.targetPath, plan.content, 'utf8')
414
+
415
+ let wired = false
416
+ if (existsSync(plan.indexPath)) {
417
+ const before = readFileSync(plan.indexPath, 'utf8')
418
+ const result = wireLayerIndex(before, plan)
419
+ wired = result.wired
420
+ if (wired && result.text !== before) writeFileSync(plan.indexPath, result.text, 'utf8')
421
+ }
422
+
423
+ return { overwrote: existed, wired }
424
+ }