diff --git a/.changeset/eight-balloons-cover.md b/.changeset/eight-balloons-cover.md new file mode 100644 index 000000000000..ea6364668ab0 --- /dev/null +++ b/.changeset/eight-balloons-cover.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Uses `magicast` to update the config for `astro add` diff --git a/packages/astro/package.json b/packages/astro/package.json index ea1a562e067d..d8a9c7c0240b 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -127,10 +127,7 @@ "@astrojs/markdown-remark": "workspace:*", "@astrojs/telemetry": "workspace:*", "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.5", - "@babel/parser": "^7.25.4", "@babel/plugin-transform-react-jsx": "^7.25.2", - "@babel/traverse": "^7.25.4", "@babel/types": "^7.25.4", "@oslojs/encoding": "^0.4.1", "@rollup/pluginutils": "^5.1.0", @@ -164,6 +161,7 @@ "js-yaml": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.11", + "magicast": "^0.3.5", "micromatch": "^4.0.8", "mrmime": "^2.0.0", "neotraverse": "^0.6.18", diff --git a/packages/astro/src/cli/add/babel.ts b/packages/astro/src/cli/add/babel.ts deleted file mode 100644 index facaabd54e65..000000000000 --- a/packages/astro/src/cli/add/babel.ts +++ /dev/null @@ -1,16 +0,0 @@ -import generator from '@babel/generator'; -import parser from '@babel/parser'; -import traverse from '@babel/traverse'; -import * as t from '@babel/types'; - -export const visit = traverse.default; -export { t }; - -export async function generate(ast: t.File) { - const astToText = generator.default; - const { code } = astToText(ast); - return code; -} - -export const parse = (code: string) => - parser.parse(code, { sourceType: 'unambiguous', plugins: ['typescript'] }); diff --git a/packages/astro/src/cli/add/imports.ts b/packages/astro/src/cli/add/imports.ts deleted file mode 100644 index 375ca1dd8a5e..000000000000 --- a/packages/astro/src/cli/add/imports.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { t, visit } from './babel.js'; - -export function ensureImport(root: t.File, importDeclaration: t.ImportDeclaration) { - let specifiersToFind = [...importDeclaration.specifiers]; - - visit(root, { - ImportDeclaration(path) { - if (path.node.source.value === importDeclaration.source.value) { - path.node.specifiers.forEach((specifier) => - specifiersToFind.forEach((specifierToFind, i) => { - if (specifier.type !== specifierToFind.type) return; - if (specifier.local.name === specifierToFind.local.name) { - specifiersToFind.splice(i, 1); - } - }), - ); - } - }, - }); - - if (specifiersToFind.length === 0) return; - - visit(root, { - Program(path) { - const declaration = t.importDeclaration(specifiersToFind, importDeclaration.source); - const latestImport = path - .get('body') - .filter((statement) => statement.isImportDeclaration()) - .pop(); - - if (latestImport) latestImport.insertAfter(declaration); - else path.unshiftContainer('body', declaration); - }, - }); -} diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index f710184d2cb9..91d5b54352c6 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -30,9 +30,8 @@ import { ensureProcessNodeEnv, parseNpmName } from '../../core/util.js'; import { eventCliSession, telemetry } from '../../events/index.js'; import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js'; import { fetchPackageJson, fetchPackageVersions } from '../install-package.js'; -import { generate, parse, t, visit } from './babel.js'; -import { ensureImport } from './imports.js'; -import { wrapDefaultExport } from './wrapper.js'; +import { loadFile, generateCode, builders, type ASTNode, type ProxifiedModule } from 'magicast'; +import { getDefaultExportOptions } from 'magicast/helpers'; interface AddOptions { flags: Flags; @@ -261,29 +260,26 @@ export async function add(names: string[], { flags }: AddOptions) { await fs.writeFile(fileURLToPath(configURL), STUBS.ASTRO_CONFIG, { encoding: 'utf-8' }); } - let ast: t.File | null = null; + let mod: ProxifiedModule | undefined; try { - ast = await parseAstroConfig(configURL); - + mod = await loadFile(fileURLToPath(configURL)); logger.debug('add', 'Parsed astro config'); - const defineConfig = t.identifier('defineConfig'); - ensureImport( - ast, - t.importDeclaration( - [t.importSpecifier(defineConfig, defineConfig)], - t.stringLiteral('astro/config'), - ), - ); - wrapDefaultExport(ast, defineConfig); - + if (mod.exports.default.$type !== 'function-call') { + // ensure config is wrapped with `defineConfig` + mod.imports.$prepend({ imported: 'defineConfig', from: 'astro/config' }); + mod.exports.default = builders.functionCall('defineConfig', mod.exports.default); + } else if (mod.exports.default.$args[0] == null) { + // ensure first argument of `defineConfig` is not empty + mod.exports.default.$args[0] = {}; + } logger.debug('add', 'Astro config ensured `defineConfig`'); for (const integration of integrations) { if (isAdapter(integration)) { const officialExportName = OFFICIAL_ADAPTER_TO_IMPORT_MAP[integration.id]; if (officialExportName) { - await setAdapter(ast, integration, officialExportName); + setAdapter(mod, integration); } else { logger.info( 'SKIP_FORMAT', @@ -295,7 +291,7 @@ export async function add(names: string[], { flags }: AddOptions) { ); } } else { - await addIntegration(ast, integration); + addIntegration(mod, integration); } logger.debug('add', `Astro config added integration ${integration.id}`); } @@ -306,11 +302,11 @@ export async function add(names: string[], { flags }: AddOptions) { let configResult: UpdateResult | undefined; - if (ast) { + if (mod) { try { configResult = await updateAstroConfig({ configURL, - ast, + mod, flags, logger, logAdapterInstructions: integrations.some(isAdapter), @@ -390,17 +386,6 @@ function isAdapter( return integration.type === 'adapter'; } -async function parseAstroConfig(configURL: URL): Promise { - const source = await fs.readFile(fileURLToPath(configURL), { encoding: 'utf-8' }); - const result = parse(source); - - if (!result) throw new Error('Unknown error parsing astro config'); - if (result.errors.length > 0) - throw new Error('Error parsing astro config: ' + JSON.stringify(result.errors)); - - return result; -} - // Convert an arbitrary NPM package name into a JS identifier // Some examples: // - @astrojs/image => image @@ -437,130 +422,47 @@ Documentation: https://docs.astro.build/en/guides/integrations-guide/`; return err; } -async function addIntegration(ast: t.File, integration: IntegrationInfo) { - const integrationId = t.identifier(toIdent(integration.id)); - - ensureImport( - ast, - t.importDeclaration( - [t.importDefaultSpecifier(integrationId)], - t.stringLiteral(integration.packageName), - ), - ); +function addIntegration(mod: ProxifiedModule, integration: IntegrationInfo) { + const config = getDefaultExportOptions(mod); + const integrationId = toIdent(integration.id); - visit(ast, { - // eslint-disable-next-line @typescript-eslint/no-shadow - ExportDefaultDeclaration(path) { - if (!t.isCallExpression(path.node.declaration)) return; - - const configObject = path.node.declaration.arguments[0]; - if (!t.isObjectExpression(configObject)) return; - - let integrationsProp = configObject.properties.find((prop) => { - if (prop.type !== 'ObjectProperty') return false; - if (prop.key.type === 'Identifier') { - if (prop.key.name === 'integrations') return true; - } - if (prop.key.type === 'StringLiteral') { - if (prop.key.value === 'integrations') return true; - } - return false; - }) as t.ObjectProperty | undefined; - - const integrationCall = t.callExpression(integrationId, []); - - if (!integrationsProp) { - configObject.properties.push( - t.objectProperty(t.identifier('integrations'), t.arrayExpression([integrationCall])), - ); - return; - } - - if (integrationsProp.value.type !== 'ArrayExpression') - throw new Error('Unable to parse integrations'); - - const existingIntegrationCall = integrationsProp.value.elements.find( - (expr) => - t.isCallExpression(expr) && - t.isIdentifier(expr.callee) && - expr.callee.name === integrationId.name, - ); - - if (existingIntegrationCall) return; + if (!mod.imports.$items.some((imp) => imp.local === integrationId)) { + mod.imports.$append({ imported: integrationId, from: integration.packageName }); + } - integrationsProp.value.elements.push(integrationCall); - }, - }); + config.integrations ??= []; + if ( + !config.integrations.$ast.elements.some( + (el: ASTNode) => + el.type === 'CallExpression' && + el.callee.type === 'Identifier' && + el.callee.name === integrationId, + ) + ) { + config.integrations.push(builders.functionCall(integrationId)); + } } -async function setAdapter(ast: t.File, adapter: IntegrationInfo, exportName: string) { - const adapterId = t.identifier(toIdent(adapter.id)); +export function setAdapter(mod: ProxifiedModule, adapter: IntegrationInfo) { + const config = getDefaultExportOptions(mod); + const adapterId = toIdent(adapter.id); - ensureImport( - ast, - t.importDeclaration([t.importDefaultSpecifier(adapterId)], t.stringLiteral(exportName)), - ); - - visit(ast, { - // eslint-disable-next-line @typescript-eslint/no-shadow - ExportDefaultDeclaration(path) { - if (!t.isCallExpression(path.node.declaration)) return; - - const configObject = path.node.declaration.arguments[0]; - if (!t.isObjectExpression(configObject)) return; - - let outputProp = configObject.properties.find((prop) => { - if (prop.type !== 'ObjectProperty') return false; - if (prop.key.type === 'Identifier') { - if (prop.key.name === 'output') return true; - } - if (prop.key.type === 'StringLiteral') { - if (prop.key.value === 'output') return true; - } - return false; - }) as t.ObjectProperty | undefined; - - if (!outputProp) { - configObject.properties.push( - t.objectProperty(t.identifier('output'), t.stringLiteral('server')), - ); - } - - let adapterProp = configObject.properties.find((prop) => { - if (prop.type !== 'ObjectProperty') return false; - if (prop.key.type === 'Identifier') { - if (prop.key.name === 'adapter') return true; - } - if (prop.key.type === 'StringLiteral') { - if (prop.key.value === 'adapter') return true; - } - return false; - }) as t.ObjectProperty | undefined; - - let adapterCall; - switch (adapter.id) { - // the node adapter requires a mode - case 'node': { - adapterCall = t.callExpression(adapterId, [ - t.objectExpression([ - t.objectProperty(t.identifier('mode'), t.stringLiteral('standalone')), - ]), - ]); - break; - } - default: { - adapterCall = t.callExpression(adapterId, []); - } - } + if (!mod.imports.$items.some((imp) => imp.local === adapterId)) { + mod.imports.$append({ imported: adapterId, from: adapter.packageName }); + } - if (!adapterProp) { - configObject.properties.push(t.objectProperty(t.identifier('adapter'), adapterCall)); - return; - } + if (!config.output) { + config.output = 'server'; + } - adapterProp.value = adapterCall; - }, - }); + switch (adapter.id) { + case 'node': + config.adapter = builders.functionCall(adapterId, { mode: 'standalone' }); + break; + default: + config.adapter = builders.functionCall(adapterId); + break; + } } const enum UpdateResult { @@ -572,23 +474,25 @@ const enum UpdateResult { async function updateAstroConfig({ configURL, - ast, + mod, flags, logger, logAdapterInstructions, }: { configURL: URL; - ast: t.File; + mod: ProxifiedModule; flags: Flags; logger: Logger; logAdapterInstructions: boolean; }): Promise { const input = await fs.readFile(fileURLToPath(configURL), { encoding: 'utf-8' }); - let output = await generate(ast); - const comment = '// https://astro.build/config'; - const defaultExport = 'export default defineConfig'; - output = output.replace(`\n${comment}`, ''); - output = output.replace(`${defaultExport}`, `\n${comment}\n${defaultExport}`); + const output = generateCode(mod, { + format: { + objectCurlySpacing: true, + useTabs: false, + tabWidth: 2, + }, + }).code; if (input === output) { return UpdateResult.none; diff --git a/packages/astro/src/cli/add/wrapper.ts b/packages/astro/src/cli/add/wrapper.ts deleted file mode 100644 index c86e87698ee8..000000000000 --- a/packages/astro/src/cli/add/wrapper.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { t, visit } from './babel.js'; - -export function wrapDefaultExport(ast: t.File, functionIdentifier: t.Identifier) { - visit(ast, { - ExportDefaultDeclaration(path) { - if (!t.isExpression(path.node.declaration)) return; - if ( - t.isCallExpression(path.node.declaration) && - t.isIdentifier(path.node.declaration.callee) && - path.node.declaration.callee.name === functionIdentifier.name - ) - return; - path.node.declaration = t.callExpression(functionIdentifier, [path.node.declaration]); - }, - }); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 093d3615e7ef..7bae5f21d368 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,18 +570,9 @@ importers: '@babel/core': specifier: ^7.25.2 version: 7.25.2 - '@babel/generator': - specifier: ^7.25.5 - version: 7.25.5 - '@babel/parser': - specifier: ^7.25.4 - version: 7.25.4 '@babel/plugin-transform-react-jsx': specifier: ^7.25.2 version: 7.25.2(@babel/core@7.25.2) - '@babel/traverse': - specifier: ^7.25.4 - version: 7.25.4 '@babel/types': specifier: ^7.25.4 version: 7.25.4 @@ -681,6 +672,9 @@ importers: magic-string: specifier: ^0.30.11 version: 0.30.11 + magicast: + specifier: ^0.3.5 + version: 0.3.5 micromatch: specifier: ^4.0.8 version: 4.0.8 @@ -9491,6 +9485,9 @@ packages: resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==} engines: {node: '>=12'} + 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'} @@ -15570,6 +15567,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.25.4 + '@babel/types': 7.25.4 + source-map-js: 1.2.0 + make-dir@3.1.0: dependencies: semver: 6.3.1