Skip to content

Commit 68054c0

Browse files
MajorTalclaude
andcommitted
feat(astro): expose ./blurhash subpath export + pictureAttrs renderer option
Closes kychee-com/run402-private#414. Consumers that hand-build <picture> markup (data-driven sites with app-specific data-* attrs on the wrapper) couldn't reach decodeBlurhashToDataUri / averageColorFromBlurhash even though dist/blurhash-decoder.js was already shipping — the exports map omitted the entry. Add the subpath and a structural regression test. Also extend ImageProps / RenderPictureOptions with an optional pictureAttrs: Record<string, string> so renderPicture can stamp custom attrs on the outer wrapper (lands on <picture> with variants, on <img> in the sub-320 fallback). Keys are validated against [a-zA-Z][a-zA-Z0-9-]*; values are HTML-attribute-escaped. This is the preferred path — keeps consumers from reinventing the renderer just to attach instrumentation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b6dcd59 commit 68054c0

8 files changed

Lines changed: 155 additions & 2 deletions

File tree

astro/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,31 @@ function renderHeroImage(imageUrl: string, alt: string): string {
245245

246246
`renderPicture` produces the same `<picture>` HTML the static `<Image>` component does, with the same CLS-prevention contract (#4 in **Before you start**). No Vite or Astro runtime dependency — safe to import from any SSR / SSG / API-route module. It accepts any `AssetRef`, whether resolved from the manifest or read straight off a row, so the persistence pattern and the manifest pattern share the same renderer.
247247

248+
**Attaching app-specific attrs to the `<picture>` element (v0.2.5+).** Pass `pictureAttrs` to splice `data-*`, `id`, `role`, or any other HTML attribute onto the outer wrapper without forking the renderer:
249+
250+
```ts
251+
renderPicture(ref, {
252+
alt,
253+
sizes: '100vw',
254+
priority: true,
255+
pictureAttrs: { 'data-hero-picture': '', 'data-hero-aspect': '21/9' },
256+
});
257+
// → <picture data-hero-picture="" data-hero-aspect="21/9">…</picture>
258+
```
259+
260+
Keys are validated against `[a-zA-Z][a-zA-Z0-9-]*` and silently dropped if invalid; values are HTML-attribute-escaped. When the source falls back to a single `<img>` (sub-320 / no variants), the attrs land on the `<img>` instead — the wrapper-most element either way.
261+
262+
**Decoding a blurhash on your own (v0.2.5+).** If you're emitting custom `<picture>` markup that bypasses `renderPicture` entirely, the LQIP helpers ship as a subpath export:
263+
264+
```ts
265+
import { decodeBlurhashToDataUri, averageColorFromBlurhash } from '@run402/astro/blurhash';
266+
267+
const lqip = ref.blurhash ? decodeBlurhashToDataUri(ref.blurhash) : null;
268+
// → 'data:image/png;base64,…' (32×32 PNG, ≈600 bytes)
269+
```
270+
271+
Both functions are pure over the blurhash string — no I/O, no Vite virtual modules, byte-equivalent to Wolt's `blurhash@2.0.5` reference (see `blurhash-decoder.test.ts`). Closes [run402-private#414](https://github.com/kychee-com/run402-private/issues/414).
272+
248273
**Combining both paths.** Set BOTH `assetsDir` and use `<Image>` for static-template images. The integration deduplicates by absolute path + CAS dedup at the gateway, so an image referenced via both paths uploads once.
249274

250275
### Reading the manifest during `astro build` (v0.2.4+)
@@ -338,6 +363,7 @@ For sources smaller than 320 pixels on either axis (logos, icons), the component
338363
| `height` | `number` | source height | Override height; width auto-recomputed preserving aspect ratio. |
339364
| `class` | `string` || Passthrough to `<img>`. |
340365
| `placeholder` | `"blurhash" \| "color" \| "none"` | `"blurhash"` | LQIP strategy. |
366+
| `pictureAttrs` | `Record<string, string>` || v0.2.5+. Extra attributes on the outer `<picture>` (or fallback `<img>`). Keys must match `[a-zA-Z][a-zA-Z0-9-]*`; values escaped. |
341367

342368
## Integration options
343369

astro/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"./build-manifest": {
1919
"types": "./dist/build-manifest.d.ts",
2020
"import": "./dist/build-manifest.js"
21+
},
22+
"./blurhash": {
23+
"types": "./dist/blurhash-decoder.d.ts",
24+
"import": "./dist/blurhash-decoder.js"
2125
}
2226
},
2327
"files": [

astro/src/blurhash-decoder.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
import { describe, it } from "node:test";
1717
import assert from "node:assert/strict";
18+
import { readFileSync } from "node:fs";
19+
import { fileURLToPath } from "node:url";
1820

1921
import {
2022
averageColorFromBlurhash,
@@ -100,3 +102,22 @@ describe("blurhash helpers", () => {
100102
assert.throws(() => decodeBlurhashToDataUri("LXXXXXX"), /length mismatch/);
101103
});
102104
});
105+
106+
describe("package.json exports", () => {
107+
it("publishes `./blurhash` as a public subpath (regression guard for run402-private#414)", () => {
108+
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
109+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
110+
exports: Record<string, { types?: string; import?: string } | string>;
111+
};
112+
const entry = pkg.exports["./blurhash"];
113+
assert.ok(entry, "package.json `exports` is missing the `./blurhash` subpath");
114+
assert.equal(
115+
typeof entry === "object" ? entry.import : entry,
116+
"./dist/blurhash-decoder.js",
117+
);
118+
assert.equal(
119+
typeof entry === "object" ? entry.types : undefined,
120+
"./dist/blurhash-decoder.d.ts",
121+
);
122+
});
123+
});

astro/src/component.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,58 @@ describe("buildPictureHtml", () => {
195195
});
196196
assert.match(html, /sizes="\(min-width: 768px\) 50vw, 100vw"/);
197197
});
198+
199+
it("pictureAttrs splices data-* attrs onto the <picture> element", () => {
200+
const { html } = buildPictureHtml({
201+
ref: jpegRef,
202+
props: {
203+
...baseProps,
204+
pictureAttrs: { "data-hero-picture": "", "data-hero-aspect": "21/9" },
205+
},
206+
});
207+
assert.match(html, /^<picture data-hero-picture="" data-hero-aspect="21\/9">/);
208+
// None of the inner elements should pick up the attrs.
209+
assert.doesNotMatch(html, /<source[^>]*data-hero/);
210+
assert.doesNotMatch(html, /<img[^>]*data-hero/);
211+
});
212+
213+
it("pictureAttrs values are HTML-attribute-escaped", () => {
214+
const { html } = buildPictureHtml({
215+
ref: jpegRef,
216+
props: { ...baseProps, pictureAttrs: { "data-meta": 'A "tricky" <value>' } },
217+
});
218+
assert.match(html, /data-meta="A &quot;tricky&quot; &lt;value&gt;"/);
219+
});
220+
221+
it("pictureAttrs silently drops keys outside the safe HTML attribute pattern", () => {
222+
const { html } = buildPictureHtml({
223+
ref: jpegRef,
224+
props: {
225+
...baseProps,
226+
pictureAttrs: {
227+
// Valid — kept.
228+
"data-keep": "yes",
229+
// Invalid — would let a value break out of the tag.
230+
"evil onclick": "alert(1)",
231+
'evil"': "x",
232+
"1leading-digit": "x",
233+
},
234+
},
235+
});
236+
assert.match(html, /<picture data-keep="yes">/);
237+
assert.doesNotMatch(html, /onclick/);
238+
assert.doesNotMatch(html, /alert/);
239+
assert.doesNotMatch(html, /leading-digit/);
240+
});
241+
242+
it("pictureAttrs lands on the <img> when the source falls back (sub-320)", () => {
243+
const { html } = buildPictureHtml({
244+
ref: subSmallRef,
245+
props: { ...baseProps, pictureAttrs: { "data-hero-picture": "" } },
246+
});
247+
// No <picture> wrapper in the fallback path — attrs go on the <img>.
248+
assert.doesNotMatch(html, /<picture/);
249+
assert.match(html, /^<img/);
250+
assert.match(html, /data-hero-picture=""/);
251+
});
198252
});

astro/src/manifest.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,12 @@ describe("renderPicture", () => {
101101
assert.match(html, /class="hero-img"/);
102102
assert.doesNotMatch(html, /background-image/);
103103
});
104+
105+
it("threads pictureAttrs through to the <picture> wrapper", () => {
106+
const html = renderPicture(jpegRef, {
107+
alt: "hero",
108+
pictureAttrs: { "data-hero-picture": "", "data-hero-aspect": "21/9" },
109+
});
110+
assert.match(html, /^<picture data-hero-picture="" data-hero-aspect="21\/9">/);
111+
});
104112
});

astro/src/manifest.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ export interface RenderPictureOptions {
8585
class?: string;
8686
/** LQIP placeholder strategy. Default: `"blurhash"`. */
8787
placeholder?: "blurhash" | "color" | "none";
88+
/**
89+
* Extra attributes spliced onto the outer wrapper element (`<picture>`,
90+
* or the fallback `<img>` when the source has no variants). Useful for
91+
* app-specific hooks the integration doesn't model: `data-*`
92+
* instrumentation, custom `id`, `role`, etc. Keys must match
93+
* `[a-zA-Z][a-zA-Z0-9-]*`; invalid keys are dropped. Values are
94+
* HTML-attribute-escaped.
95+
*/
96+
pictureAttrs?: Record<string, string>;
8897
}
8998

9099
/**
@@ -132,6 +141,7 @@ export function renderPicture(ref: AssetRef, options: RenderPictureOptions): str
132141
...(options.height !== undefined && { height: options.height }),
133142
...(options.class !== undefined && { class: options.class }),
134143
...(options.placeholder !== undefined && { placeholder: options.placeholder }),
144+
...(options.pictureAttrs !== undefined && { pictureAttrs: options.pictureAttrs }),
135145
};
136146
return buildPictureHtml({ ref, props }).html;
137147
}

astro/src/picture-builder.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export function buildPictureHtml(input: BuildPictureInput): BuildPictureOutput {
5050
// Class passthrough.
5151
const classAttr = props.class ? ` class="${escapeAttr(props.class)}"` : "";
5252

53+
// Caller-supplied attrs for the outer wrapper element. Goes on
54+
// <picture> in the variant path; on the <img> in the no-variant
55+
// fallback (the <img> IS the outer element when there's no wrapper).
56+
const wrapperAttrs = formatExtraAttrs(props.pictureAttrs);
57+
5358
// Variants: if no variants present OR neither thumb/medium/large are set,
5459
// we emit a single <img> (sub-320 / decode-failed fallback).
5560
const variants = ref.variants;
@@ -79,7 +84,7 @@ export function buildPictureHtml(input: BuildPictureInput): BuildPictureOutput {
7984
return {
8085
html:
8186
`<img src="${escapeAttr(fallbackSrc)}" alt="${altAttr}"${dim}${styleAttr}${classAttr} ` +
82-
`loading="${loadingAttr}"${fetchPriorityAttr} />`,
87+
`loading="${loadingAttr}"${fetchPriorityAttr}${wrapperAttrs} />`,
8388
warnings,
8489
};
8590
}
@@ -98,7 +103,7 @@ export function buildPictureHtml(input: BuildPictureInput): BuildPictureOutput {
98103

99104
return {
100105
html:
101-
`<picture>` +
106+
`<picture${wrapperAttrs}>` +
102107
`<source type="image/webp" srcset="${srcsetAttr}" sizes="${sizesAttr}" />` +
103108
`<img src="${escapeAttr(fallbackSrc)}" alt="${altAttr}"${dim}${styleAttr}${classAttr} ` +
104109
`loading="${loadingAttr}"${fetchPriorityAttr} />` +
@@ -107,6 +112,23 @@ export function buildPictureHtml(input: BuildPictureInput): BuildPictureOutput {
107112
};
108113
}
109114

115+
/**
116+
* Serialize a `Record<string, string>` of extra attributes into the
117+
* `' k1="v1" k2="v2"'` form. Skips keys that don't match the safe
118+
* HTML attribute name pattern (defends against a typo or
119+
* untrusted-input footgun that could break out of the tag). Values
120+
* are HTML-attribute-escaped.
121+
*/
122+
function formatExtraAttrs(attrs: Record<string, string> | undefined): string {
123+
if (!attrs) return "";
124+
let out = "";
125+
for (const [key, value] of Object.entries(attrs)) {
126+
if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(key)) continue;
127+
out += ` ${key}="${escapeAttr(value)}"`;
128+
}
129+
return out;
130+
}
131+
110132
function pickFallbackSrc(ref: AssetRef): string {
111133
// HEIC source: variants.display_jpeg is the browser-safe fallback. NEVER
112134
// serve raw HEIC bytes from <img>.

astro/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ export interface ImageProps {
154154
class?: string;
155155
/** LQIP placeholder strategy. Default: `"blurhash"`. */
156156
placeholder?: "blurhash" | "color" | "none";
157+
/**
158+
* Extra attributes spliced onto the outer wrapper element (`<picture>`,
159+
* or the fallback `<img>` when no variants exist). Useful for app-specific
160+
* hooks the integration doesn't model: `data-*` instrumentation, custom
161+
* `id`, `role`, etc. Keys must match `[a-zA-Z][a-zA-Z0-9-]*`; invalid
162+
* keys are dropped. Values are HTML-attribute-escaped.
163+
*/
164+
pictureAttrs?: Record<string, string>;
157165
}
158166

159167
/** Build-cache entry shape for `node_modules/.run402/assetMap.json`. */

0 commit comments

Comments
 (0)