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/README.md +166 -11
- package/bin/create-kywi-app.mjs +128 -6
- package/lib/config-scan.mjs +385 -0
- package/lib/eject.mjs +424 -0
- package/lib/templates.mjs +619 -1871
- package/lib/upgrade.mjs +490 -0
- package/package.json +1 -1
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
|
+
}
|