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