Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-admin-windows-lingui-macro.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes the admin UI failing to load in dev mode on Windows (stuck on "Loading EmDash..." with a `babel-plugin-macros` / `process is not defined` console error). The Lingui macro compiler that runs against admin source in local-monorepo dev never matched any files on Windows, so `@lingui/core/macro` imports shipped uncompiled to the browser instead of being transformed away.
29 changes: 22 additions & 7 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

import type { AstroConfig } from "astro";
import type { Plugin } from "vite";
Expand Down Expand Up @@ -73,10 +73,21 @@ const LOCALE_MESSAGES_RE = /[/\\]([a-z]{2}(?:-[A-Z]{2})?)[/\\]messages\.mjs$/;
* @babel/core is dynamically imported from admin's devDependencies —
* not declared by core, never ships to end users.
*/
function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plugin {
// Resolve @babel/core from admin's devDependencies, not core's.
export function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plugin {
// Resolve @babel/core and the lingui macro plugin from admin's devDependencies,
// not core's — and as absolute paths, since Babel's plugin resolution normally
// walks up from `filename` (an admin source file aliased into whatever app is
// running EmDash, e.g. demos/simple), where these devDependencies aren't reachable.
const adminRequire = createRequire(resolve(adminDistPath, "index.js"));
const babelCorePath = adminRequire.resolve("@babel/core");
const linguiMacroPluginPath = adminRequire.resolve("@lingui/babel-plugin-lingui-macro");

// Vite normalizes module ids/importers to forward slashes even on Windows,
// but `resolve()` returns OS-native (backslash) separators there — compare
// against a POSIX-normalized form so this plugin actually matches on Windows.
// (A literal backslash replace, not `path.sep`-driven, so this also works
// when a Windows-style path is passed on a non-Windows host, e.g. in tests.)
const adminSourcePathPosix = adminSourcePath.replaceAll("\\", "/");

return {
name: "emdash-lingui-macro",
Expand All @@ -85,18 +96,22 @@ function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plug
// Redirect relative locale catalog imports (e.g. ./de/messages.mjs) from
// within admin source to the compiled dist/locales/ directory, since
// lingui compile only runs during build — not in dev watch mode.
if (!importer?.startsWith(adminSourcePath)) return;
if (!importer?.startsWith(adminSourcePathPosix)) return;
const match = id.match(LOCALE_MESSAGES_RE);
if (match?.[1]) {
return resolve(adminDistPath, "locales", match[1], "messages.mjs");
}
},
async transform(code, id) {
if (!id.startsWith(adminSourcePath) || !code.includes("@lingui")) return;
const { transformAsync } = (await import(babelCorePath)) as typeof import("@babel/core");
if (!id.startsWith(adminSourcePathPosix) || !code.includes("@lingui")) return;
// Dynamic import() requires a file:// URL for absolute paths on Windows —
// a raw drive-letter path (e.g. "E:\...") is rejected by the ESM loader.
const { transformAsync } = (await import(
pathToFileURL(babelCorePath).href
)) as typeof import("@babel/core");
const result = await transformAsync(code, {
filename: id,
plugins: ["@lingui/babel-plugin-lingui-macro"],
plugins: [linguiMacroPluginPath],
parserOpts: { plugins: ["jsx", "typescript"] },
});
if (!result?.code) return;
Expand Down
87 changes: 85 additions & 2 deletions packages/core/tests/unit/astro/vite-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { existsSync } from "node:fs";
import { basename, isAbsolute } from "node:path";
import { createRequire } from "node:module";
import { basename, dirname, isAbsolute, resolve } from "node:path";

import type { AstroConfig } from "astro";
import { describe, expect, it } from "vitest";

import { createViteConfig } from "../../../src/astro/integration/vite-config.js";
import { createViteConfig, linguiMacroPlugin } from "../../../src/astro/integration/vite-config.js";

// Vite/Rollup type hook fields as `T | { handler: T; order?: ... }`. This
// plugin always uses the plain-function form, so unwrap that shape to get a
// directly callable function without pulling in Rollup's types as a dependency.
function unwrapHook<T>(hook: T | { handler: T } | null | undefined): T {
if (hook == null) throw new Error("Hook is not defined");
if (typeof hook === "object" && "handler" in hook) return hook.handler;
return hook;
}

describe("createViteConfig admin aliasing", () => {
const monorepoDemoRoot = new URL("../../../../../demos/simple/", import.meta.url);
Expand Down Expand Up @@ -170,3 +180,76 @@ describe("createViteConfig use-sync-external-store shim aliasing", () => {
});
}
});

// Regression: on Windows, `path.resolve()` returns backslash-separated
// paths, but Vite always normalizes module ids/importers to forward
// slashes — even on Windows. `linguiMacroPlugin` used to compare an
// `adminSourcePath` straight out of `resolve()` against those ids with
// `id.startsWith(adminSourcePath)`, which is silently always `false` on
// Windows. The plugin's hooks became permanent no-ops there: Lingui macro
// calls (`@lingui/core/macro`) shipped uncompiled to the browser, which
// then failed to hydrate the admin UI entirely (it never got past the
// "Loading EmDash..." screen). These tests reproduce that mismatch
// directly, with a synthetic backslash `adminSourcePath` — independent of
// the host OS running the test — so they fail on the pre-fix
// `id.startsWith(adminSourcePath)` comparison on any platform, not just
// Windows CI.
Comment on lines +184 to +196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This large comment block is a PR-description summary: it narrates the bug being fixed, the pre-failure behavior, and how the tests reproduce it. AGENTS.md says comments are not PR descriptions or summaries of the change — that context belongs in the commit message/PR body, not in code. The describe title and the test names already explain the behavior; this block should be deleted.

Suggested change
// Regression: on Windows, `path.resolve()` returns backslash-separated
// paths, but Vite always normalizes module ids/importers to forward
// slashes — even on Windows. `linguiMacroPlugin` used to compare an
// `adminSourcePath` straight out of `resolve()` against those ids with
// `id.startsWith(adminSourcePath)`, which is silently always `false` on
// Windows. The plugin's hooks became permanent no-ops there: Lingui macro
// calls (`@lingui/core/macro`) shipped uncompiled to the browser, which
// then failed to hydrate the admin UI entirely (it never got past the
// "Loading EmDash..." screen). These tests reproduce that mismatch
// directly, with a synthetic backslash `adminSourcePath` — independent of
// the host OS running the test — so they fail on the pre-fix
// `id.startsWith(adminSourcePath)` comparison on any platform, not just
// Windows CI.
describe("linguiMacroPlugin Windows path-separator handling", () => {

describe("linguiMacroPlugin Windows path-separator handling", () => {
const require = createRequire(import.meta.url);
const adminDistPath = dirname(require.resolve("@emdash-cms/admin"));
// Derive both separator styles deterministically from the real (OS-native)
// path so this test proves the same thing whether it runs on Windows,
// macOS, or Linux. `adminSourcePathPosix` stands in for what Vite always
// hands hooks (forward slashes); `adminSourcePathWindows` stands in for
// what `path.resolve()` returns on win32 (backslashes) — on a real
// Windows host these would otherwise be identical and this test would
// prove nothing.
const adminSourceDirNative = resolve(adminDistPath, "..", "src");
const adminSourcePathPosix = adminSourceDirNative.replaceAll("\\", "/");
const adminSourcePathWindows = adminSourcePathPosix.replaceAll("/", "\\");

const plugin = linguiMacroPlugin(adminSourcePathWindows, adminDistPath);

it("compiles away @lingui macro calls in admin source files", async () => {
const id = `${adminSourcePathPosix}/components/Example.tsx`;
const code = ['import { t } from "@lingui/core/macro";', "t`Hello`;", ""].join("\n");

const transform = unwrapHook(plugin.transform);
const result = await transform.call(
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- test-only stand-in for Rollup's TransformPluginContext, which the hook never touches.
{} as never,
code,
id,
);

expect(result).toBeTruthy();
const output = typeof result === "string" ? result : (result?.code ?? "");
expect(output).not.toContain("@lingui/core/macro");
});

it("redirects locale catalog imports from admin source to dist/locales", () => {
const importer = `${adminSourcePathPosix}/locales/loadMessages.ts`;

const resolveId = unwrapHook(plugin.resolveId);
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- test-only stand-in for Rollup's PluginContext, which the hook never touches.
const resolved = resolveId.call({} as never, "./de/messages.mjs", importer, {
attributes: {},
isEntry: false,
});

expect(resolved).toBeTruthy();
const resolvedPath = typeof resolved === "string" ? resolved : (resolved as { id: string })?.id;
expect(resolvedPath).toContain(resolve(adminDistPath, "locales", "de", "messages.mjs"));
});

it("does not match files outside admin source", async () => {
const id = `${adminDistPath}/index.js`;
const code = 'import { t } from "@lingui/core/macro";';

const transform = unwrapHook(plugin.transform);
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- test-only stand-in for Rollup's TransformPluginContext, which the hook never touches.
const result = await transform.call({} as never, code, id);

expect(result).toBeFalsy();
});
});
Loading