Writing code
Images, fonts, icons and cards
Import an image and it is resized to the widths your layout asks for. Import a
font and it is self-hosted, declared, and paired with a fallback that occupies
exactly the same space. Import an icon and it joins one sprite built from the
set your build actually reached. Import a card template and it is drawn. All of
it happens in uf itself, at build time, and this page is as clear about what
the pipeline cannot do as about what it can.
An image
Import it, and hand the whole import to Image:
import * as React from "@uniflowed/react";
import { Image } from "@uniflowed/web";
import hero from "./hero.jpg";
export component Page() {
return <Image src={hero} alt="The bridge at dusk" sizes="(max-width: 640px) 100vw, 640px" />;
}
hero is not a URL. It is what uf assets produced when it decoded the file:
the intrinsic width and height, every variant it wrote, and a blur placeholder.
Image turns that into markup — a srcset naming each width, the intrinsic
dimensions that reserve the box before the bytes arrive, loading="lazy" and
decoding="async".
That is the difference from writing <img> yourself, and it is not a small one:
a srcset can only be written by something that knows what other sizes exist,
and until you have made them, nothing does.
What ends up in dist/
One variant per declared width that is not wider than the source — uf never upscales — plus one at the source's own width, so there is always a largest. Each is content-hashed, so its name changes exactly when its bytes do and never otherwise: a rebuild that changed nothing invalidates nothing.
The blur placeholder
A tiny version of the image is inlined into the document as a background-image
and the real one paints over it. It costs one decode uf was doing anyway, and no
element — so there is nothing to remove when the image lands, and nothing extra
for a screen reader to announce.
An image with transparency gets none, and that is deliberate: the placeholder is
painted under the image, so under a logo with a transparent background it
would stay visible through it forever. Set placeholder={false} to turn it off
for an opaque image too.
The one above the fold
<Image src={hero} alt="…" priority={true} />
priority makes the image eager, gives it fetchpriority="high", and emits a
<link rel="preload"> asking for the same variant the <img> will. A srcset
behind a lazy <img> is discovered late, and for the largest image on the page
that is most of the Largest Contentful Paint. Everything else stays lazy, which
is the right default for the images nobody has scrolled to yet.
sizes
Set it. A srcset with w descriptors and no sizes means 100vw to the
browser, so it fetches a variant wide enough for the whole viewport however
small the image is drawn. When you say nothing, uf writes
(max-width: <intrinsic>px) 100vw, <intrinsic>px, which at least never asks for
more pixels than exist — but it is a guess about a layout uf cannot see, and
yours is better.
A font
import { Font } from "@uniflowed/web";
import inter from "./Inter.woff2";
export component Layout(children: React.Node) {
return (
<div style={{ fontFamily: inter.fontFamily }}>
<Font src={inter} />
{children}
</div>
);
}
Three things happen. The file is self-hosted — copied into your build under
a content hash, so no request goes to a third party at runtime. It is
declared, with font-display: swap and the right format(). And it is
paired with a metric-matched fallback: a second @font-face over a font the
reader already has, carrying size-adjust, ascent-override,
descent-override and line-gap-override computed from your font's own head,
hhea and OS/2 tables.
That last one is the reason to bother. font-display: swap shows text
immediately in a fallback and then swaps, and the swap moves every line, because
the two faces are different sizes. Scale the fallback until it occupies the same
space and the swap moves nothing.
Use inter.fontFamily rather than writing the family name yourself: it is the
real face, then the matched fallback, then the local face that was scaled. Name
only the real family and you get the download and none of the metric matching.
The unicode-range split
A family that covers Latin, Greek and Cyrillic is three scripts in one file, and a reader of one of them downloads all three. Turn the split on and they do not:
export default defineConfig({
app: { builtins: { fonts: { subset: "ranges" } } },
});
uf reads the font's own character map, partitions it into script buckets, and
emits one file per bucket with the exact unicode-range of what is in it —
computed from the subset, not copied from a published table, so the browser
never fetches a bucket for a character it does not have and never misses one.
The buckets are disjoint, so nothing is in two files.
Nothing is lost. That is the reason this is the mode to turn on and
subset: "none" is the default: a page that renders Cyrillic still gets
Cyrillic, it just does not pay for it before it needs it. The subsetting itself
is skera, Google Fonts' Rust port
of HarfBuzz's hb-subset, so the glyph closure, the GSUB/GPOS pruning and
the cmap rebuild are the same ones the rest of the web's fonts go through.
There is a second, lossy mode — cutting a face down to a named string —
and it is reachable from the uf assets protocol and not yet from a .js
file. The characters a face is cut to are a property of one use of it rather
than of the project, so it wants to be said at the import, and an import can
only carry a query — which is Vite's. Until that is resolved, text
subsetting is something a tool driving uf assets can ask for and an
application cannot.
Why the default is none. Subsetting is lossy by construction: a glyph that
was removed is a character the page can no longer draw. A build cannot see the
text a server will render, a user will type, or a translation will introduce, so
a toolchain that quietly cut fonts down to the strings in your source would
produce pages that look right to you and render in tofu for somebody with a
diacritic in their name. "ranges" is safe because it removes nothing.
The preload
Font emits exactly one <link rel="preload">, whatever the family was split
into. That is deliberate: a preload for every bucket downloads the whole family
up front, which is what the split existed to stop, and four preloaded faces have
pushed your own stylesheet down the same connection.
<Font src={inter} preload={false} />
for a face that does not paint the first screen. app.builtins.fonts.preload
sets the default for every import, and preload={true} overrides it the other
way for a page that does need the face early. One link is the most Font ever
emits.
An icon
import { Icon, IconSprite } from "@uniflowed/web";
import sprite from "uf:icon-sprite";
import star from "uf:icon/star";
export component Layout(children: React.Node) {
return (
<>
<IconSprite sprite={sprite} />
<button type="button"><Icon icon={star} label="Favourite" /></button>
{children}
</>
);
}
uf:icon/star resolves to icons/star.svg — the directory is
app.builtins.icons.dir — and evaluates to the symbol's id and viewBox, not
to markup. uf:icon-sprite is one <svg> holding a <symbol> for every icon
the build reached, and no others.
The sprite is inlined into the document rather than fetched. An external
sprite would be the better answer if it worked — one file cached across every
page — but <use href="sprite.svg#id"> has never resolved across documents in
Safari and is blocked cross-origin in Chrome, so a sprite file in dist/ would
be weight nothing could use.
That last part — "and no others" — is what a runtime icon library cannot do. At runtime nothing
knows which of a set's nine hundred icons an application imported, so the
library ships all of them or asks you to import each one as its own component
and repeat the same <svg> wrapper nine hundred times. A build resolved every
import; the set it reached is the set.
label is the whole accessibility decision an icon needs, and it is the one
most often made backwards: pass it when the icon is the control, leave it
off when there is text beside it. An unlabelled icon button is announced as
"button"; a labelled icon next to its own visible text is the same word read
twice.
uf:icon/… rather than ./star.svg
.svg stays Vite's. Claiming the extension would take it from
vite-plugin-svgr and everything like it, which is red line 8: if Vite can do
it, a uf project can do it. So icons live in uf's own uf: namespace and take
nothing away — import url from "./star.svg" still means exactly what it
always did.
What an icon may contain
An icon is inlined into your document and can come from a dependency, so uf
refuses rather than strips: a <script>, a <foreignObject>, an on…
attribute, a javascript: value, or a reference to another origin makes the
import fail with a message naming the file. Stripping would render something
different from the file in your repository and nobody would find out until it
looked wrong.
Everything else passes through unchanged, with one rewrite: every id inside an
icon, and every url(#…) and href="#…" that points at one, is prefixed with
the symbol's own id. Two icons that both define a gradient called a is the
normal case, and in one document the second would win for both.
An Open Graph card
import { OgImage } from "@uniflowed/web";
import card from "./guide.og.json";
export component Head() {
return <OgImage card={card} origin="https://example.com" />;
}
guide.og.json is a template, not a document. The compound extension is
the whole of what uf claims: ordinary .json stays Vite's, and
./guide.og.json?raw still reaches the file.
{
"eyebrow": "Writing code",
"title": "Images, fonts, icons and cards",
"subtitle": "Everything the build knows and the runtime cannot",
"background": { "from": "#0b1020", "to": "#161d3d" },
"foreground": "#f8fafc",
"accent": "#7c8cff",
"font": "../../brand/Inter-SemiBold.ttf"
}
uf draws it at 1200x630 — the ratio every consumer crops to — content-hashes the
PNG, and the import evaluates to its URL, size and alt text. OgImage writes the
tags, including the two things everybody gets wrong: an absolute URL, because
a relative og:image is not an Open Graph image at all, and
twitter:card: summary_large_image, without which X renders a thumbnail of a
1200-pixel picture. A project using @uniflowed/router's Metadata should pass
the import through openGraph.images instead and let its metadataBase do the
absolute URL.
This is a template, and deliberately not a renderer
The obvious version of this feature is "an image from JSX", and uf does not do
it. Rendering a document to an image is a CSS layout engine — flow, flex, grid,
line-height, text-overflow — and the two ways to have one are to ship a
browser or to approximate it. A browser is a dependency uf refuses to make
building an application need. An approximation produces cards that are subtly
wrong, and nobody looks at an Open Graph card until it is on somebody else's
website.
So: a fixed arrangement — background, rule, eyebrow, title, subtitle — drawn
natively with ab_glyph, which turns
a glyph into an outline and an outline into coverage. That covers most Open
Graph images and it is honest about not being JSX. A card that needs more is a
PNG you draw yourself, and Image has rendered one since the pipeline existed.
What it refuses, and why refusing is the feature
ab_glyph does no shaping, so uf places glyphs left to right at their
advance widths with the font's legacy kern. For Latin, Greek, Cyrillic, CJK
and the punctuation around them that is correct. For anything else it is not,
and uf fails the build with a message naming the character rather than
drawing it:
- right-to-left scripts — Hebrew, Arabic, Syriac, Thaana, N'Ko — which are reordered, and in Arabic joined, by a shaping engine;
- Brahmic scripts — Devanagari through Sinhala — whose vowel signs move around their consonant;
- Thai, Lao, Khmer, Myanmar, which do not separate words with spaces, so there is nothing to break lines on;
- combining marks, which are placed over their base by
GPOS; - a zero-width joiner or a variation selector, each of which says several code points are one glyph;
- a character the font has no glyph for, because drawing
.notdefgives a row of empty boxes that reads as a bug in whatever is showing the card; - a character the font draws from a colour or bitmap table —
COLR,sbix,CBDT— which is what an emoji usually is. That one is asked of the font rather than guessed from the character's block, so a check mark or an arrow your font has an outline for is drawn rather than refused for living near the emoji, and an emoji it has only in colour is refused rather than advanced past as a gap of exactly the right width.
A card that is wrong is worse than a card that does not exist, because the build said it succeeded.
uf also ships no font. A template with text and no font is refused: a
toolchain that carried a default typeface would carry a licence with it. Point
app.builtins.og.font at one and your templates stay text and colour.
Configuration
export default defineConfig({
app: {
builtins: {
images: {
widths: [640, 750, 828, 1080, 1200, 1440, 1920],
quality: 75,
placeholder: true,
},
fonts: { display: "swap", fallback: "Arial", subset: "none", preload: true },
icons: { dir: "icons" },
og: { font: null },
},
},
});
Those are the defaults. fallback is the local face uf scales, and it has to be
one uf has measured — Arial, Times New Roman or Courier New. A face uf
cannot measure is a face uf declines to scale, and it says so rather than
guessing: a confident, wrong size-adjust moves text just as much as none.
uf explain build prints what the stage will do with your settings.
Development and the build agree
There is one pipeline. uf dev and uf build both drive uf assets with the
same parameters, both write to the same cache under .uf/cache/assets, and both
get the same files under the same names. A dev session warms a cache the build
reuses. The only thing that differs is the URL prefix they are served under.
The cache
Every emitted file is named by a digest of its source and its parameters, so a rebuild writes the same bytes to the same path and nothing downstream re-downloads. That makes the output incremental and left the work untouched: the second build still decoded, resized and re-encoded everything before writing what was already there.
It does not any more. The manifest is cached beside the files under the same digest, so a second build of an unchanged project reads one small JSON document per asset and decodes, resizes, subsets and rasterises nothing.
There is no invalidation step because there is nothing to invalidate: the key
is the digest of every input. Change the image, a width, the quality, the
family, the subset mode or the base URL and the key changes; a stale entry
cannot be read because nothing asks for it. The one thing a key cannot cover is
the files, so a hit is only a hit when every file the manifest names is still on
disk — rm -rf dist with .uf/cache left behind redoes the work rather than
producing a build that names files nobody can serve.
The sprite is the exception: it is assembled once per build, after every module has been loaded, because that is the first moment the set of icons the build reached is the whole set.
What this does not do
The encoders in uf are pure Rust — no C library, no native toolchain, nothing
to install. That has a price, and it is worth stating plainly rather than
letting you discover it in a Lighthouse report:
- No AVIF. It needs an AV1 encoder. A pure-Rust one exists and is not bundled: it is around eighty more crates compiled under a whole-program-LTO release profile, which is a decision to make deliberately rather than as a side effect of an asset pipeline.
- No lossy WebP. There is no pure-Rust encoder for it;
libwebpis C.
So the modern format uf can emit is lossless WebP, and where that wins is not obvious: a logo or a gradient comes out roughly half the size of the PNG, a photograph comes out about the same or worse, and a UI screenshot resampled to a narrow width comes out seven times larger, because resampling turns hard edges into rings of near-colours that PNG's adaptive filtering absorbs and this encoder does not.
uf therefore does not guess. It encodes both at every width, and offers the WebP
only when it is smaller at every width — all the rungs or none of them,
because a <picture> source carrying only the wide rungs sends a phone a wide
one. When the WebP loses, no WebP file is written, and the byte counts that
decided it are on the import as declined.
A photograph therefore gets several JPEG widths and no <source>. That is the
correct answer, and you can see the numbers behind it.
- A subsetted face is emitted as WOFF 1.0, not WOFF2. uf has a WOFF2
reader and no encoder, and writing one whose only proof of correctness is a
browser CI cannot run is not a trade worth making. Brotli beats zlib by about
a fifth on a font; subsetting a family to one script removes rather more than
that, and WOFF 1.0 is readable by every browser that has
@font-faceat all. - Subsetting needs a
.ttf,.otfor.woffwith TrueType outlines. A.woff2stores itsglyftable in a transformed form uf does not reverse — point the import at the file the.woff2was built from. A font with CFF outlines is refused becauseskeradoes not rebuildCFF, and a CFF font that went through it would come back with no glyphs at all. Both refusals leave the whole font self-hosted and working, with the reason on the import assubsetDeclined. - One weight per import. Every imported font is declared at
font-weight: 400; font-style: normal, and the family is the file's stem. SoInter-Bold.woff2becomes the familyInter-Boldrather thanInterat 700, andfont-weight: boldwill not switch to it — use its ownfontFamily. A variable font, which is one file covering the range, is unaffected. Theuf assetsprotocol already carriesweightandstyle; what is missing is a way for an import to say them. - There is no request-time endpoint, so a remote or user-supplied image is not handled. That is deliberate: an endpoint that resizes whatever a query string names is a denial-of-service amplifier, and the allow-list that makes one safe belongs in its first commit rather than after it. See security.
An image uf has no decoder for — a GIF, an existing AVIF — is copied through
unchanged, and the import says so in its note rather than pretending it was
resized. An existing WebP is copied through as well but is measured, so the
page still gets the intrinsic size that reserves its box.
.svg is not claimed at all. uf could only ever copy one through — it is already
resolution independent — and claiming the extension would take it away from
vite-plugin-svgr and everything like it. An SVG import stays Vite's, and
<Image src={url} width={…} height={…} /> renders one.
Reaching past it
An import with a query is Vite's, not uf's:
import url from "./hero.png?url";
uf claims the plain form and leaves every Vite asset convention — ?url,
?raw, ?inline — exactly where it was.