From fbbf92bae447f6720edd72c4eeb2c3d61c40be86 Mon Sep 17 00:00:00 2001 From: Diogo Carneiro Date: Thu, 24 Sep 2026 11:06:06 +0100 Subject: [PATCH] docs: document Astro config loading and refactor to use Vite fallback Add documentation explaining how the CLI loads Astro configs with the same Node-then-Vite strategy Astro uses, enabling TypeScript-only package imports. Extract `loadAstroConfig` helper to core that tries plain Node import first, then falls back to Astro's Vite runner when that fails. Support all standard Astro config file extensions (mjs, js, ts, mts). Update CLI usage text to reflect supported config file patterns. --- .changeset/vite-astro-config-fallback.md | 6 +++ docs/src/content/docs/cli/index.md | 8 +++ packages/astro/src/cli.ts | 4 +- packages/astro/src/cli/i18n-config.ts | 16 +++--- .../tests/cli/astro-config-loader.test.ts | 51 ++++++++++++++++++ packages/core/src/cli/config.ts | 53 ++++++++++++++++--- 6 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 .changeset/vite-astro-config-fallback.md create mode 100644 packages/astro/tests/cli/astro-config-loader.test.ts 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;