Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/vite-astro-config-fallback.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions docs/src/content/docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \`<root>/.astro/i18n-staging\` — exactly as \`astro
build\` would.

Expand Down
16 changes: 6 additions & 10 deletions packages/astro/src/cli/i18n-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand All @@ -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<AstroI18nLike | undefined> {
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;
}
Expand Down
51 changes: 51 additions & 0 deletions packages/astro/tests/cli/astro-config-loader.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<string> {
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<unknown> {
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);
});
53 changes: 45 additions & 8 deletions packages/core/src/cli/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";

Expand Down Expand Up @@ -64,18 +66,53 @@ export interface CatalogResolvedConfig {
maxRetries: number;
}

export async function loadAstroI18n(cwd: string): Promise<AstroI18nConfig | undefined> {
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<string, unknown>) => Promise<unknown>;

/** Returns the Astro config's default export, found and loaded the way Astro does. */
export async function loadAstroConfig(cwd: string): Promise<unknown> {
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<unknown> {
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<AstroI18nConfig | undefined> {
const config = asRecordOrUndefined(await loadAstroConfig(cwd));
const i18n = asRecordOrUndefined(config?.i18n);
if (i18n === undefined) return undefined;

Expand Down
Loading