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.
@@ -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
+ }