diff --git a/.changeset/vite-astro-config-fallback.md b/.changeset/vite-astro-config-fallback.md new file mode 100644 index 0000000..e7b66c1 --- /dev/null +++ b/.changeset/vite-astro-config-fallback.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/polystella-core": patch +"@cloudflare/polystella-astro": patch +--- + +Load the Astro config the way Astro does in every CLI command: find `astro.config.{mjs,js,ts,mts}`, try a plain Node import, and fall back to the project's Astro-installed Vite when that fails. Configs that import TypeScript-only packages no longer break `check-ui`, `sync-ui`, `translate-ui`, `translate`, or `audit-mdx`. diff --git a/docs/src/content/docs/cli/index.md b/docs/src/content/docs/cli/index.md index 73f6acb..9431dbd 100644 --- a/docs/src/content/docs/cli/index.md +++ b/docs/src/content/docs/cli/index.md @@ -46,6 +46,14 @@ Run `audit-mdx` while onboarding an MDX-heavy project or adding a new component recipe. It is warn-only and does not need provider or R2 credentials. +## How the Astro config is loaded + +Every subcommand reads the locale set from the project's +`astro.config.{mjs,js,ts,mts}`, loaded the same way Astro loads it: +a plain Node import first, then — if that fails — through the Vite +installed with the project's `astro`. Configs that import +TypeScript-only packages therefore work without workarounds. + ## Exit codes Across all subcommands: diff --git a/packages/astro/src/cli.ts b/packages/astro/src/cli.ts index 386e7ee..01ea996 100755 --- a/packages/astro/src/cli.ts +++ b/packages/astro/src/cli.ts @@ -106,8 +106,8 @@ export interface TranslateCliArgs { export const TRANSLATE_USAGE = `polystella translate Run the translation pipeline outside an Astro build. Reads -\`astro.config.mjs\` and \`polystella.config.mjs\` from the current -working directory, then walks sources, translates, caches, and stages +\`astro.config.{mjs,js,ts,mts}\` and \`polystella.config.mjs\` from the +current working directory, then walks sources, translates, caches, and stages results under \`/.astro/i18n-staging\` — exactly as \`astro build\` would. diff --git a/packages/astro/src/cli/i18n-config.ts b/packages/astro/src/cli/i18n-config.ts index 2e160de..8db1691 100644 --- a/packages/astro/src/cli/i18n-config.ts +++ b/packages/astro/src/cli/i18n-config.ts @@ -5,8 +5,9 @@ * translation orchestrator. * * Two public functions: - * - `loadAstroI18n(cwd)` — returns the `i18n` object from - * `astro.config.mjs`, or `undefined` if absent. + * - `loadAstroI18n(cwd)` — returns the raw `i18n` object from the + * Astro config (loaded by core, with Astro's Vite fallback), or + * `undefined` if absent. `resolveOptions` validates it. * - `loadPolystellaConfig(cwd)` — default-exports from * `polystella.config.mjs`, used as input to `resolveOptions`. * @@ -18,17 +19,12 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; +import { loadAstroConfig } from "@cloudflare/polystella-core/cli/config"; + import type { AstroI18nLike } from "../config/options.js"; export async function loadAstroI18n(cwd: string): Promise { - const candidatePath = path.resolve(cwd, "astro.config.mjs"); - let module: { default?: unknown }; - try { - module = await import(pathToFileURL(candidatePath).href); - } catch (err) { - throw new Error(`failed to load ${candidatePath}: ${(err as Error).message}`); - } - const exported = module.default ?? module; + const exported = await loadAstroConfig(cwd); if (typeof exported !== "object" || exported === null) { return undefined; } diff --git a/packages/astro/tests/cli/astro-config-loader.test.ts b/packages/astro/tests/cli/astro-config-loader.test.ts new file mode 100644 index 0000000..8fbc3ac --- /dev/null +++ b/packages/astro/tests/cli/astro-config-loader.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +// Runs core's built loader in a plain Node subprocess: under Vitest, the +// config import would go through Vite and hide the plain-Node failure. +const require = createRequire(import.meta.url); +const coreConfigModule = require.resolve("@cloudflare/polystella-core/cli/config"); +const astroPackageDir = path.dirname(require.resolve("astro/package.json")); +const I18N = { defaultLocale: "en-US", locales: ["en-US", "pt-BR"] }; + +async function scaffoldProject(files: Record): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "polystella-astro-config-")); + await mkdir(path.join(dir, "node_modules"), { recursive: true }); + await symlink(astroPackageDir, path.join(dir, "node_modules", "astro"), "dir"); + for (const [relativePath, contents] of Object.entries(files)) { + await mkdir(path.dirname(path.join(dir, relativePath)), { recursive: true }); + await writeFile(path.join(dir, relativePath), contents, "utf8"); + } + return dir; +} + +async function loadI18nInNode(cwd: string): Promise { + const script = `const { loadAstroI18n } = await import(process.argv[1]); console.log(JSON.stringify(await loadAstroI18n(process.cwd())));`; + const { stdout } = await promisify(execFile)(process.execPath, ["--input-type=module", "-e", script, coreConfigModule], { cwd }); + return JSON.parse(stdout); +} + +describe("loadAstroI18n", () => { + it("falls back to Astro's Vite when the config imports a TypeScript-only package", async () => { + const cwd = await scaffoldProject({ + "node_modules/ts-only-pkg/package.json": JSON.stringify({ + name: "ts-only-pkg", + type: "module", + exports: { "./static": "./src/index.ts" }, + }), + "node_modules/ts-only-pkg/src/index.ts": `export function integration(): { name: string } { return { name: "ts-only" }; }\n`, + "astro.config.mjs": `import { integration } from "ts-only-pkg/static";\nexport default { integrations: [integration()], i18n: ${JSON.stringify(I18N)} };\n`, + }); + await expect(loadI18nInNode(cwd)).resolves.toEqual(I18N); + }, 20_000); + + it("finds astro.config.ts", async () => { + const cwd = await scaffoldProject({ "astro.config.ts": `const i18n: object = ${JSON.stringify(I18N)};\nexport default { i18n };\n` }); + await expect(loadI18nInNode(cwd)).resolves.toEqual(I18N); + }, 20_000); +}); diff --git a/packages/core/src/cli/config.ts b/packages/core/src/cli/config.ts index 4889976..239e475 100644 --- a/packages/core/src/cli/config.ts +++ b/packages/core/src/cli/config.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -64,18 +66,53 @@ export interface CatalogResolvedConfig { maxRetries: number; } -export async function loadAstroI18n(cwd: string): Promise { - const candidatePath = path.resolve(cwd, "astro.config.mjs"); +const ASTRO_CONFIG_FILES = ["astro.config.mjs", "astro.config.js", "astro.config.ts", "astro.config.mts"]; + +type RunnerImport = (moduleId: string, inlineConfig: Record) => Promise; + +/** Returns the Astro config's default export, found and loaded the way Astro does. */ +export async function loadAstroConfig(cwd: string): Promise { + const configPath = ASTRO_CONFIG_FILES.map((file) => path.resolve(cwd, file)).find((file) => existsSync(file)); + if (configPath === undefined) { + throw new Error(`no Astro config found in ${cwd} (looked for ${ASTRO_CONFIG_FILES.join(", ")})`); + } + let loaded: unknown; try { - loaded = await import(pathToFileURL(candidatePath).href); - } catch (error) { - throw new Error(`failed to load ${candidatePath}: ${errorMessage(error)}`); + loaded = await import(pathToFileURL(configPath).href); + } catch (nodeError) { + // Mirrors Astro's loadConfigWithVite: configs importing TypeScript-only packages load only through Vite. + loaded = await importWithAstroVite(cwd, configPath, nodeError); } - const module = asRecordOrUndefined(loaded); - const exported = module?.default ?? loaded; - const config = asRecordOrUndefined(exported); + return module?.default ?? loaded; +} + +async function importWithAstroVite(cwd: string, configPath: string, nodeError: unknown): Promise { + let runnerImport: RunnerImport; + try { + // Use the Vite copy Astro itself loads configs with; PolyStella doesn't depend on Vite directly. + const astroManifest = createRequire(path.join(cwd, "package.json")).resolve("astro/package.json"); + const vite = asRecordOrUndefined(await import(pathToFileURL(createRequire(astroManifest).resolve("vite")).href)); + if (typeof vite?.runnerImport !== "function") throw new Error("Astro's Vite does not export runnerImport"); + runnerImport = vite.runnerImport as RunnerImport; + } catch (viteLookupError) { + throw new Error( + `failed to load ${configPath}: ${errorMessage(nodeError)}\n` + + `The Vite fallback is unavailable (${errorMessage(viteLookupError)}); install astro in this project to enable it.`, + ); + } + + try { + const result = await runnerImport(configPath, { root: cwd, logLevel: "error", resolve: { tsconfigPaths: true } }); + return asRecordOrUndefined(result)?.module; + } catch (viteError) { + throw new Error(`failed to load ${configPath} with Node (${errorMessage(nodeError)}) or Vite (${errorMessage(viteError)})`); + } +} + +export async function loadAstroI18n(cwd: string): Promise { + const config = asRecordOrUndefined(await loadAstroConfig(cwd)); const i18n = asRecordOrUndefined(config?.i18n); if (i18n === undefined) return undefined;