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
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the SITES and THEMES out of a project's `kywi.config.ts` — as text.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT PARSE IT PROPERLY. `create-kywi-app` is dependency-free (Node
|
|
5
|
+
* builtins only, `engines.node >= 20`): it is run through `npx` before the
|
|
6
|
+
* project it scaffolds exists, so it cannot pull in TypeScript or an AST
|
|
7
|
+
* library, and it cannot `import()` the config either — that file imports
|
|
8
|
+
* `@kywi-software/core/config` and calls `assertProductionAuthSecret`. The
|
|
9
|
+
* precedent is already in the CLI: `detectMode` reads the same file with one
|
|
10
|
+
* regex. This is that trade taken one level up, and it stays honest about it —
|
|
11
|
+
* anything it cannot understand comes back in `problems` rather than as a
|
|
12
|
+
* confident wrong answer.
|
|
13
|
+
*
|
|
14
|
+
* WHY IT EXISTS AT ALL. `upgrade` regenerates `kywi.layers.ts`, and that
|
|
15
|
+
* registry must enumerate exactly the sites the config declares and, for each,
|
|
16
|
+
* its theme: core's `assertLayerContracts` (plan E2) throws at boot when a
|
|
17
|
+
* configured site or theme has no layer. Regenerating from the scaffold's
|
|
18
|
+
* hard-wired single `default` site would break every project that has grown a
|
|
19
|
+
* second one (kywi-marketing-v3 carries a demo site alongside its own).
|
|
20
|
+
*
|
|
21
|
+
* HOW IT READS. One tokenizer pass masks out what must not be read as
|
|
22
|
+
* structure — `//` and block comments, and the CONTENTS of `'…'`, `"…"` and
|
|
23
|
+
* `` `…` `` literals — keeping every character position, so a second pass can
|
|
24
|
+
* track `{`/`[` depth over the masked text and single-line regexes can find
|
|
25
|
+
* properties at a KNOWN depth. That is what stops a comment's stray `{`, a
|
|
26
|
+
* string's `}`, or a nested `tokens: { name: … }` from being read as config
|
|
27
|
+
* structure. String VALUES are then taken from the original text, using the
|
|
28
|
+
* literal bounds the mask recorded.
|
|
29
|
+
*
|
|
30
|
+
* WHAT IT DOES NOT HANDLE (and reports rather than guesses): quoted property
|
|
31
|
+
* keys (`'id': 'x'`), computed or spread properties, a site object built
|
|
32
|
+
* outside the array and referenced by name, and regex literals containing
|
|
33
|
+
* quotes or braces.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** @typedef {'coupled'|'headless'|'decoupled'} KywiMode */
|
|
37
|
+
/** @typedef {{ mode: KywiMode|null, sites: Array<{ id: string, theme: string|null }>, themes: Array<{ name: string }>, problems: string[] }} ConfigScan */
|
|
38
|
+
|
|
39
|
+
/** The same pattern `bin/create-kywi-app.mjs`'s `detectMode` uses, made global. */
|
|
40
|
+
const MODE_RE = /mode:\s*['"](coupled|headless|decoupled)['"]/g
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Blank every comment, and the inside of every string literal, to spaces —
|
|
44
|
+
* newlines kept, so positions and line numbers survive. Returns the masked text
|
|
45
|
+
* plus the bounds of each string literal, which is how a caller reads a VALUE
|
|
46
|
+
* back out of the original text without re-implementing quote handling.
|
|
47
|
+
* @param {string} text
|
|
48
|
+
* @returns {{ masked: string, strings: Map<number, number> }} strings: opening quote index → closing quote index
|
|
49
|
+
*/
|
|
50
|
+
function maskText(text) {
|
|
51
|
+
const chars = text.split('')
|
|
52
|
+
/** @type {Map<number, number>} */
|
|
53
|
+
const strings = new Map()
|
|
54
|
+
const n = text.length
|
|
55
|
+
const blank = (from, to) => {
|
|
56
|
+
for (let k = from; k < to && k < n; k++) if (chars[k] !== '\n') chars[k] = ' '
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let i = 0
|
|
60
|
+
while (i < n) {
|
|
61
|
+
const ch = text[i]
|
|
62
|
+
|
|
63
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
64
|
+
const nl = text.indexOf('\n', i)
|
|
65
|
+
const end = nl === -1 ? n : nl
|
|
66
|
+
blank(i, end)
|
|
67
|
+
i = end
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
72
|
+
const close = text.indexOf('*/', i + 2)
|
|
73
|
+
const end = close === -1 ? n : close + 2
|
|
74
|
+
blank(i, end)
|
|
75
|
+
i = end
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
80
|
+
// A template literal may contain `${…}`; the whole literal is opaque, so
|
|
81
|
+
// the interpolation is masked with everything else and its braces never
|
|
82
|
+
// reach the depth pass. Nesting a literal inside `${}` is not supported
|
|
83
|
+
// (nor does any config need it).
|
|
84
|
+
let j = i + 1
|
|
85
|
+
while (j < n) {
|
|
86
|
+
if (text[j] === '\\') {
|
|
87
|
+
j += 2
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (text[j] === ch) break
|
|
91
|
+
if (ch !== '`' && text[j] === '\n') break // unterminated — do not swallow the file
|
|
92
|
+
j++
|
|
93
|
+
}
|
|
94
|
+
if (j < n && text[j] === ch) {
|
|
95
|
+
blank(i + 1, j)
|
|
96
|
+
strings.set(i, j)
|
|
97
|
+
i = j + 1
|
|
98
|
+
} else {
|
|
99
|
+
blank(i + 1, j)
|
|
100
|
+
i = Math.min(j, n) // j > i always, so this terminates
|
|
101
|
+
}
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
i++
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { masked: chars.join(''), strings }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Nesting depth at every character of the masked text. An opening brace carries
|
|
113
|
+
* the depth OUTSIDE it and its closer the same, so everything strictly between
|
|
114
|
+
* a `{…}` reads one deeper — which is what "a property at depth 1 of the
|
|
115
|
+
* outermost object" means concretely.
|
|
116
|
+
* @param {string} masked
|
|
117
|
+
* @returns {Int32Array}
|
|
118
|
+
*/
|
|
119
|
+
function depthMap(masked) {
|
|
120
|
+
const depth = new Int32Array(masked.length)
|
|
121
|
+
let cur = 0
|
|
122
|
+
for (let i = 0; i < masked.length; i++) {
|
|
123
|
+
const ch = masked[i]
|
|
124
|
+
if (ch === '}' || ch === ']') cur--
|
|
125
|
+
depth[i] = cur
|
|
126
|
+
if (ch === '{' || ch === '[') cur++
|
|
127
|
+
}
|
|
128
|
+
return depth
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Index of the `}`/`]` closing the opener at `open`, or -1. */
|
|
132
|
+
function matchingClose(masked, depth, open) {
|
|
133
|
+
const closer = masked[open] === '{' ? '}' : ']'
|
|
134
|
+
const d = depth[open]
|
|
135
|
+
for (let i = open + 1; i < masked.length; i++) {
|
|
136
|
+
if (masked[i] === closer && depth[i] === d) return i
|
|
137
|
+
}
|
|
138
|
+
return -1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Index of the first non-whitespace character in `[from, to)`, or -1. */
|
|
142
|
+
function firstNonSpace(masked, from, to) {
|
|
143
|
+
for (let i = from; i < to; i++) if (!/\s/.test(masked[i])) return i
|
|
144
|
+
return -1
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Locate the config object literal's opening `{`. Three accepted shapes, all in
|
|
149
|
+
* the wild: `export default { … }`, `export default defineKywiConfig({ … })`
|
|
150
|
+
* (what the scaffold emits) and `const config = { … }` + `export default config`.
|
|
151
|
+
* @returns {number} index of the `{`, or -1
|
|
152
|
+
*/
|
|
153
|
+
function findConfigObject(masked) {
|
|
154
|
+
const ed = /export\s+default\s+/.exec(masked)
|
|
155
|
+
if (!ed) return -1
|
|
156
|
+
const after = ed.index + ed[0].length
|
|
157
|
+
if (masked[after] === '{') return after
|
|
158
|
+
|
|
159
|
+
const ident = /^([A-Za-z_$][\w$]*)\s*/.exec(masked.slice(after))
|
|
160
|
+
if (!ident) return -1
|
|
161
|
+
const afterIdent = after + ident[0].length
|
|
162
|
+
|
|
163
|
+
if (masked[afterIdent] === '(') {
|
|
164
|
+
// `defineKywiConfig({ … })` — the call's first object argument.
|
|
165
|
+
const brace = firstNonSpace(masked, afterIdent + 1, masked.length)
|
|
166
|
+
return brace !== -1 && masked[brace] === '{' ? brace : -1
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `export default config` — follow the binding back to its initialiser.
|
|
170
|
+
const decl = new RegExp(`(?:const|let|var)\\s+${ident[1]}\\s*(?::[^=\\n]*)?=\\s*`).exec(masked)
|
|
171
|
+
if (!decl) return -1
|
|
172
|
+
const start = decl.index + decl[0].length
|
|
173
|
+
return masked[start] === '{' ? start : -1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The value position of a property `name:` found at exactly `wantDepth` inside
|
|
178
|
+
* `(from, to)`. Depth is what makes this safe: `tokens: { name: 'x' }` sits one
|
|
179
|
+
* level deeper than the theme's own `name`.
|
|
180
|
+
* @returns {number} index just past `name:` and its whitespace, or -1
|
|
181
|
+
*/
|
|
182
|
+
function propertyValueAt(masked, depth, name, from, to, wantDepth) {
|
|
183
|
+
const re = new RegExp(`\\b${name}\\s*:\\s*`, 'g')
|
|
184
|
+
const region = masked.slice(from, to)
|
|
185
|
+
let m
|
|
186
|
+
while ((m = re.exec(region)) !== null) {
|
|
187
|
+
const at = from + m.index
|
|
188
|
+
if (depth[at] !== wantDepth) continue
|
|
189
|
+
return at + m[0].length
|
|
190
|
+
}
|
|
191
|
+
return -1
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** JS string escapes, enough for the values a config puts in an id or a theme name. */
|
|
195
|
+
function unescapeString(s) {
|
|
196
|
+
return s.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (_, g) => {
|
|
197
|
+
if (g[0] === 'u') {
|
|
198
|
+
const hex = g[1] === '{' ? g.slice(2, -1) : g.slice(1)
|
|
199
|
+
return String.fromCodePoint(parseInt(hex, 16))
|
|
200
|
+
}
|
|
201
|
+
if (g[0] === 'x') return String.fromCharCode(parseInt(g.slice(1), 16))
|
|
202
|
+
const simple = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', '0': '\0' }
|
|
203
|
+
return simple[g] ?? g
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read a STRING property of the object literal opening at `objOpen`.
|
|
209
|
+
* @returns {{ found: boolean, value: string|null }} `found` false = no such
|
|
210
|
+
* property; `found` true with a null value = present but not a string literal.
|
|
211
|
+
*/
|
|
212
|
+
function readStringProp(text, masked, depth, strings, objOpen, objClose, name) {
|
|
213
|
+
const at = propertyValueAt(masked, depth, name, objOpen + 1, objClose, depth[objOpen] + 1)
|
|
214
|
+
if (at === -1) return { found: false, value: null }
|
|
215
|
+
const end = strings.get(at)
|
|
216
|
+
if (end === undefined) return { found: true, value: null }
|
|
217
|
+
return { found: true, value: unescapeString(text.slice(at + 1, end)) }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Split an array literal into its elements at the array's own depth, so a
|
|
222
|
+
* comma inside a nested object — or inside a string — never splits.
|
|
223
|
+
* @returns {Array<[number, number]>} half-open `[start, end)` spans
|
|
224
|
+
*/
|
|
225
|
+
function arrayElements(masked, depth, open, close) {
|
|
226
|
+
const inner = depth[open] + 1
|
|
227
|
+
/** @type {Array<[number, number]>} */
|
|
228
|
+
const spans = []
|
|
229
|
+
let start = open + 1
|
|
230
|
+
for (let i = start; i < close; i++) {
|
|
231
|
+
if (masked[i] === ',' && depth[i] === inner) {
|
|
232
|
+
spans.push([start, i])
|
|
233
|
+
start = i + 1
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
spans.push([start, close])
|
|
237
|
+
return spans.filter(([a, b]) => masked.slice(a, b).trim() !== '')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The object literal an array element IS, or that it wraps:
|
|
242
|
+
* `{ id: 'a' }` and `defineSite({ id: 'a' })` both resolve to the same `{`.
|
|
243
|
+
* @returns {number} index of the `{`, or -1 when the element is not an object
|
|
244
|
+
*/
|
|
245
|
+
function elementObject(masked, from, to) {
|
|
246
|
+
const start = firstNonSpace(masked, from, to)
|
|
247
|
+
if (start === -1) return -1
|
|
248
|
+
if (masked[start] === '{') return start
|
|
249
|
+
const call = /^[A-Za-z_$][\w$]*\s*\(\s*/.exec(masked.slice(start, to))
|
|
250
|
+
if (!call) return -1
|
|
251
|
+
const brace = start + call[0].length
|
|
252
|
+
return masked[brace] === '{' ? brace : -1
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Find a top-level array property of the config object and hand back its
|
|
257
|
+
* element spans.
|
|
258
|
+
* @returns {{ open: number, close: number, elements: Array<[number, number]> }|null}
|
|
259
|
+
*/
|
|
260
|
+
function arrayProperty(masked, depth, name, objOpen, objClose) {
|
|
261
|
+
const at = propertyValueAt(masked, depth, name, objOpen + 1, objClose, depth[objOpen] + 1)
|
|
262
|
+
if (at === -1 || masked[at] !== '[') return null
|
|
263
|
+
const close = matchingClose(masked, depth, at)
|
|
264
|
+
if (close === -1) return null
|
|
265
|
+
return { open: at, close, elements: arrayElements(masked, depth, at, close) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Scan a `kywi.config.ts` for the deployment mode, the sites and their themes,
|
|
270
|
+
* and the declared theme names. Never throws: anything unreadable is described
|
|
271
|
+
* in `problems`, which the caller (`upgrade`) surfaces instead of writing a
|
|
272
|
+
* registry it cannot stand behind.
|
|
273
|
+
* @param {string} text the contents of kywi.config.ts
|
|
274
|
+
* @returns {ConfigScan}
|
|
275
|
+
*/
|
|
276
|
+
export function scanKywiConfig(text) {
|
|
277
|
+
/** @type {ConfigScan} */
|
|
278
|
+
const result = { mode: null, sites: [], themes: [], problems: [] }
|
|
279
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
280
|
+
result.problems.push('kywi.config.ts is empty — no config object to read.')
|
|
281
|
+
return result
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const { masked, strings } = maskText(text)
|
|
286
|
+
const depth = depthMap(masked)
|
|
287
|
+
|
|
288
|
+
const objOpen = findConfigObject(masked)
|
|
289
|
+
if (objOpen === -1) {
|
|
290
|
+
result.problems.push(
|
|
291
|
+
'could not find the exported config object — expected `export default { … }`, `export default defineKywiConfig({ … })` or `const config = { … }` with `export default config`.',
|
|
292
|
+
)
|
|
293
|
+
return result
|
|
294
|
+
}
|
|
295
|
+
const objClose = matchingClose(masked, depth, objOpen)
|
|
296
|
+
if (objClose === -1) {
|
|
297
|
+
result.problems.push('the config object literal is never closed — unbalanced `{` in kywi.config.ts.')
|
|
298
|
+
return result
|
|
299
|
+
}
|
|
300
|
+
const topDepth = depth[objOpen] + 1
|
|
301
|
+
|
|
302
|
+
// ── mode ──
|
|
303
|
+
MODE_RE.lastIndex = 0
|
|
304
|
+
let m
|
|
305
|
+
while ((m = MODE_RE.exec(text)) !== null) {
|
|
306
|
+
const at = m.index
|
|
307
|
+
// Real code, not a comment or a string: the mask blanked both, so only a
|
|
308
|
+
// live `mode:` still reads as itself.
|
|
309
|
+
if (masked.slice(at, at + 5) !== 'mode:') continue
|
|
310
|
+
if (at < objOpen || at > objClose || depth[at] !== topDepth) continue
|
|
311
|
+
result.mode = /** @type {KywiMode} */ (m[1])
|
|
312
|
+
break
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── sites ──
|
|
316
|
+
const sites = arrayProperty(masked, depth, 'sites', objOpen, objClose)
|
|
317
|
+
if (!sites) {
|
|
318
|
+
result.problems.push(
|
|
319
|
+
'no top-level `sites:` array found in kywi.config.ts — every Kywi project declares at least one site.',
|
|
320
|
+
)
|
|
321
|
+
} else {
|
|
322
|
+
sites.elements.forEach(([from, to], index) => {
|
|
323
|
+
const open = elementObject(masked, from, to)
|
|
324
|
+
if (open === -1) {
|
|
325
|
+
result.problems.push(
|
|
326
|
+
`sites[${index}] is not an object literal or a \`defineSite({ … })\` call — it reads \`${masked.slice(from, to).trim()}\`, which this scanner cannot follow.`,
|
|
327
|
+
)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
const close = matchingClose(masked, depth, open)
|
|
331
|
+
if (close === -1) {
|
|
332
|
+
result.problems.push(`sites[${index}] is never closed — unbalanced \`{\` in kywi.config.ts.`)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
const id = readStringProp(text, masked, depth, strings, open, close, 'id')
|
|
336
|
+
if (!id.value) {
|
|
337
|
+
result.problems.push(
|
|
338
|
+
`sites[${index}] has no \`id:\` string property — a site cannot be placed in the layer registry without one.`,
|
|
339
|
+
)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const theme = readStringProp(text, masked, depth, strings, open, close, 'theme')
|
|
343
|
+
if (!theme.value) {
|
|
344
|
+
result.problems.push(
|
|
345
|
+
`site '${id.value}' has no \`theme:\` string property — its theme layer cannot be generated.`,
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
result.sites.push({ id: id.value, theme: theme.value })
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── themes ──
|
|
353
|
+
const themes = arrayProperty(masked, depth, 'themes', objOpen, objClose)
|
|
354
|
+
if (!themes) {
|
|
355
|
+
result.problems.push('no top-level `themes:` array found in kywi.config.ts.')
|
|
356
|
+
} else {
|
|
357
|
+
themes.elements.forEach(([from, to], index) => {
|
|
358
|
+
const open = elementObject(masked, from, to)
|
|
359
|
+
if (open === -1) {
|
|
360
|
+
result.problems.push(
|
|
361
|
+
`themes[${index}] is not an object literal or a \`defineTheme({ … })\` call — it reads \`${masked.slice(from, to).trim()}\`, which this scanner cannot follow.`,
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
const close = matchingClose(masked, depth, open)
|
|
366
|
+
if (close === -1) {
|
|
367
|
+
result.problems.push(`themes[${index}] is never closed — unbalanced \`{\` in kywi.config.ts.`)
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
const name = readStringProp(text, masked, depth, strings, open, close, 'name')
|
|
371
|
+
if (!name.value) {
|
|
372
|
+
result.problems.push(`themes[${index}] has no \`name:\` string property.`)
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
result.themes.push({ name: name.value })
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// A scanner that throws on a file it does not own would take `upgrade` down
|
|
380
|
+
// with it. Whatever went wrong is a problem to report, not a crash.
|
|
381
|
+
result.problems.push(`kywi.config.ts could not be scanned: ${err && err.message ? err.message : String(err)}`)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return result
|
|
385
|
+
}
|