From b5a3094fd6344d1ba3d0851c32fdb7f1d04e8adf Mon Sep 17 00:00:00 2001 From: Adam Skoufis Date: Thu, 21 Nov 2024 15:17:26 +1100 Subject: [PATCH] Implement `SkuConfigUpdater` for codemodding sku config (#1086) Co-authored-by: Dan Drory <101684796+DanDroryAu@users.noreply.github.com> --- .../sku/config/prettier/prettierConfig.js | 3 + packages/sku/lib/SkuConfigUpdater.js | 186 +++++++++++++++ packages/sku/lib/SkuConfigUpdater.test.js | 213 ++++++++++++++++++ packages/sku/package.json | 4 + pnpm-lock.yaml | 27 +++ 5 files changed, 433 insertions(+) create mode 100644 packages/sku/lib/SkuConfigUpdater.js create mode 100644 packages/sku/lib/SkuConfigUpdater.test.js diff --git a/packages/sku/config/prettier/prettierConfig.js b/packages/sku/config/prettier/prettierConfig.js index 50def6891..35ac8fc80 100644 --- a/packages/sku/config/prettier/prettierConfig.js +++ b/packages/sku/config/prettier/prettierConfig.js @@ -1,3 +1,6 @@ +// @ts-check + +/** @type {import("prettier").Options} */ module.exports = { singleQuote: true, tabWidth: 2, diff --git a/packages/sku/lib/SkuConfigUpdater.js b/packages/sku/lib/SkuConfigUpdater.js new file mode 100644 index 000000000..c9ded4c6a --- /dev/null +++ b/packages/sku/lib/SkuConfigUpdater.js @@ -0,0 +1,186 @@ +// @ts-check + +const assert = require('node:assert'); +const { readFile, writeFile } = require('node:fs/promises'); +const t = require('@babel/types'); +const { parseModule, builders, generateCode } = require('magicast'); +// eslint-plugin-import doesn't support subpath imports +// eslint-disable-next-line import/no-unresolved +const { getConfigFromVariableDeclaration } = require('magicast/helpers'); + +const debug = require('debug'); +const log = debug('sku:update-sku-config'); + +const prettier = require('prettier'); +const prettierConfig = require('../config/prettier/prettierConfig.js'); + +class SkuConfigUpdater { + /** @typedef {import("sku").SkuConfig} SkuConfig */ + /** @typedef {import("magicast").ProxifiedObject} ProxifiedSkuConfig */ + /** @typedef {import("@babel/types").ObjectExpression} ObjectExpression */ + /** @typedef {import("@babel/types").VariableDeclarator} VariableDeclarator */ + + /** @typedef {{type: 'esm', configAst: ProxifiedSkuConfig}} EsmConfig */ + /** @typedef {{type: 'esm-non-literal', configAst: ProxifiedSkuConfig, configDeclaration: VariableDeclarator}} EsmNonLiteralConfig */ + /** @typedef {{type: 'cjs', configAst: ObjectExpression }} CjsConfig */ + + /** @type {EsmConfig | EsmNonLiteralConfig | CjsConfig} The AST or AST proxy of the sku config */ + #config; + /** The path to the sku config being modified */ + #path; + /** The parsed sku config file from magicast. Used for serializing the AST after updating it. */ + #module; + + /** + * @param {object} options + * @param {string} options.path - An absolute path to a sku config + * @param {string} options.contents - The contents of the sku config + */ + constructor({ path, contents }) { + this.#path = path; + + const skuConfigModule = parseModule(contents); + this.#module = skuConfigModule; + + if (typeof skuConfigModule.exports.default === 'undefined') { + /** @type {ObjectExpression} */ + let configAst; + + log( + 'No default export found in sku config. Config is either CJS or invalid.', + ); + + t.assertProgram(skuConfigModule.$ast); + const lastStatement = skuConfigModule.$ast.body.at(-1); + t.assertExpressionStatement(lastStatement); + + const { expression } = lastStatement; + t.assertAssignmentExpression(expression); + t.assertMemberExpression(expression.left); + t.assertIdentifier(expression.left.object, { + name: 'module', + }); + t.assertIdentifier(expression.left.property, { + name: 'exports', + }); + + if (t.isObjectExpression(expression.right)) { + configAst = expression.right; + } else if (t.isIdentifier(expression.right)) { + const skuConfigIdentifierName = expression.right.name; + const skuConfigDeclaration = skuConfigModule.$ast.body.find( + (node) => + t.isVariableDeclaration(node) && + t.isIdentifier(node.declarations[0].id) && + node.declarations[0].id.name === skuConfigIdentifierName, + ); + assert(skuConfigDeclaration, 'Expected skuConfig to be defined'); + t.assertVariableDeclaration(skuConfigDeclaration); + t.assertVariableDeclarator(skuConfigDeclaration.declarations[0]); + t.assertObjectExpression(skuConfigDeclaration.declarations[0].init); + configAst = skuConfigDeclaration.declarations[0].init; + } else { + throw new Error("Couldn't find config object in CJS sku config"); + } + + this.#config = { + type: 'cjs', + configAst, + }; + } else { + log('Found sku config with ESM export'); + + if (skuConfigModule.exports.default.$type === 'object') { + const configAst = skuConfigModule.exports.default; + this.#config = { + type: 'esm', + configAst, + }; + } else { + const { declaration: configDeclaration, config: configAst } = + getConfigFromVariableDeclaration(skuConfigModule); + + assert(configAst, 'Expected skuConfig to be defined'); + this.#config = { + type: 'esm-non-literal', + configAst, + configDeclaration, + }; + } + } + } + + /** + * Creates a new instance of SkuConfigUpdater from a file path + * + * @param {string} path - An absoulte path to a sku config + */ + static async fromFile(path) { + const contents = await readFile(path, 'utf8'); + + return new SkuConfigUpdater({ path, contents }); + } + + /** + * Updates `property` in sku config with the provided `value`. Inserts the `property` and `value` if it doesn't exist. + * + * This method does not write the changes to the file system. Use `commitConfig` to do that. + * + * @template {keyof SkuConfig} T + * @param {{property: T, value: SkuConfig[T]}} options + */ + upsertConfig({ property, value }) { + if (this.#config.type === 'cjs') { + const propertyToUpdate = this.#config.configAst.properties.find( + (prop) => + t.isObjectProperty(prop) && + t.isIdentifier(prop.key) && + prop.key.name === property, + ); + + if (propertyToUpdate) { + t.assertObjectProperty(propertyToUpdate); + propertyToUpdate.value = builders.literal(value); + } else { + const { + properties: [propertyLiteral], + } = builders.literal({ + [property]: value, + }); + this.#config.configAst.properties.push(propertyLiteral); + } + + return; + } + + // @ts-expect-error We have to mutate here because of magicast, but typescript complains + this.#config.configAst[property] = value; + + if (this.#config.type === 'esm') { + return; + } + + // At this point `this.#config.type` is `esm-non-literal` + + // Copied from magicast/helpers https://github.com/unjs/magicast/blob/50e2207842672e2c1c75898f0b1b97909f3b6c92/src/helpers/vite.ts#L129 + // @ts-expect-error This works because of recast magic + this.#config.configDeclaration.init = generateCode( + this.#config.configAst, + ).code; + } + + /** + * Writes the current state of the sku config to the file system + */ + async commitConfig() { + const newContents = this.#module.generate().code; + const formattedNewContents = prettier.format(newContents, { + parser: 'typescript', + ...prettierConfig, + }); + + await writeFile(this.#path, formattedNewContents); + } +} + +module.exports = { SkuConfigUpdater }; diff --git a/packages/sku/lib/SkuConfigUpdater.test.js b/packages/sku/lib/SkuConfigUpdater.test.js new file mode 100644 index 000000000..e206fa286 --- /dev/null +++ b/packages/sku/lib/SkuConfigUpdater.test.js @@ -0,0 +1,213 @@ +/** + * @jest-environment node + */ + +// @ts-check +import { createFixture } from 'fs-fixture'; +import dedent from 'dedent'; +import { SkuConfigUpdater } from './SkuConfigUpdater'; + +describe('updateSkuConfig', () => { + describe('SkuConfigUpdater', () => { + it('Should update a TypeScript sku config with a literal ESM default export', async () => { + const skuConfigFileName = 'sku.config.ts'; + + const fixture = await createFixture({ + [skuConfigFileName]: dedent/* ts */ ` + export default { + renderEntry: 'src/render.tsx', + clientEntry: 'src/client.tsx', + };`, + }); + + const modifier = await SkuConfigUpdater.fromFile( + fixture.getPath(skuConfigFileName), + ); + modifier.upsertConfig({ + property: 'renderEntry', + value: 'src/updated-render.tsx', + }); + modifier.upsertConfig({ + property: 'srcPaths', + value: ['./src', './cypress'], + }); + await modifier.commitConfig(); + + const result = (await fixture.readFile(skuConfigFileName)).toString(); + expect(result).toMatchInlineSnapshot(` + "export default { + renderEntry: 'src/updated-render.tsx', + clientEntry: 'src/client.tsx', + srcPaths: ['./src', './cypress'], + }; + " + `); + + await fixture.rm(); + }); + + it('Should update a TypeScript sku config with a literal ESM default export and "satisfies"', async () => { + const skuConfigFileName = 'sku.config.ts'; + + const fixture = await createFixture({ + [skuConfigFileName]: dedent/* ts */ ` + import type { SkuConfig } from 'sku'; + + export default { + renderEntry: 'src/render.tsx', + clientEntry: 'src/client.tsx', + } satisfies SkuConfig;`, + }); + + const modifier = await SkuConfigUpdater.fromFile( + fixture.getPath(skuConfigFileName), + ); + modifier.upsertConfig({ + property: 'renderEntry', + value: 'src/updated-render.tsx', + }); + modifier.upsertConfig({ + property: 'srcPaths', + value: ['./src', './cypress'], + }); + await modifier.commitConfig(); + + const result = (await fixture.readFile(skuConfigFileName)).toString(); + expect(result).toMatchInlineSnapshot(` + "import type { SkuConfig } from 'sku'; + + export default { + renderEntry: 'src/updated-render.tsx', + clientEntry: 'src/client.tsx', + srcPaths: ['./src', './cypress'], + } satisfies SkuConfig; + " + `); + + await fixture.rm(); + }); + + it('Should update a TypeScript sku config with a non-literal ESM default export', async () => { + const skuConfigFileName = 'sku.config.ts'; + + const fixture = await createFixture({ + [skuConfigFileName]: dedent/* ts */ ` + import type { SkuConfig } from 'sku'; + + const skuConfig: SkuConfig = { + renderEntry: 'src/render.tsx', + clientEntry: 'src/client.tsx', + }; + + export default skuConfig;`, + }); + + const modifier = await SkuConfigUpdater.fromFile( + fixture.getPath(skuConfigFileName), + ); + modifier.upsertConfig({ + property: 'renderEntry', + value: 'src/updated-render.tsx', + }); + modifier.upsertConfig({ + property: 'srcPaths', + value: ['./src', './cypress'], + }); + await modifier.commitConfig(); + + const result = (await fixture.readFile(skuConfigFileName)).toString(); + expect(result).toMatchInlineSnapshot(` + "import type { SkuConfig } from 'sku'; + + const skuConfig: SkuConfig = { + renderEntry: 'src/updated-render.tsx', + clientEntry: 'src/client.tsx', + srcPaths: ['./src', './cypress'], + }; + + export default skuConfig; + " + `); + + await fixture.rm(); + }); + + it('Should update a JavaScript sku config with a literal CJS default export', async () => { + const skuConfigFileName = 'sku.config.ts'; + + const fixture = await createFixture({ + [skuConfigFileName]: dedent/* ts */ ` + module.exports = { + renderEntry: 'src/render.tsx', + clientEntry: 'src/client.tsx', + };`, + }); + + const modifier = await SkuConfigUpdater.fromFile( + fixture.getPath(skuConfigFileName), + ); + modifier.upsertConfig({ + property: 'renderEntry', + value: 'src/updated-render.tsx', + }); + modifier.upsertConfig({ + property: 'srcPaths', + value: ['./src', './cypress'], + }); + await modifier.commitConfig(); + + const result = (await fixture.readFile(skuConfigFileName)).toString(); + expect(result).toMatchInlineSnapshot(` + "module.exports = { + renderEntry: 'src/updated-render.tsx', + clientEntry: 'src/client.tsx', + srcPaths: ['./src', './cypress'], + }; + " + `); + + await fixture.rm(); + }); + + it('Should update a JavaScript sku config with a non-literal CJS default export', async () => { + const skuConfigFileName = 'sku.config.ts'; + + const fixture = await createFixture({ + [skuConfigFileName]: dedent/* ts */ ` + const skuConfig = { + renderEntry: 'src/render.tsx', + clientEntry: 'src/client.tsx', + }; + + module.exports = skuConfig;`, + }); + + const modifier = await SkuConfigUpdater.fromFile( + fixture.getPath(skuConfigFileName), + ); + modifier.upsertConfig({ + property: 'renderEntry', + value: 'src/updated-render.tsx', + }); + modifier.upsertConfig({ + property: 'srcPaths', + value: ['./src', './cypress'], + }); + await modifier.commitConfig(); + + const result = (await fixture.readFile(skuConfigFileName)).toString(); + expect(result).toMatchInlineSnapshot(` + "const skuConfig = { + renderEntry: 'src/updated-render.tsx', + clientEntry: 'src/client.tsx', + srcPaths: ['./src', './cypress'], + }; + + module.exports = skuConfig; + " + `); + + await fixture.rm(); + }); + }); +}); diff --git a/packages/sku/package.json b/packages/sku/package.json index 02a8d0d35..ecfd4d9f8 100644 --- a/packages/sku/package.json +++ b/packages/sku/package.json @@ -58,6 +58,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.5", "@babel/runtime": "^7.21.0", + "@babel/types": "^7.26.0", "@loadable/babel-plugin": "^5.13.2", "@loadable/component": "^5.14.1", "@loadable/server": "^5.14.0", @@ -115,6 +116,7 @@ "jest-environment-jsdom": "^29.0.0", "jest-watch-typeahead": "^2.2.0", "lint-staged": "^15.2.10", + "magicast": "^0.3.5", "mini-css-extract-plugin": "^2.6.1", "minimist": "^1.2.8", "nano-memoize": "^3.0.16", @@ -151,6 +153,7 @@ "@types/minimist": "^1.2.5", "@types/node": "^18.19.31", "@types/picomatch": "^2.3.3", + "@types/prettier": "^2.7.3", "@types/react": "^18.2.3", "@types/react-dom": "^18.2.3", "@types/webpack-bundle-analyzer": "^4.7.0", @@ -158,6 +161,7 @@ "@vanilla-extract/css": "^1.0.0", "@vocab/react": "^1.1.11", "braid-design-system": "^32.0.0", + "fs-fixture": "^2.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92a909207..8ab37aecd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,6 +584,9 @@ importers: '@babel/runtime': specifier: ^7.21.0 version: 7.26.0 + '@babel/types': + specifier: ^7.26.0 + version: 7.26.0 '@loadable/babel-plugin': specifier: ^5.13.2 version: 5.16.1(@babel/core@7.26.0) @@ -758,6 +761,9 @@ importers: lint-staged: specifier: ^15.2.10 version: 15.2.10 + magicast: + specifier: ^0.3.5 + version: 0.3.5 mini-css-extract-plugin: specifier: ^2.6.1 version: 2.9.2(webpack@5.96.1(@swc/core@1.9.2)(esbuild@0.24.0)) @@ -861,6 +867,9 @@ importers: '@types/picomatch': specifier: ^2.3.3 version: 2.3.4 + '@types/prettier': + specifier: ^2.7.3 + version: 2.7.3 '@types/react': specifier: ^18.2.3 version: 18.3.12 @@ -882,6 +891,9 @@ importers: braid-design-system: specifier: ^32.0.0 version: 32.24.1(@types/react@18.3.12)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sku@13.2.1(@storybook/react-webpack5@8.4.4(@swc/core@1.9.2)(esbuild@0.24.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.4(prettier@2.8.8))(typescript@5.6.3))(@types/node@18.19.64)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(terser@5.36.0)(type-fest@2.19.0)(webpack-hot-middleware@2.26.1)) + fs-fixture: + specifier: ^2.6.0 + version: 2.6.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -4926,6 +4938,10 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fs-fixture@2.6.0: + resolution: {integrity: sha512-XQNHBGYgth08BSgThjQNrUuzE9XUmr7CDgGFStKaAeB9Oq+kxUHd1zS6hp1YO11B501Sjv9Jq+B2VFn+CdwmIg==} + engines: {node: '>=18.0.0'} + fs-monkey@1.0.6: resolution: {integrity: sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==} @@ -6033,6 +6049,9 @@ packages: magic-string@0.30.12: resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -13535,6 +13554,8 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fs-fixture@2.6.0: {} + fs-monkey@1.0.6: {} fs.realpath@1.0.0: {} @@ -14921,6 +14942,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.26.2 + '@babel/types': 7.26.0 + source-map-js: 1.2.1 + make-dir@3.1.0: dependencies: semver: 6.3.1