Skip to content

Commit 72c87ff

Browse files
committed
fix(astro): resolve v0.1.1 blockers caught by kychon.com migration
Fixes four issues filed during the kychon-private marketing-site validation: kychee-com/run402-private#396 — Image.astro shipped from src/ couldn't resolve its sibling JS imports (./picture-builder.js etc. only existed in dist/). Now copied into dist/ at build time; exports map updated to point at dist/Image.astro. kychee-com/run402-private#397 — Vite transform() ran AFTER Astro's compiler had already turned <Image src='...'> into a $$createComponent JS call, so the source-byte regex matched zero <Image tags and the rewrite was a no-op. Switched to a load(id) hook for .astro files, which runs BEFORE Astro's compile, so the absolute-path rewrite lands in the source Astro then compiles. The transform path is removed entirely. .tsx/.jsx/.mdx/.md files no longer get source-rewritten — that's a documented v0.1.2 limitation; a future v0.2 will pivot to an import-based pattern (import hero from './hero.jpg') that sidesteps the source-rewrite question entirely. kychee-com/run402-private#399 — Named re-export of <Image> from dist/index.js added so 'import { Image } from "@run402/astro"' works (the form most users will try first, matching React/Next conventions). The subpath form 'import Image from "@run402/astro/Image.astro"' continues to work. README updated to lead with the named form. kychee-com/run402-private#398 is doc-only: the kychon migration brief told consumers to pin @run402/sdk@^2.4, which isn't published yet. Briefs have been updated separately.
1 parent aea1e8f commit 72c87ff

5 files changed

Lines changed: 94 additions & 20 deletions

File tree

astro/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ In CI, the integration auto-detects GitHub OIDC credentials when the workflow ha
3636

3737
```astro
3838
---
39-
import { Image } from '@run402/astro/Image.astro';
39+
import { Image } from '@run402/astro';
4040
---
4141
<Image src="./images/hero.jpg" alt="Sunset over the Pacific" sizes="100vw" priority />
4242
@@ -45,6 +45,16 @@ import { Image } from '@run402/astro/Image.astro';
4545

4646
`src` is resolved relative to the importing `.astro` file. TypeScript path aliases (`@/*`) also work if you have them in `tsconfig.json`.
4747

48+
**Alternative subpath import** also works if you prefer being explicit about the `.astro` component file:
49+
50+
```astro
51+
---
52+
import Image from '@run402/astro/Image.astro';
53+
---
54+
```
55+
56+
(Note: subpath form uses default-import syntax because `.astro` files only have a default export. Both forms resolve to the same component.)
57+
4858
## Generated HTML
4959

5060
For an image source with v1.49 variants (≥ 320 pixels on both axes), the component emits:

astro/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,15 @@
1010
"types": "./dist/index.d.ts",
1111
"import": "./dist/index.js"
1212
},
13-
"./Image.astro": "./src/Image.astro"
13+
"./Image.astro": "./dist/Image.astro"
1414
},
1515
"files": [
1616
"dist",
17-
"src/Image.astro",
1817
"!dist/*.test.*",
1918
"README.md"
2019
],
2120
"scripts": {
22-
"build": "tsc",
21+
"build": "tsc && cp src/Image.astro dist/Image.astro",
2322
"test": "node --experimental-test-module-mocks --test --import tsx src/resolver.test.ts src/cache.test.ts src/scanner.test.ts src/uploader.test.ts src/component.test.ts"
2423
},
2524
"engines": {

astro/src/astro-modules.d.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Ambient declaration so `.astro` re-exports type-check under tsc.
3+
*
4+
* Astro's own SFC tooling provides richer types when the consumer has
5+
* `astro/client` in their tsconfig types — this declaration is just for
6+
* THIS package's build, where tsc needs to know that `./Image.astro`
7+
* has a default export of unknown shape. The actual runtime export is
8+
* an Astro component (a function the Astro compiler emits); we don't
9+
* need to type its props here because the consumer-facing types live
10+
* in `src/types.ts` (`ImageProps`).
11+
*/
12+
13+
declare module "*.astro" {
14+
const Component: unknown;
15+
export default Component;
16+
}

astro/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,5 +143,21 @@ function configRootToPath(root: URL | string | undefined): string {
143143
return String(root);
144144
}
145145

146+
// Named re-export of the <Image> component so consumers can either:
147+
// - import { Image } from '@run402/astro' — ergonomic, matches React/Next conventions
148+
// - import Image from '@run402/astro/Image.astro' — explicit subpath form
149+
//
150+
// Both forms resolve to the SAME `.astro` component shipped under
151+
// `dist/Image.astro`. The subpath form was the only thing that worked
152+
// in v0.1.1 (and was documented inconsistently — see kychee-com/
153+
// run402-private#399). v0.1.2 makes both forms work and aligns docs to
154+
// the named form.
155+
//
156+
// The tsc-side `*.astro` ambient declaration in `astro-modules.d.ts`
157+
// lets this re-export type-check; at runtime Vite/Astro resolves the
158+
// `./Image.astro` specifier against `dist/Image.astro` per the
159+
// package's exports map.
160+
export { default as Image } from "./Image.astro";
161+
146162
// Type re-exports for consumers.
147163
export type { AssetRef, AssetVariant, ImageProps, Run402AstroOptions } from "./types.js";

astro/src/vite-plugin.ts

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export interface MinimalVitePlugin {
6868
enforce?: "pre" | "post";
6969
configResolved?(config: { root?: string }): void;
7070
buildStart?(): void | Promise<void>;
71-
transform?(code: string, id: string): null | { code: string; map: null };
71+
load?(id: string): null | string | Promise<null | string>;
7272
closeBundle?(): void | Promise<void>;
7373
}
7474

@@ -154,13 +154,52 @@ export function createVitePlugin(state: VitePluginState): MinimalVitePlugin {
154154
);
155155
},
156156

157-
transform(code, id) {
158-
if (!shouldTransform(id)) return null;
159-
160-
const fileRefs = collectRefsForFile(state.refMap, id);
157+
// Source-rewrite via `load(id)` instead of `transform(code, id)`.
158+
//
159+
// v0.1.1 used `transform`, which fails because Astro's `.astro`
160+
// compiler runs as a Vite **load** hook (`enforce` doesn't matter —
161+
// first-load-wins for a given id). By the time any transform fires,
162+
// the `.astro` source has already been compiled to a JS module:
163+
// `<Image src="./foo.jpg">` is now `$$createComponent(Image, { src:
164+
// "./foo.jpg" })`. Our regex over the JS bytes finds zero `<Image`
165+
// matches, the rewrite is a no-op, and the component looks up the
166+
// registry with the still-relative src — which doesn't match the
167+
// absolute-path key the build-start scan wrote.
168+
//
169+
// The fix: claim `.astro` files in `load`. Since we declare
170+
// `enforce: 'pre'` AND there's only one plugin allowed to win `load`
171+
// for a given id, claiming first means we read the raw source from
172+
// disk, rewrite it, return the rewritten string, and let Astro's
173+
// compiler transform that rewritten source — which now embeds the
174+
// absolute path the registry expects.
175+
//
176+
// We only claim `.astro` (not .tsx/.jsx/.mdx) because Astro's
177+
// compiler is the only Vite plugin that does its work in `load`;
178+
// for the JSX-family file types the transform pipeline still
179+
// applies, which is too late. Users mounting React components via
180+
// `@astrojs/react` that internally use `<Image>` would not be
181+
// covered by v0.1.2's source-rewrite — they'd hit the same
182+
// MissingAssetRefError. That's a known limitation; documented in
183+
// the spec under "build-time discovery is brittle." A future v0.2
184+
// pivots to the import-based pattern (`import hero from './hero.jpg'`)
185+
// which sidesteps source-rewriting entirely.
186+
async load(id) {
187+
const cleanId = id.split("?")[0] ?? id;
188+
if (!cleanId.endsWith(".astro")) return null;
189+
190+
const fileRefs = collectRefsForFile(state.refMap, cleanId);
161191
if (fileRefs.length === 0) return null;
162192

163-
let modified = code;
193+
let source: string;
194+
try {
195+
source = await readFile(cleanId, "utf-8");
196+
} catch {
197+
// File unreadable — let Astro's load handle it (and probably
198+
// surface its own error).
199+
return null;
200+
}
201+
202+
let modified = source;
164203
let didChange = false;
165204
for (const { reference, absolutePath } of fileRefs) {
166205
const next = rewriteImageSrc(modified, reference.src, absolutePath);
@@ -169,7 +208,10 @@ export function createVitePlugin(state: VitePluginState): MinimalVitePlugin {
169208
didChange = true;
170209
}
171210
}
172-
return didChange ? { code: modified, map: null } : null;
211+
// If the rewrite was a no-op, returning null lets Astro's load run
212+
// as normal — avoids double-reading the file from disk and dodges
213+
// any subtle plugin-ordering surprise.
214+
return didChange ? modified : null;
173215
},
174216

175217
closeBundle() {
@@ -213,15 +255,6 @@ function collectRefsForFile(
213255
return out;
214256
}
215257

216-
function shouldTransform(id: string): boolean {
217-
// Vite passes IDs with a `?` suffix for sub-modules; strip those first.
218-
const cleanId = id.split("?")[0] ?? id;
219-
const dot = cleanId.lastIndexOf(".");
220-
if (dot === -1) return false;
221-
const ext = cleanId.slice(dot).toLowerCase();
222-
return ext === ".astro" || ext === ".tsx" || ext === ".jsx" || ext === ".mdx" || ext === ".md";
223-
}
224-
225258
/**
226259
* Find every occurrence of `<Image ... src="<src>" ... />` in `code`
227260
* (handling either-quote literals) and replace the src value with

0 commit comments

Comments
 (0)