Skip to content
academia.sh

Lesson 11 / 19

Asset Processing

Images, fonts, and static files entering the build output; extracting asset links into the graph as nodes, the embed-threshold decision, rewriting references, the asset manifest, and the directly copied directory.

Contents

For three lessons, the graph was built only from script files. The North Slope Measurement Station interface’s output is not limited to that: the measurement list’s style file, the icon in front of every row in the list, the drawing of the station’s elevation curve, the interface’s font, and a handful of fixed-name files search engines read are also published.

These files enter the output through two separate routes. Some are linked from code or a style file; the link is an edge in the graph, and the asset becomes a node. Some are linked from nowhere but must still be published. This lesson separates the two routes and runs the pipeline linked assets pass through.

An Asset Is a Graph Node

For an asset to enter the graph, its link must be statically readable. Two standard syntaxes provide this.

A url() declaration in a style file gives a relative path as its address. The path in the source is resolved against the directory the style file sits in.

A new URL("./x.svg", import.meta.url) expression in code does the same job on the script side. The second argument is the module’s own address; the expression produces an address resolved against the module’s location. This syntax is part of the standard and is not tied to any tool; it works with no tool present, and can also be caught at build time when a tool is present.

A syntax that cannot be caught is one where the address is assembled at runtime. A concatenation in the form "/asset/" + name + ".svg" does not say which file it points to during the build. Such a link does not enter the pipeline: the file is not copied, its name is not rewritten, and a request goes to an address absent from the output. The rule is clear — asset addresses computed at runtime are served from the directly copied directory.

Two Flows

The processed flow covers linked assets. Depending on their size, these assets are either written to the output as a separate file or embedded into the body of the file that links them; either way, the link in the source is translated into an address in the output.

The directly copied flow is for files that must be published with their name and location unchanged: the file carrying crawler rules, domain verification files, data files downloaded from a fixed address. These files are not scanned, their names are preserved, and their content does not change. The distinguishing criterion is a single question: is the build free to determine the address, or is it pinned from outside?

The Asset Tree

The block below produces assets deterministically. A filler file of the same size stands in for a font body; because the pipeline’s decisions depend on a file’s size, not its content, this does not change the result.

The block below adds asset links to the source/ tree set up in the previous lessons; those lessons’ setup blocks must run in this directory first.

#!/usr/bin/env bash
mkdir -p asset public style

cat > asset/icon.svg <<'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#243">
<path d="M2 13 L6 5 L9 10 L11 7 L14 13 Z"/><circle cx="12" cy="3" r="2"/>
</svg>
EOF

node --input-type=module -e '
import { writeFileSync } from "node:fs";
// Station map: a 60-point elevation curve, deterministic generation.
const point = Array.from({ length: 60 }, (_, i) =>
  `${(i * 8).toFixed(0)} ${(120 - 40 * Math.sin(i / 6) - i / 3).toFixed(2)}`);
writeFileSync("asset/station.svg",
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 160">\n` +
  `<path fill="none" stroke="#1b3a2f" stroke-width="2" d="M${point.join(" L")}"/>\n` +
  point.filter((_, i) => i % 6 === 0).map((n) => `<circle cx="${n.split(" ")[0]}" cy="${n.split(" ")[1]}" r="3"/>`).join("\n") +
  `\n</svg>\n`);
// A deterministic binary file of the same size stands in for a font body.
writeFileSync("asset/measurement.woff2", Buffer.alloc(4096, 0x77));
'

cat > style/station.css <<'EOF'
@font-face {
  font-family: "Measurement";
  src: url("../asset/measurement.woff2") format("woff2");
  font-display: swap;
}
.measurement li::before {
  content: "";
  background-image: url("../asset/icon.svg");
  width: 16px;
  height: 16px;
}
.station-map {
  background-image: url("../asset/station.svg");
  min-height: 160px;
}
EOF

cat > source/asset-links.js <<'EOF'
export const iconUrl = new URL("../asset/icon.svg", import.meta.url);
export const mapUrl = new URL("../asset/station.svg", import.meta.url);
EOF

printf 'User-agent: *\nDisallow: /panel/\n' > public/robots.txt
printf '{ "station": "north-slope", "elevation_m": 1840 }\n' > public/station.json

The Pipeline

The pipeline is four steps: scan for links, decide embedding for each asset, replace the reference with its output address, and write the mapping between source and output into a manifest.

// asset.mjs — asset pipeline: scanning, embed decision, rewriting, manifest generation.
import { readFileSync, writeFileSync, mkdirSync, cpSync, statSync, rmSync } from "node:fs";
import { basename, dirname, join, relative } from "node:path";

const EMBED_THRESHOLD = 1024;                    // bytes
const OUTPUT = "output";
const TYPE = { ".svg": ["image/svg+xml", "text"], ".css": ["text/css", "text"],
              ".woff2": ["font/woff2", "binary"], ".png": ["image/png", "binary"] };
const extension = (p) => p.slice(p.lastIndexOf("."));

const SOURCES = ["style/station.css", "source/asset-links.js"];
const LINK = [
  /url\(\s*["']?([^"')]+)["']?\s*\)/g,                          // link inside a style file
  /new URL\(\s*["']([^"']+)["']\s*,\s*import\.meta\.url\s*\)/g, // link inside code
];

function dataUrl(filePath) {                     // percent-encoding for text assets, base64 for binary
  const [mime, kind] = TYPE[extension(filePath)], body = readFileSync(filePath);
  return kind === "text"
    ? "data:" + mime + "," + encodeURIComponent(body.toString("utf8")).replace(/%20/g, " ")
    : "data:" + mime + ";base64," + body.toString("base64");
}

rmSync(OUTPUT, { recursive: true, force: true });
mkdirSync(join(OUTPUT, "asset"), { recursive: true });

const assets = new Map();                        // path -> { size, refs, embedded, target, outputSize }
const manifest = {};

for (const sourcePath of SOURCES) {
  let text = readFileSync(sourcePath, "utf8");
  for (const pattern of LINK) {
    text = text.replace(pattern, (full, link) => {
      const filePath = relative(".", join(dirname(sourcePath), link));
      if (!assets.has(filePath)) {
        const size = statSync(filePath).size;
        const embedded = size <= EMBED_THRESHOLD;
        const target = embedded ? dataUrl(filePath) : "/asset/" + basename(filePath);
        if (!embedded) cpSync(filePath, join(OUTPUT, target));
        assets.set(filePath, { size, refs: 0, embedded, target,
          outputSize: embedded ? Buffer.byteLength(target) : size });
      }
      const a = assets.get(filePath);
      a.refs++;
      return full.startsWith("url") ? 'url("' + a.target + '")'
        : 'new URL("' + a.target + '", import.meta.url)';
    });
  }
  const outputPath = join(OUTPUT, basename(sourcePath));
  writeFileSync(outputPath, text);
  manifest["/" + basename(sourcePath)] = { source: sourcePath, size: statSync(outputPath).size };
}

for (const [filePath, a] of assets)
  if (!a.embedded) manifest[a.target] = { source: filePath, size: a.size };
cpSync("public", OUTPUT, { recursive: true });   // directly copied directory
writeFileSync(join(OUTPUT, "asset-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");

const base64Length = (filePath) => Math.ceil(statSync(filePath).size / 3) * 4;
console.log("asset".padEnd(20) + "size".padStart(6) + "  ref  decision    output  if embedded");
for (const [filePath, a] of assets)
  console.log(basename(filePath).padEnd(20) + String(a.size).padStart(6) + "   " + a.refs +
    "   " + (a.embedded ? "embedded  " : "separate  ") + String(a.outputSize).padStart(7) +
    String(base64Length(filePath)).padStart(12));

console.log("-- rewritten style file (long values truncated) --");
for (const s of readFileSync(join(OUTPUT, "station.css"), "utf8").trimEnd().split("\n"))
  console.log("  " + (s.length > 64 ? s.slice(0, 64) + "[...]" : s));

console.log("-- manifest keys --");
for (const [url, k] of Object.entries(manifest))
  console.log("  " + url.padEnd(24) + String(k.size).padStart(5) + " B  <- " + k.source);

const separate = [...assets.values()].filter((a) => !a.embedded);
console.log("-- total --");
console.log("  style file      : " + manifest["/station.css"].size + " B  (source was " +
  statSync("style/station.css").size + " B)");
console.log("  separate asset  : " + separate.length + " file, " +
  separate.reduce((t, a) => t + a.size, 0) + " B");
console.log("  direct copy     : robots.txt, station.json (name and content unchanged)");
$ node asset.mjs
asset                 size  ref  decision    output  if embedded
measurement.woff2     4096   1   separate     4096        5464
icon.svg               168   2   embedded      275         224
station.svg           1162   2   separate     1162        1552
-- rewritten style file (long values truncated) --
  @font-face {
    font-family: "Measurement";
    src: url("/asset/measurement.woff2") format("woff2");
    font-display: swap;
  }
  .measurement li::before {
    content: "";
    background-image: url("data:image/svg+xml,%3Csvg xmlns%3D%22ht[...]
    width: 16px;
    height: 16px;
  }
  .station-map {
    background-image: url("/asset/station.svg");
    min-height: 160px;
  }
-- manifest keys --
  /station.css              586 B  <- style/station.css
  /asset-links.js           398 B  <- source/asset-links.js
  /asset/measurement.woff2 4096 B  <- asset/measurement.woff2
  /asset/station.svg       1162 B  <- asset/station.svg
-- total --
  style file      : 586 B  (source was 332 B)
  separate asset  : 2 file, 5258 B
  direct copy     : robots.txt, station.json (name and content unchanged)

In the rewritten style file, all three links have changed: two into a root-relative address, one into the body itself. The ../asset/ relative path in the source never appears in the output — output layout is independent of source layout.

The ref column exposes a detail: the icon and the map are linked from both the style file and the code. The map, written as a separate file, is copied once, and both references point at the same address. The embedded icon, on the other hand, is rewritten at every reference; the 168-byte file totals 550 bytes across two outputs.

The Embed Threshold

The embed decision is made with a threshold, and the threshold balances two opposing costs.

Embedding’s gain is one request. Every asset written as a separate file brings a request’s latency alongside the byte it carries. For small assets, this latency is more expensive than the file itself.

Embedding’s cost has three items. First is encoding expansion: binary data grows by roughly a third when converted to base64 — a 4096-byte font body becomes 5464 characters. Text assets can use percent-encoding, but it is not always cheaper: the example’s icon is 275 bytes with percent-encoding and 224 with base64, because the proportion of characters that need escaping is high. Second is duplication: an embedded asset sits separately in every output that links it. Third is caching — an embedded asset has no separate address, so it cannot be cached separately; it downloads again with the file that links it every time that file changes.

This third item explains why the threshold is kept small. Embedding a rarely changing image inside a frequently changing style file makes that image download again with every release. The next lesson will make this connection measurable.

Decisions Specific to Asset Classes

The threshold alone is not a sufficient policy; each asset class has its own constraints.

Fonts are written as separate files regardless of size. A font body is shared across multiple pages and cached long-lived; embedding it destroys that sharing. The two settings established in the Font Loading lesson of the Visual Presentation with CSS course apply here as well: display behavior is declared with font-display, and the body can be requested with an early loading hint inside the document. A third thing the build side can add is subsetting — keeping only the used character ranges from the body shrinks the file noticeably, and the decision depends on the targeted language set.

Images carry two extra pieces of metadata. First are dimensions: when width and height are read during the build and written into the markup, the content shift defined in the Web Fundamentals and HTML course is prevented. Second are derivatives: the candidate set in the Layout Systems and Responsive Design course’s Responsive Images lesson asks for several widths of the same image; the pipeline is what produces those versions. Format choice is made by class, not by product name — drawings that need lossless compression and photographs suited to lossy compression are handled separately.

Style files are both asset and source: the links inside them are scanned, and they themselves are written to the output. This is why they appear twice in the pipeline.

The Asset Manifest

The pipeline’s final output is a mapping: which source file corresponds to which address in the output. This file is called the asset manifest, and it is the contract between the build and everything outside it.

At least three parties read the manifest. The document template takes the address of the style file and the entry chunk from it. A runtime rendering on the server writes the same addresses into the markup it produces. A service worker running offline generates the list of files it will precache from it — the Caching Strategies lesson in the Browser and the Web Platform course showed how fragile writing that list by hand is; the manifest lets the build produce the list instead.

Name similarity invites confusing three separate files. The package manifest carries dependencies and package metadata; the application manifest carries installability information; the asset manifest is only the source-to-output mapping. The three are read at different times, by different parties.

Summary

  • Assets enter the graph through two standard links: a url() declaration in a style file and a new URL(...) expression in code; addresses assembled at runtime cannot enter the pipeline.
  • Linked assets pass through the processed flow; files with a fixed name pass through the directly copied flow — the criterion is whether the build is free to determine the address.
  • The pipeline is four steps: scan for links, decide embedding, replace the reference with the output address, write the mapping into the manifest.
  • Embedding buys one request; in exchange it brings encoding expansion, duplication at every reference, and the loss of separate caching — a 4096-byte body becomes 5464 characters in base64.
  • Fonts are written as separate files regardless of size; images are produced together with dimension metadata and width derivatives.
  • The asset manifest is the shared contract for the document template, the server runtime, and the service worker.

Next Step

The files in the output still carry their source names for now. This leaves both of two questions unresolved. If a published file is cached long-lived, the user keeps getting the old version once its content changes; if it is cached short-lived, revalidation happens on every visit and the cache’s value is lost. The way to avoid both extremes is binding the name to the content: when a file’s name carries a digest of its content, the name does not change unless the content changes, and the cache stays valid as long as the name does not change. The next lesson produces these names, measures how names change in a chain across interdependent chunks, and builds the mechanism that breaks that chain.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close