mirror of
https://github.com/withastro/astro.git
synced 2025-03-10 23:01:26 -05:00
Use magicast for astro add (#11772)
This commit is contained in:
parent
fe80a2cc0c
commit
6272e6cec0
7 changed files with 77 additions and 234 deletions
5
.changeset/eight-balloons-cover.md
Normal file
5
.changeset/eight-balloons-cover.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Uses `magicast` to update the config for `astro add`
|
|
@ -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",
|
||||
|
|
|
@ -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'] });
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
|
@ -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<any> | 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<t.File> {
|
||||
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));
|
||||
function addIntegration(mod: ProxifiedModule<any>, integration: IntegrationInfo) {
|
||||
const config = getDefaultExportOptions(mod);
|
||||
const integrationId = toIdent(integration.id);
|
||||
|
||||
ensureImport(
|
||||
ast,
|
||||
t.importDeclaration(
|
||||
[t.importDefaultSpecifier(integrationId)],
|
||||
t.stringLiteral(integration.packageName),
|
||||
),
|
||||
);
|
||||
if (!mod.imports.$items.some((imp) => imp.local === integrationId)) {
|
||||
mod.imports.$append({ imported: integrationId, from: integration.packageName });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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<any>, adapter: IntegrationInfo) {
|
||||
const config = getDefaultExportOptions(mod);
|
||||
const adapterId = toIdent(adapter.id);
|
||||
|
||||
ensureImport(
|
||||
ast,
|
||||
t.importDeclaration([t.importDefaultSpecifier(adapterId)], t.stringLiteral(exportName)),
|
||||
);
|
||||
if (!mod.imports.$items.some((imp) => imp.local === adapterId)) {
|
||||
mod.imports.$append({ imported: adapterId, from: adapter.packageName });
|
||||
}
|
||||
|
||||
visit(ast, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
ExportDefaultDeclaration(path) {
|
||||
if (!t.isCallExpression(path.node.declaration)) return;
|
||||
if (!config.output) {
|
||||
config.output = 'server';
|
||||
}
|
||||
|
||||
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 (!adapterProp) {
|
||||
configObject.properties.push(t.objectProperty(t.identifier('adapter'), adapterCall));
|
||||
return;
|
||||
}
|
||||
|
||||
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<any>;
|
||||
flags: Flags;
|
||||
logger: Logger;
|
||||
logAdapterInstructions: boolean;
|
||||
}): Promise<UpdateResult> {
|
||||
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;
|
||||
|
|
|
@ -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]);
|
||||
},
|
||||
});
|
||||
}
|
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue