The content import finished without a single error and every row count it printed matched what I expected. Only when I opened the site in a browser did it become clear that every image slot was a broken box, everywhere. The markup was honest about why:
<img src="MEDIA.cover">That is not a URL. That is a variable name stored as text.
The expensive part is that this bug only surfaces after the deploy completes successfully. The numbers look fine, nothing fails, so there is no signal anywhere telling you to stop earlier. The cost was one full cycle wasted: clean the residual data, seed again, run the full import, and only then find out.
Three files, and the third one does the parsing
The theme deploy for this client site runs through three files called in order. The first wipes residual data. The second seeds eight authors, all taxonomy terms, nineteen nested static pages with post_parent resolution, and the site options. The third parses the TypeScript content file with PHP regex and a hand-written bracket walker that understands strings and comments, then seeds five custom post types.
The theme itself is a custom WordPress theme ported from a React 19 plus Vite 7 plus Tailwind v4 source, and I ported all seventeen of its templates in a single session.
The bracket walker is the part that works. The value reader is the part that does not.
The convention hoists assets to the top of the file
The content file coming out of the React source generator has a habit of hoisting shared assets into a single object at the top of the file, and then the content arrays refer into it through identifiers.
export const MEDIA = {
cover: "https://cdn.example.net/cover.webp",
portrait: "https://cdn.example.net/portrait.webp",
// ...
};
export const FEATURES = [
{ slug: "profile-one", img: MEDIA.cover, /* ... */ },
{ slug: "profile-two", img: MEDIA.portrait, /* ... */ },
];To TypeScript this is unremarkable. To a regex parser, MEDIA.cover is just a run of characters that happens not to be wrapped in quotes.
A naive value reader hands back the token verbatim
The value reader in the importer has a branch for identifiers, and it works as simply as this: read until a comma, a closing brace, or a newline, then return the token. For img: MEDIA.cover what comes back is the string "MEDIA.cover", not the CDN URL that key points at.
That string then gets stored as external image post meta. This site does store external CDN images in post meta and reads them back through one helper that prefers the WordPress featured image and falls back to the external URL when there is no featured image. The helper behaved exactly as written: no featured image, so it used the external value, and the external value happened not to be a URL. What reached the front end was an img tag with a src the browser could do nothing with.
Nothing errored anywhere along that chain. The parser returned a string, update_post_meta stored a string, the helper returned a string, the template printed a string. Every component succeeded, and the combined result was completely broken.
The fix: build the map before the seeders run
The rule I took away from this fits in one sentence: the importer must resolve symbolic identifier references that point into top-of-file registries, rather than storing the literal token text.
Ordering is what matters. Before the content array seeders run, each top-of-file registry is parsed once into a key to value map. The fix I put in added four things: an object body extractor mirroring the existing array extractor, but for the {...} shape; a map builder that regex-extracts the registry entries into a key to URL map; one global populated at the top of the run; and resolution inside the identifier branch of the value reader.
global $theme_media_map;
$theme_media_map = theme_build_media_map( $src ); // key => URL, once per run
// Inside the identifier branch of theme_read_value():
if ( preg_match( '/^MEDIA\.(\w+)$/', $token, $m ) ) {
global $theme_media_map;
if ( isset( $theme_media_map[ $m[1] ] ) ) {
return $theme_media_map[ $m[1] ];
}
}
return $token;Placement decides whether this works: the ^MEDIA\.(\w+)$ match has to happen in the identifier branch before the token is returned, not later at the storage layer. Once a literal token escapes the value reader, nothing downstream knows it was ever supposed to be a reference.
Print the map size at the start of the run
One cheap line of verification: echo the map size and its keys at the start of every import run.
echo 'MEDIA map keys: ' . count( $theme_media_map )
. ' (' . implode( ', ', array_keys( $theme_media_map ) ) . ')';On a healthy run that line prints MEDIA map keys: 12. Zero or a partial count means the regex missed entries, and that has to be fixed before continuing. This is exactly what was absent on the first run. The only numbers printed back then were the post counts per type, and the post counts were correct, so the numbers were reassuring rather than useful.
Idempotent at the meta level, not just the post level
The import skips posts whose slug already exists. If meta updates are also gated behind a "newly inserted" check, fixing the parser heals nothing, because every post already exists and none of them get touched again. So the importer has to be idempotent at the meta level: post insert may be skip-by-slug, but update_post_meta runs unconditionally.
$post_id = theme_find_by_slug( $slug );
if ( ! $post_id ) {
$post_id = wp_insert_post( $args );
}
// Always runs, not only for newly created posts.
update_post_meta( $post_id, '_theme_external_image', $image_url );Because of that, re-running the import repairs every post that already exists. I applied the fix in the same session, re-ran the import, and the images loaded.
Prevention is one grep before the first import
Identifier references into a sibling export are not only an image concern. A top-of-file registry can be named anything, and any of them referenced symbolically must be resolved by the importer instead of stored as literal token text. So before the first import, grep the source for identifier-style references into a sibling export.
grep -nE ': (MEDIA|WRITERS|GLYPHS)\.' data.tsFor each pattern that matches, confirm the parser has a resolver for it. If it does not, grep the importer for the same pattern as a fallback safety net.
One note so the fix does not overshoot: referring into the registry is not a mistake in the source, it is the convention, and one template deliberately uses the CDN URL from one of the registry keys for an avatar. The resolver has to follow the key actually written in the source rather than guessing a key from the row's slug.
This bug was not expensive because it was hard. It was expensive because it waited patiently until everything I could check had been checked and had passed. Now, whenever my importer touches a TypeScript content file, the first thing it prints is the size of the registry map, before a single post is created.