@yopem-ui/cli 0.0.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.
- package/README.md +21 -0
- package/package.json +44 -0
- package/src/cli.ts +75 -0
- package/src/init.ts +1795 -0
- package/src/install.ts +380 -0
package/src/init.ts
ADDED
|
@@ -0,0 +1,1795 @@
|
|
|
1
|
+
import { readFile, realpath, writeFile, mkdir, unlink } from "node:fs/promises"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import ts from "typescript-api"
|
|
4
|
+
|
|
5
|
+
import type { InstallOptions, JsonObject, JsonValue } from "./install"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
existingFile,
|
|
9
|
+
installItem,
|
|
10
|
+
isRecord,
|
|
11
|
+
isString,
|
|
12
|
+
runBun,
|
|
13
|
+
} from "./install"
|
|
14
|
+
|
|
15
|
+
type Framework =
|
|
16
|
+
| "vite"
|
|
17
|
+
| "tanstack-router"
|
|
18
|
+
| "tanstack-start"
|
|
19
|
+
| "react-router"
|
|
20
|
+
| "next"
|
|
21
|
+
| "astro"
|
|
22
|
+
|
|
23
|
+
type PackageManager = "bun" | "npm" | "pnpm" | "yarn"
|
|
24
|
+
|
|
25
|
+
interface Edit {
|
|
26
|
+
start: number
|
|
27
|
+
end: number
|
|
28
|
+
text: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InitOptions extends InstallOptions {
|
|
32
|
+
framework?: Framework
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function object(value: JsonValue | undefined): value is JsonObject {
|
|
36
|
+
return isRecord(value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function applyEdits(source: string, edits: Edit[]) {
|
|
40
|
+
const sorted = edits.sort(
|
|
41
|
+
(left, right) =>
|
|
42
|
+
right.start - left.start ||
|
|
43
|
+
Number(left.text === ",") - Number(right.text === ","),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
for (const [index, edit] of sorted.entries()) {
|
|
47
|
+
if (edit.start < 0 || edit.end < edit.start || edit.end > source.length) {
|
|
48
|
+
throw new Error("Invalid configuration edit")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (index && edit.end > sorted[index - 1]!.start) {
|
|
52
|
+
throw new Error("Overlapping configuration edits")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
source = source.slice(0, edit.start) + edit.text + source.slice(edit.end)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return source
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasParseErrors(source: ts.SourceFile) {
|
|
62
|
+
return (
|
|
63
|
+
"parseDiagnostics" in source &&
|
|
64
|
+
Array.isArray(source.parseDiagnostics) &&
|
|
65
|
+
source.parseDiagnostics.length > 0
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parsed(path: string, content: string) {
|
|
70
|
+
const kind = /\.(?:js|mjs|cjs)$/.test(path)
|
|
71
|
+
? ts.ScriptKind.JS
|
|
72
|
+
: path.endsWith(".tsx")
|
|
73
|
+
? ts.ScriptKind.TSX
|
|
74
|
+
: ts.ScriptKind.TS
|
|
75
|
+
|
|
76
|
+
const source = ts.createSourceFile(
|
|
77
|
+
path,
|
|
78
|
+
content,
|
|
79
|
+
ts.ScriptTarget.Latest,
|
|
80
|
+
true,
|
|
81
|
+
kind,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if (hasParseErrors(source)) {
|
|
85
|
+
throw new Error(`Cannot parse ${path}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return source
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sameSyntax(left: ts.Node, right: ts.Node): boolean {
|
|
92
|
+
if (left.kind !== right.kind) return false
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
ts.isIdentifier(left) &&
|
|
96
|
+
ts.isIdentifier(right) &&
|
|
97
|
+
left.text !== right.text
|
|
98
|
+
)
|
|
99
|
+
return false
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
ts.isLiteralExpression(left) &&
|
|
103
|
+
ts.isLiteralExpression(right) &&
|
|
104
|
+
left.text !== right.text
|
|
105
|
+
)
|
|
106
|
+
return false
|
|
107
|
+
const leftChildren: ts.Node[] = []
|
|
108
|
+
const rightChildren: ts.Node[] = []
|
|
109
|
+
ts.forEachChild(left, (child) => {
|
|
110
|
+
leftChildren.push(child)
|
|
111
|
+
})
|
|
112
|
+
ts.forEachChild(right, (child) => {
|
|
113
|
+
rightChildren.push(child)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
leftChildren.length === rightChildren.length &&
|
|
118
|
+
leftChildren.every((child, index) =>
|
|
119
|
+
sameSyntax(child, rightChildren[index]!),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function property(node: ts.ObjectLiteralExpression, name: string) {
|
|
125
|
+
if (node.properties.some(ts.isSpreadAssignment)) {
|
|
126
|
+
throw new Error("Cannot safely edit a configuration object with spreads")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return node.properties.find(
|
|
130
|
+
(entry): entry is ts.PropertyAssignment =>
|
|
131
|
+
ts.isPropertyAssignment(entry) &&
|
|
132
|
+
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
|
|
133
|
+
entry.name.text === name,
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function objectValue(entry: ts.PropertyAssignment | undefined, path: string) {
|
|
138
|
+
if (!entry || !ts.isObjectLiteralExpression(entry.initializer)) {
|
|
139
|
+
throw new Error(`Unsupported configuration in ${path}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return entry.initializer
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function configObject(source: ts.SourceFile, path: string) {
|
|
146
|
+
const assignment = source.statements.find(ts.isExportAssignment)
|
|
147
|
+
|
|
148
|
+
if (!assignment)
|
|
149
|
+
throw new Error(`Expected a default configuration in ${path}`)
|
|
150
|
+
let expression: ts.Expression = assignment.expression
|
|
151
|
+
|
|
152
|
+
if (ts.isIdentifier(expression)) {
|
|
153
|
+
const declaration = source.statements
|
|
154
|
+
.filter(ts.isVariableStatement)
|
|
155
|
+
.flatMap((statement) => [...statement.declarationList.declarations])
|
|
156
|
+
.find(
|
|
157
|
+
(entry) => entry.name.getText(source) === expression.getText(source),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if (!declaration?.initializer) {
|
|
161
|
+
throw new Error(`Unsupported configuration in ${path}`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
expression = declaration.initializer
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (ts.isCallExpression(expression)) expression = expression.arguments[0]!
|
|
168
|
+
|
|
169
|
+
if (!expression || !ts.isObjectLiteralExpression(expression)) {
|
|
170
|
+
throw new Error(`Unsupported configuration in ${path}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return expression
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function addProperty(
|
|
177
|
+
source: ts.SourceFile,
|
|
178
|
+
target: ts.ObjectLiteralExpression,
|
|
179
|
+
text: string,
|
|
180
|
+
edits: Edit[],
|
|
181
|
+
) {
|
|
182
|
+
const close = target.getEnd() - 1
|
|
183
|
+
const last = target.properties.at(-1)
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
last &&
|
|
187
|
+
!/^\s*,/.test(source.text.slice(last.end, close)) &&
|
|
188
|
+
!edits.some((edit) => edit.start === last.end && edit.text === ",")
|
|
189
|
+
) {
|
|
190
|
+
edits.push({ start: last.end, end: last.end, text: "," })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const inserted = edits.find(
|
|
194
|
+
(edit) => edit.start === close && edit.end === close && edit.text !== ",",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if (inserted) inserted.text = `${inserted.text.trimEnd()},\n ${text}\n`
|
|
198
|
+
else edits.push({ start: close, end: close, text: `\n ${text}\n` })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function addArrayEntries(
|
|
202
|
+
source: ts.SourceFile,
|
|
203
|
+
target: ts.ArrayLiteralExpression,
|
|
204
|
+
values: string[],
|
|
205
|
+
edits: Edit[],
|
|
206
|
+
) {
|
|
207
|
+
const start = target.getStart(source) + 1
|
|
208
|
+
edits.push({
|
|
209
|
+
start,
|
|
210
|
+
end: start,
|
|
211
|
+
text: `\n ${values.join(",\n ")}${target.elements.length ? "," : ""}\n`,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function addAlias(
|
|
216
|
+
source: ts.SourceFile,
|
|
217
|
+
config: ts.ObjectLiteralExpression,
|
|
218
|
+
path: string,
|
|
219
|
+
edits: Edit[],
|
|
220
|
+
) {
|
|
221
|
+
const resolve = property(config, "resolve")
|
|
222
|
+
|
|
223
|
+
if (!resolve) {
|
|
224
|
+
addProperty(
|
|
225
|
+
source,
|
|
226
|
+
config,
|
|
227
|
+
'resolve: { alias: { "@": yopemSource } }',
|
|
228
|
+
edits,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const resolution = objectValue(resolve, path)
|
|
235
|
+
const alias = property(resolution, "alias")
|
|
236
|
+
|
|
237
|
+
if (!alias) {
|
|
238
|
+
addProperty(source, resolution, 'alias: { "@": yopemSource }', edits)
|
|
239
|
+
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const aliases = objectValue(alias, path)
|
|
244
|
+
const current = property(aliases, "@")
|
|
245
|
+
|
|
246
|
+
if (!current) {
|
|
247
|
+
addProperty(source, aliases, '"@": yopemSource', edits)
|
|
248
|
+
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const value = current.initializer.getText(source)
|
|
253
|
+
|
|
254
|
+
const declaration = ts.isIdentifier(current.initializer)
|
|
255
|
+
? source.statements
|
|
256
|
+
.filter(ts.isVariableStatement)
|
|
257
|
+
.flatMap((statement) => [...statement.declarationList.declarations])
|
|
258
|
+
.find((entry) => entry.name.getText(source) === value)
|
|
259
|
+
: undefined
|
|
260
|
+
|
|
261
|
+
const destination = declaration?.initializer?.getText(source) ?? value
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
!/["'](?:\.\/)?src(?:\/|["'])/.test(destination) &&
|
|
265
|
+
value !== "yopemSource"
|
|
266
|
+
) {
|
|
267
|
+
throw new Error(`Incompatible @ alias in ${path}`)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function moduleConfig(source: ts.SourceFile, path: string) {
|
|
272
|
+
if (source.statements.some(ts.isExportAssignment))
|
|
273
|
+
return configObject(source, path)
|
|
274
|
+
|
|
275
|
+
const exported = source.statements
|
|
276
|
+
.filter(ts.isExpressionStatement)
|
|
277
|
+
.map((statement) => statement.expression)
|
|
278
|
+
.find(
|
|
279
|
+
(expression): expression is ts.BinaryExpression =>
|
|
280
|
+
ts.isBinaryExpression(expression) &&
|
|
281
|
+
expression.left.getText(source) === "module.exports",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if (!exported || !ts.isObjectLiteralExpression(exported.right))
|
|
285
|
+
throw new Error(`Unsupported configuration in ${path}`)
|
|
286
|
+
|
|
287
|
+
return exported.right
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isConfigExport(statement: ts.Statement, source: ts.SourceFile) {
|
|
291
|
+
return (
|
|
292
|
+
ts.isExportAssignment(statement) ||
|
|
293
|
+
(ts.isExpressionStatement(statement) &&
|
|
294
|
+
ts.isBinaryExpression(statement.expression) &&
|
|
295
|
+
statement.expression.left.getText(source) === "module.exports")
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function staticLiteral(node: ts.Expression): boolean {
|
|
300
|
+
if (
|
|
301
|
+
ts.isStringLiteral(node) ||
|
|
302
|
+
ts.isNumericLiteral(node) ||
|
|
303
|
+
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
304
|
+
node.kind === ts.SyntaxKind.FalseKeyword ||
|
|
305
|
+
node.kind === ts.SyntaxKind.NullKeyword
|
|
306
|
+
)
|
|
307
|
+
return true
|
|
308
|
+
|
|
309
|
+
if (ts.isArrayLiteralExpression(node))
|
|
310
|
+
return node.elements.every((element) => staticLiteral(element))
|
|
311
|
+
|
|
312
|
+
if (ts.isObjectLiteralExpression(node))
|
|
313
|
+
return node.properties.every(
|
|
314
|
+
(entry) =>
|
|
315
|
+
ts.isPropertyAssignment(entry) &&
|
|
316
|
+
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
|
|
317
|
+
staticLiteral(entry.initializer),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function migrateBabel(content: string, path: string) {
|
|
324
|
+
if (path === ".babelrc" || path === ".babelrc.json") {
|
|
325
|
+
const config: JsonValue = JSON.parse(content)
|
|
326
|
+
|
|
327
|
+
if (
|
|
328
|
+
!object(config) ||
|
|
329
|
+
Object.keys(config).some((key) => key !== "plugins" && key !== "presets")
|
|
330
|
+
)
|
|
331
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
332
|
+
const parsedConfig = config
|
|
333
|
+
|
|
334
|
+
function entries(name: string) {
|
|
335
|
+
const values = parsedConfig[name]
|
|
336
|
+
|
|
337
|
+
if (values === undefined) return []
|
|
338
|
+
|
|
339
|
+
if (!Array.isArray(values))
|
|
340
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
341
|
+
|
|
342
|
+
return values.flatMap((value) => {
|
|
343
|
+
if (value === "@stylexjs/babel-plugin") return []
|
|
344
|
+
|
|
345
|
+
if (Array.isArray(value) && value[0] === "@stylexjs/babel-plugin") {
|
|
346
|
+
const options: JsonValue | undefined = value[1]
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
options !== undefined &&
|
|
350
|
+
(!object(options) ||
|
|
351
|
+
Object.keys(options).some((key) => key !== "runtimeInjection") ||
|
|
352
|
+
options.runtimeInjection !== false)
|
|
353
|
+
)
|
|
354
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
355
|
+
|
|
356
|
+
return []
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return [JSON.stringify(value)]
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { plugins: entries("plugins"), presets: entries("presets") }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const source = parsed(path, content)
|
|
367
|
+
const config = moduleConfig(source, path)
|
|
368
|
+
|
|
369
|
+
const names = config.properties.map((entry) =>
|
|
370
|
+
ts.isPropertyAssignment(entry) &&
|
|
371
|
+
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name))
|
|
372
|
+
? entry.name.text
|
|
373
|
+
: "",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if (names.some((name) => name !== "plugins" && name !== "presets"))
|
|
377
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
378
|
+
|
|
379
|
+
const legacy =
|
|
380
|
+
content.includes("function expandLocalSpreads(") &&
|
|
381
|
+
content.includes("/src/styles/tokens.stylex.ts")
|
|
382
|
+
|
|
383
|
+
if (
|
|
384
|
+
!legacy &&
|
|
385
|
+
source.statements.some((statement) => !isConfigExport(statement, source))
|
|
386
|
+
)
|
|
387
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
388
|
+
|
|
389
|
+
const entries = (name: string) => {
|
|
390
|
+
const value = property(config, name)
|
|
391
|
+
|
|
392
|
+
if (!value) return []
|
|
393
|
+
|
|
394
|
+
if (!ts.isArrayLiteralExpression(value.initializer))
|
|
395
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
396
|
+
|
|
397
|
+
return value.initializer.elements.flatMap((entry) => {
|
|
398
|
+
const plugin = ts.isArrayLiteralExpression(entry)
|
|
399
|
+
? entry.elements[0]
|
|
400
|
+
: entry
|
|
401
|
+
|
|
402
|
+
if (
|
|
403
|
+
name === "plugins" &&
|
|
404
|
+
plugin &&
|
|
405
|
+
ts.isStringLiteral(plugin) &&
|
|
406
|
+
plugin.text === "@stylexjs/babel-plugin"
|
|
407
|
+
) {
|
|
408
|
+
if (!legacy && ts.isArrayLiteralExpression(entry)) {
|
|
409
|
+
const options = entry.elements[1]
|
|
410
|
+
|
|
411
|
+
if (
|
|
412
|
+
options &&
|
|
413
|
+
(!ts.isObjectLiteralExpression(options) ||
|
|
414
|
+
options.properties.some(
|
|
415
|
+
(setting) =>
|
|
416
|
+
!ts.isPropertyAssignment(setting) ||
|
|
417
|
+
!(
|
|
418
|
+
ts.isIdentifier(setting.name) ||
|
|
419
|
+
ts.isStringLiteral(setting.name)
|
|
420
|
+
) ||
|
|
421
|
+
setting.name.text !== "runtimeInjection" ||
|
|
422
|
+
setting.initializer.kind !== ts.SyntaxKind.FalseKeyword,
|
|
423
|
+
))
|
|
424
|
+
)
|
|
425
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return []
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (
|
|
432
|
+
name === "plugins" &&
|
|
433
|
+
legacy &&
|
|
434
|
+
ts.isIdentifier(plugin) &&
|
|
435
|
+
plugin.text === "expandLocalSpreads"
|
|
436
|
+
)
|
|
437
|
+
return []
|
|
438
|
+
|
|
439
|
+
if (!staticLiteral(entry))
|
|
440
|
+
throw new Error(`Unsupported Babel config in ${path}`)
|
|
441
|
+
|
|
442
|
+
return [entry.getText(source)]
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { plugins: entries("plugins"), presets: entries("presets") }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function migratePostcss(content: string, path: string) {
|
|
450
|
+
const source = parsed(path, content)
|
|
451
|
+
const config = moduleConfig(source, path)
|
|
452
|
+
|
|
453
|
+
if (
|
|
454
|
+
source.statements.some(
|
|
455
|
+
(statement) =>
|
|
456
|
+
!isConfigExport(statement, source) &&
|
|
457
|
+
!(
|
|
458
|
+
ts.isVariableStatement(statement) &&
|
|
459
|
+
statement.getText(source) ===
|
|
460
|
+
'const babelConfig = require("./babel.config.cjs")' &&
|
|
461
|
+
content.includes('"@stylexjs/postcss-plugin"')
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
466
|
+
|
|
467
|
+
if (
|
|
468
|
+
config.properties.some(
|
|
469
|
+
(entry) =>
|
|
470
|
+
!ts.isPropertyAssignment(entry) ||
|
|
471
|
+
!(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) ||
|
|
472
|
+
entry.name.text !== "plugins",
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
476
|
+
const plugins = property(config, "plugins")?.initializer
|
|
477
|
+
|
|
478
|
+
if (plugins && ts.isArrayLiteralExpression(plugins)) {
|
|
479
|
+
return plugins.elements.map((entry) => {
|
|
480
|
+
if (ts.isStringLiteral(entry))
|
|
481
|
+
return `yopemCreateRequire(import.meta.url)(${JSON.stringify(entry.text)})()`
|
|
482
|
+
|
|
483
|
+
if (!ts.isCallExpression(entry))
|
|
484
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
485
|
+
|
|
486
|
+
const factory = ts.isCallExpression(entry.expression)
|
|
487
|
+
? entry.expression
|
|
488
|
+
: entry
|
|
489
|
+
|
|
490
|
+
if (
|
|
491
|
+
!ts.isIdentifier(factory.expression) ||
|
|
492
|
+
factory.expression.text !== "require" ||
|
|
493
|
+
factory.arguments.length !== 1 ||
|
|
494
|
+
!ts.isStringLiteral(factory.arguments[0]) ||
|
|
495
|
+
(factory === entry && entry.arguments.length !== 1) ||
|
|
496
|
+
(factory !== entry &&
|
|
497
|
+
(entry.arguments.length !== 1 || !staticLiteral(entry.arguments[0]!)))
|
|
498
|
+
)
|
|
499
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
500
|
+
|
|
501
|
+
const options =
|
|
502
|
+
factory === entry ? "" : entry.arguments[0]!.getText(source)
|
|
503
|
+
|
|
504
|
+
return `yopemCreateRequire(import.meta.url)(${JSON.stringify(factory.arguments[0].text)})(${options})`
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!plugins || !ts.isObjectLiteralExpression(plugins))
|
|
509
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
510
|
+
|
|
511
|
+
return plugins.properties.flatMap((entry) => {
|
|
512
|
+
if (
|
|
513
|
+
!ts.isPropertyAssignment(entry) ||
|
|
514
|
+
!(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name))
|
|
515
|
+
)
|
|
516
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
517
|
+
const name = entry.name.text
|
|
518
|
+
|
|
519
|
+
if (name === "@stylexjs/postcss-plugin") {
|
|
520
|
+
if (
|
|
521
|
+
!content.includes(
|
|
522
|
+
'const babelConfig = require("./babel.config.cjs")',
|
|
523
|
+
) &&
|
|
524
|
+
(!ts.isObjectLiteralExpression(entry.initializer) ||
|
|
525
|
+
entry.initializer.properties.length > 0)
|
|
526
|
+
)
|
|
527
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
528
|
+
|
|
529
|
+
return []
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (entry.initializer.kind === ts.SyntaxKind.FalseKeyword) return []
|
|
533
|
+
|
|
534
|
+
if (!staticLiteral(entry.initializer))
|
|
535
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
536
|
+
|
|
537
|
+
const options =
|
|
538
|
+
entry.initializer.kind === ts.SyntaxKind.TrueKeyword
|
|
539
|
+
? ""
|
|
540
|
+
: entry.initializer.getText(source)
|
|
541
|
+
|
|
542
|
+
return [
|
|
543
|
+
`yopemCreateRequire(import.meta.url)(${JSON.stringify(name)})(${options})`,
|
|
544
|
+
]
|
|
545
|
+
})
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function babelPlugin(presets = false) {
|
|
549
|
+
return `babel({ plugins: yopemBabelPlugins${presets ? ", presets: yopemBabelPresets" : ""} })`
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function stylexSetup(babelPlugins: string[] = [], babelPresets: string[] = []) {
|
|
553
|
+
return `import babel from "@rolldown/plugin-babel"
|
|
554
|
+
import { createRequire as yopemCreateRequire } from "node:module"
|
|
555
|
+
import { fileURLToPath as yopemFileURLToPath } from "node:url"
|
|
556
|
+
|
|
557
|
+
const yopemSource = yopemFileURLToPath(new URL("./src", import.meta.url))
|
|
558
|
+
const yopemBabelPlugins = [${babelPlugins.join(", ")}${babelPlugins.length ? ", " : ""}["@stylexjs/babel-plugin", {
|
|
559
|
+
aliases: { "@/*": [yopemSource + "/*"] },
|
|
560
|
+
dev: process.env.NODE_ENV !== "production",
|
|
561
|
+
runtimeInjection: false,
|
|
562
|
+
treeshakeCompensation: true,
|
|
563
|
+
unstable_moduleResolution: { type: "commonJS" },
|
|
564
|
+
}]]${babelPresets.length ? `\nconst yopemBabelPresets = [${babelPresets.join(", ")}]` : ""}
|
|
565
|
+
const yopemPostcssPlugin = yopemCreateRequire(import.meta.url)("@stylexjs/postcss-plugin")({
|
|
566
|
+
cwd: yopemFileURLToPath(new URL(".", import.meta.url)),
|
|
567
|
+
include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}"],
|
|
568
|
+
babelConfig: {
|
|
569
|
+
babelrc: false,
|
|
570
|
+
parserOpts: { plugins: ["typescript", "jsx"] },
|
|
571
|
+
plugins: yopemBabelPlugins,${babelPresets.length ? "\n presets: yopemBabelPresets," : ""}
|
|
572
|
+
},
|
|
573
|
+
useCSSLayers: true,
|
|
574
|
+
})
|
|
575
|
+
if (typeof yopemPostcssPlugin?.postcssPlugin !== "string")
|
|
576
|
+
throw new Error("Invalid StyleX PostCSS plugin")
|
|
577
|
+
`
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function hasYopemStylexConfig(content: string) {
|
|
581
|
+
return content.includes("babel({ plugins: yopemBabelPlugins")
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function addStylexPostcss(
|
|
585
|
+
source: ts.SourceFile,
|
|
586
|
+
config: ts.ObjectLiteralExpression,
|
|
587
|
+
path: string,
|
|
588
|
+
edits: Edit[],
|
|
589
|
+
existingPlugins: string[],
|
|
590
|
+
) {
|
|
591
|
+
const css = property(config, "css")
|
|
592
|
+
|
|
593
|
+
if (css) {
|
|
594
|
+
const options = objectValue(css, path)
|
|
595
|
+
const postcss = property(options, "postcss")
|
|
596
|
+
|
|
597
|
+
if (postcss) {
|
|
598
|
+
const plugins = property(objectValue(postcss, path), "plugins")
|
|
599
|
+
|
|
600
|
+
if (!plugins || !ts.isArrayLiteralExpression(plugins.initializer))
|
|
601
|
+
throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
602
|
+
addArrayEntries(
|
|
603
|
+
source,
|
|
604
|
+
plugins.initializer,
|
|
605
|
+
["yopemPostcssPlugin", ...existingPlugins],
|
|
606
|
+
edits,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
addProperty(
|
|
613
|
+
source,
|
|
614
|
+
options,
|
|
615
|
+
`postcss: { plugins: [yopemPostcssPlugin${existingPlugins.map((plugin) => `, ${plugin}`).join("")}] }`,
|
|
616
|
+
edits,
|
|
617
|
+
)
|
|
618
|
+
} else {
|
|
619
|
+
addProperty(
|
|
620
|
+
source,
|
|
621
|
+
config,
|
|
622
|
+
`css: { postcss: { plugins: [yopemPostcssPlugin${existingPlugins.map((plugin) => `, ${plugin}`).join("")}] } }`,
|
|
623
|
+
edits,
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function stripLegacyConfig(content: string, path: string) {
|
|
629
|
+
const legacyImport = 'import yopemBabelConfig from "./babel.config.cjs"'
|
|
630
|
+
|
|
631
|
+
if (!content.includes(legacyImport)) return content
|
|
632
|
+
const source = parsed(path, content)
|
|
633
|
+
const pluginText = "babel({ plugins: yopemBabelConfig.plugins })"
|
|
634
|
+
const config = configObject(source, path)
|
|
635
|
+
|
|
636
|
+
const vite = path.startsWith("astro.config")
|
|
637
|
+
? objectValue(property(config, "vite"), path)
|
|
638
|
+
: config
|
|
639
|
+
|
|
640
|
+
const plugins = property(vite, "plugins")?.initializer
|
|
641
|
+
|
|
642
|
+
if (!plugins || !ts.isArrayLiteralExpression(plugins))
|
|
643
|
+
throw new Error(`Unsupported legacy configuration in ${path}`)
|
|
644
|
+
|
|
645
|
+
const index = plugins.elements.findIndex(
|
|
646
|
+
(entry) => entry.getText(source) === pluginText,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
if (index < 0) throw new Error(`Unsupported legacy configuration in ${path}`)
|
|
650
|
+
const entry = plugins.elements[index]!
|
|
651
|
+
const previous = plugins.elements[index - 1]
|
|
652
|
+
const next = plugins.elements[index + 1]
|
|
653
|
+
|
|
654
|
+
const edits: Edit[] = [
|
|
655
|
+
{
|
|
656
|
+
start: previous && !next ? previous.end : entry.getStart(source),
|
|
657
|
+
end: next ? next.getStart(source) : entry.end,
|
|
658
|
+
text: "",
|
|
659
|
+
},
|
|
660
|
+
]
|
|
661
|
+
|
|
662
|
+
for (const statement of source.statements) {
|
|
663
|
+
if (
|
|
664
|
+
ts.isImportDeclaration(statement) &&
|
|
665
|
+
ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
666
|
+
["@rolldown/plugin-babel", "./babel.config.cjs", "node:url"].includes(
|
|
667
|
+
statement.moduleSpecifier.text,
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
edits.push({
|
|
671
|
+
start: statement.getStart(source),
|
|
672
|
+
end: statement.end,
|
|
673
|
+
text: "",
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
if (
|
|
677
|
+
ts.isVariableStatement(statement) &&
|
|
678
|
+
statement.declarationList.declarations.some(
|
|
679
|
+
(declaration) => declaration.name.getText(source) === "yopemSource",
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
edits.push({
|
|
683
|
+
start: statement.getStart(source),
|
|
684
|
+
end: statement.end,
|
|
685
|
+
text: "",
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (edits.length !== 5)
|
|
690
|
+
throw new Error(`Unsupported legacy configuration in ${path}`)
|
|
691
|
+
|
|
692
|
+
return applyEdits(content, edits)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function viteConfig(
|
|
696
|
+
content: string,
|
|
697
|
+
path: string,
|
|
698
|
+
babelConfig: ReturnType<typeof migrateBabel>,
|
|
699
|
+
postcssPlugins: string[],
|
|
700
|
+
) {
|
|
701
|
+
content = stripLegacyConfig(content, path)
|
|
702
|
+
|
|
703
|
+
if (content.includes("stylex.vite(") || hasYopemStylexConfig(content)) {
|
|
704
|
+
if (!content.includes('"@":') && !content.includes("'@':")) {
|
|
705
|
+
throw new Error(`Existing StyleX configuration lacks @ alias in ${path}`)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (
|
|
709
|
+
!hasYopemStylexConfig(content) ||
|
|
710
|
+
!content.includes("plugins: [yopemPostcssPlugin")
|
|
711
|
+
) {
|
|
712
|
+
throw new Error(`Incomplete Yopem build configuration in ${path}`)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return content
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const source = parsed(path, content)
|
|
719
|
+
const config = configObject(source, path)
|
|
720
|
+
const plugins = property(config, "plugins")
|
|
721
|
+
const entries = [babelPlugin(babelConfig.presets.length > 0)]
|
|
722
|
+
const edits: Edit[] = []
|
|
723
|
+
|
|
724
|
+
if (plugins) {
|
|
725
|
+
if (!ts.isArrayLiteralExpression(plugins.initializer)) {
|
|
726
|
+
throw new Error(`Unsupported plugins configuration in ${path}`)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
addArrayEntries(source, plugins.initializer, entries, edits)
|
|
730
|
+
} else {
|
|
731
|
+
addProperty(source, config, `plugins: [${entries.join(", ")}]`, edits)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
addAlias(source, config, path, edits)
|
|
735
|
+
addStylexPostcss(source, config, path, edits, postcssPlugins)
|
|
736
|
+
edits.push({
|
|
737
|
+
start: 0,
|
|
738
|
+
end: 0,
|
|
739
|
+
text: stylexSetup(babelConfig.plugins, babelConfig.presets),
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
return applyEdits(content, edits)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function astroConfig(
|
|
746
|
+
content: string,
|
|
747
|
+
path: string,
|
|
748
|
+
babelConfig: ReturnType<typeof migrateBabel>,
|
|
749
|
+
postcssPlugins: string[],
|
|
750
|
+
) {
|
|
751
|
+
content = stripLegacyConfig(content, path)
|
|
752
|
+
const source = parsed(path, content)
|
|
753
|
+
|
|
754
|
+
const reactImport = source.statements.find(
|
|
755
|
+
(statement): statement is ts.ImportDeclaration =>
|
|
756
|
+
ts.isImportDeclaration(statement) &&
|
|
757
|
+
ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
758
|
+
statement.moduleSpecifier.text === "@astrojs/react",
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
if (reactImport && !reactImport.importClause?.name) {
|
|
762
|
+
throw new Error(`Unsupported React integration import in ${path}`)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const reactName = reactImport?.importClause?.name?.text ?? "react"
|
|
766
|
+
|
|
767
|
+
if (content.includes("react()") && !reactImport) {
|
|
768
|
+
throw new Error(`Unknown react() integration in ${path}`)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const alreadyConfigured =
|
|
772
|
+
hasYopemStylexConfig(content) && content.includes(`${reactName}()`)
|
|
773
|
+
|
|
774
|
+
if (alreadyConfigured) {
|
|
775
|
+
if (!content.includes('"@":') && !content.includes("'@':")) {
|
|
776
|
+
throw new Error(`Existing StyleX configuration lacks @ alias in ${path}`)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (!content.includes("plugins: [yopemPostcssPlugin")) {
|
|
780
|
+
throw new Error(`Incomplete Yopem build configuration in ${path}`)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return content
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (content.includes("stylex.vite(")) {
|
|
787
|
+
throw new Error(`Incomplete Yopem build configuration in ${path}`)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const config = configObject(source, path)
|
|
791
|
+
const edits: Edit[] = []
|
|
792
|
+
const integrations = property(config, "integrations")
|
|
793
|
+
|
|
794
|
+
if (integrations) {
|
|
795
|
+
if (!ts.isArrayLiteralExpression(integrations.initializer)) {
|
|
796
|
+
throw new Error(`Unsupported integrations in ${path}`)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (!content.includes(`${reactName}()`)) {
|
|
800
|
+
addArrayEntries(
|
|
801
|
+
source,
|
|
802
|
+
integrations.initializer,
|
|
803
|
+
[`${reactName}()`],
|
|
804
|
+
edits,
|
|
805
|
+
)
|
|
806
|
+
}
|
|
807
|
+
} else {
|
|
808
|
+
addProperty(source, config, `integrations: [${reactName}()]`, edits)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const vite = property(config, "vite")
|
|
812
|
+
|
|
813
|
+
if (!vite) {
|
|
814
|
+
addProperty(
|
|
815
|
+
source,
|
|
816
|
+
config,
|
|
817
|
+
`vite: { resolve: { alias: { "@": yopemSource } }, plugins: [${babelPlugin(babelConfig.presets.length > 0)}], css: { postcss: { plugins: [yopemPostcssPlugin${postcssPlugins.map((plugin) => `, ${plugin}`).join("")}] } } }`,
|
|
818
|
+
edits,
|
|
819
|
+
)
|
|
820
|
+
} else {
|
|
821
|
+
const nested = objectValue(vite, path)
|
|
822
|
+
addAlias(source, nested, path, edits)
|
|
823
|
+
addStylexPostcss(source, nested, path, edits, postcssPlugins)
|
|
824
|
+
const plugins = property(nested, "plugins")
|
|
825
|
+
|
|
826
|
+
if (plugins) {
|
|
827
|
+
if (!ts.isArrayLiteralExpression(plugins.initializer)) {
|
|
828
|
+
throw new Error(`Unsupported Vite plugins in ${path}`)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
addArrayEntries(
|
|
832
|
+
source,
|
|
833
|
+
plugins.initializer,
|
|
834
|
+
[babelPlugin(babelConfig.presets.length > 0)],
|
|
835
|
+
edits,
|
|
836
|
+
)
|
|
837
|
+
} else {
|
|
838
|
+
addProperty(
|
|
839
|
+
source,
|
|
840
|
+
nested,
|
|
841
|
+
`plugins: [${babelPlugin(babelConfig.presets.length > 0)}]`,
|
|
842
|
+
edits,
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
edits.push({
|
|
848
|
+
start: 0,
|
|
849
|
+
end: 0,
|
|
850
|
+
text: `${reactImport ? "" : 'import react from "@astrojs/react"\n'}${stylexSetup(babelConfig.plugins, babelConfig.presets)}`,
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
return applyEdits(content, edits)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function tsconfig(content: string, path: string) {
|
|
857
|
+
const source = ts.parseJsonText(path, content)
|
|
858
|
+
|
|
859
|
+
if (hasParseErrors(source)) throw new Error(`Cannot parse ${path}`)
|
|
860
|
+
const root = source.statements[0]
|
|
861
|
+
|
|
862
|
+
if (
|
|
863
|
+
!root ||
|
|
864
|
+
!ts.isExpressionStatement(root) ||
|
|
865
|
+
!ts.isObjectLiteralExpression(root.expression)
|
|
866
|
+
) {
|
|
867
|
+
throw new Error(`Unsupported ${path}`)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const edits: Edit[] = []
|
|
871
|
+
const config = root.expression
|
|
872
|
+
const compiler = property(config, "compilerOptions")
|
|
873
|
+
|
|
874
|
+
if (!compiler) {
|
|
875
|
+
addProperty(
|
|
876
|
+
source,
|
|
877
|
+
config,
|
|
878
|
+
'"compilerOptions": { "noEmit": true, "allowImportingTsExtensions": true, "paths": { "@/*": ["./src/*"] } }',
|
|
879
|
+
edits,
|
|
880
|
+
)
|
|
881
|
+
} else {
|
|
882
|
+
const options = objectValue(compiler, path)
|
|
883
|
+
|
|
884
|
+
for (const name of ["noEmit", "allowImportingTsExtensions"]) {
|
|
885
|
+
const current = property(options, name)
|
|
886
|
+
|
|
887
|
+
if (!current) addProperty(source, options, `"${name}": true`, edits)
|
|
888
|
+
else if (current.initializer.kind !== ts.SyntaxKind.TrueKeyword) {
|
|
889
|
+
throw new Error(`Incompatible ${name} in ${path}`)
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const baseUrl = property(options, "baseUrl")
|
|
894
|
+
|
|
895
|
+
if (
|
|
896
|
+
baseUrl &&
|
|
897
|
+
(!ts.isStringLiteral(baseUrl.initializer) ||
|
|
898
|
+
![".", "./"].includes(baseUrl.initializer.text))
|
|
899
|
+
) {
|
|
900
|
+
throw new Error(`Incompatible baseUrl in ${path}`)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const paths = property(options, "paths")
|
|
904
|
+
|
|
905
|
+
if (!paths) {
|
|
906
|
+
addProperty(source, options, '"paths": { "@/*": ["./src/*"] }', edits)
|
|
907
|
+
} else {
|
|
908
|
+
const aliases = objectValue(paths, path)
|
|
909
|
+
const alias = property(aliases, "@/*")
|
|
910
|
+
|
|
911
|
+
if (!alias) {
|
|
912
|
+
addProperty(source, aliases, '"@/*": ["./src/*"]', edits)
|
|
913
|
+
} else if (
|
|
914
|
+
!ts.isArrayLiteralExpression(alias.initializer) ||
|
|
915
|
+
alias.initializer.elements.length !== 1 ||
|
|
916
|
+
!ts.isStringLiteral(alias.initializer.elements[0]!) ||
|
|
917
|
+
!["./src/*", "src/*"].includes(alias.initializer.elements[0]!.text)
|
|
918
|
+
) {
|
|
919
|
+
throw new Error(`Incompatible @/* alias in ${path}`)
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return applyEdits(content, edits)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function reactEntry(content: string, path: string) {
|
|
928
|
+
if (content.includes("themeMarker") || content.includes("rootStyles.html")) {
|
|
929
|
+
if (
|
|
930
|
+
!content.includes('"@/styles/styles.css"') ||
|
|
931
|
+
!content.includes("rootStyles.html") ||
|
|
932
|
+
!content.includes('dataset.theme = "light"')
|
|
933
|
+
) {
|
|
934
|
+
throw new Error(`Incomplete Yopem root styles in ${path}`)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return content
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const source = parsed(path, content)
|
|
941
|
+
const imports = source.statements.filter(ts.isImportDeclaration)
|
|
942
|
+
|
|
943
|
+
if (!imports.length) throw new Error(`No React imports in ${path}`)
|
|
944
|
+
const last = imports.at(-1)!
|
|
945
|
+
|
|
946
|
+
return applyEdits(content, [
|
|
947
|
+
{
|
|
948
|
+
start: 0,
|
|
949
|
+
end: 0,
|
|
950
|
+
text: 'import "@/styles/styles.css"\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n',
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
start: last.end,
|
|
954
|
+
end: last.end,
|
|
955
|
+
text: '\n\nconst yopemRoot = stylex.props(themeMarker, lightTheme, rootStyles.html)\ndocument.documentElement.classList.add(...(yopemRoot.className ?? "").split(" ").filter(Boolean))\ndocument.documentElement.dataset.theme = "light"\n',
|
|
956
|
+
},
|
|
957
|
+
])
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function jsxLayout(
|
|
961
|
+
content: string,
|
|
962
|
+
path: string,
|
|
963
|
+
framework: "next" | "tanstack-start" | "react-router",
|
|
964
|
+
) {
|
|
965
|
+
if (content.includes("stylexProps(") || content.includes("rootStyles.html")) {
|
|
966
|
+
if (
|
|
967
|
+
!content.includes('"@/styles/styles.css"') ||
|
|
968
|
+
!content.includes("rootStyles.html") ||
|
|
969
|
+
!content.includes("rootStyles.body")
|
|
970
|
+
) {
|
|
971
|
+
throw new Error(`Incomplete Yopem layout in ${path}`)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return content
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const source = parsed(path, content)
|
|
978
|
+
|
|
979
|
+
const elements: Record<"html" | "body" | "head", ts.JsxOpeningElement[]> = {
|
|
980
|
+
html: [],
|
|
981
|
+
body: [],
|
|
982
|
+
head: [],
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function visit(node: ts.Node): void {
|
|
986
|
+
if (ts.isJsxOpeningElement(node)) {
|
|
987
|
+
const tag = node.tagName.getText(source)
|
|
988
|
+
|
|
989
|
+
if (tag === "html" || tag === "body" || tag === "head") {
|
|
990
|
+
elements[tag].push(node)
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
ts.forEachChild(node, visit)
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
visit(source)
|
|
998
|
+
|
|
999
|
+
if (
|
|
1000
|
+
elements.html.length !== 1 ||
|
|
1001
|
+
elements.body.length !== 1 ||
|
|
1002
|
+
(framework === "tanstack-start" && elements.head.length !== 1)
|
|
1003
|
+
) {
|
|
1004
|
+
throw new Error(
|
|
1005
|
+
`Expected one html/body root${framework === "tanstack-start" ? "/head" : ""} in ${path}`,
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const edits: Edit[] = []
|
|
1010
|
+
|
|
1011
|
+
for (const tag of ["html", "body"] as const) {
|
|
1012
|
+
const opening = elements[tag][0]!
|
|
1013
|
+
|
|
1014
|
+
if (opening.attributes.properties.some(ts.isJsxSpreadAttribute)) {
|
|
1015
|
+
throw new Error(`Unsupported ${tag} props spread in ${path}`)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const original = opening.getText(source)
|
|
1019
|
+
|
|
1020
|
+
const className = opening.attributes.properties.find(
|
|
1021
|
+
(attribute): attribute is ts.JsxAttribute =>
|
|
1022
|
+
ts.isJsxAttribute(attribute) &&
|
|
1023
|
+
attribute.name.getText(source) === "className",
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
let existing = "undefined"
|
|
1027
|
+
let next = original
|
|
1028
|
+
|
|
1029
|
+
if (className) {
|
|
1030
|
+
const initializer = className.initializer
|
|
1031
|
+
|
|
1032
|
+
if (initializer && ts.isStringLiteral(initializer)) {
|
|
1033
|
+
existing = JSON.stringify(initializer.text)
|
|
1034
|
+
} else if (
|
|
1035
|
+
initializer &&
|
|
1036
|
+
ts.isJsxExpression(initializer) &&
|
|
1037
|
+
initializer.expression
|
|
1038
|
+
) {
|
|
1039
|
+
existing = initializer.expression.getText(source)
|
|
1040
|
+
} else {
|
|
1041
|
+
throw new Error(`Unsupported root className in ${path}`)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const from = className.getStart(source) - opening.getStart(source)
|
|
1045
|
+
next =
|
|
1046
|
+
next.slice(0, from) +
|
|
1047
|
+
next.slice(from + className.getText(source).length)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (tag === "html" && !/\bdata-theme\s*=/.test(next)) {
|
|
1051
|
+
next = next.replace(/>$/, ' data-theme="light">')
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const styles =
|
|
1055
|
+
tag === "html"
|
|
1056
|
+
? "themeMarker, lightTheme, rootStyles.html"
|
|
1057
|
+
: "rootStyles.body"
|
|
1058
|
+
|
|
1059
|
+
next = next.replace(/>$/, ` {...stylexProps(${existing}, ${styles})}>`)
|
|
1060
|
+
edits.push({
|
|
1061
|
+
start: opening.getStart(source),
|
|
1062
|
+
end: opening.getEnd(),
|
|
1063
|
+
text: next,
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const css = 'import "@/styles/styles.css"\n'
|
|
1068
|
+
edits.push({
|
|
1069
|
+
start: 0,
|
|
1070
|
+
end: 0,
|
|
1071
|
+
text: `${css}import { stylexProps } from "@/lib/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n`,
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
return applyEdits(content, edits)
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function astroLayout(content: string, path: string) {
|
|
1078
|
+
if (content.includes("yopemHtml.className")) {
|
|
1079
|
+
if (
|
|
1080
|
+
!content.includes('"@/styles/styles.css"') ||
|
|
1081
|
+
!content.includes("yopemBody.className")
|
|
1082
|
+
) {
|
|
1083
|
+
throw new Error(`Incomplete Yopem layout in ${path}`)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return content
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (content.includes("rootStyles.html") || content.includes("yopemBody")) {
|
|
1090
|
+
throw new Error(`Incomplete Yopem layout in ${path}`)
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (!/^---\s*\n/.test(content)) {
|
|
1094
|
+
if (content.startsWith("---"))
|
|
1095
|
+
throw new Error(`Expected Astro frontmatter in ${path}`)
|
|
1096
|
+
content = `---\n---\n${content}`
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const end = content.indexOf("\n---", 3)
|
|
1100
|
+
const head = [...content.matchAll(/<head(?:\s+[^<>]*)?>/g)]
|
|
1101
|
+
const html = [...content.matchAll(/<html(?:\s+[^<>]*)?>/g)]
|
|
1102
|
+
const body = [...content.matchAll(/<body(?:\s+[^<>]*)?>/g)]
|
|
1103
|
+
|
|
1104
|
+
if (
|
|
1105
|
+
end < 0 ||
|
|
1106
|
+
head.length !== 1 ||
|
|
1107
|
+
html.length !== 1 ||
|
|
1108
|
+
body.length !== 1 ||
|
|
1109
|
+
head[0]!.index! < end ||
|
|
1110
|
+
html[0]!.index! < end ||
|
|
1111
|
+
body[0]!.index! < end
|
|
1112
|
+
) {
|
|
1113
|
+
throw new Error(`Expected one html/head/body layout in ${path}`)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const edits: Edit[] = [
|
|
1117
|
+
{
|
|
1118
|
+
start: end,
|
|
1119
|
+
end,
|
|
1120
|
+
text: `${content.includes('"@/styles/styles.css"') ? "" : '\nimport "@/styles/styles.css"'}\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n\nconst yopemHtml = stylex.props(themeMarker, lightTheme, rootStyles.html)\nconst yopemBody = stylex.props(rootStyles.body)`,
|
|
1121
|
+
},
|
|
1122
|
+
]
|
|
1123
|
+
|
|
1124
|
+
for (const [tag, match] of [
|
|
1125
|
+
["html", html[0]!],
|
|
1126
|
+
["body", body[0]!],
|
|
1127
|
+
] as const) {
|
|
1128
|
+
let opening = match[0]
|
|
1129
|
+
|
|
1130
|
+
if (/\bclass:list\s*=/.test(opening)) {
|
|
1131
|
+
throw new Error(`Unsupported ${tag} class:list in ${path}`)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const existing = opening.match(/\bclass\s*=\s*(["'])([^"']*)\1/)
|
|
1135
|
+
|
|
1136
|
+
if (/\bclass\s*=/.test(opening) && !existing) {
|
|
1137
|
+
throw new Error(`Unsupported ${tag} class in ${path}`)
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const className = `yopem${tag === "html" ? "Html" : "Body"}.className`
|
|
1141
|
+
opening = existing
|
|
1142
|
+
? opening.replace(
|
|
1143
|
+
existing[0],
|
|
1144
|
+
`class:list={[
|
|
1145
|
+
${JSON.stringify(existing[2])}, ${className}
|
|
1146
|
+
]}`,
|
|
1147
|
+
)
|
|
1148
|
+
: opening.replace(/>$/, ` class={${className}}>`)
|
|
1149
|
+
|
|
1150
|
+
if (tag === "html") {
|
|
1151
|
+
if (
|
|
1152
|
+
/\bdata-theme\s*=/.test(opening) &&
|
|
1153
|
+
!/\bdata-theme\s*=\s*["']light["']/.test(opening)
|
|
1154
|
+
) {
|
|
1155
|
+
throw new Error(`Conflicting data-theme in ${path}`)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (!/\bdata-theme\s*=/.test(opening)) {
|
|
1159
|
+
opening = opening.replace(/>$/, ' data-theme="light">')
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
edits.push({
|
|
1164
|
+
start: match.index!,
|
|
1165
|
+
end: match.index! + match[0].length,
|
|
1166
|
+
text: opening,
|
|
1167
|
+
})
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return applyEdits(content, edits)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function nextBabel(esm: boolean) {
|
|
1174
|
+
const header = esm
|
|
1175
|
+
? `import { createRequire } from "node:module"
|
|
1176
|
+
import { dirname } from "node:path"
|
|
1177
|
+
import { fileURLToPath } from "node:url"
|
|
1178
|
+
const require = createRequire(import.meta.url)
|
|
1179
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))`
|
|
1180
|
+
: ""
|
|
1181
|
+
|
|
1182
|
+
return `${header}
|
|
1183
|
+
// Next loads this Babel config synchronously through CommonJS.
|
|
1184
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1185
|
+
const path = require("node:path")
|
|
1186
|
+
|
|
1187
|
+
function expandLocalSpreads({ types: t }) {
|
|
1188
|
+
return {
|
|
1189
|
+
visitor: {
|
|
1190
|
+
Program: {
|
|
1191
|
+
enter(program, state) {
|
|
1192
|
+
if (!state.filename?.replaceAll("\\\\", "/").endsWith("/src/styles/tokens.stylex.ts")) return
|
|
1193
|
+
program.traverse({
|
|
1194
|
+
ObjectExpression(path) {
|
|
1195
|
+
path.node.properties = path.node.properties.flatMap((property) => {
|
|
1196
|
+
if (!t.isSpreadElement(property) || !t.isIdentifier(property.argument)) return [property]
|
|
1197
|
+
const binding = path.scope.getBinding(property.argument.name)
|
|
1198
|
+
const value = binding?.path.node.init
|
|
1199
|
+
if (!t.isObjectExpression(value) || value.properties.some(t.isSpreadElement)) return [property]
|
|
1200
|
+
return value.properties.map((part) => t.cloneNode(part, true))
|
|
1201
|
+
})
|
|
1202
|
+
},
|
|
1203
|
+
})
|
|
1204
|
+
},
|
|
1205
|
+
},
|
|
1206
|
+
},
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
${esm ? 'export const presets = ["next/babel"]\nexport const plugins = [' : 'module.exports = {\n presets: ["next/babel"],\n plugins: ['}expandLocalSpreads, ["@stylexjs/babel-plugin", {
|
|
1211
|
+
aliases: { "@/*": [path.join(__dirname, "src/*")] },
|
|
1212
|
+
dev: process.env.NODE_ENV !== "production",
|
|
1213
|
+
runtimeInjection: false,
|
|
1214
|
+
treeshakeCompensation: true,
|
|
1215
|
+
unstable_moduleResolution: { type: "commonJS" },
|
|
1216
|
+
}]]${esm ? "" : ",\n}"}
|
|
1217
|
+
`
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function nextPostcss() {
|
|
1221
|
+
return `// Next loads this PostCSS config synchronously through CommonJS.
|
|
1222
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1223
|
+
const babelConfig = require("./babel.config.js")
|
|
1224
|
+
|
|
1225
|
+
module.exports = {
|
|
1226
|
+
plugins: {
|
|
1227
|
+
"@stylexjs/postcss-plugin": {
|
|
1228
|
+
include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"],
|
|
1229
|
+
babelConfig: {
|
|
1230
|
+
babelrc: false,
|
|
1231
|
+
parserOpts: { plugins: ["typescript", "jsx"] },
|
|
1232
|
+
plugins: babelConfig.plugins,
|
|
1233
|
+
},
|
|
1234
|
+
useCSSLayers: true,
|
|
1235
|
+
},
|
|
1236
|
+
autoprefixer: {},
|
|
1237
|
+
},
|
|
1238
|
+
}
|
|
1239
|
+
`
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function postcss(content: string, path: string, esm: boolean) {
|
|
1243
|
+
if (content.includes('"@stylexjs/postcss-plugin"')) return content
|
|
1244
|
+
const source = parsed(path, content)
|
|
1245
|
+
let config: ts.ObjectLiteralExpression | undefined
|
|
1246
|
+
const exported = source.statements.find(ts.isExportAssignment)
|
|
1247
|
+
|
|
1248
|
+
if (exported) {
|
|
1249
|
+
config = configObject(source, path)
|
|
1250
|
+
} else {
|
|
1251
|
+
const assignment = source.statements
|
|
1252
|
+
.filter(ts.isExpressionStatement)
|
|
1253
|
+
.map((entry) => entry.expression)
|
|
1254
|
+
.find(
|
|
1255
|
+
(entry): entry is ts.BinaryExpression =>
|
|
1256
|
+
ts.isBinaryExpression(entry) &&
|
|
1257
|
+
entry.left.getText(source) === "module.exports",
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
if (assignment && ts.isObjectLiteralExpression(assignment.right)) {
|
|
1261
|
+
config = assignment.right
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (!config) throw new Error(`Unsupported PostCSS config in ${path}`)
|
|
1266
|
+
const plugins = objectValue(property(config, "plugins"), path)
|
|
1267
|
+
const edits: Edit[] = []
|
|
1268
|
+
addProperty(
|
|
1269
|
+
source,
|
|
1270
|
+
plugins,
|
|
1271
|
+
`"@stylexjs/postcss-plugin": {
|
|
1272
|
+
include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"],
|
|
1273
|
+
babelConfig: { babelrc: false, parserOpts: { plugins: ["typescript", "jsx"] }, plugins: yopemBabelConfig.plugins },
|
|
1274
|
+
useCSSLayers: true,
|
|
1275
|
+
}`,
|
|
1276
|
+
edits,
|
|
1277
|
+
)
|
|
1278
|
+
edits.push({
|
|
1279
|
+
start: 0,
|
|
1280
|
+
end: 0,
|
|
1281
|
+
text: esm
|
|
1282
|
+
? 'import { createRequire } from "node:module"\nconst yopemBabelConfig = createRequire(import.meta.url)("./babel.config.js")\n'
|
|
1283
|
+
: 'const yopemBabelConfig = require("./babel.config.js")\n',
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
return applyEdits(content, edits)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const lintRules = {
|
|
1290
|
+
"yopem-ui/enforce-styling-methods": "error",
|
|
1291
|
+
"yopem-ui/no-restyle": "error",
|
|
1292
|
+
"yopem-ui/no-raw-stylex-colors": "error",
|
|
1293
|
+
"yopem-ui/prefer-layout-primitives": "error",
|
|
1294
|
+
"yopem-ui/static-stylex": "error",
|
|
1295
|
+
"yopem-ui/valid-polymorphic-as": "error",
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function lintConfig(content: string) {
|
|
1299
|
+
const config: JsonValue = JSON.parse(content)
|
|
1300
|
+
|
|
1301
|
+
if (!object(config)) throw new Error("Invalid .oxlintrc.json")
|
|
1302
|
+
const plugins = config.jsPlugins ?? []
|
|
1303
|
+
const rules = config.rules ?? {}
|
|
1304
|
+
const overrides = config.overrides ?? []
|
|
1305
|
+
|
|
1306
|
+
if (!Array.isArray(plugins) || !object(rules) || !Array.isArray(overrides)) {
|
|
1307
|
+
throw new Error("Invalid .oxlintrc.json plugins, rules, or overrides")
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const componentOverride = {
|
|
1311
|
+
files: ["src/components/ui/**/*.{tsx,jsx}"],
|
|
1312
|
+
rules: Object.fromEntries(
|
|
1313
|
+
Object.keys(lintRules).map((name) => [name, "off"]),
|
|
1314
|
+
),
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const existing = plugins.find(
|
|
1318
|
+
(plugin) => object(plugin) && plugin.name === "yopem-ui",
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
if (
|
|
1322
|
+
existing &&
|
|
1323
|
+
(!object(existing) || existing.specifier !== "@yopem-ui/oxlint-plugin")
|
|
1324
|
+
) {
|
|
1325
|
+
throw new Error("Conflicting yopem-ui plugin in .oxlintrc.json")
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const next = {
|
|
1329
|
+
...config,
|
|
1330
|
+
jsPlugins: existing
|
|
1331
|
+
? plugins
|
|
1332
|
+
: [
|
|
1333
|
+
...plugins,
|
|
1334
|
+
{ name: "yopem-ui", specifier: "@yopem-ui/oxlint-plugin" },
|
|
1335
|
+
],
|
|
1336
|
+
rules: { ...lintRules, ...rules },
|
|
1337
|
+
overrides: overrides.some(
|
|
1338
|
+
(entry) => JSON.stringify(entry) === JSON.stringify(componentOverride),
|
|
1339
|
+
)
|
|
1340
|
+
? overrides
|
|
1341
|
+
: [...overrides, componentOverride],
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
return JSON.stringify(config) === JSON.stringify(next)
|
|
1345
|
+
? content
|
|
1346
|
+
: `${JSON.stringify(next, null, 2)}\n`
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function nextScripts(value: JsonValue | undefined) {
|
|
1350
|
+
if (!object(value)) throw new Error("Invalid package.json scripts")
|
|
1351
|
+
const scripts = { ...value }
|
|
1352
|
+
|
|
1353
|
+
for (const name of ["dev", "build"]) {
|
|
1354
|
+
const command = scripts[name]
|
|
1355
|
+
|
|
1356
|
+
if (
|
|
1357
|
+
!isString(command) ||
|
|
1358
|
+
!new RegExp(`^next ${name}(?:\\s|$)`).test(command) ||
|
|
1359
|
+
/--turbopack|--turbo|[;&|`$]/.test(command)
|
|
1360
|
+
) {
|
|
1361
|
+
throw new Error(`Unsupported Next.js ${name} script`)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (!command.includes("--webpack")) scripts[name] = `${command} --webpack`
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if (scripts.lint === "eslint") scripts.lint = "eslint && oxlint ."
|
|
1368
|
+
|
|
1369
|
+
return scripts
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async function chooseFile(root: string, names: string[], fallback?: string) {
|
|
1373
|
+
const matches: string[] = []
|
|
1374
|
+
|
|
1375
|
+
for (const name of names)
|
|
1376
|
+
if (await existingFile(root, name)) matches.push(name)
|
|
1377
|
+
|
|
1378
|
+
if (matches.length > 1)
|
|
1379
|
+
throw new Error(`Ambiguous configuration: ${matches.join(", ")}`)
|
|
1380
|
+
|
|
1381
|
+
return (
|
|
1382
|
+
matches[0] ??
|
|
1383
|
+
fallback ??
|
|
1384
|
+
(() => {
|
|
1385
|
+
throw new Error(`Missing ${names[0]}`)
|
|
1386
|
+
})()
|
|
1387
|
+
)
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function detectFramework(
|
|
1391
|
+
dependencies: JsonObject,
|
|
1392
|
+
chosen?: Framework,
|
|
1393
|
+
): Framework {
|
|
1394
|
+
const present = (name: string) => isString(dependencies[name])
|
|
1395
|
+
const detected: Framework[] = []
|
|
1396
|
+
|
|
1397
|
+
if (present("next")) detected.push("next")
|
|
1398
|
+
|
|
1399
|
+
if (present("astro")) detected.push("astro")
|
|
1400
|
+
|
|
1401
|
+
if (present("@tanstack/react-start")) detected.push("tanstack-start")
|
|
1402
|
+
else if (present("@tanstack/react-router")) detected.push("tanstack-router")
|
|
1403
|
+
else if (
|
|
1404
|
+
present("@react-router/dev") ||
|
|
1405
|
+
present("react-router") ||
|
|
1406
|
+
present("react-router-dom")
|
|
1407
|
+
)
|
|
1408
|
+
detected.push("react-router")
|
|
1409
|
+
else if (present("vite") && present("react")) detected.push("vite")
|
|
1410
|
+
|
|
1411
|
+
if (chosen && detected.includes(chosen)) return chosen
|
|
1412
|
+
|
|
1413
|
+
if (detected.length !== 1 || chosen) {
|
|
1414
|
+
throw new Error(
|
|
1415
|
+
`Cannot detect one supported framework; found: ${detected.join(", ") || "none"}`,
|
|
1416
|
+
)
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
return detected[0]!
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async function packageManager(
|
|
1423
|
+
root: string,
|
|
1424
|
+
declared: JsonValue | undefined,
|
|
1425
|
+
): Promise<PackageManager> {
|
|
1426
|
+
const markers: [PackageManager, string][] = [
|
|
1427
|
+
["bun", "bun.lock"],
|
|
1428
|
+
["bun", "bun.lockb"],
|
|
1429
|
+
["npm", "package-lock.json"],
|
|
1430
|
+
["pnpm", "pnpm-lock.yaml"],
|
|
1431
|
+
["yarn", "yarn.lock"],
|
|
1432
|
+
]
|
|
1433
|
+
|
|
1434
|
+
const found = new Set<PackageManager>()
|
|
1435
|
+
|
|
1436
|
+
for (const [manager, path] of markers) {
|
|
1437
|
+
if (await existingFile(root, path)) found.add(manager)
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (found.size > 1) throw new Error("Conflicting package manager lockfiles")
|
|
1441
|
+
|
|
1442
|
+
const named = isString(declared) ? declared.split("@")[0] : undefined
|
|
1443
|
+
|
|
1444
|
+
if (
|
|
1445
|
+
named &&
|
|
1446
|
+
named !== "bun" &&
|
|
1447
|
+
named !== "npm" &&
|
|
1448
|
+
named !== "pnpm" &&
|
|
1449
|
+
named !== "yarn"
|
|
1450
|
+
) {
|
|
1451
|
+
throw new Error(`Unsupported package manager: ${named}`)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const locked = [...found][0]
|
|
1455
|
+
|
|
1456
|
+
if (locked && named && locked !== named) {
|
|
1457
|
+
throw new Error("Package manager and lockfile disagree")
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
return locked ?? named ?? "bun"
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
export async function initProject(options: InitOptions = {}) {
|
|
1464
|
+
const root = await realpath(options.cwd ?? process.cwd())
|
|
1465
|
+
|
|
1466
|
+
if (!(await existingFile(root, "package.json"))) {
|
|
1467
|
+
throw new Error("Run init from a project with package.json")
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const packageText = await readFile(join(root, "package.json"), "utf8")
|
|
1471
|
+
const manifest: JsonValue = JSON.parse(packageText)
|
|
1472
|
+
|
|
1473
|
+
if (
|
|
1474
|
+
!object(manifest) ||
|
|
1475
|
+
(!object(manifest.dependencies) && !object(manifest.devDependencies))
|
|
1476
|
+
) {
|
|
1477
|
+
throw new Error("Invalid project package.json")
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const dependencies = Object.fromEntries(
|
|
1481
|
+
[manifest.dependencies, manifest.devDependencies].flatMap((entry) =>
|
|
1482
|
+
object(entry) ? Object.entries(entry) : [],
|
|
1483
|
+
),
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
const framework = detectFramework(dependencies, options.framework)
|
|
1487
|
+
const manager = await packageManager(root, manifest.packageManager)
|
|
1488
|
+
const edits = new Map<string, { before: string | null; after: string }>()
|
|
1489
|
+
|
|
1490
|
+
async function plan(
|
|
1491
|
+
path: string,
|
|
1492
|
+
transform: (content: string) => string,
|
|
1493
|
+
fallback?: string,
|
|
1494
|
+
) {
|
|
1495
|
+
const exists = await existingFile(root, path)
|
|
1496
|
+
|
|
1497
|
+
if (!exists && fallback === undefined) throw new Error(`Missing ${path}`)
|
|
1498
|
+
const before = exists ? await readFile(join(root, path), "utf8") : null
|
|
1499
|
+
const after = transform(before ?? fallback!)
|
|
1500
|
+
|
|
1501
|
+
if (before !== after) edits.set(path, { before, after })
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const retired = new Map<string, string>()
|
|
1505
|
+
|
|
1506
|
+
let babelConfig: ReturnType<typeof migrateBabel> = {
|
|
1507
|
+
plugins: [],
|
|
1508
|
+
presets: [],
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
let postcssPlugins: string[] = []
|
|
1512
|
+
|
|
1513
|
+
if (framework !== "next") {
|
|
1514
|
+
const babelPath = await chooseFile(
|
|
1515
|
+
root,
|
|
1516
|
+
[
|
|
1517
|
+
"babel.config.js",
|
|
1518
|
+
"babel.config.cjs",
|
|
1519
|
+
"babel.config.mjs",
|
|
1520
|
+
".babelrc",
|
|
1521
|
+
".babelrc.json",
|
|
1522
|
+
],
|
|
1523
|
+
"",
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
const postcssPath = await chooseFile(
|
|
1527
|
+
root,
|
|
1528
|
+
["postcss.config.js", "postcss.config.cjs", "postcss.config.mjs"],
|
|
1529
|
+
"",
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
if (babelPath) {
|
|
1533
|
+
const content = await readFile(join(root, babelPath), "utf8")
|
|
1534
|
+
babelConfig = migrateBabel(content, babelPath)
|
|
1535
|
+
retired.set(babelPath, content)
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
if (postcssPath) {
|
|
1539
|
+
const content = await readFile(join(root, postcssPath), "utf8")
|
|
1540
|
+
postcssPlugins = migratePostcss(content, postcssPath)
|
|
1541
|
+
retired.set(postcssPath, content)
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const tsPath =
|
|
1546
|
+
framework !== "next" &&
|
|
1547
|
+
framework !== "astro" &&
|
|
1548
|
+
(await existingFile(root, "tsconfig.app.json"))
|
|
1549
|
+
? "tsconfig.app.json"
|
|
1550
|
+
: "tsconfig.json"
|
|
1551
|
+
|
|
1552
|
+
await plan(tsPath, (content) => tsconfig(content, tsPath))
|
|
1553
|
+
await plan(".oxlintrc.json", lintConfig, "{}")
|
|
1554
|
+
|
|
1555
|
+
const devDependencies = [
|
|
1556
|
+
"oxlint@^1.79.0",
|
|
1557
|
+
"@yopem-ui/oxlint-plugin@^0.1.0",
|
|
1558
|
+
"@rolldown/plugin-babel@^0.2.4",
|
|
1559
|
+
"@babel/core@^7.29.7",
|
|
1560
|
+
"@stylexjs/babel-plugin@^0.19.0",
|
|
1561
|
+
"@stylexjs/postcss-plugin@^0.19.0",
|
|
1562
|
+
]
|
|
1563
|
+
|
|
1564
|
+
const runtimeDependencies: string[] = []
|
|
1565
|
+
|
|
1566
|
+
if (framework === "astro") {
|
|
1567
|
+
const config = await chooseFile(root, [
|
|
1568
|
+
"astro.config.mjs",
|
|
1569
|
+
"astro.config.ts",
|
|
1570
|
+
"astro.config.js",
|
|
1571
|
+
])
|
|
1572
|
+
|
|
1573
|
+
await plan(config, (content) =>
|
|
1574
|
+
astroConfig(content, config, babelConfig, postcssPlugins),
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
const layout = await chooseFile(root, [
|
|
1578
|
+
"src/layouts/Layout.astro",
|
|
1579
|
+
"src/layouts/layout.astro",
|
|
1580
|
+
])
|
|
1581
|
+
|
|
1582
|
+
await plan(layout, (content) => astroLayout(content, layout))
|
|
1583
|
+
|
|
1584
|
+
for (const name of ["@astrojs/react", "react", "react-dom"]) {
|
|
1585
|
+
if (!(name in dependencies)) runtimeDependencies.push(name)
|
|
1586
|
+
}
|
|
1587
|
+
} else if (framework === "next") {
|
|
1588
|
+
if (!object(manifest.scripts))
|
|
1589
|
+
throw new Error("Next.js scripts are missing")
|
|
1590
|
+
nextScripts(manifest.scripts)
|
|
1591
|
+
|
|
1592
|
+
const layout = await chooseFile(root, [
|
|
1593
|
+
"src/app/layout.tsx",
|
|
1594
|
+
"app/layout.tsx",
|
|
1595
|
+
])
|
|
1596
|
+
|
|
1597
|
+
await plan(layout, (content) => jsxLayout(content, layout, "next"))
|
|
1598
|
+
|
|
1599
|
+
const babel = await chooseFile(
|
|
1600
|
+
root,
|
|
1601
|
+
[
|
|
1602
|
+
"babel.config.js",
|
|
1603
|
+
"babel.config.cjs",
|
|
1604
|
+
"babel.config.mjs",
|
|
1605
|
+
".babelrc",
|
|
1606
|
+
".babelrc.json",
|
|
1607
|
+
],
|
|
1608
|
+
"babel.config.js",
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
if (babel !== "babel.config.js") {
|
|
1612
|
+
throw new Error(`Unsupported Babel configuration: ${babel}`)
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const babelContent = nextBabel(manifest.type === "module")
|
|
1616
|
+
await plan(
|
|
1617
|
+
babel,
|
|
1618
|
+
(content) => {
|
|
1619
|
+
if (!sameSyntax(parsed(babel, content), parsed(babel, babelContent))) {
|
|
1620
|
+
throw new Error("Existing Babel config requires manual review")
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
return content
|
|
1624
|
+
},
|
|
1625
|
+
babelContent,
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
const postcssPath = await chooseFile(
|
|
1629
|
+
root,
|
|
1630
|
+
["postcss.config.mjs", "postcss.config.cjs", "postcss.config.js"],
|
|
1631
|
+
"postcss.config.cjs",
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
await plan(
|
|
1635
|
+
postcssPath,
|
|
1636
|
+
(content) =>
|
|
1637
|
+
postcssPath === "postcss.config.cjs" && content === nextPostcss()
|
|
1638
|
+
? content
|
|
1639
|
+
: postcss(
|
|
1640
|
+
content,
|
|
1641
|
+
postcssPath,
|
|
1642
|
+
postcssPath.endsWith(".mjs") ||
|
|
1643
|
+
(postcssPath.endsWith(".js") && manifest.type === "module"),
|
|
1644
|
+
),
|
|
1645
|
+
nextPostcss(),
|
|
1646
|
+
)
|
|
1647
|
+
devDependencies.splice(
|
|
1648
|
+
0,
|
|
1649
|
+
devDependencies.length,
|
|
1650
|
+
"@babel/core@^7.29.7",
|
|
1651
|
+
"@stylexjs/babel-plugin@^0.19.0",
|
|
1652
|
+
"@stylexjs/postcss-plugin@^0.19.0",
|
|
1653
|
+
"autoprefixer@^10.4.0",
|
|
1654
|
+
"oxlint@^1.79.0",
|
|
1655
|
+
"@yopem-ui/oxlint-plugin@^0.1.0",
|
|
1656
|
+
)
|
|
1657
|
+
} else {
|
|
1658
|
+
const config = await chooseFile(root, [
|
|
1659
|
+
"vite.config.ts",
|
|
1660
|
+
"vite.config.mts",
|
|
1661
|
+
"vite.config.js",
|
|
1662
|
+
"vite.config.mjs",
|
|
1663
|
+
])
|
|
1664
|
+
|
|
1665
|
+
await plan(config, (content) =>
|
|
1666
|
+
viteConfig(content, config, babelConfig, postcssPlugins),
|
|
1667
|
+
)
|
|
1668
|
+
|
|
1669
|
+
if (
|
|
1670
|
+
framework === "tanstack-start" ||
|
|
1671
|
+
(framework === "react-router" && "@react-router/dev" in dependencies)
|
|
1672
|
+
) {
|
|
1673
|
+
const layout = await chooseFile(
|
|
1674
|
+
root,
|
|
1675
|
+
framework === "tanstack-start"
|
|
1676
|
+
? ["src/routes/__root.tsx"]
|
|
1677
|
+
: ["app/root.tsx", "src/root.tsx"],
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
await plan(layout, (content) => jsxLayout(content, layout, framework))
|
|
1681
|
+
} else {
|
|
1682
|
+
const entry = await chooseFile(root, [
|
|
1683
|
+
"src/main.tsx",
|
|
1684
|
+
"src/main.jsx",
|
|
1685
|
+
"src/index.tsx",
|
|
1686
|
+
"src/index.jsx",
|
|
1687
|
+
])
|
|
1688
|
+
|
|
1689
|
+
await plan(entry, (content) => reactEntry(content, entry))
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
const packageRun =
|
|
1694
|
+
options.run ??
|
|
1695
|
+
(manager === "bun"
|
|
1696
|
+
? runBun
|
|
1697
|
+
: async function runPackage(args: string[], cwd: string) {
|
|
1698
|
+
const command =
|
|
1699
|
+
manager === "npm" && args[0] === "add" ? "install" : args[0]!
|
|
1700
|
+
|
|
1701
|
+
const flags = args
|
|
1702
|
+
.slice(1)
|
|
1703
|
+
.map((argument) => (argument === "-d" ? "-D" : argument))
|
|
1704
|
+
|
|
1705
|
+
const child = Bun.spawn([manager, command, ...flags], {
|
|
1706
|
+
cwd,
|
|
1707
|
+
stdout: "inherit",
|
|
1708
|
+
stderr: "inherit",
|
|
1709
|
+
})
|
|
1710
|
+
|
|
1711
|
+
if ((await child.exited) !== 0)
|
|
1712
|
+
throw new Error(`${manager} ${command} failed`)
|
|
1713
|
+
})
|
|
1714
|
+
|
|
1715
|
+
await installItem("base", { ...options, cwd: root, run: packageRun })
|
|
1716
|
+
|
|
1717
|
+
if (
|
|
1718
|
+
!(await readFile(join(root, "src/styles/styles.css"), "utf8")).includes(
|
|
1719
|
+
"@stylex;",
|
|
1720
|
+
)
|
|
1721
|
+
) {
|
|
1722
|
+
throw new Error(
|
|
1723
|
+
"Add @stylex; to src/styles/styles.css before configuring StyleX",
|
|
1724
|
+
)
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const packages = await readFile(join(root, "package.json"), "utf8")
|
|
1728
|
+
const installed: JsonValue = JSON.parse(packages)
|
|
1729
|
+
|
|
1730
|
+
if (!object(installed))
|
|
1731
|
+
throw new Error("Invalid package.json after installation")
|
|
1732
|
+
|
|
1733
|
+
const available = Object.fromEntries(
|
|
1734
|
+
[installed.dependencies, installed.devDependencies].flatMap((entry) =>
|
|
1735
|
+
object(entry) ? Object.entries(entry) : [],
|
|
1736
|
+
),
|
|
1737
|
+
)
|
|
1738
|
+
|
|
1739
|
+
const neededRuntime = runtimeDependencies.filter(
|
|
1740
|
+
(name) => !(name in available),
|
|
1741
|
+
)
|
|
1742
|
+
|
|
1743
|
+
if (neededRuntime.length) await packageRun(["add", ...neededRuntime], root)
|
|
1744
|
+
|
|
1745
|
+
const neededDev = devDependencies.filter(
|
|
1746
|
+
(name) => !(name.split("@").slice(0, -1).join("@") in available),
|
|
1747
|
+
)
|
|
1748
|
+
|
|
1749
|
+
if (neededDev.length) await packageRun(["add", "-d", ...neededDev], root)
|
|
1750
|
+
|
|
1751
|
+
for (const [path, { before }] of edits) {
|
|
1752
|
+
const exists = await existingFile(root, path)
|
|
1753
|
+
const current = exists ? await readFile(join(root, path), "utf8") : null
|
|
1754
|
+
|
|
1755
|
+
if (current !== before) throw new Error(`File changed during init: ${path}`)
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
for (const [path, before] of retired) {
|
|
1759
|
+
if ((await readFile(join(root, path), "utf8")) !== before)
|
|
1760
|
+
throw new Error(`File changed during init: ${path}`)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
for (const [path, { before, after }] of edits) {
|
|
1764
|
+
await mkdir(dirname(join(root, path)), { recursive: true })
|
|
1765
|
+
await writeFile(join(root, path), after, {
|
|
1766
|
+
flag: before === null ? "wx" : "w",
|
|
1767
|
+
})
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
for (const path of retired.keys()) await unlink(join(root, path))
|
|
1771
|
+
|
|
1772
|
+
if (framework === "next") {
|
|
1773
|
+
const latest: JsonValue = JSON.parse(
|
|
1774
|
+
await readFile(join(root, "package.json"), "utf8"),
|
|
1775
|
+
)
|
|
1776
|
+
|
|
1777
|
+
if (!object(latest))
|
|
1778
|
+
throw new Error("Invalid package.json after installation")
|
|
1779
|
+
|
|
1780
|
+
if (JSON.stringify(latest.scripts) !== JSON.stringify(manifest.scripts)) {
|
|
1781
|
+
throw new Error("Next.js scripts changed during init")
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const scripts = nextScripts(latest.scripts)
|
|
1785
|
+
|
|
1786
|
+
if (JSON.stringify(latest.scripts) !== JSON.stringify(scripts)) {
|
|
1787
|
+
await writeFile(
|
|
1788
|
+
join(root, "package.json"),
|
|
1789
|
+
`${JSON.stringify({ ...latest, scripts }, null, 2)}\n`,
|
|
1790
|
+
)
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
return { framework, configured: edits.size }
|
|
1795
|
+
}
|