@yopem-ui/cli 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.ts CHANGED
@@ -1,16 +1,25 @@
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"
1
+ import type {
2
+ FileChange,
3
+ InstallOptions,
4
+ JsonObject,
5
+ JsonValue,
6
+ } from "@yopem-ui/cli/install"
6
7
 
7
8
  import {
8
9
  existingFile,
9
10
  installItem,
10
11
  isRecord,
11
12
  isString,
12
- runBun,
13
- } from "./install"
13
+ writeFiles,
14
+ } from "@yopem-ui/cli/install"
15
+ import {
16
+ isPackageName,
17
+ packageRunner,
18
+ workspaceRoot,
19
+ } from "@yopem-ui/cli/project"
20
+ import { readFile, realpath } from "node:fs/promises"
21
+ import { join, relative, resolve } from "node:path"
22
+ import ts from "typescript-api"
14
23
 
15
24
  type Framework =
16
25
  | "vite"
@@ -20,7 +29,10 @@ type Framework =
20
29
  | "next"
21
30
  | "astro"
22
31
 
23
- type PackageManager = "bun" | "npm" | "pnpm" | "yarn"
32
+ interface SharedUI {
33
+ name: string
34
+ source: string
35
+ }
24
36
 
25
37
  interface Edit {
26
38
  start: number
@@ -30,6 +42,7 @@ interface Edit {
30
42
 
31
43
  export interface InitOptions extends InstallOptions {
32
44
  framework?: Framework
45
+ ui?: string
33
46
  }
34
47
 
35
48
  function object(value: JsonValue | undefined): value is JsonObject {
@@ -266,6 +279,19 @@ function addAlias(
266
279
  ) {
267
280
  throw new Error(`Incompatible @ alias in ${path}`)
268
281
  }
282
+
283
+ const aliasValue = declaration?.initializer ?? current.initializer
284
+
285
+ if (
286
+ ts.isStringLiteral(aliasValue) &&
287
+ /^(?:\.\/)?src\/?$/.test(aliasValue.text)
288
+ ) {
289
+ edits.push({
290
+ start: current.initializer.getStart(source),
291
+ end: current.initializer.end,
292
+ text: "yopemSource",
293
+ })
294
+ }
269
295
  }
270
296
 
271
297
  function moduleConfig(source: ts.SourceFile, path: string) {
@@ -549,14 +575,18 @@ function babelPlugin(presets = false) {
549
575
  return `babel({ plugins: yopemBabelPlugins${presets ? ", presets: yopemBabelPresets" : ""} })`
550
576
  }
551
577
 
552
- function stylexSetup(babelPlugins: string[] = [], babelPresets: string[] = []) {
578
+ function stylexSetup(
579
+ babelPlugins: string[] = [],
580
+ babelPresets: string[] = [],
581
+ shared?: SharedUI,
582
+ ) {
553
583
  return `import babel from "@rolldown/plugin-babel"
554
584
  import { createRequire as yopemCreateRequire } from "node:module"
555
585
  import { fileURLToPath as yopemFileURLToPath } from "node:url"
556
586
 
557
- const yopemSource = yopemFileURLToPath(new URL("./src", import.meta.url))
587
+ const yopemSource = yopemFileURLToPath(new URL("./src", import.meta.url))${shared ? `\nconst yopemUISource = yopemFileURLToPath(new URL(${JSON.stringify(shared.source)}, import.meta.url))` : ""}
558
588
  const yopemBabelPlugins = [${babelPlugins.join(", ")}${babelPlugins.length ? ", " : ""}["@stylexjs/babel-plugin", {
559
- aliases: { "@/*": [yopemSource + "/*"] },
589
+ aliases: { "@/*": [yopemSource + "/*"]${shared ? `, ${JSON.stringify(`${shared.name}/*`)}: [yopemUISource + "/*"]` : ""} },
560
590
  dev: process.env.NODE_ENV !== "production",
561
591
  runtimeInjection: false,
562
592
  treeshakeCompensation: true,
@@ -564,7 +594,7 @@ const yopemBabelPlugins = [${babelPlugins.join(", ")}${babelPlugins.length ? ",
564
594
  }]]${babelPresets.length ? `\nconst yopemBabelPresets = [${babelPresets.join(", ")}]` : ""}
565
595
  const yopemPostcssPlugin = yopemCreateRequire(import.meta.url)("@stylexjs/postcss-plugin")({
566
596
  cwd: yopemFileURLToPath(new URL(".", import.meta.url)),
567
- include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}"],
597
+ include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}"${shared ? ', yopemUISource + "/**/*.{js,jsx,ts,tsx}"' : ""}],
568
598
  babelConfig: {
569
599
  babelrc: false,
570
600
  parserOpts: { plugins: ["typescript", "jsx"] },
@@ -697,10 +727,20 @@ function viteConfig(
697
727
  path: string,
698
728
  babelConfig: ReturnType<typeof migrateBabel>,
699
729
  postcssPlugins: string[],
730
+ shared?: SharedUI,
700
731
  ) {
701
732
  content = stripLegacyConfig(content, path)
702
733
 
703
734
  if (content.includes("stylex.vite(") || hasYopemStylexConfig(content)) {
735
+ if (
736
+ shared &&
737
+ (!content.includes(JSON.stringify(`${shared.name}/*`)) ||
738
+ !content.includes(JSON.stringify(shared.source)))
739
+ )
740
+ throw new Error(
741
+ `Existing StyleX configuration targets a different UI package in ${path}`,
742
+ )
743
+
704
744
  if (!content.includes('"@":') && !content.includes("'@':")) {
705
745
  throw new Error(`Existing StyleX configuration lacks @ alias in ${path}`)
706
746
  }
@@ -736,7 +776,7 @@ function viteConfig(
736
776
  edits.push({
737
777
  start: 0,
738
778
  end: 0,
739
- text: stylexSetup(babelConfig.plugins, babelConfig.presets),
779
+ text: stylexSetup(babelConfig.plugins, babelConfig.presets, shared),
740
780
  })
741
781
 
742
782
  return applyEdits(content, edits)
@@ -747,6 +787,7 @@ function astroConfig(
747
787
  path: string,
748
788
  babelConfig: ReturnType<typeof migrateBabel>,
749
789
  postcssPlugins: string[],
790
+ shared?: SharedUI,
750
791
  ) {
751
792
  content = stripLegacyConfig(content, path)
752
793
  const source = parsed(path, content)
@@ -772,6 +813,15 @@ function astroConfig(
772
813
  hasYopemStylexConfig(content) && content.includes(`${reactName}()`)
773
814
 
774
815
  if (alreadyConfigured) {
816
+ if (
817
+ shared &&
818
+ (!content.includes(JSON.stringify(`${shared.name}/*`)) ||
819
+ !content.includes(JSON.stringify(shared.source)))
820
+ )
821
+ throw new Error(
822
+ `Existing StyleX configuration targets a different UI package in ${path}`,
823
+ )
824
+
775
825
  if (!content.includes('"@":') && !content.includes("'@':")) {
776
826
  throw new Error(`Existing StyleX configuration lacks @ alias in ${path}`)
777
827
  }
@@ -847,7 +897,7 @@ function astroConfig(
847
897
  edits.push({
848
898
  start: 0,
849
899
  end: 0,
850
- text: `${reactImport ? "" : 'import react from "@astrojs/react"\n'}${stylexSetup(babelConfig.plugins, babelConfig.presets)}`,
900
+ text: `${reactImport ? "" : 'import react from "@astrojs/react"\n'}${stylexSetup(babelConfig.plugins, babelConfig.presets, shared)}`,
851
901
  })
852
902
 
853
903
  return applyEdits(content, edits)
@@ -924,10 +974,10 @@ function tsconfig(content: string, path: string) {
924
974
  return applyEdits(content, edits)
925
975
  }
926
976
 
927
- function reactEntry(content: string, path: string) {
977
+ function reactEntry(content: string, path: string, prefix = "@") {
928
978
  if (content.includes("themeMarker") || content.includes("rootStyles.html")) {
929
979
  if (
930
- !content.includes('"@/styles/styles.css"') ||
980
+ !content.includes(`"${prefix}/styles/styles.css"`) ||
931
981
  !content.includes("rootStyles.html") ||
932
982
  !content.includes('dataset.theme = "light"')
933
983
  ) {
@@ -947,7 +997,7 @@ function reactEntry(content: string, path: string) {
947
997
  {
948
998
  start: 0,
949
999
  end: 0,
950
- text: 'import "@/styles/styles.css"\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n',
1000
+ text: `import "${prefix}/styles/styles.css"\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "${prefix}/styles/tokens.stylex"\n`,
951
1001
  },
952
1002
  {
953
1003
  start: last.end,
@@ -961,10 +1011,11 @@ function jsxLayout(
961
1011
  content: string,
962
1012
  path: string,
963
1013
  framework: "next" | "tanstack-start" | "react-router",
1014
+ prefix = "@",
964
1015
  ) {
965
1016
  if (content.includes("stylexProps(") || content.includes("rootStyles.html")) {
966
1017
  if (
967
- !content.includes('"@/styles/styles.css"') ||
1018
+ !content.includes(`"${prefix}/styles/styles.css"`) ||
968
1019
  !content.includes("rootStyles.html") ||
969
1020
  !content.includes("rootStyles.body")
970
1021
  ) {
@@ -1064,20 +1115,20 @@ function jsxLayout(
1064
1115
  })
1065
1116
  }
1066
1117
 
1067
- const css = 'import "@/styles/styles.css"\n'
1118
+ const css = `import "${prefix}/styles/styles.css"\n`
1068
1119
  edits.push({
1069
1120
  start: 0,
1070
1121
  end: 0,
1071
- text: `${css}import { stylexProps } from "@/lib/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n`,
1122
+ text: `${css}import { stylexProps } from "${prefix}/lib/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "${prefix}/styles/tokens.stylex"\n`,
1072
1123
  })
1073
1124
 
1074
1125
  return applyEdits(content, edits)
1075
1126
  }
1076
1127
 
1077
- function astroLayout(content: string, path: string) {
1128
+ function astroLayout(content: string, path: string, prefix = "@") {
1078
1129
  if (content.includes("yopemHtml.className")) {
1079
1130
  if (
1080
- !content.includes('"@/styles/styles.css"') ||
1131
+ !content.includes(`"${prefix}/styles/styles.css"`) ||
1081
1132
  !content.includes("yopemBody.className")
1082
1133
  ) {
1083
1134
  throw new Error(`Incomplete Yopem layout in ${path}`)
@@ -1117,7 +1168,7 @@ function astroLayout(content: string, path: string) {
1117
1168
  {
1118
1169
  start: end,
1119
1170
  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)`,
1171
+ text: `${content.includes(`"${prefix}/styles/styles.css"`) ? "" : `\nimport "${prefix}/styles/styles.css"`}\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "${prefix}/styles/tokens.stylex"\n\nconst yopemHtml = stylex.props(themeMarker, lightTheme, rootStyles.html)\nconst yopemBody = stylex.props(rootStyles.body)`,
1121
1172
  },
1122
1173
  ]
1123
1174
 
@@ -1170,7 +1221,7 @@ function astroLayout(content: string, path: string) {
1170
1221
  return applyEdits(content, edits)
1171
1222
  }
1172
1223
 
1173
- function nextBabel(esm: boolean) {
1224
+ function nextBabel(esm: boolean, shared?: SharedUI) {
1174
1225
  const header = esm
1175
1226
  ? `import { createRequire } from "node:module"
1176
1227
  import { dirname } from "node:path"
@@ -1208,7 +1259,7 @@ function expandLocalSpreads({ types: t }) {
1208
1259
  }
1209
1260
 
1210
1261
  ${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/*")] },
1262
+ aliases: { "@/*": [path.join(__dirname, "src/*")]${shared ? `, ${JSON.stringify(`${shared.name}/*`)}: [path.join(__dirname, ${JSON.stringify(`${shared.source}*`)})]` : ""} },
1212
1263
  dev: process.env.NODE_ENV !== "production",
1213
1264
  runtimeInjection: false,
1214
1265
  treeshakeCompensation: true,
@@ -1217,7 +1268,40 @@ ${esm ? 'export const presets = ["next/babel"]\nexport const plugins = [' : 'mod
1217
1268
  `
1218
1269
  }
1219
1270
 
1220
- function nextPostcss() {
1271
+ function nextConfig(content: string, path: string, name: string) {
1272
+ const source = parsed(path, content)
1273
+ const config = moduleConfig(source, path)
1274
+ const packages = property(config, "transpilePackages")
1275
+ const edits: Edit[] = []
1276
+
1277
+ if (!packages) {
1278
+ addProperty(
1279
+ source,
1280
+ config,
1281
+ `transpilePackages: [${JSON.stringify(name)}]`,
1282
+ edits,
1283
+ )
1284
+ } else {
1285
+ if (!ts.isArrayLiteralExpression(packages.initializer))
1286
+ throw new Error(`Unsupported transpilePackages in ${path}`)
1287
+
1288
+ if (
1289
+ !packages.initializer.elements.some(
1290
+ (entry) => ts.isStringLiteral(entry) && entry.text === name,
1291
+ )
1292
+ )
1293
+ addArrayEntries(
1294
+ source,
1295
+ packages.initializer,
1296
+ [JSON.stringify(name)],
1297
+ edits,
1298
+ )
1299
+ }
1300
+
1301
+ return applyEdits(content, edits)
1302
+ }
1303
+
1304
+ function nextPostcss(shared?: SharedUI) {
1221
1305
  return `// Next loads this PostCSS config synchronously through CommonJS.
1222
1306
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1223
1307
  const babelConfig = require("./babel.config.js")
@@ -1225,7 +1309,7 @@ const babelConfig = require("./babel.config.js")
1225
1309
  module.exports = {
1226
1310
  plugins: {
1227
1311
  "@stylexjs/postcss-plugin": {
1228
- include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"],
1312
+ include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"${shared ? `, require("node:path").join(__dirname, ${JSON.stringify(`${shared.source}**/*.{js,jsx,ts,tsx}`)})` : ""}],
1229
1313
  babelConfig: {
1230
1314
  babelrc: false,
1231
1315
  parserOpts: { plugins: ["typescript", "jsx"] },
@@ -1239,7 +1323,12 @@ module.exports = {
1239
1323
  `
1240
1324
  }
1241
1325
 
1242
- function postcss(content: string, path: string, esm: boolean) {
1326
+ function postcss(
1327
+ content: string,
1328
+ path: string,
1329
+ esm: boolean,
1330
+ shared?: SharedUI,
1331
+ ) {
1243
1332
  if (content.includes('"@stylexjs/postcss-plugin"')) return content
1244
1333
  const source = parsed(path, content)
1245
1334
  let config: ts.ObjectLiteralExpression | undefined
@@ -1269,7 +1358,7 @@ function postcss(content: string, path: string, esm: boolean) {
1269
1358
  source,
1270
1359
  plugins,
1271
1360
  `"@stylexjs/postcss-plugin": {
1272
- include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"],
1361
+ include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"${shared ? `, ${esm ? "yopemRequire" : "require"}("node:path").resolve(${esm ? 'yopemRequire("node:url").fileURLToPath(new URL(".", import.meta.url))' : "__dirname"}, ${JSON.stringify(`${shared.source}**/*.{js,jsx,ts,tsx}`)})` : ""}],
1273
1362
  babelConfig: { babelrc: false, parserOpts: { plugins: ["typescript", "jsx"] }, plugins: yopemBabelConfig.plugins },
1274
1363
  useCSSLayers: true,
1275
1364
  }`,
@@ -1279,7 +1368,7 @@ function postcss(content: string, path: string, esm: boolean) {
1279
1368
  start: 0,
1280
1369
  end: 0,
1281
1370
  text: esm
1282
- ? 'import { createRequire } from "node:module"\nconst yopemBabelConfig = createRequire(import.meta.url)("./babel.config.js")\n'
1371
+ ? 'import { createRequire } from "node:module"\nconst yopemRequire = createRequire(import.meta.url)\nconst yopemBabelConfig = yopemRequire("./babel.config.js")\n'
1283
1372
  : 'const yopemBabelConfig = require("./babel.config.js")\n',
1284
1373
  })
1285
1374
 
@@ -1295,7 +1384,7 @@ const lintRules = {
1295
1384
  "yopem-ui/valid-polymorphic-as": "error",
1296
1385
  }
1297
1386
 
1298
- function lintConfig(content: string) {
1387
+ function lintConfig(content: string, shared?: SharedUI) {
1299
1388
  const config: JsonValue = JSON.parse(content)
1300
1389
 
1301
1390
  if (!object(config)) throw new Error("Invalid .oxlintrc.json")
@@ -1325,6 +1414,45 @@ function lintConfig(content: string) {
1325
1414
  throw new Error("Conflicting yopem-ui plugin in .oxlintrc.json")
1326
1415
  }
1327
1416
 
1417
+ const configuredRules: JsonObject = { ...lintRules, ...rules }
1418
+
1419
+ if (shared) {
1420
+ for (const name of [
1421
+ "yopem-ui/enforce-styling-methods",
1422
+ "yopem-ui/no-restyle",
1423
+ "yopem-ui/valid-polymorphic-as",
1424
+ ]) {
1425
+ const setting = configuredRules[name] ?? "error"
1426
+ const level = Array.isArray(setting) ? (setting[0] ?? "error") : setting
1427
+
1428
+ if (level === "off" || level === 0) continue
1429
+
1430
+ const options = Array.isArray(setting) ? (setting[1] ?? {}) : {}
1431
+
1432
+ if (!object(options)) throw new Error(`Invalid options for ${name}`)
1433
+
1434
+ const sources = options.componentSources ?? [
1435
+ "@/components/ui/",
1436
+ "@registry/components/ui/",
1437
+ "@yopem-ui/ui",
1438
+ ]
1439
+
1440
+ if (!Array.isArray(sources) || !sources.every(isString)) {
1441
+ throw new Error(`Invalid componentSources for ${name}`)
1442
+ }
1443
+
1444
+ configuredRules[name] = [
1445
+ level,
1446
+ {
1447
+ ...options,
1448
+ componentSources: [
1449
+ ...new Set([...sources, `${shared.name}/components/ui/`]),
1450
+ ],
1451
+ },
1452
+ ]
1453
+ }
1454
+ }
1455
+
1328
1456
  const next = {
1329
1457
  ...config,
1330
1458
  jsPlugins: existing
@@ -1333,7 +1461,7 @@ function lintConfig(content: string) {
1333
1461
  ...plugins,
1334
1462
  { name: "yopem-ui", specifier: "@yopem-ui/oxlint-plugin" },
1335
1463
  ],
1336
- rules: { ...lintRules, ...rules },
1464
+ rules: configuredRules,
1337
1465
  overrides: overrides.some(
1338
1466
  (entry) => JSON.stringify(entry) === JSON.stringify(componentOverride),
1339
1467
  )
@@ -1419,48 +1547,8 @@ function detectFramework(
1419
1547
  return detected[0]!
1420
1548
  }
1421
1549
 
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
1550
  export async function initProject(options: InitOptions = {}) {
1551
+ if (options.dryRun) throw new Error("--dry-run is not supported for init")
1464
1552
  const root = await realpath(options.cwd ?? process.cwd())
1465
1553
 
1466
1554
  if (!(await existingFile(root, "package.json"))) {
@@ -1484,7 +1572,33 @@ export async function initProject(options: InitOptions = {}) {
1484
1572
  )
1485
1573
 
1486
1574
  const framework = detectFramework(dependencies, options.framework)
1487
- const manager = await packageManager(root, manifest.packageManager)
1575
+ const uiRoot = options.ui ? await realpath(resolve(root, options.ui)) : root
1576
+ let shared: SharedUI | undefined
1577
+
1578
+ if (options.ui) {
1579
+ if (uiRoot === root)
1580
+ throw new Error("Shared UI package must differ from app")
1581
+
1582
+ const uiManifest: JsonValue = JSON.parse(
1583
+ await readFile(join(uiRoot, "package.json"), "utf8"),
1584
+ )
1585
+
1586
+ if (!object(uiManifest) || !isPackageName(uiManifest.name))
1587
+ throw new Error("Shared UI package requires a valid package name")
1588
+
1589
+ const owner = await workspaceRoot(root)
1590
+
1591
+ if ((await workspaceRoot(uiRoot)) !== owner)
1592
+ throw new Error("App and UI package must belong to the same workspace")
1593
+
1594
+ shared = {
1595
+ name: uiManifest.name,
1596
+ source: `${relative(root, join(uiRoot, "src")).replaceAll("\\", "/")}/`,
1597
+ }
1598
+ }
1599
+
1600
+ const packageRun = await packageRunner(root, options.run)
1601
+ const uiRun = await packageRunner(uiRoot, options.run)
1488
1602
  const edits = new Map<string, { before: string | null; after: string }>()
1489
1603
 
1490
1604
  async function plan(
@@ -1550,7 +1664,49 @@ export async function initProject(options: InitOptions = {}) {
1550
1664
  : "tsconfig.json"
1551
1665
 
1552
1666
  await plan(tsPath, (content) => tsconfig(content, tsPath))
1553
- await plan(".oxlintrc.json", lintConfig, "{}")
1667
+ await plan(".oxlintrc.json", (content) => lintConfig(content, shared), "{}")
1668
+
1669
+ if (shared) {
1670
+ const prefix = relative(root, uiRoot).replaceAll("\\", "/")
1671
+
1672
+ await plan(`${prefix}/package.json`, (content) => {
1673
+ const value: JsonValue = JSON.parse(content)
1674
+
1675
+ if (
1676
+ !object(value) ||
1677
+ (value.exports !== undefined && !object(value.exports))
1678
+ )
1679
+ throw new Error("Shared UI package requires object exports")
1680
+ const exports = object(value.exports) ? { ...value.exports } : {}
1681
+
1682
+ const paths = {
1683
+ "./components/ui/*": "./src/components/ui/*.tsx",
1684
+ "./lib/*": "./src/lib/*.ts",
1685
+ "./styles/tokens.stylex": "./src/styles/tokens.stylex.ts",
1686
+ "./styles/styles.css": "./src/styles/styles.css",
1687
+ "./theme/*": "./src/theme/*.tsx",
1688
+ }
1689
+
1690
+ for (const [path, target] of Object.entries(paths)) {
1691
+ if (exports[path] !== undefined && exports[path] !== target)
1692
+ throw new Error(`Conflicting UI package export: ${path}`)
1693
+ exports[path] = target
1694
+ }
1695
+
1696
+ const sideEffects =
1697
+ value.sideEffects === false
1698
+ ? ["**/*.css"]
1699
+ : Array.isArray(value.sideEffects) &&
1700
+ !value.sideEffects.includes("**/*.css")
1701
+ ? [...value.sideEffects, "**/*.css"]
1702
+ : value.sideEffects
1703
+
1704
+ return JSON.stringify(value.exports) === JSON.stringify(exports) &&
1705
+ JSON.stringify(value.sideEffects) === JSON.stringify(sideEffects)
1706
+ ? content
1707
+ : `${JSON.stringify({ ...value, exports, sideEffects }, null, 2)}\n`
1708
+ })
1709
+ }
1554
1710
 
1555
1711
  const devDependencies = [
1556
1712
  "oxlint@^1.79.0",
@@ -1561,7 +1717,9 @@ export async function initProject(options: InitOptions = {}) {
1561
1717
  "@stylexjs/postcss-plugin@^0.19.0",
1562
1718
  ]
1563
1719
 
1564
- const runtimeDependencies: string[] = []
1720
+ const runtimeDependencies: string[] = shared
1721
+ ? [`${shared.name}@workspace:*`, "@stylexjs/stylex@^0.19.0"]
1722
+ : []
1565
1723
 
1566
1724
  if (framework === "astro") {
1567
1725
  const config = await chooseFile(root, [
@@ -1571,7 +1729,7 @@ export async function initProject(options: InitOptions = {}) {
1571
1729
  ])
1572
1730
 
1573
1731
  await plan(config, (content) =>
1574
- astroConfig(content, config, babelConfig, postcssPlugins),
1732
+ astroConfig(content, config, babelConfig, postcssPlugins, shared),
1575
1733
  )
1576
1734
 
1577
1735
  const layout = await chooseFile(root, [
@@ -1579,12 +1737,27 @@ export async function initProject(options: InitOptions = {}) {
1579
1737
  "src/layouts/layout.astro",
1580
1738
  ])
1581
1739
 
1582
- await plan(layout, (content) => astroLayout(content, layout))
1740
+ await plan(layout, (content) => astroLayout(content, layout, shared?.name))
1583
1741
 
1584
1742
  for (const name of ["@astrojs/react", "react", "react-dom"]) {
1585
1743
  if (!(name in dependencies)) runtimeDependencies.push(name)
1586
1744
  }
1587
1745
  } else if (framework === "next") {
1746
+ if (shared) {
1747
+ const config = await chooseFile(
1748
+ root,
1749
+ ["next.config.ts", "next.config.mjs", "next.config.js"],
1750
+ "next.config.mjs",
1751
+ )
1752
+
1753
+ const name = shared.name
1754
+ await plan(
1755
+ config,
1756
+ (content) => nextConfig(content, config, name),
1757
+ "export default {}\n",
1758
+ )
1759
+ }
1760
+
1588
1761
  if (!object(manifest.scripts))
1589
1762
  throw new Error("Next.js scripts are missing")
1590
1763
  nextScripts(manifest.scripts)
@@ -1594,7 +1767,9 @@ export async function initProject(options: InitOptions = {}) {
1594
1767
  "app/layout.tsx",
1595
1768
  ])
1596
1769
 
1597
- await plan(layout, (content) => jsxLayout(content, layout, "next"))
1770
+ await plan(layout, (content) =>
1771
+ jsxLayout(content, layout, "next", shared?.name),
1772
+ )
1598
1773
 
1599
1774
  const babel = await chooseFile(
1600
1775
  root,
@@ -1612,7 +1787,7 @@ export async function initProject(options: InitOptions = {}) {
1612
1787
  throw new Error(`Unsupported Babel configuration: ${babel}`)
1613
1788
  }
1614
1789
 
1615
- const babelContent = nextBabel(manifest.type === "module")
1790
+ const babelContent = nextBabel(manifest.type === "module", shared)
1616
1791
  await plan(
1617
1792
  babel,
1618
1793
  (content) => {
@@ -1634,15 +1809,16 @@ export async function initProject(options: InitOptions = {}) {
1634
1809
  await plan(
1635
1810
  postcssPath,
1636
1811
  (content) =>
1637
- postcssPath === "postcss.config.cjs" && content === nextPostcss()
1812
+ postcssPath === "postcss.config.cjs" && content === nextPostcss(shared)
1638
1813
  ? content
1639
1814
  : postcss(
1640
1815
  content,
1641
1816
  postcssPath,
1642
1817
  postcssPath.endsWith(".mjs") ||
1643
1818
  (postcssPath.endsWith(".js") && manifest.type === "module"),
1819
+ shared,
1644
1820
  ),
1645
- nextPostcss(),
1821
+ nextPostcss(shared),
1646
1822
  )
1647
1823
  devDependencies.splice(
1648
1824
  0,
@@ -1663,7 +1839,7 @@ export async function initProject(options: InitOptions = {}) {
1663
1839
  ])
1664
1840
 
1665
1841
  await plan(config, (content) =>
1666
- viteConfig(content, config, babelConfig, postcssPlugins),
1842
+ viteConfig(content, config, babelConfig, postcssPlugins, shared),
1667
1843
  )
1668
1844
 
1669
1845
  if (
@@ -1677,7 +1853,9 @@ export async function initProject(options: InitOptions = {}) {
1677
1853
  : ["app/root.tsx", "src/root.tsx"],
1678
1854
  )
1679
1855
 
1680
- await plan(layout, (content) => jsxLayout(content, layout, framework))
1856
+ await plan(layout, (content) =>
1857
+ jsxLayout(content, layout, framework, shared?.name),
1858
+ )
1681
1859
  } else {
1682
1860
  const entry = await chooseFile(root, [
1683
1861
  "src/main.tsx",
@@ -1686,110 +1864,138 @@ export async function initProject(options: InitOptions = {}) {
1686
1864
  "src/index.jsx",
1687
1865
  ])
1688
1866
 
1689
- await plan(entry, (content) => reactEntry(content, entry))
1867
+ await plan(entry, (content) => reactEntry(content, entry, shared?.name))
1690
1868
  }
1691
1869
  }
1692
1870
 
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
- })
1871
+ let baseInstalled = false
1710
1872
 
1711
- if ((await child.exited) !== 0)
1712
- throw new Error(`${manager} ${command} failed`)
1713
- })
1873
+ try {
1874
+ await installItem("base", {
1875
+ ...options,
1876
+ cwd: uiRoot,
1877
+ importPrefix: shared?.name,
1878
+ run: uiRun,
1879
+ })
1880
+ baseInstalled = true
1881
+
1882
+ if (
1883
+ !(await readFile(join(uiRoot, "src/styles/styles.css"), "utf8")).includes(
1884
+ "@stylex;",
1885
+ )
1886
+ ) {
1887
+ throw new Error(
1888
+ "Add @stylex; to src/styles/styles.css before configuring StyleX",
1889
+ )
1890
+ }
1714
1891
 
1715
- await installItem("base", { ...options, cwd: root, run: packageRun })
1892
+ if (!(await existingFile(root, "package.json")))
1893
+ throw new Error("Missing package.json after installation")
1894
+ const packages = await readFile(join(root, "package.json"), "utf8")
1895
+ const installed: JsonValue = JSON.parse(packages)
1716
1896
 
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",
1897
+ if (!object(installed))
1898
+ throw new Error("Invalid package.json after installation")
1899
+
1900
+ const available = Object.fromEntries(
1901
+ [installed.dependencies, installed.devDependencies].flatMap((entry) =>
1902
+ object(entry) ? Object.entries(entry) : [],
1903
+ ),
1724
1904
  )
1725
- }
1726
1905
 
1727
- const packages = await readFile(join(root, "package.json"), "utf8")
1728
- const installed: JsonValue = JSON.parse(packages)
1906
+ const neededRuntime = runtimeDependencies.filter((name) => {
1907
+ if (shared && name === `${shared.name}@workspace:*`) {
1908
+ const version = available[shared.name]
1729
1909
 
1730
- if (!object(installed))
1731
- throw new Error("Invalid package.json after installation")
1910
+ return (
1911
+ !isString(version) ||
1912
+ (version !== "*" && !version.startsWith("workspace:"))
1913
+ )
1914
+ }
1732
1915
 
1733
- const available = Object.fromEntries(
1734
- [installed.dependencies, installed.devDependencies].flatMap((entry) =>
1735
- object(entry) ? Object.entries(entry) : [],
1736
- ),
1737
- )
1916
+ const packageName = name.includes("@", 1)
1917
+ ? name.slice(0, name.lastIndexOf("@"))
1918
+ : name
1738
1919
 
1739
- const neededRuntime = runtimeDependencies.filter(
1740
- (name) => !(name in available),
1741
- )
1920
+ return !(packageName in available)
1921
+ })
1742
1922
 
1743
- if (neededRuntime.length) await packageRun(["add", ...neededRuntime], root)
1923
+ if (neededRuntime.length) await packageRun(["add", ...neededRuntime], root)
1744
1924
 
1745
- const neededDev = devDependencies.filter(
1746
- (name) => !(name.split("@").slice(0, -1).join("@") in available),
1747
- )
1925
+ const neededDev = devDependencies.filter(
1926
+ (name) => !(name.split("@").slice(0, -1).join("@") in available),
1927
+ )
1748
1928
 
1749
- if (neededDev.length) await packageRun(["add", "-d", ...neededDev], root)
1929
+ if (neededDev.length) await packageRun(["add", "-d", ...neededDev], root)
1930
+
1931
+ if (shared) {
1932
+ const path = `${relative(root, uiRoot).replaceAll("\\", "/")}/package.json`
1933
+ const planned = edits.get(path)
1934
+
1935
+ if (planned) {
1936
+ if (!(await existingFile(root, path)))
1937
+ throw new Error("Missing UI package.json after installation")
1938
+ const current = await readFile(join(uiRoot, "package.json"), "utf8")
1939
+ const before: JsonValue = JSON.parse(planned.before!)
1940
+ const after: JsonValue = JSON.parse(planned.after)
1941
+ const value: JsonValue = JSON.parse(current)
1942
+
1943
+ if (
1944
+ !object(before) ||
1945
+ !object(after) ||
1946
+ !object(value) ||
1947
+ value.name !== before.name ||
1948
+ JSON.stringify(value.exports) !== JSON.stringify(before.exports) ||
1949
+ JSON.stringify(value.sideEffects) !==
1950
+ JSON.stringify(before.sideEffects)
1951
+ )
1952
+ throw new Error("UI package exports changed during installation")
1750
1953
 
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
1954
+ edits.set(path, {
1955
+ before: current,
1956
+ after: `${JSON.stringify({ ...value, exports: after.exports, sideEffects: after.sideEffects }, null, 2)}\n`,
1957
+ })
1958
+ }
1959
+ }
1754
1960
 
1755
- if (current !== before) throw new Error(`File changed during init: ${path}`)
1756
- }
1961
+ const changes = new Map<string, FileChange>(edits)
1757
1962
 
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
- }
1963
+ for (const [path, before] of retired) {
1964
+ changes.set(path, { before, after: null })
1965
+ }
1762
1966
 
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
- }
1967
+ if (framework === "next") {
1968
+ if (!(await existingFile(root, "package.json")))
1969
+ throw new Error("Missing package.json after installation")
1970
+ const before = await readFile(join(root, "package.json"), "utf8")
1971
+ const latest: JsonValue = JSON.parse(before)
1769
1972
 
1770
- for (const path of retired.keys()) await unlink(join(root, path))
1973
+ if (!object(latest))
1974
+ throw new Error("Invalid package.json after installation")
1771
1975
 
1772
- if (framework === "next") {
1773
- const latest: JsonValue = JSON.parse(
1774
- await readFile(join(root, "package.json"), "utf8"),
1775
- )
1976
+ if (JSON.stringify(latest.scripts) !== JSON.stringify(manifest.scripts)) {
1977
+ throw new Error("Next.js scripts changed during init")
1978
+ }
1776
1979
 
1777
- if (!object(latest))
1778
- throw new Error("Invalid package.json after installation")
1980
+ const scripts = nextScripts(latest.scripts)
1779
1981
 
1780
- if (JSON.stringify(latest.scripts) !== JSON.stringify(manifest.scripts)) {
1781
- throw new Error("Next.js scripts changed during init")
1982
+ changes.set("package.json", {
1983
+ before,
1984
+ after:
1985
+ JSON.stringify(latest.scripts) === JSON.stringify(scripts)
1986
+ ? before
1987
+ : `${JSON.stringify({ ...latest, scripts }, null, 2)}\n`,
1988
+ })
1782
1989
  }
1783
1990
 
1784
- const scripts = nextScripts(latest.scripts)
1991
+ await writeFiles(root, changes)
1785
1992
 
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
- }
1993
+ return { framework, configured: edits.size }
1994
+ } catch (cause) {
1995
+ if (!baseInstalled) throw cause
1996
+ throw new Error(
1997
+ `${cause instanceof Error ? cause.message : String(cause)}\nDependencies may have changed (package.json, lockfiles, node_modules); package-manager changes were not rolled back.\nPrior init base install may have changed source files and ui.json; that install was not rolled back.`,
1998
+ { cause },
1999
+ )
1792
2000
  }
1793
-
1794
- return { framework, configured: edits.size }
1795
2001
  }