From the workshop

71 colliding selectors: the page-builder migration I had to invert

Pooling a page builder's per-page CSS into one site-wide bundle let 71 colliding selectors restyle pages they never belonged to. Storing each page verbatim, with small live seams cut back in, fixed it.

An imported page was showing the letter "E" where an icon should have been. One of the theme's icon-font glyphs, with no font behind it, had fallen back to its raw code point. Pages I hadn't touched were being restyled by rules that belonged to another page's section entirely. Nothing had thrown an error. The pages were just wrong.

This was the first importer I built to bring existing sites into our multi-site content management system, and it took the route that looks correct on a whiteboard. For each page, pull the meaningful content out of the page-builder soup, rebuild a clean shell in fresh templates, and pool all the captured CSS and JavaScript into site-wide bundles the way any sensible asset pipeline would. It is the architecture you would defend in review, and it produced visually broken pages.

The pooling itself was unremarkable, which was the problem. The CSS capture walked every page and concatenated what it found into one site-wide list:

def capture_from_multiple_pages(pages_html_map)
  assets = []

  pages_html_map.each do |page_url, html|
    page_assets = capture_from_page(html, page_url)
    assets.concat(page_assets)
  end

  assets
end

To understand the silent restyling, I pulled up two pages of the live source site and diffed the rules for the same selector on each. They disagreed, and not slightly: the same class name carried completely different declarations depending on which page you were looking at. That one diff was the diagnosis. What followed was measuring the blast radius: 71 colliding selectors across 34 stylesheets. That count is cheap to take before a merge rather than after one: list every selector in every per-page sheet, and count those that more than one sheet defines differently. Anything above zero means order, not intent, decides those rules once the sheets share a cascade.

Reduced to one selector on two pages, the collision is mechanical. The table is a two-page reproduction I rebuilt afterwards on invented markup, not the theme's own stylesheets, but it has the shape the diff showed:

per-page stylesheet .section_0 as authored isolated per page pooled site-wide (last in order wins)
Page A (page-a.css) background:#dbeafe (blue card) blue background:#dcfce7 (green card)
Page B (page-b.css, comes last) background:#dcfce7 (green card) green background:#dcfce7 (green card)

That is not a bug in the theme. It is how the page builder works. It emits a separate stylesheet per page, and the class names are positional: .section_0 means "the first section on this page"; .column_4 means some column on that same page, the number being an index into that page's own layout and nothing more. The strings are stable, but the first section of one page and the first section of another share nothing beyond the name, so .section_0 carries different, conflicting declarations in each page's stylesheet. Both rules use the same selector, so they carry identical specificity, and the cascade has nothing left to decide by but order of appearance: the last declaration wins. When I pooled those stylesheets into one bundle, I stacked 71 colliding selectors with conflicting rules across 34 per-page stylesheets into a single cascade. Whichever page's CSS happened to come last won, globally, and silently restyled every other page.

Try it: pool the stylesheets

Page A stylesheet
.section_0 — first section
Page B stylesheet
.section_0 — first section

Isolated per page: each .section_0 is scoped to its own page.

The letter E was a different failure entirely, one layer down at the assets rather than the cascade. The theme's icons are literal characters styled by an icon font. After capture, the font's localised files were missing from disk, the reference still pointed at the original host, and fonts are CORS-restricted, so the browser refused it. That is why the glyph had degraded to a bare letter: there was no font left on the page to shape it. The two failures shared no mechanism; what they shared was the assumption underneath the whole importer, that the thing worth capturing was "the site".

No amount of better extraction fixes the cascade collision. The extraction was fine; the problem was pooling per-page stylesheets into one bundle at all. The builder's CSS assumes exactly one page's stylesheet is present at a time, so a migration or scraping tool that merges them breaks that assumption however clean its extraction is: the defect lives in the merge, not the parse. The fix could not be a cleverer merge. The only thing that worked was to stop merging, and change the unit of capture from "site" to "page".

Store the page, not the site

So I threw the reconstruction away and built an archive-fidelity import mode. Each page is stored verbatim — its own <head>, its own <body>, its own body classes, in document order — as the snapshot it was on the source. No shell, no template, no pooling: the page that loads is the page that existed.

The assets travel with the page. At import time the importer walks each page's referenced CSS, JS, images and fonts, following CSS url() and @import references recursively, to a fixed depth and with a guard so a circular import chain cannot loop. Nothing is left hot-linked to the origin unless its download fails, and then the reference keeps its original URL rather than breaking the page. Every asset is hashed with SHA-256 on the way in, stored through Rails' Active Storage, and served from a path that is the hash:

def serving_path
  return nil unless sha256.present?

  "/wp-assets/#{sha256}#{extension}"
end

Addressing assets by the hash of their bytes bought two things in one move: far-future immutable cache headers became safe, because a given URL's contents can never change, and every stored asset stopped depending on the source host at runtime — no hot-linking, and the font's own files now served from the site's own origin instead of being fetched cross-origin and CORS-blocked. Verbatim document capture killed a third, unrelated bug for free: the old capture had re-emitted the theme's inline config globals after the external scripts that consumed them, inverting the platform's own print order and breaking initialisation. Archive the document as it was, and there is nothing left to re-emit out of sequence.

The archive is permanent, too: switch a page to editable-blocks rendering later and the verbatim snapshot is kept behind the flag, so the frozen original stays a floor you can always drop back to.

At this point the pages were faithful and completely dead. A verbatim archive cannot show a contact form that submits, and it cannot show articles written after the import. The migration wasn't finished if every new post or enquiry meant hand-editing archived HTML. The temptation here is to conclude that the "real" pages need to be dynamic after all, go back, and rebuild them properly. That is the trap I had just climbed out of. The pages did not need to be dynamic — specific regions of them did.

Cut the seams back in

Live behaviour comes back as a chain of render-time transforms applied to the stored archive HTML on the way out, not as edits to the snapshot. The scraped third-party contact form is swapped for a native enquiry form, but the native form is rendered into the archived markup's own class structure, so the page's own captured CSS styles it identically. Nobody writes CSS for the new form; it inherits the old form's, because to the stylesheet it is the old form.

The blog index grid is regenerated the same way, and it makes the preservation move again at a smaller scale. The transform does not rebuild the index from scratch and re-skin it. It indexes the archived grid entries by their normalised link path, and for every article that already existed at import it reuses that entry's captured markup, thumbnail and excerpt included, as it was. Only records created after the import get a freshly cloned entry, so even the live grid leaves every imported entry looking exactly as it did.

Brand-new articles get the same discipline from the other direction. One representative post page is archived as a shell, and at render time the renderer substitutes the article's title, date and block-rendered content into that shell's DOM by targeted node substitution. An article authored as nothing but plain content blocks then renders natively inside the imported theme, because the theme's own captured stylesheet already targets plain elements inside the content region. Nobody styles the new article; the theme dresses it exactly as it dressed the old ones, with zero hand-written markup.

What keeps this chain from damaging the pages it has no business with is the transform contract itself. This is the chain as I first committed it, with its two transforms, the contact form and the blog grid:

class BodyTransforms
  TRANSFORMS = [
    ContactFormTransform,
    NewsGridTransform
  ].freeze

  def self.apply(archive, site:, view_context:)
    TRANSFORMS.reduce(archive.body_html.to_s) do |html, transform|
      transform.new(archive: archive, site: site, view_context: view_context).apply(html)
    rescue StandardError => e
      Rails.logger.error("[wp-archive] #{transform.name} failed: #{e.message}")
      html
    end
  end
end

Two defensive properties are doing all the work. Each transform is a no-op unless the page actually contains its target markup, so a page with no contact form flows through ContactFormTransform untouched, and the chain can run over every archived page without touching the ones it has no seam for. And the reducer rescues per transform, passing the pre-transform HTML forward on failure, so a transform that raises hands the reader the frozen archive for that region rather than a broken page. A seam that breaks therefore degrades towards fidelity: the worst case is the faithful snapshot the whole exercise set out to preserve.

That contract also had to hold for the tenants that never triggered a seam at all. The contact-form fields became per-page data auto-detected from the scraped markup, and a site with no such config falls back to the original fixed form, unchanged. That was regression-tested, not assumed, and within days the chain itself became per-site, with anything tenant-specific opt-in: in a shared-schema multi-tenant codebase, "this transform only touches pages that opted in" is a claim you have to be able to prove before you let the chain run against every tenant's archive.

I did not want to trust my own eyes on whether any of this actually matched. Faithfulness was checked by a fleet of headless-browser agents that captured full-page screenshots of every page on both the live source and the imported copy and judged them section by section. That pass caught a stale capture and, on the first full run, confirmed 15 of 16 pages visually identical to the live source.

The reconstruction approach was never lazy. It was the more sophisticated design — extract, template, pool the assets site-wide — and every instinct that made it attractive was the same instinct that broke it. Page-builder output is a set of documents that each assume they are the only one present, and you cannot pool what was authored to stand alone. The fidelity came from doing less to each page: freezing it as it was, addressing its assets by their content, and cutting only the smallest live seams back in. Each transform is a no-op until the page asks for it, and each one falls back to the frozen original if it fails.

Fidelity comes from preserving, not reconstructing.

Jump into the build.

Move straight to the part you want to revisit.

Every word, easy to find.

Search the transcript or choose a passage to jump to that moment.

Keep building.

References and working notes to keep beside the editor.

Introduction to the CSS cascade (MDN) Specificity (MDN) Cache-Control: immutable (MDN) Download captions WebVTT

Search the articles, series and videos.