Skip to content

Commit 29016b3

Browse files
MajorTalclaude
andcommitted
fix(astro): widen Run402Image style + decouple asset from broad SDK shape (#401)
Two type-surface DX fixes from Kychon's React wrapper feedback (no runtime changes): - `Run402ImageProps.style` accepts `React.CSSProperties` directly. React consumers can pass `{ objectFit: 'cover' }` without casting through `Record<string, string | number>`. `mergeStyles` now skips undefined/null values defensively so serialization stays byte-identical. - `Run402ImageProps.asset` retyped to a new `Run402ImageAsset` interface that declares only the fields the component actually reads (cdn_url + content_type + display_url + width_px + height_px + blurhash_data_url + asset_schema + variants + key/sha256). Both the SDK's `AssetRef` and the manifest pipeline's narrower `AssetRef` satisfy it structurally, so `resolveVariants(manifest, key)` flows into `<Run402Image asset={...}>` without dual-import friction. - Main entry (`@run402/astro`) re-exports `AssetRef` from `@run402/functions` — matching what `/react` and `/components` already did. The narrower manifest-pipeline shape stays accessible as `Run402AstroManifestAssetRef`. Adds three regression tests in `types.test.ts`: CSSProperties assignment, structural-subset asset accepting both source shapes, and a type-level `extends` assertion guarding future drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 998ca82 commit 29016b3

7 files changed

Lines changed: 216 additions & 22 deletions

File tree

astro/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@run402/astro",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "Astro integration + <Image> component for Run402. One-line wiring for pre-encoded image variants (3-width WebP ladder + HEIC display_jpeg + blurhash + width/height) with zero runtime function cost.",
55
"type": "module",
66
"main": "dist/index.js",

astro/src/components/Run402Image/core.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@
3535
* does NOT skip it.
3636
*/
3737

38-
import type { AssetRef } from "@run402/functions";
39-
4038
import {
4139
Run402ImageError,
4240
type DegradationEntry,
@@ -47,10 +45,17 @@ import {
4745
type PreloadAttrs,
4846
type RenderContext,
4947
type RenderTreeNode,
48+
type Run402ImageAsset,
5049
type Run402ImageProps,
5150
type SourceAttrs,
5251
} from "./types.js";
5352

53+
// v1.0.3 — `<Run402Image>` declares what it consumes, not the broader SDK
54+
// `AssetRef`. `Run402ImageAsset` (in types.ts) is a structural supertype of
55+
// both the SDK's `AssetRef` (broader) and the manifest pipeline's `AssetRef`
56+
// (narrower) so both flow into `<Run402Image asset={…}>` without casting.
57+
// See GH #401.
58+
5459
// =============================================================================
5560
// Error-code constants (single source — all references go through these)
5661
// =============================================================================
@@ -302,7 +307,7 @@ function validateProps(props: Run402ImageProps): void {
302307
// 2.8 — HEIC correctness floor (unconditional hard-fail)
303308
// =============================================================================
304309

305-
function enforceHeicCorrectnessFloor(asset: AssetRef): void {
310+
function enforceHeicCorrectnessFloor(asset: Run402ImageAsset): void {
306311
const ct = asset.content_type;
307312
if (ct !== "image/heic" && ct !== "image/heif") return;
308313
if (asset.variants?.display_jpeg) return;
@@ -341,7 +346,7 @@ function enforceHeicCorrectnessFloor(asset: AssetRef): void {
341346
* is what the gateway sets for HEIC sources (points at the JPEG variant);
342347
* for non-HEIC sources `display_url === cdn_url` so the fallback is a no-op.
343348
*/
344-
function resolveImgSrc(asset: AssetRef): string {
349+
function resolveImgSrc(asset: Run402ImageAsset): string {
345350
if (typeof asset.display_url === "string" && asset.display_url !== "") {
346351
return asset.display_url;
347352
}
@@ -375,7 +380,7 @@ interface OrderedVariant {
375380
* gateway eventually produces AVIF variants, this function stays unchanged
376381
* — the source-type-precedence footgun is documented in the spec.
377382
*/
378-
function collectOrderedVariants(asset: AssetRef): OrderedVariant[] {
383+
function collectOrderedVariants(asset: Run402ImageAsset): OrderedVariant[] {
379384
const variants = asset.variants;
380385
if (!variants) return [];
381386

@@ -401,7 +406,7 @@ function collectOrderedVariants(asset: AssetRef): OrderedVariant[] {
401406
// =============================================================================
402407

403408
function enforceSizesRequired(
404-
asset: AssetRef,
409+
asset: Run402ImageAsset,
405410
sizes: string | undefined,
406411
variantCount: number,
407412
): void {
@@ -429,7 +434,7 @@ function enforceSizesRequired(
429434

430435
type AssetSchema = "v1.49" | "v1.50" | "v1.54" | null;
431436

432-
function resolveAssetSchema(asset: AssetRef): AssetSchema {
437+
function resolveAssetSchema(asset: Run402ImageAsset): AssetSchema {
433438
const raw = asset.asset_schema;
434439
if (raw === "v1.49" || raw === "v1.50" || raw === "v1.54") return raw;
435440
return null;
@@ -522,7 +527,7 @@ function projectFilteredOutLegacyAsset(
522527
}
523528

524529
interface StrictModeInputs {
525-
asset: AssetRef;
530+
asset: Run402ImageAsset;
526531
placeholder: "auto" | "blurhash" | "none";
527532
strictApplies: boolean;
528533
variantCount: number;
@@ -561,7 +566,7 @@ function enforceStrictMode(input: StrictModeInputs): void {
561566

562567
function throwStrictDegraded(
563568
subcode: "NO_VARIANTS" | "NO_INTRINSICS" | "NO_PLACEHOLDER" | "NO_CDN_URL" | "WRONG_SHAPE",
564-
asset: AssetRef,
569+
asset: Run402ImageAsset,
565570
missing: string[],
566571
): never {
567572
throw new Run402ImageError({
@@ -609,7 +614,7 @@ function resolvePlaceholder(
609614
* — a visibly broken placeholder.
610615
*/
611616
function buildPlaceholderStyle(
612-
asset: AssetRef,
617+
asset: Run402ImageAsset,
613618
placeholder: "auto" | "blurhash" | "none",
614619
): string {
615620
if (placeholder === "none") return "";
@@ -658,9 +663,17 @@ function mergeStyles(
658663
// format matches React's `renderToStaticMarkup` object-style serialization
659664
// — `key:value;key:value` (NO spaces, NO trailing semicolon) — so the
660665
// Astro adapter and React adapter produce byte-identical HTML.
666+
//
667+
// v1.0.3 — `callerStyle` may be `React.CSSProperties`, whose values are
668+
// typed as `string | number | undefined | …`. Skip undefined/null values
669+
// so we never serialize `object-fit:undefined`; the merge result is
670+
// byte-identical to the pre-widening behavior for plain `Record<string,
671+
// string | number>` inputs.
661672
const componentProps = parseInlineStyle(componentStyle);
662673
const callerProps: Record<string, string | number> = {};
663-
for (const [k, v] of Object.entries(callerStyle)) {
674+
for (const [k, v] of Object.entries(callerStyle as Record<string, unknown>)) {
675+
if (v === undefined || v === null) continue;
676+
if (typeof v !== "string" && typeof v !== "number") continue;
664677
callerProps[normalizeStyleKey(k)] = v;
665678
}
666679
const merged = { ...componentProps, ...callerProps };
@@ -972,7 +985,7 @@ function linkToPreloadAttrs(link: LinkAttrs): PreloadAttrs {
972985
// =============================================================================
973986

974987
interface DegradationInputs {
975-
asset: AssetRef;
988+
asset: Run402ImageAsset;
976989
placeholder: "auto" | "blurhash" | "none";
977990
variantCount: number;
978991
recordDegradation?: (entry: DegradationEntry) => void;

astro/src/components/Run402Image/react.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ export const Run402Image = Run402ImageInner as unknown as ReactComponent<Run402I
166166
export type { AssetRef } from "@run402/functions";
167167
export type {
168168
Run402ImageProps,
169+
Run402ImageAsset,
170+
Run402ImageAssetVariant,
169171
DataAttributes,
170172
ImageDefaults,
171173
PreloadAttrs,

astro/src/components/Run402Image/types.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
import { describe, it } from "node:test";
1212
import assert from "node:assert/strict";
1313

14+
import type { CSSProperties } from "react";
15+
import type { AssetRef as SdkAssetRef } from "@run402/functions";
16+
import type { AssetRef as ManifestAssetRef } from "../../types.js";
17+
1418
import {
1519
Run402ImageError,
1620
type DataAttributes,
@@ -22,6 +26,7 @@ import {
2226
type PreloadAttrs,
2327
type RenderContext,
2428
type RenderTreeNode,
29+
type Run402ImageAsset,
2530
type Run402ImageProps,
2631
type SourceAttrs,
2732
} from "./types.js";
@@ -156,6 +161,78 @@ describe("Run402ImageProps — type contract", () => {
156161
void [stringForm, objectForm];
157162
assert.ok(true);
158163
});
164+
165+
it("style accepts React.CSSProperties (GH #401 — Kychon DX)", () => {
166+
// Regression guard for the v1.0.2 friction: React consumers passing a
167+
// strongly-typed `CSSProperties` value (with strict-enum keys like
168+
// `objectFit: 'cover'`) used to fail assignment against
169+
// `Record<string, string | number>` because narrower CSS value types
170+
// are not assignable to that wider record. v1.0.3 widens
171+
// `Run402ImageProps.style` to include `CSSProperties` so the React
172+
// path compiles cleanly.
173+
const objectStyle: CSSProperties = { objectFit: "cover", width: "100%" };
174+
const stylized: Run402ImageProps["style"] = objectStyle;
175+
void stylized;
176+
// The narrower record form continues to compile too (Astro path).
177+
const looseStyle: Run402ImageProps["style"] = { display: "block" };
178+
void looseStyle;
179+
assert.ok(true);
180+
});
181+
182+
it("both the SDK `AssetRef` and the manifest `AssetRef` satisfy `Run402ImageAsset` (GH #401)", () => {
183+
// Pure type-only assertion: every required field of `Run402ImageAsset`
184+
// is present in both source shapes (or they're optional). This guards
185+
// against either source shape drifting in a way that breaks the
186+
// structural-supertype contract.
187+
type AssertExtends<T, U> = T extends U ? true : false;
188+
const _sdkSatisfies: AssertExtends<SdkAssetRef, Run402ImageAsset> = true;
189+
const _manifestSatisfies: AssertExtends<ManifestAssetRef, Run402ImageAsset> = true;
190+
void _sdkSatisfies;
191+
void _manifestSatisfies;
192+
assert.ok(true);
193+
});
194+
195+
it("asset accepts structurally compatible shapes (GH #401 — Run402ImageAsset)", () => {
196+
// Regression guard for the v1.0.2 friction: `<Run402Image asset={...}>`
197+
// used to require the SDK's broad `AssetRef`; values returned by
198+
// `resolveVariants(manifest, key)` (a structural subset) failed
199+
// type-check. v1.0.3 widens the prop to `Run402ImageAsset`, a structural
200+
// supertype of both shapes.
201+
//
202+
// Manifest-pipeline shape (narrow):
203+
const fromManifest: Run402ImageAsset = {
204+
cdn_url: "https://cdn.example.com/hero.jpg",
205+
width_px: 1920,
206+
height_px: 1080,
207+
blurhash_data_url: "data:image/png;base64,iVBORw0KGgo",
208+
asset_schema: "v1.49",
209+
variants: {
210+
thumb: { url: "https://cdn.example.com/thumb", cdn_url: "https://cdn.example.com/thumb", width_px: 320, format: "webp" },
211+
},
212+
};
213+
const props1: Run402ImageProps = { asset: fromManifest, alt: "Hero" };
214+
void props1;
215+
216+
// SDK-shape (broader, with nullable URLs):
217+
const fromSdk: Run402ImageAsset = {
218+
cdn_url: null,
219+
content_type: "image/jpeg",
220+
display_url: null,
221+
width_px: 1920,
222+
height_px: 1080,
223+
};
224+
const props2: Run402ImageProps = { asset: fromSdk, alt: "Private" };
225+
void props2;
226+
227+
// Minimum required shape — just cdn_url. No other field is required at
228+
// the type level; runtime validation may still fail (e.g., empty
229+
// string), but the component declares what it consumes.
230+
const minimal: Run402ImageAsset = { cdn_url: "https://x.example.com/y.jpg" };
231+
const props3: Run402ImageProps = { asset: minimal, alt: "Minimal" };
232+
void props3;
233+
234+
assert.ok(true);
235+
});
159236
});
160237

161238
describe("DataAttributes — reserved-key exclusion", () => {

astro/src/components/Run402Image/types.ts

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,81 @@
2727
* not stringified HTML" for the rationale.
2828
*/
2929

30-
import type { AssetRef } from "@run402/functions";
30+
import type { CSSProperties } from "react";
31+
32+
// =============================================================================
33+
// Run402ImageAsset — structural-subset asset shape the component actually reads
34+
// =============================================================================
35+
36+
/**
37+
* Minimal asset shape consumed by `<Run402Image>`. v1.0.3 — see GH #401.
38+
*
39+
* `Run402ImageProps.asset` used to be typed as `AssetRef` from `@run402/functions`,
40+
* which is the broad SDK-side shape (`visibility`, `immutable`, `content_digest`,
41+
* camelCase mirrors, …). Two real callers — values returned by `r.assets.put` /
42+
* `r.assets.fromRef` AND values returned by `resolveVariants(manifest, key)`
43+
* from `@run402/astro/manifest` — both have to flow into the same `<Run402Image
44+
* asset={…}>` site. Pinning to the broader SDK shape made the manifest-pipeline
45+
* shape (a structural subset) fail type-check even though `<Run402Image>` only
46+
* reads `cdn_url + variants + width_px + height_px + blurhash_data_url +
47+
* asset_schema` at runtime.
48+
*
49+
* The component declares what it consumes, not what its data source happens to
50+
* produce. `Run402ImageAsset` is the structural supertype of every supported
51+
* shape — both the SDK's `AssetRef` (broader) and the manifest's `AssetRef`
52+
* (narrower) satisfy it. Runtime validation still enforces a non-empty
53+
* `cdn_url` string, so the `string | null` value type here just lets us accept
54+
* the SDK shape verbatim; nulls fail validation immediately with
55+
* `R402_ASTRO_IMAGE_ASSET_WRONG_SHAPE`.
56+
*/
57+
export interface Run402ImageAsset {
58+
/** Required at runtime as a non-empty string. Typed `string | null` so the
59+
* SDK's `AssetRef` (which permits `null` for private assets) is assignable. */
60+
cdn_url: string | null;
61+
/** Optional — used only in error messages + degradation-manifest entries. */
62+
key?: string;
63+
/** Optional — used only in error messages + degradation-manifest entries. */
64+
sha256?: string;
65+
/** Optional. Validated to be `image/*` when present; non-image throws
66+
* `R402_ASTRO_IMAGE_NON_IMAGE_ASSET`. HEIC sources without a `display_jpeg`
67+
* variant throw `R402_ASTRO_IMAGE_HEIC_NO_TRANSCODE`. */
68+
content_type?: string;
69+
/** Preferred over `cdn_url` for the rendered `<img src>` when non-empty.
70+
* Targets the HEIC `display_jpeg` variant for HEIC sources. */
71+
display_url?: string | null;
72+
/** Drives `<img width=…>` (caller's `width` prop overrides). */
73+
width_px?: number;
74+
/** Drives `<img height=…>` (caller's `height` prop overrides). */
75+
height_px?: number;
76+
/** Pre-decoded LQIP data URL; rendered as the `<img>`'s `background-image`. */
77+
blurhash_data_url?: string | null;
78+
/** Shape-contract stamp consumed by schema-filtered strict mode. */
79+
asset_schema?: "v1.49" | "v1.50" | "v1.54" | null;
80+
/** Per-variant `<source srcset>` entries plus the HEIC `display_jpeg`
81+
* fallback. */
82+
variants?: {
83+
thumb?: Run402ImageAssetVariant;
84+
medium?: Run402ImageAssetVariant;
85+
large?: Run402ImageAssetVariant;
86+
display_jpeg?: Run402ImageAssetVariant;
87+
};
88+
}
89+
90+
/**
91+
* Minimal variant entry shape consumed by `<Run402Image>`.
92+
*
93+
* The component reads `cdn_url ?? url` for the variant URL (with a runtime
94+
* null/empty check) and `width_px` for the srcset width descriptor. `format`
95+
* drives the `<source type=…>` attribute and defaults to `"webp"` when
96+
* missing. Other variant fields (`sha256`, `height_px`, `immutable_url`,
97+
* `cdn_immutable_url`, `kind`) are ignored by the component.
98+
*/
99+
export interface Run402ImageAssetVariant {
100+
url?: string | null;
101+
cdn_url?: string | null;
102+
width_px: number;
103+
format?: "webp" | "jpeg";
104+
}
31105

32106
// =============================================================================
33107
// 1.1 — Run402ImageProps interface (binding source of truth)
@@ -46,11 +120,15 @@ import type { AssetRef } from "@run402/functions";
46120
* adding a new optional field is minor; removing or retyping is major.
47121
*/
48122
export interface Run402ImageProps extends DataAttributes {
49-
/** The image source — typed AssetRef from `r.assets.put` / `r.assets.fromRef`.
50-
* String URLs are rejected (`R402_ASTRO_IMAGE_ASSET_STRING_URL`); null
51-
* / undefined rejected (`R402_ASTRO_IMAGE_ASSET_MISSING`); objects without
52-
* a `cdn_url` field rejected (`R402_ASTRO_IMAGE_ASSET_WRONG_SHAPE`). */
53-
asset: AssetRef;
123+
/** The image source — any object structurally matching `Run402ImageAsset`.
124+
* In practice this covers both the SDK's `AssetRef` (returned by
125+
* `r.assets.put` / `r.assets.fromRef`) and the manifest's `AssetRef`
126+
* (returned by `resolveVariants(manifest, key)` from
127+
* `@run402/astro/manifest`). String URLs are rejected
128+
* (`R402_ASTRO_IMAGE_ASSET_STRING_URL`); null / undefined rejected
129+
* (`R402_ASTRO_IMAGE_ASSET_MISSING`); objects without a non-empty
130+
* `cdn_url` rejected (`R402_ASTRO_IMAGE_ASSET_WRONG_SHAPE`). */
131+
asset: Run402ImageAsset;
54132
/** Required at the type level. Empty string (`alt=""`) signals decorative
55133
* per HTML5 §4.7.4.4 and is allowed. */
56134
alt: string;
@@ -102,8 +180,14 @@ export interface Run402ImageProps extends DataAttributes {
102180
* semantics" requirement — caller wins on property overlap.
103181
* Note: callers passing `background: <shorthand>` will reset the
104182
* placeholder `background-image`; use longhand (`background-color`,
105-
* `background-size`, etc.) to preserve it. */
106-
style?: string | Record<string, string | number>;
183+
* `background-size`, etc.) to preserve it.
184+
*
185+
* v1.0.3 — accepts `React.CSSProperties` directly so consumers of the
186+
* React entry can pass strongly-typed style objects (`{ objectFit:
187+
* 'cover' }`) without casting through `Record<string, string | number>`.
188+
* Astro consumers may still pass the looser string / object-of-strings
189+
* shape — both serialize identically. See GH #401. */
190+
style?: string | CSSProperties | Record<string, string | number>;
107191
/** Forwarded to the outermost element. */
108192
id?: string;
109193
/** Forwarded verbatim. Component does NOT emit by default. */
@@ -187,6 +271,10 @@ export type AstroComponent<P> = ((props: P) => unknown) & {
187271
// produced by a module without proper imports" issue when consumers
188272
// don't have @types/react installed. The ReactElement | null shape is
189273
// what JSX expects + survives the brand intersection.
274+
//
275+
// `CSSProperties` is imported at the top of this file for the
276+
// `Run402ImageProps.style` widening (v1.0.3, GH #401).
277+
190278
import type { ReactElement } from "react";
191279

192280
export type ReactComponent<P> = ((props: P) => ReactElement | null) & {

astro/src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export { default as Run402Image } from "./Run402Image.astro";
4040
export type { AssetRef } from "@run402/functions";
4141
export type {
4242
Run402ImageProps,
43+
Run402ImageAsset,
44+
Run402ImageAssetVariant,
4345
DataAttributes,
4446
ImageDefaults,
4547
PreloadAttrs,

0 commit comments

Comments
 (0)