Let one manifest decide which pages get special treatment

  • php
  • wordpress
  • testing

Every site eventually grows a few pages that need to behave differently from the rest. Maybe they skip the ads, maybe they hide a newsletter popup, maybe they load a lighter layout because people print them. The first version of that rule is almost always a hardcoded list: an array of post IDs or slugs sitting in a config file, with a comment that says “add new ones here.”

That list works for exactly one week. Then somebody adds a new page the normal way, forgets the second list, and the special behavior silently doesn’t apply. Nobody notices, because nothing breaks. The page just looks like every other page.

I ran into this on a side project that publishes short stories alongside printable worksheets. The worksheets page itself was already ad free, but each worksheet links back to the story it was built from, and those story pages were still running ads. Teachers click from a clean printable page straight into a page full of banners. The fix was obvious. The interesting part was where the list of “worksheet stories” should come from.

Use the source of truth you already have

The worksheets page is rendered from a small JSON manifest. A script writes one row per worksheet when it generates the PDF, and each row already includes the story’s post ID and its URL. That file is the definition of “a story that has a worksheet.” Any other list would be a copy of it, and copies drift.

So the rule became: a story is special if, and only if, it appears in the manifest. Adding a new week to the manifest adds that story to the set automatically. There is no second place to update, and there’s nothing hardcoded to the current week’s ID.

This sounds small, but it changes who has to remember things. Before, a human had to remember to update the list. After, the only way to get a worksheet onto the site is the same step that makes its story special.

Match on both the ID and the path

The tricky part is that you don’t always know which post you’re on at the moment you need to decide. Some decisions happen late in the request, after the CMS has resolved the query and you can ask for the current post ID. Others happen early, before that lookup is done, when all you have is the request path.

So the matcher accepts either signal:

  • If the request resolves to a single story and its ID is in the manifest, it matches.
  • If the path looks like a story URL and its slug matches a manifest row, it matches.

Checking the path also covers URL variants. On this site the same story is served at /stories/{slug}/ and at a few reading level suffixes like /stories/{slug}/b2/. Those are the same post through a rewrite rule, so the level segment is not part of the slug. The bare URL redirects to a default level before any HTML is sent, which is one more reason the path check has to handle both shapes.

function story_slug_from_path(string $path): string {
    $path = strtok($path, '?');               // never match on the query string
    $path = '/' . trim($path, '/');
    if (!preg_match('#^/stories/([^/]+)(?:/(?:a1|a2|b1|b2))?$#i', $path, $m)) {
        return '';
    }
    return strtolower(rawurldecode($m[1]));
}

function is_special_story(int $id, string $path, array $rows): bool {
    $ids = [];
    $slugs = [];
    foreach ($rows as $row) {
        if (!is_array($row)) continue;
        $rowId = (int) ($row['story_id'] ?? 0);
        $slug = story_slug_from_path((string) parse_url($row['story_url'] ?? '', PHP_URL_PATH));
        if ($rowId > 0) $ids[$rowId] = true;
        if ($slug !== '') $slugs[$slug] = true;
    }
    if ($id > 0 && isset($ids[$id])) return true;
    $slug = story_slug_from_path($path);
    return $slug !== '' && isset($slugs[$slug]);
}

A few details in there are deliberate:

  • The query string is stripped before matching. A URL like /stories/other-story/?next=/stories/worksheet-story/ should not count. If you match with a loose strpos, anyone can opt any page into the special behavior by appending a parameter.
  • The pattern is anchored. /stories/{slug}/feed/ is not the story page, so it doesn’t match.
  • Bad rows are skipped, not fatal. A row with a missing ID, an ID of zero, or a URL that isn’t a story URL just contributes nothing. The rest of the set still works.
  • Slugs compare lowercase. URLs get typed and pasted by people, and case is the least interesting way for a match to fail.

Turn the behavior off in one place, everywhere

Once you have a single is_special_story() check, every feature that cares can ask it. In my case that meant three switches for the ad scripts, plus two smaller pieces of the theme that assumed ads would be there: an optional in-content ad unit and a script that moves auto placed ads around. Leaving those in would have shipped dead markup to a page that no longer loads ads.

The lesson I keep relearning: when you disable a feature on a page, search for everything that depends on that feature, not just the feature itself. The helpers around it tend to assume it exists.

The other side of that is restraint. Every other page keeps the existing behavior exactly, and filters respect a value that was already set. If something upstream had already blocked a script, my code doesn’t flip it back on just because the page isn’t special.

Test against the real file and a fake one

The test I trust most for this kind of rule runs two ways.

First, against synthetic rows that are not in the live manifest: a future week’s story, a row with a relative URL, a row with an ID stored as a string, a junk row, and a row pointing at a page that isn’t a story. This proves the matcher follows whatever data it’s given instead of some ID I happened to hardcode while debugging.

Second, against the live manifest itself. For every row in the real file, the test checks that the ID matches and that every URL variant of the slug matches. If someone adds a row with a typo in the URL, the test fails the same day instead of a teacher noticing months later.

Then the negative cases, which matter as much as the positive ones: an unrelated story stays normal, the home page stays normal, a non-story page that happens to share an ID doesn’t inherit the behavior, and a slug hidden in the query string doesn’t count.

None of this needs a database. A handful of tiny stubs for the CMS functions the code calls is enough to run the whole thing from the command line in well under a second.

One more thing: caches

If your site uses a page cache, the change won’t show up on pages that were cached before you deployed. Clear the cached copy of an affected page, or wait for it to expire, before you view source and decide the change didn’t work. I’ve lost more time to that than I’d like to admit.

The general rule

When a subset of pages needs different behavior, look for the data that already defines that subset and derive the rule from it. Match on the most reliable identifier you have at each point in the request, normalize paths carefully, and test against both the real data and data that doesn’t exist yet. The goal is that the next person adding a page never has to know the special list exists, because there isn’t one.