Skip to content

Commit 031c4a1

Browse files
committed
feat(@run402/astro): add <Run402Image> v1.51 component — Astro + React entries with pre-decoded placeholder + strict-mode
Ports the v1.51 image component from run402-private/packages/astro/. Three additions over the v1.0 <Run402Picture>: 1. Pre-decoded blurhash placeholder. The v1.54 gateway pipeline pre-computes blurhash -> PNG data URL at upload time + stamps it on AssetRef.blurhash_data_url. Component emits it as <img> background-image with zero render-time decode + zero client-side blurhash work. 2. Strict mode + schema-filter. imageDefaults: { strict: { onSchema: '>=v1.49' } } makes the component hard-fail on missing v1.49+ fields. Schema-filter skips legacy pre-v1.49 AssetRefs so mixed-vintage CMS projects can adopt safely. R402_ASTRO_IMAGE_STRICT_DEGRADED carries a subcode (NO_VARIANTS / NO_INTRINSICS / NO_PLACEHOLDER / NO_CDN_URL / WRONG_SHAPE). 3. React entry point at @run402/astro/react. Same component shape, byte-identical HTML output to the Astro path (locked in by 18-fixture byte-identity test sweep). Shared core architecture: one validator + render-tree builder + URL resolver + variant ordering + strict resolver + placeholder + style merger in src/components/Run402Image/core.ts (~900 LOC). Both adapters are thin recursive serializers on top. src/components/Run402Image.astro - Astro adapter (frontmatter + <Fragment set:html>) src/components/Run402Image/react.tsx - React FC with SSR detection + runtime guard src/components/Run402Image/render-html.ts - canonical HTML serializer with stable attribute order src/components/Run402Image/render-react.tsx - parallel React serializer (lowercase HTML attr names for byte-identity) src/components/Run402Image/degradation-manifest.ts - build-time accumulator + JSON writer for <outDir>/run402/image-degradations.json 12 R402_ASTRO_IMAGE_* error codes (all documented at site/errors/index.html in run402-private): _ASSET_MISSING / _ASSET_STRING_URL / _ASSET_WRONG_SHAPE / _NON_IMAGE_ASSET / _ALT_REQUIRED / _CONFLICTING_CLASS_PROPS / _CONFLICTING_LOADING_PROPS / _HEIC_NO_TRANSCODE (unconditional hard-fail floor) / _SIZES_REQUIRED / _STRICT_DEGRADED / _RESERVED_DATA_ATTR / _WRONG_ENTRY_POINT Dependencies: - Bumps @run402/functions peer dep to ^2.7.0 for the new AssetRef.blurhash_data_url + asset_schema fields - Adds react + react-dom as optional peer dependencies (consumers using only Astro see no warnings) Tests: 215 / 215 pass (existing 78 + 137 new from the v1.51 component: 16 types + 65 core + 9 react + 18 byte-identity + 19 manifest + 5 wrong-entry + 2 avif + 3 attribute-defaults). tsc clean. Build produces all expected dist artifacts. Adoption runbook (operator-side): docs/migrations/run402-image-component-adoption.md in run402-private. Includes prerequisite checklist, import-path migration, imageDefaults recipes, CI regression-gate patterns, and four-tier rollback path.
1 parent 79c0266 commit 031c4a1

18 files changed

Lines changed: 4822 additions & 13 deletions

astro/README.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,81 @@ Behavior:
9696

9797
For static template-literal images (e.g., `<Image src="./hero.jpg">`), use the build-time `<Image>` — same upload pipeline, build-time srcset emission, no runtime data needed.
9898

99+
## `<Run402Image>` — pre-decoded placeholders + strict-mode degradation detection (v1.51+)
100+
101+
`<Run402Image>` is the v1.51 sibling of `<Run402Picture>` — same shape (`asset={AssetRef}` + `alt` + `sizes`), but with three additions that matter at scale:
102+
103+
1. **Pre-decoded blurhash placeholder.** The v1.54 gateway pipeline pre-computes the blurhash → PNG data URL at upload time and stamps it on `AssetRef.blurhash_data_url`. `<Run402Image>` emits it as the `<img>` element's `background-image` so the placeholder is visible during fetch with zero client-side decode + zero SSR-render CPU cost.
104+
2. **Strict mode.** `imageDefaults: { strict: { onSchema: ">=v1.49" } }` makes the component hard-fail when an AssetRef would render below the full v1.49+ target — catches the "28 of 30 assets render correctly and 2 silently degrade" failure mode at build time rather than at user-visible time. The schema-filter form skips legacy pre-v1.49 AssetRefs, so mixed-vintage CMS projects can adopt safely.
105+
3. **React entry point.** Same component shape, importable from `@run402/astro/react` for React islands or React-only consumers. Byte-identical HTML output to the Astro path.
106+
107+
### Quick start
108+
109+
```astro
110+
---
111+
// Astro entry
112+
import { Run402Image } from "@run402/astro/components";
113+
---
114+
<Run402Image asset={page.hero_asset} alt={page.title} sizes="100vw" priority />
115+
```
116+
117+
```tsx
118+
// React entry
119+
import { Run402Image } from "@run402/astro/react";
120+
import type { AssetRef } from "@run402/functions";
121+
122+
export function Hero({ asset }: { asset: AssetRef }) {
123+
return <Run402Image asset={asset} alt="..." sizes="100vw" priority />;
124+
}
125+
```
126+
127+
### When to use `<Run402Image>` vs `<Run402Picture>`
128+
129+
| | `<Run402Picture>` (v1.0) | `<Run402Image>` (v1.51+) |
130+
|---|---|---|
131+
| Pre-decoded blurhash placeholder |||
132+
| Strict-mode degradation detection |||
133+
| React entry point | ✗ (Astro only) | ✅ Astro + React |
134+
| Default-mode degradation manifest |||
135+
| Behavior on legacy AssetRefs | renders best-effort silently | renders best-effort + records to manifest (or hard-fails under strict) |
136+
137+
If your tenant has heavy mixed-vintage data + you want a CI regression gate against silent degradation, use `<Run402Image>` with `imageDefaults: { strict: { onSchema: ">=v1.49" } }`. For simple existing pages where best-effort silent rendering is fine, `<Run402Picture>` stays the lighter choice.
138+
139+
### Configuration
140+
141+
```js
142+
// astro.config.mjs
143+
import run402 from "@run402/astro";
144+
145+
export default run402({
146+
imageDefaults: {
147+
strict: { onSchema: ">=v1.49" }, // mixed-vintage projects (Kychon shape)
148+
// OR: strict: true, // greenfield, every render strict-checked
149+
// OR: strict: false, // explicit lenient (default)
150+
placeholder: "auto", // render placeholder if blurhash_data_url present
151+
},
152+
});
153+
```
154+
155+
### HEIC precondition
156+
157+
If your tenant has legacy HEIC AssetRefs (uploaded before the v1.49 `display_jpeg` transcode landed) and you want to enable schema-filtered strict mode, run the `asset-image-variants-v1-51` backfill with `--regenerate-heic-transcodes` **first**. Without it, `<Run402Image>` hard-fails with `R402_ASTRO_IMAGE_HEIC_NO_TRANSCODE` on every legacy HEIC render. See the run402-private repo's `docs/migrations/asset-image-variants-v1-51-backfill.md` for the operator workflow.
158+
159+
### Error codes
160+
161+
All `R402_ASTRO_IMAGE_*` codes are documented at https://run402.com/errors/ — see the index for the full list including:
162+
163+
- `_ASSET_MISSING` / `_ASSET_STRING_URL` / `_ASSET_WRONG_SHAPE` — input validation failures
164+
- `_NON_IMAGE_ASSET``content_type` is not `image/*`
165+
- `_ALT_REQUIRED``alt` prop missing
166+
- `_CONFLICTING_CLASS_PROPS` — both `class` and `className` passed
167+
- `_CONFLICTING_LOADING_PROPS``priority={true}` + `loading="lazy"`
168+
- `_HEIC_NO_TRANSCODE` — HEIC source missing `display_jpeg` (hard-fail floor)
169+
- `_SIZES_REQUIRED` — multi-variant AssetRef without `sizes` prop
170+
- `_STRICT_DEGRADED` — strict-mode hit a missing field (carries `subcode`)
171+
- `_RESERVED_DATA_ATTR` — caller passed reserved `data-run402-image`
172+
- `_WRONG_ENTRY_POINT` — JS consumer imported the wrong entry point
173+
99174
## CLI
100175

101176
The Run402 CLI ships everything an agent needs to scaffold, develop, deploy, and debug an Astro project:

astro/package.json

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,32 @@
3535
"types": "./dist/runtime/server.d.ts",
3636
"import": "./dist/runtime/server.js"
3737
},
38-
"./components/Run402Picture.astro": "./dist/components/Run402Picture.astro"
38+
"./components/Run402Picture.astro": "./dist/components/Run402Picture.astro",
39+
"./components/Run402Image.astro": "./dist/components/Run402Image.astro",
40+
"./react": {
41+
"types": "./dist/components/Run402Image/react.d.ts",
42+
"import": "./dist/components/Run402Image/react.js"
43+
}
3944
},
4045
"files": [
4146
"dist",
4247
"!dist/*.test.*",
48+
"!dist/**/*.test.*",
4349
"README.md"
4450
],
4551
"scripts": {
46-
"build": "tsc && cp src/Image.astro dist/Image.astro && mkdir -p dist/components && cp src/components/Run402Picture.astro dist/components/Run402Picture.astro",
47-
"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 src/manifest.test.ts src/blurhash-decoder.test.ts src/build-manifest.test.ts"
52+
"build": "tsc && cp src/Image.astro dist/Image.astro && mkdir -p dist/components && cp src/components/Run402Picture.astro dist/components/Run402Picture.astro && cp src/components/Run402Image.astro dist/components/Run402Image.astro",
53+
"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 src/manifest.test.ts src/blurhash-decoder.test.ts src/build-manifest.test.ts src/components/Run402Image/types.test.ts src/components/Run402Image/core.test.ts src/components/Run402Image/react.test.tsx src/components/Run402Image/byte-identity.test.tsx src/components/Run402Image/degradation-manifest.test.ts src/components/Run402Image/wrong-entry-point.test.tsx src/components/Run402Image/avif-deferral.test.ts"
4854
},
4955
"engines": {
5056
"node": ">=18"
5157
},
5258
"peerDependencies": {
53-
"@run402/functions": "*",
59+
"@run402/functions": "^2.7.0",
5460
"@run402/sdk": ">=2.3",
55-
"astro": ">=5 <7"
61+
"astro": ">=5 <7",
62+
"react": ">=18.0.0",
63+
"react-dom": ">=18.0.0"
5664
},
5765
"peerDependenciesMeta": {
5866
"@run402/sdk": {
@@ -63,11 +71,21 @@
6371
},
6472
"@run402/functions": {
6573
"optional": true
74+
},
75+
"react": {
76+
"optional": true
77+
},
78+
"react-dom": {
79+
"optional": true
6680
}
6781
},
6882
"devDependencies": {
6983
"@types/node": "^22.0.0",
84+
"@types/react": "^19.2.14",
85+
"@types/react-dom": "^19.2.3",
7086
"astro": "^5.18.1",
87+
"react": "^19.2.6",
88+
"react-dom": "^19.2.6",
7189
"tsx": "^4.20.0",
7290
"typescript": "^5.5.0"
7391
},
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
/**
3+
* `<Run402Image>` — Astro adapter (§4 of run402-image-component-impl).
4+
*
5+
* Thin wrapper over the shared core (`Run402Image/core.ts`). The core
6+
* produces a framework-agnostic RenderTreeNode; this `.astro` file
7+
* serializes it to HTML via `Run402Image/render-html.ts` and injects
8+
* it via `set:html` so the rendered output stays byte-deterministic
9+
* across all Astro adapters (server.ts, static, prerender).
10+
*
11+
* Consumer usage:
12+
*
13+
* ```astro
14+
* ---
15+
* import { Run402Image } from "@run402/astro/components";
16+
* import { r } from "../lib/run402";
17+
* const hero = await r.assets.fromRef(Astro.locals.row.hero);
18+
* ---
19+
* <Run402Image asset={hero} alt="..." sizes="100vw" priority />
20+
* ```
21+
*
22+
* **Wrong-entry-point detection** (§7): the inverse check (Astro entry
23+
* being imported from inside `.tsx`) is caught at TypeScript compile
24+
* time via the `AstroComponent<Run402ImageProps>` brand on the
25+
* exported symbol. The runtime guard here catches the case where the
26+
* component runs WITHOUT Astro's expected `Astro` global — meaning it
27+
* was somehow invoked outside the Astro renderer.
28+
*
29+
* **`Astro.locals.run402` integration**:
30+
* - `Astro.locals.run402?.registerPreload` is threaded into the
31+
* RenderContext; when exposed (the SSR runtime sets it in v1.1+),
32+
* preload links are registered via the hook for head-injection
33+
* instead of adjacent placement. v1.0 of the SSR runtime doesn't
34+
* expose the hook — preload `<link>` goes adjacent in that case.
35+
* - `Astro.locals.run402?.imageDefaults` provides project-level
36+
* defaults for `strict` and `placeholder`. Per-call props win on
37+
* overlap per spec §"Strict mode + visible build-time warnings".
38+
* - `recordDegradation` is also threaded; the build-time accumulator
39+
* in §6 collects entries and writes the manifest at
40+
* `astro:build:done`.
41+
*/
42+
43+
import { Run402ImageError, type RenderContext, type Run402ImageProps } from "./Run402Image/types.js";
44+
import { buildRun402ImageRenderTree } from "./Run402Image/core.js";
45+
import { serializeRenderTree } from "./Run402Image/render-html.js";
46+
47+
const props = Astro.props as Run402ImageProps;
48+
49+
// §7 runtime guard: if a consumer somehow renders `Run402Image` outside
50+
// an Astro context (e.g., compiled into a React app by mistake — won't
51+
// happen in practice because the .astro file fails to load in a React
52+
// build, but defense-in-depth), throw a structured error.
53+
if (typeof Astro === "undefined" || Astro === null) {
54+
throw new Run402ImageError({
55+
code: "R402_ASTRO_IMAGE_WRONG_ENTRY_POINT",
56+
message:
57+
"Run402Image from `@run402/astro/components` is an Astro component (`.astro` file). " +
58+
"It can only be rendered inside Astro. For React, import from `@run402/astro/react`.",
59+
suggestedFix:
60+
'import { Run402Image } from "@run402/astro/components"; // Astro\n' +
61+
'import { Run402Image } from "@run402/astro/react"; // React',
62+
docs: "https://run402.com/errors/#R402_ASTRO_IMAGE_WRONG_ENTRY_POINT",
63+
});
64+
}
65+
66+
// Extract project-level config from Astro.locals.run402 (set by the
67+
// `@run402/astro` integration when the user enables it via
68+
// `astro.config.mjs`). Astro.locals is shaped per the SSR runtime spec;
69+
// `.run402` is the namespaced sub-bag.
70+
const run402Locals = (Astro.locals as { run402?: {
71+
registerPreload?: RenderContext["registerPreload"];
72+
imageDefaults?: RenderContext["imageDefaults"];
73+
recordDegradation?: RenderContext["recordDegradation"];
74+
} }).run402;
75+
76+
const context: RenderContext = {
77+
isSSR: true, // Astro components render server-side by default; hydrated islands re-render in the browser but Run402Image itself isn't a client island
78+
registerPreload: run402Locals?.registerPreload,
79+
imageDefaults: run402Locals?.imageDefaults,
80+
recordDegradation: run402Locals?.recordDegradation,
81+
};
82+
83+
const { root, preload } = buildRun402ImageRenderTree(props, context);
84+
85+
const preloadHtml = preload ? serializeRenderTree(preload) : "";
86+
const rootHtml = serializeRenderTree(root);
87+
const combined = preloadHtml + rootHtml;
88+
---
89+
<Fragment set:html={combined} />
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* AVIF deferral guard — §10 of run402-image-component-impl.
3+
*
4+
* Per spec §"AVIF is NOT emitted in v1.0 (deferred per the platform-wide
5+
* stance)": even when the gateway's image pipeline starts producing AVIF
6+
* variants in a future release, `<Run402Image>` v1.0 SHALL NOT emit
7+
* `<source type="image/avif">` elements in the rendered `<picture>`.
8+
*
9+
* The reason is the `<picture>` source-type-precedence footgun: browsers
10+
* pick by `type` before size, so a single AVIF source at full-res defeats
11+
* the variant ladder on mobile.
12+
*
13+
* This test is the CI floor that catches accidental AVIF emission. If
14+
* anyone adds `type="image/avif"` or a similar marker to any source file
15+
* in `Run402Image/`, this test fails with a pointer at the spec section
16+
* explaining the deferral.
17+
*
18+
* The grep targets:
19+
*
20+
* - `type="image/avif"` — the literal output that would surface AVIF
21+
* - `imageavif` / `image/avif` — anywhere in the component code that
22+
* would suggest the component is producing AVIF-related logic
23+
* - `kind: "avif"` — variant-set additions that would expand the
24+
* `OrderedVariant` lineup beyond `thumb` / `medium` / `large`
25+
*
26+
* Exception: this file itself contains the literal strings (in the
27+
* grep patterns). The check filters out `avif-deferral.test.ts` from
28+
* the result set so the guard doesn't trip on its own contents.
29+
*/
30+
31+
import { describe, it } from "node:test";
32+
import assert from "node:assert/strict";
33+
import { readdirSync, readFileSync, statSync } from "node:fs";
34+
import { dirname, join } from "node:path";
35+
import { fileURLToPath } from "node:url";
36+
37+
// =============================================================================
38+
// Forbidden-strings list (the actual deferral floor)
39+
// =============================================================================
40+
41+
const FORBIDDEN_PATTERNS: RegExp[] = [
42+
/type="image\/avif"/,
43+
/image\/avif/,
44+
/imageavif/i,
45+
/kind:\s*["']avif["']/,
46+
];
47+
48+
/** This guard is scoped to the component's source files specifically.
49+
* Other parts of the codebase MAY reference AVIF (e.g., docs that
50+
* explain the deferral), but Run402Image's own source MUST NOT. */
51+
const SCOPE_DIR_NAME = "Run402Image";
52+
53+
/** This file's own basename — excluded from the grep result set so the
54+
* patterns above (which appear as literals in this test) don't trip
55+
* the guard on themselves. */
56+
const SELF_BASENAME = "avif-deferral.test.ts";
57+
58+
// =============================================================================
59+
// Walk + grep
60+
// =============================================================================
61+
62+
function walk(dir: string): string[] {
63+
const out: string[] = [];
64+
for (const entry of readdirSync(dir)) {
65+
const full = join(dir, entry);
66+
const stat = statSync(full);
67+
if (stat.isDirectory()) {
68+
out.push(...walk(full));
69+
} else if (stat.isFile()) {
70+
out.push(full);
71+
}
72+
}
73+
return out;
74+
}
75+
76+
interface Hit {
77+
file: string;
78+
pattern: string;
79+
line: number;
80+
text: string;
81+
}
82+
83+
function grepForbidden(files: string[]): Hit[] {
84+
const hits: Hit[] = [];
85+
for (const file of files) {
86+
if (file.endsWith(SELF_BASENAME)) continue;
87+
const lines = readFileSync(file, "utf8").split("\n");
88+
for (let i = 0; i < lines.length; i++) {
89+
const text = lines[i]!;
90+
for (const pattern of FORBIDDEN_PATTERNS) {
91+
if (pattern.test(text)) {
92+
hits.push({ file, pattern: pattern.source, line: i + 1, text });
93+
}
94+
}
95+
}
96+
}
97+
return hits;
98+
}
99+
100+
// =============================================================================
101+
// Test
102+
// =============================================================================
103+
104+
describe("AVIF deferral guard (§10)", () => {
105+
it("Run402Image/ source files contain ZERO AVIF emissions", () => {
106+
const here = dirname(fileURLToPath(import.meta.url));
107+
// `here` is `.../Run402Image`; walk it.
108+
assert.equal(
109+
here.endsWith(SCOPE_DIR_NAME),
110+
true,
111+
`expected this test to live in ${SCOPE_DIR_NAME}/, got ${here}`,
112+
);
113+
114+
const files = walk(here);
115+
const hits = grepForbidden(files);
116+
if (hits.length > 0) {
117+
const summary = hits
118+
.map(
119+
(h) =>
120+
` ${h.file}:${h.line}\n pattern: /${h.pattern}/\n text: ${h.text.trim()}`,
121+
)
122+
.join("\n\n");
123+
assert.fail(
124+
`AVIF deferral guard tripped — Run402Image source files contain\n` +
125+
`AVIF references that would surface AVIF emission. v1.0 of the\n` +
126+
`component intentionally does NOT emit \`<source type="image/avif">\`\n` +
127+
`because browsers pick \`<picture>\` sources by \`type\` before\n` +
128+
`size; a full-resolution AVIF source would defeat the variant\n` +
129+
`ladder on mobile.\n\n` +
130+
`See: openspec/changes/run402-image-component/specs/run402-image-component/spec.md\n` +
131+
` §"AVIF is NOT emitted in v1.0 (deferred per the platform-wide stance)"\n\n` +
132+
`Hits:\n${summary}`,
133+
);
134+
}
135+
});
136+
137+
it("the FORBIDDEN_PATTERNS list itself is non-empty (defensive)", () => {
138+
// If someone empties the list as a "workaround," the test would
139+
// pass vacuously. Pin the patterns count + spot-check one entry.
140+
assert.ok(FORBIDDEN_PATTERNS.length >= 4);
141+
assert.ok(FORBIDDEN_PATTERNS.some((p) => p.source.includes("avif")));
142+
});
143+
});

0 commit comments

Comments
 (0)