# MICE MAKERS

*Dead shortcodes printed as raw text where the service card grids belonged. I parsed them back into cards.*

**Category:** WEB PLATFORM / CMS MIGRATION
**Status:** in progress
**Period:** 29 July – 1 August 2026
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**Stack:** Next.js 16 (App Router), React 19, TypeScript (strict), Payload CMS 3, PostgreSQL, Tailwind CSS v4, CSS keyframes + IntersectionObserver, jsdom

## The challenge

MICE MAKERS, a Singapore and Malaysia events agency, ran on WordPress and WooCommerce. Its page-builder shortcodes had stopped rendering, so service pages printed raw `[interective_banner]` text where the card grid belonged. On those pages the card grid was the page. An outside rebuild attempt already existed, so I cloned it and ran it locally first. It was distorted too, so I rebuilt from scratch instead of repairing either one.

## What I built

I built a statically generated Next.js 16 App Router app with Payload CMS 3 mounted in the same app over Postgres. I pulled content structurally out of the site's own WordPress REST API, checking every count against the `X-WP-Total` header, and parsed the dead shortcodes back into real service cards rather than stripping them. Twenty-six service pages each get their own section composition: a JSON block sequence drawn from an eleven-type vocabulary, filled with that page's own extracted copy, so pages differ from one another instead of sharing a template. The public frontend is complete. The Payload collections and the seed import are the outstanding work, with every route reading a compiled JSON layer I built to be swapped for Payload's local API in one file.

## The detail

### Reading what WordPress refused to render

The service card grids lived in `[interective_banner]` shortcodes that the REST API returned as literal text — WordPress only expanded them through the theme's frontend. Two things made them hard to catch: the source misspells the tag as "interective", so any sensible pattern misses it, and the attribute values are wrapped in HTML-encoded smart quotes rather than straight ones. My parser normalises the entities first, then reads title, description, image id and a pipe-delimited url structure, and discards the presentation attributes because the new design system owns styling. One detail paid off twice: `bgclr` was `#7f0096` across every banner, which is how I confirmed the brand purple as a measured value rather than a guess.

### Contrast as a pass/fail gate, not a design opinion

`scripts/contrast.ts` computes WCAG relative luminance for thirteen token pairings and exits non-zero on any failure. That includes composited values, since `--fg-soft` renders as foreground at 64% alpha over the canvas and has to be measured as what it actually paints. The palette hinges on one token: `--link` resolves to the dark purple on light surfaces and the bright tint on dark ones, because a vivid accent that reads beautifully on a dark section almost always fails on light and will pass a glance on a good monitor. Run today it reports 13 of 13. It is a standalone script I run by hand — nothing in the build invokes it yet.

### A scroll reveal that fails visible

The earlier attempt gated content visibility on its JavaScript bundle, which on a slow phone means a blank page. Here I make `.reveal` `opacity: 1` by default and opt into hiding with an `html.js` class set by a pre-paint inline script, so every failure mode ends with content on screen. The script throwing removes the class. A missing IntersectionObserver reveals immediately. A MutationObserver rescans late DOM, and a seven-second sweep force-reveals anything the observer missed. The component itself is a server component: a div with a class and two custom properties, zero JavaScript per element on a forty-card page.

### Unresolved copy ships as a counted marker, never as prose

Anything I could not trace to the source site goes into the real component as a `[CONFIRM]` marker and gets counted by `npm run confirm`. Seven of eight client logos stay as filenames with empty alt, because naming them would mean inventing client relationships. I leave 198 images with no alt text uncaptioned, because invented alt text that reads as real is worse than a visible hole. The FAQ ships without FAQPage JSON-LD because the source is one HTML blob, and marking up a guessed question-and-answer mapping is a structured-data violation. My copy deck refuses a number outright: "dozens of countries" is kept deliberately unquantified, with a comment in `site.ts` saying not to turn it into a figure.

### An audit that caught the generated content leaking

Because the 26 page compositions were machine-authored, I diffed them back against each page's own source body: every string sentence-split, normalised and matched by exact containment then fuzzy windowing, across all 26 pages rather than a sample. The honest finding is that body copy traces near-verbatim while the structural labels the block system needed do not — roughly a quarter of label slots are authored, and the audit counts them instead of pretending otherwise. It also found the real blocker: ten internal reviewer notes left inside rendered content fields on eight pages, one of them inside a `buttonHref`, which would have published as visible body copy and a broken anchor. My fix script moves those notes into a non-rendered `_confirms` array so the finding survives without shipping, and repoints the CTA hrefs aimed at routes that do not exist. Verified now: zero notes in rendered blocks, sixteen parked.

## The outcome

156 routes prerender and the whole public site boots and renders locally — home, about, contact, articles, events, FAQ and the service pages all return 200, and `tsc --noEmit` is clean. The standalone WCAG gate checks thirteen token pairings and exits non-zero below 4.5:1; it passes 13 of 13 today. Content extraction is count-verified against the source site's own headers across every content type. I self-host about 85 MB of the client's own media, so nothing hotlinks the old site, with licensed Unsplash replacement where the source art was unusable at full bleed and every asset credited in code. I have not deployed the rebuild; the domain still serves the original WordPress site.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/micemakers.html)
