Skip to content
AstroCraft Docs
On this theme

Images

Images in Urbic go through Astro’s asset pipeline, which means an image imported or referenced from frontmatter gets optimized, hashed and dimension-typed at build time. What is specific to this theme is which directory a photo belongs in, and why that is not a matter of taste.

The two directories

src/assets/images/ keeps what is bound to a record: projects/, blog/, team/, services/, partners/, testimonials/. These reach a component through a loader that already exists, they carry alt text on the entry beside them, and they must not become a name an author can swap out from under the record that describes them.

src/assets/library/ is the CMS’s. /admin/images/ lists exactly what is in it, an upload lands in it, and a section resolves a photo by file name out of it. It ships with 18 images.

The rule is: if alt text lives on a record next to the file path, use images/. If the photo is a section’s dressing that an author might swap, use library/.

Resolving a photo by name

The composer writes section props as JSON, so an ImageMetadata prop is one the CMS can never set — it would have to write an import statement. A section whose photo varies per instance therefore takes the file name and resolves it through src/js/sectionImage.ts:

<PageHero headline="…" photo="concept-interior.jpg" />

That helper runs one eager glob for the whole site rather than one per section. The modules are metadata records — width, height, format, the hashed src — not the pixels, so the cost is a few hundred bytes and <Image> still optimizes exactly the photos a page actually renders.

Two details there are load-bearing. The glob path is relative, not the @assets/* alias: the argument is a build-time literal Vite resolves against that file, and an alias in it resolves in some Vite versions and silently matches nothing in others — a mistake costing an empty map and no error. And the directory it points at has to be the one the CMS’s librarySource names; sectionImage.test.ts fails if the two ever drift, so the two agree by moving photos rather than by editing a constant inside package territory.

If you name a photo that is not there, the error lists every name the helper does answer to.

The image library screen

/admin/images/ reports on the repository, and there are two mechanisms behind it that deliberately are not one.

The listing is a readdir — what exists right now, which is the question a library screen actually asks. The thumbnails come from an import.meta.glob, which the build resolves; in a production bundle that is a literal map that can never grow.

Their difference is exactly “changed since the last build”. A file on disk the glob has never heard of is marked pending and served raw, because <Image> has no module to optimize. A glob entry gone from disk is a deletion the screen must not resurrect.

The records describe the repository, not what the screen ships. A 2400px source is a 2400px source there, while the tile you are looking at renders a small derivative — which is why the reported byte size is a statSync of the source rather than the length of what <Image> emitted.

Alt text and usage counts come from a reference scan rather than from a field, because an image file has no alt text — alt lives at each use, since it describes what the picture is doing in that place. One git grep over the tracked tree answers both questions: how many files mention this image, and what the first of them calls it.

That scan is a documented heuristic. It matches a filename anywhere in a tracked text file and reads alt from the matching line, so it over-counts a file that names an image in a comment and under-reads alt written on the line after the src. The stated upgrade path is parsing .md and .mdx with the mdast walker the CMS already has and leaving the rest on the grep — worth doing when an alt-text audit is load-bearing for you.

The optimise pipeline

The library’s optimise verb is one transform: resample down to a 1920px ceiling if wider, then encode AVIF at the configured quality. The default is 50, which on AVIF’s scale is roughly visually lossless for photography — AVIF’s quality numbers are not JPEG’s.

You can change it in src/admin.config.ts:

images: { avifQuality: 60 }

The number the panel promises is the number the commit delivers, because the savings estimate and the applied transform are the same function. Two implementations of “resample then encode” would drift the first time the quality default moved.

sharp is imported lazily inside that function so a native binding does not load on requests that never optimize.

The screen also flags oversized sources (wider than the 1920px ceiling) and unreferenced ones (used in nothing), which are the two lists worth acting on before a launch.

See The image library for the operator’s side.

Social images

public/og.jpg is the site-wide fallback at 1200×630 — replace it. A page passing an image prop to BaseLayout overrides it, and BaseHead emits that image’s real dimensions rather than the convention.

Collections point their social image at a field: blog uses heroImage (required, which is why no note can ship without an OG image), projects uses cover, authors uses avatar. See SEO.

A note on parallax

The scroll-driven parallax utilities size their child from the same knob the keyframes use, and object-cover pays for vertical oversize in horizontal crop. If a photo under a parallax frame looks cropped at the sides, the shift is too large rather than the image being wrong. Motion has the arithmetic.

One more image-specific gotcha lives in the design notes: an image under a scroll-driven transform in a position: fixed subtree needs decoding="sync". A compositor layer’s first paint can land before an async decode finishes, and what you see is a flat grey frame. PageHero documents it.

NEXT STEPComponents Reference