astro-aeo-image
v0.4.0
Published
Astro image service that embeds your <Image> alt text (and optional description/keywords) as standards XMP into the optimized output files — so your dist/_astro assets are self-describing for Google Images and AI answer engines. Wraps Astro's default shar
Maintainers
Readme
astro-aeo-image
An Astro image service that embeds your
<Image>alt text (and optional description/keywords/license) as standards XMP/IPTC directly into the optimized output files — so yourdist/_astroassets are self-describing: Google Images reads embedded IPTC metadata and recommends embedding it, and the description travels with the file for accessibility, attribution, and the AI-search era.
Astro already requires an alt on every <Image /> — but that text normally lives only in the HTML attribute. The moment the optimized file is downloaded, hot-linked, or indexed as a file, the page context is gone. astro-aeo-image writes the description (and attribution/license) into the image bytes, where it travels with the file.
What's documented: Google Images reads embedded IPTC metadata (creator/credit/copyright/license) and recommends embedding it. For image ranking, Google uses the HTML
alt— so embedding complements it (durability, accessibility, attribution), it doesn't replace it or claim a ranking boost. AI engines consuming embedded metadata is forward-looking, not yet a spec.
How it's different from image.service.config.keepMetadata
This is an authoring layer, not a preservation one — and that distinction is the whole point:
| | sharp's keepMetadata / keepExif | astro-aeo-image |
| --- | --- | --- |
| Carries through metadata already in the source file | ✅ | — |
| Authors new descriptive metadata from your app data (the alt you already wrote, captions, keywords) | ❌ | ✅ |
| Writes Iptc4xmpCore:AltTextAccessibility + dc:description for AEO/accessibility | ❌ | ✅ |
sharp can preserve a camera's existing EXIF; it can't compose a fresh XMP packet from the alt prop in your .astro file. That's the gap this fills.
How it works
It's a thin wrapper around Astro's own default sharp service. Everything sharp does is unchanged — same resizing, formats, quality, caching. After sharp encodes each variant, this service splices descriptive XMP into the output buffer via aeo-image: byte-preserving, no re-encode — the compressed pixels are identical; only a metadata block is added.
Install
npm install astro-aeo-imageaeo-image comes along as its only dependency (zero-dependency itself). astro is a peer dependency (you already have it).
Configure
Add the integration — one line:
// astro.config.mjs
import { defineConfig } from "astro/config";
import aeoImage from "astro-aeo-image";
export default defineConfig({
integrations: [
aeoImage(), // or aeoImage({ useAltAsDescription: false })
],
});// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
image: {
service: {
entrypoint: "astro-aeo-image/service",
config: { useAltAsDescription: true },
},
},
});The integration is just a thin wrapper that sets this for you and forwards options.
That's it. Every <Image /> you already have now ships its alt inside the file:
---
import { Image } from "astro:assets";
import barn from "../assets/barn.jpg";
---
<Image src={barn} alt="A weathered red barn under a violet dusk sky in rural Vermont" width={1200} height={800} />The generated dist/_astro/barn.*.webp now contains:
Iptc4xmpCore:AltTextAccessibility= the alt textdc:description= the alt text (unlessuseAltAsDescription: false)
Optional richer metadata
Pass extra props on <Image> for fuller AEO signals (distinct SEO description, keywords, title):
<Image
src={barn}
alt="A weathered red barn at dusk"
description="A restored 1890s dairy barn in Vermont, now a working agrivoltaics site"
keywords={["barn", "vermont", "agrivoltaics", "rural"]}
title="Vermont Agrivoltaics Barn"
width={1200} height={800}
/>| Prop | XMP field |
| --- | --- |
| alt (required by Astro) | Iptc4xmpCore:AltTextAccessibility (+ dc:description) |
| description | dc:description |
| keywords (array or comma string) | dc:subject |
| title | dc:title |
| creator | dc:creator |
| credit | photoshop:Credit |
| rights | dc:rights |
| copyrightNotice | photoshop:Copyright |
| licenseUrl | xmpRights:WebStatement — Google Licensable |
| licensor {url, name?} (or flat licensorUrl/licensorName) | IPTC PLUS plus:Licensor — Google "Get this image" link |
| digitalSourceType (full IRI or bare term like "trainedAlgorithmicMedia") | Iptc4xmpExt:DigitalSourceType — AI-generated disclosure |
| aiGenerated (boolean shorthand) | sets Iptc4xmpExt:DigitalSourceType to trainedAlgorithmicMedia |
| ai {prompt?, promptWriter?, system?, systemVersion?} (or flat aiPrompt/aiPromptWriter/aiSystem/aiSystemVersion) | IPTC 2025.1 Iptc4xmpExt:AIPromptInformation / AIPromptWriterName / AISystemUsed / AISystemVersionUsed |
Make images Licensable in Google
The last fields implement what Google Images reads for the Licensable badge:
<Image
src={barn}
alt="A weathered red barn at dusk"
creator="Jane Doe"
copyrightNotice="© 2026 Example Studio"
licenseUrl="https://example.com/license/barn"
licensorUrl="https://example.com/buy/barn"
width={1200} height={800}
/>Label AI-generated images (IPTC 2025.1)
For AI-generated assets, embed the IPTC 2025.1 provenance fields plus the Digital Source Type IRI that downstream tools read to label an image as AI-generated:
<Image
src={market}
alt="A neon-lit street market at night in the rain"
aiGenerated
aiPrompt="neon street market, rain reflections, cinematic 35mm"
aiPromptWriter="Jane Doe"
aiSystem="DALL-E via Bing Image Creator"
aiSystemVersion="3"
width={1200} height={800}
/>aiGenerated is shorthand for
digitalSourceType="trainedAlgorithmicMedia"; pass digitalSourceType
explicitly for other terms (e.g. "compositeSynthetic" — bare CV terms are
expanded to the full IPTC IRI). Per IPTC guidance, leave creator off for
fully AI-generated images — the prompt writer is explicitly not the creator.
For TypeScript autocomplete on the custom props, augment Astro's image props in src/env.d.ts:
declare namespace Astro {
interface CustomImageProps {
description?: string;
keywords?: string[] | string;
title?: string;
aiGenerated?: boolean;
aiPrompt?: string;
aiSystem?: string;
}
}Supported formats
Whatever you output from Astro: WebP, AVIF, JPEG, PNG (via aeo-image). SVG/GIF and any format aeo-image doesn't handle are passed through untouched — the service degrades gracefully and never fails a build over metadata.
Standards
Metadata is written as Adobe XMP (not legacy IPTC-IIM), conforming to the IPTC Photo Metadata Standard 2025.1 (descriptive + accessibility + rights/licensing + AI-provenance subset) plus Dublin Core, Adobe, and PLUS namespaces — see aeo-image's conformance notes. This includes the 2025.1 AI-generation provenance properties and Iptc4xmpExt:DigitalSourceType (readable by exiftool, named tags from 13.40).
Verifying it worked
Given <Image alt="A weathered red barn under a violet dusk sky in rural Vermont" />, the built file gains embedded metadata:
$ exiftool dist/_astro/barn.abc123.webp | grep -iE "description|alt"
# ── before (default Astro service) ──
# (no metadata)
# ── after (astro-aeo-image) ──
Description : A weathered red barn under a violet dusk sky in rural Vermont
Alt Text Accessibility : A weathered red barn under a violet dusk sky in rural VermontOr check it programmatically:
node -e "import('aeo-image').then(async m=>{const {readFileSync}=await import('node:fs');console.log(m.readMetadata(new Uint8Array(readFileSync(process.argv[1]))))})" dist/_astro/<your-image>.webp
# → { description: '…', altText: '…' }The pixels are untouched — only a metadata block is added.
Status & scope
- The metadata-embedding core is unit-tested against the published
aeo-image. - On static builds, Astro passes
<Image>props (includingalt) to the service'stransform, which is where embedding happens. For on-demand/SSR image endpoints, the custom props are included inpropertiesToHashso they survive intotransform; if you rely on SSR image optimization, please smoke-test and open an issue with your Astro version if a prop doesn't come through. - Wraps
astro/assets/services/sharp. If you use a different image service, this won't layer on top of it.
License
MIT
