Software & Web

ylfamarin.is

An artist profile for an Icelandic actress, grown from one hand-written page into a split source with 191 test declarations, responsive images and self-hosted fonts, served by a Cloudflare Worker that enforces one canonical domain.

Role
Founder · product owner · architect (built with an AI agent team)
Timeline
Oct 2026
Status
Live
Live
ylfamarin.is
  • HTML
  • CSS
  • Vanilla JavaScript
  • Node.js build and tests
  • Cloudflare Workers
  • Cloudflare Static Assets
  • AVIF and WebP
  • schema.org JSON-LD
  • 191 in 18 filesTest declarations
  • 266 KB to 12 KBFeatured photo, smallest size
  • 5, no Google requestsSelf-hosted font files
  • 39 (6 to 25 Oct 2026)Commits
ylfamarin.is home page after the redesign, with the name Ylfa Marín in large serif type beside a portrait, her three roles above it, two buttons and a line naming her next production

The problem

Ylfa Marín Haraldsdóttir is an Icelandic actress, singer and voice actress, and a family client. Her site has one job: help casting directors, producers and dubbing studios see a showreel, check credits and get in touch.

Four things make a small site like this harder than it looks. The default language is Icelandic, with English on request. Several variants of her name point at the site (.com, www hosts and an accented IDN), and they must all land on one address. The credits change often, so adding a role has to be a one-line edit with no CMS. And time-bound items, such as a coming production, must disappear on their own.

What I built

I built it twice. The first version was one hand-written HTML file of about 40 KB, with its CSS and JavaScript inline and no tests. The second is a redesign from a written spec: a 36 KB page shell, three scripts (data, renderers and behaviour), a build that fills the page with its content and inlines the scripts, 18 test files, responsive photos and self-hosted fonts. Icelandic is the default and an EN button switches the text.

It is live at ylfamarin.is, served by a Cloudflare Worker with Static Assets. The deployed page is about 90 KB before compression and about 27 KB after it.

Architecture

ylfamarin.is request and build pathsTop row, the request path: a visitor reaches worker.js, which serves Static Assets. Bottom row, the build path: the page shell and three scripts go through build.js and stage.js, and the staged out folder feeds Static Assets.request pathVisitorworker.jsStatic Assetsbuild pathhtml + 3 scriptsbuild.jsstage.js

Engineering highlights

One canonical host, with headers on everything

Every host except ylfamarin.is gets a 301 to https://ylfamarin.is, covering .com, every www host and the accented domain in its punycode form. Preview hosts (workers.dev and localhost) are exempt so I can test, and they get X-Robots-Tag: noindex. run_worker_first is set in wrangler.jsonc, so the Worker sees asset requests too and four security headers, including HSTS, apply to images as well as HTML. Evidence: worker.js lines 13 to 31 and wrangler.jsonc line 10.

// source: ylfamarin/worker.js (lines removed, none edited)
// Content-hashed media (name.<8 hex>.ext) never changes, so it is cached for a year; everything else keeps the default revalidation.
export function cacheControl(pathname) {
  return /^\/(img|audio|dl|fonts)\/.+\.[0-9a-f]{8}\.\w+$/.test(pathname) ? "public, max-age=31536000, immutable" : null;
}
    const isPreview = host.endsWith(".workers.dev") || host === "localhost";
    if (!isPreview && host !== CANONICAL) {
      url.protocol = "https:";
      url.hostname = CANONICAL;
      url.port = "";
      return Response.redirect(url.toString(), 301);
    }
    const res = await env.ASSETS.fetch(request);
    const h = new Headers(res.headers);
    h.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
    const cache = cacheControl(url.pathname);
    if (cache && res.ok) h.set("Cache-Control", cache);
    if (isPreview) h.set("X-Robots-Tag", "noindex");
    return new Response(res.body, { status: res.status, headers: h });
Redirect everything but the canonical host, then add headers and a long cache to what is served. worker.js

Hashed files, cached for a year

Responsive photos, audio, downloads and fonts get a content hash in their file name, and the Worker marks those paths immutable for a year, only when the response succeeds: a test checks that a missing hashed file is not cached. The HTML revalidates on every visit, so a new page reaches visitors at once. Older photos still have plain names and revalidate too. The first version’s Worker set no cache header at all.

Responsive photos and local fonts

The featured stage photo was one 265,708-byte JPEG. It is now nine hashed files, 480, 960 and 1600 pixels wide in AVIF, WebP and JPG, chosen by srcset and sizes, and the smallest AVIF, for narrow screens, is 12,272 bytes. A test checks that every photo has dimensions and alt text in both languages. The five font files are served from the site itself, with no Google Fonts request, and a test fails if one comes back. YouTube players are click-to-load facades using youtube-nocookie; only their thumbnails still come from YouTube’s image host.

Data in one file, a build that checks itself

All changing content sits in content.js. The browser renders it, and at build time the same renderers write the Icelandic version into the HTML, so a crawler without JavaScript sees all 20 credits and 4 trailers. One set of renderers serves both jobs, which replaced the two hand-synced templates the first version had. The build throws if a mount point has no renderer, a renderer has no mount point, a script is missing or a field still holds a TODO(human) placeholder, so an unfinished edit cannot ship. A section with no data, such as an empty voice list, is built hidden.

// source: ylfamarin/build.js (lines removed, none edited)
  const used = [...html.matchAll(/data-mount="([^"]+)"/g)].map(m => m[1]);
  for (const name of Object.keys(render)) if (!used.includes(name)) throw new Error('build.js: no mount point data-mount="' + name + '" in index.html');
  for (const name of used) if (!render[name]) throw new Error('build.js: mount point data-mount="' + name + '" has no renderer');
  for (const name of used) {
    const re = new RegExp('(<(\\w+)[^>]*\\sdata-mount="' + name + '"[^>]*>)(</\\2>)');
    if (!re.test(html)) throw new Error('build.js: data-mount="' + name + '" must be an empty element');
    html = html.replace(re, (_, open, tag, close) => {
      const out = render[name]();
      return (out ? open : open.replace(/>$/, " hidden>")) + out + close;
    });
  }
Match mount points to renderers, fail loudly on a mismatch, and hide a section that has nothing to show. build.js

Content that expires on its own

The now-and-next items carry an inclusive UTC end date (3 November for the Sweeney Todd musical). From the next day the card, the hero line and the marquee entry drop it, the hero falls back to a featured credit, and the page’s structured data stops announcing the event. Tests cover the day after, so I do not have to remember to edit the page.

Search and two languages

There is one canonical tag, lang="is", hreflang for Icelandic and x-default, and 11 Open Graph properties. The schema.org data is generated from the same content. English is a client-side toggle, offered once to browsers that are not set to Icelandic, and a test requires an English twin for every alt text, aria label and title.

Process & quality

The repository defines seven agents in .claude/agents/: an art director on Opus, a front-end developer, copywriter, image producer, pixel artist and Cloudflare engineer on Sonnet, and a QA agent on Haiku. The main session briefs them and reviews their work. The redesign was specified and planned in committed documents before any code changed, and all 39 commits carry a Claude co-author trailer, 12 naming Opus and 27 Sonnet. The live footer credits Móberg Studios.

My part was the rules and the facts. CLAUDE.md says no generated likeness of Ylfa, so every photo is real and credited where shown; no deploy, DNS or domain change without my approval; keys stay outside the repository; commits only when I ask. I also decide what is true on the page, for instance that she is a guest actor at the National Theatre, never “ensemble member”.

The first version was checked by hand. This one has 191 test declarations in 18 files, run locally with node --test. I have not run them for this study. There is no CI, and npm run deploy does not run the tests first. The spec sets targets, a first view under 300 KB and LCP under 2.0 s, and I have not measured either on the live site.

What I’d do differently

  • Make the tests a gate. Put npm test in the deploy script and add CI, so a failing test blocks a deploy instead of waiting for someone to run it.
  • Measure the spec’s targets on the live site and keep the numbers, instead of leaving them as goals.
  • Add a Content-Security-Policy. The Worker still sets none, and the page has inline scripts and third-party embeds that a policy would have to account for.
  • Give English its own URL. A client-side toggle means crawlers index Icelandic only.

Source available on request.