From d6ea3d1ee6d82c8e8579b2d809470599f3213d39 Mon Sep 17 00:00:00 2001
From: umeshmore45
Date: Mon, 21 Sep 2026 12:24:40 +0530
Subject: [PATCH] feat(wordpress): migrate the post featured image and its alt
text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WordPress stores a post's featured image as a `_thumbnail_id` postmeta pointing at an attachment
item, but the connector never read it. Attachments were downloaded (getAllAssets pulls every
attachment in the export) yet nothing referenced them, and the generated content type had no field
for one — so the featured image was absent from the Field Mapping step and orphaned in the stack.
Three additions:
- extractItems now emits a `featured_image` file field when an item in the post type declares a
`_thumbnail_id`, so the field is there to map.
- saveEntry resolves that id to the downloaded asset. saveAsset already registers every attachment
under `assets_`, which is exactly what `_thumbnail_id` holds, so it is a one-hop
lookup. A thumbnail whose download failed is absent from assetData and is left unset rather than
written as a dangling uid.
- saveAsset prefers the attachment's `_wp_attachment_image_alt` postmeta for the asset description,
which is where WordPress keeps alt text. It surfaces as `featured_image.description` on the entry.
The previous description/content/excerpt fallbacks are unchanged and still apply when alt is empty.
Ported as a standalone slice of the equivalent work on feature/wordpress-acf, without that branch's
ACF, Yoast SEO, excerpt and lifecycle-field changes.
Reported via support ticket 00062373.
---
api/src/services/wordpress.service.ts | 50 +++++++
.../wordpress.service.featuredImage.test.ts | 126 ++++++++++++++++++
.../migration-wordpress/libs/extractItems.ts | 27 ++++
.../extractItems.featuredImage.test.ts | 122 +++++++++++++++++
4 files changed, 325 insertions(+)
create mode 100644 api/tests/unit/services/wordpress.service.featuredImage.test.ts
create mode 100644 upload-api/tests/unit/migration-wordpress/extractItems.featuredImage.test.ts
diff --git a/api/src/services/wordpress.service.ts b/api/src/services/wordpress.service.ts
index ee090dcdb..0ffbf75f5 100644
--- a/api/src/services/wordpress.service.ts
+++ b/api/src/services/wordpress.service.ts
@@ -1189,6 +1189,51 @@ const extractCategoryReference = (categories: any) => {
}
+/** xml2js emits a lone repeated child as an object rather than a one-element array. */
+const normalizePostMeta = (postMeta: any): any[] => {
+ if (!postMeta) return [];
+ return Array.isArray(postMeta) ? postMeta : [postMeta];
+};
+
+const findPostMetaValue = (postMeta: any, metaKey: string): string => {
+ const match = normalizePostMeta(postMeta).find(
+ (meta: any) => meta?.["wp:meta_key"] === metaKey && meta?.["wp:meta_value"]
+ );
+ const value = match?.["wp:meta_value"];
+ return typeof value === "string" ? value : (value != null ? String(value) : "");
+};
+
+/**
+ * WordPress keeps image alt text on the attachment as the `_wp_attachment_image_alt` postmeta. It is
+ * the canonical accessibility/SEO text for an image, so it is preferred over the usually-empty
+ * description/content fallbacks when setting the Contentstack asset's description. For a featured
+ * image this is what surfaces as `featured_image.description` on the entry.
+ */
+export const getAttachmentAltText = (assets: any): string =>
+ findPostMetaValue(assets?.["wp:postmeta"], "_wp_attachment_image_alt");
+
+/**
+ * Resolve a post's featured image to an already-downloaded Contentstack asset.
+ *
+ * WordPress points at the post thumbnail via the `_thumbnail_id` postmeta, whose value is the
+ * attachment's `wp:post_id` — and saveAsset registers every attachment under `assets_`,
+ * so the id resolves in one hop. Returns undefined when the post has no featured image, or when the
+ * attachment never made it into assetData (a failed download); a dangling uid would break the import.
+ */
+export const resolveFeaturedImageAsset = (item: any, assetData: any): any => {
+ const thumbnailId = findPostMetaValue(item?.["wp:postmeta"], "_thumbnail_id");
+ if (!thumbnailId) return undefined;
+
+ const asset = assetData?.[`assets_${thumbnailId}`];
+ if (!asset) {
+ console.warn(
+ `Featured image asset assets_${thumbnailId} not found for post ${item?.["wp:post_id"]}; leaving featured_image unset.`
+ );
+ return undefined;
+ }
+ return asset;
+};
+
const extractTermsReference = (terms: any) => {
const termArray = Array?.isArray(terms) ? terms : [terms];
const termReference = termArray?.filter((term: any) => term?.attributes?.domain !== 'category');
@@ -1288,6 +1333,10 @@ async function saveEntry(fields: any, entry: any, file_path: string, assetData
}
entryData[uid]['tags'] = tags?.map((tag: any) => tag?.text);
entryData[uid]['author'] = authorData;
+ const featuredImage = resolveFeaturedImageAsset(item, assetData);
+ if (featuredImage) {
+ entryData[uid]['featured_image'] = featuredImage;
+ }
entryData[uid]['locale'] = locale;
entryData[uid]['publish_details'] = [];
@@ -1786,6 +1835,7 @@ async function saveAsset(assets: any, retryCount: number, affix: string, destina
const nameWithoutExt = originalName.includes('.') ? originalName.substring(0, originalName.lastIndexOf('.')) : originalName;
let description =
+ getAttachmentAltText(assets) ||
assets["description"] ||
assets["content:encoded"] ||
assets["excerpt:encoded"] ||
diff --git a/api/tests/unit/services/wordpress.service.featuredImage.test.ts b/api/tests/unit/services/wordpress.service.featuredImage.test.ts
new file mode 100644
index 000000000..127ae96ef
--- /dev/null
+++ b/api/tests/unit/services/wordpress.service.featuredImage.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ resolveFeaturedImageAsset,
+ getAttachmentAltText
+} from '../../../src/services/wordpress.service.js';
+
+/**
+ * Shapes below mirror the real WXR export from ticket 00062373 (PetHealthMD), as xml2js parses it
+ * with `explicitArray: false`: post 18098 carries `_thumbnail_id` = 18113, and attachment 18113 is
+ * the image. saveAsset registers that attachment as `assets_18113`.
+ */
+const postMeta = (key: string, value: string) => ({
+ 'wp:meta_key': key,
+ 'wp:meta_value': value
+});
+
+const post = (postmeta: any) => ({
+ 'wp:post_id': '18098',
+ 'wp:post_type': 'post',
+ 'wp:postmeta': postmeta
+});
+
+const downloadedAsset = {
+ uid: 'assets_18113',
+ filename: 'assets_18113.jpg',
+ url: 'https://pethealthmd.com/wp-content/uploads/2025/11/retriever-and-snow-fall.jpg'
+};
+
+const assetData = { assets_18113: downloadedAsset };
+
+describe('resolveFeaturedImageAsset', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ it('resolves _thumbnail_id to the downloaded asset', () => {
+ const result = resolveFeaturedImageAsset(
+ post([postMeta('_thumbnail_id', '18113')]),
+ assetData
+ );
+ expect(result).toBe(downloadedAsset);
+ });
+
+ it('resolves when postmeta is a single object rather than an array', () => {
+ const result = resolveFeaturedImageAsset(
+ post(postMeta('_thumbnail_id', '18113')),
+ assetData
+ );
+ expect(result).toBe(downloadedAsset);
+ });
+
+ it('picks _thumbnail_id out from among other postmeta keys', () => {
+ const result = resolveFeaturedImageAsset(
+ post([
+ postMeta('_yoast_wpseo_title', 'Joint Pain & Mobility Support'),
+ postMeta('_thumbnail_id', '18113'),
+ postMeta('_yoast_wpseo_canonical', 'https://pethealthmd.com/cats/joint-pain/')
+ ]),
+ assetData
+ );
+ expect(result).toBe(downloadedAsset);
+ });
+
+ it('coerces a numeric _thumbnail_id to the asset key', () => {
+ const result = resolveFeaturedImageAsset(
+ post([{ 'wp:meta_key': '_thumbnail_id', 'wp:meta_value': 18113 }]),
+ assetData
+ );
+ expect(result).toBe(downloadedAsset);
+ });
+
+ it('returns undefined when the post has no featured image', () => {
+ expect(resolveFeaturedImageAsset(post([postMeta('_yoast_wpseo_title', 'x')]), assetData))
+ .toBeUndefined();
+ });
+
+ it('returns undefined when the post has no postmeta at all', () => {
+ expect(resolveFeaturedImageAsset({ 'wp:post_id': '18098' }, assetData)).toBeUndefined();
+ });
+
+ it('returns undefined when _thumbnail_id is empty', () => {
+ expect(resolveFeaturedImageAsset(post([postMeta('_thumbnail_id', '')]), assetData))
+ .toBeUndefined();
+ });
+
+ it('returns undefined and warns when the asset failed to download', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ expect(resolveFeaturedImageAsset(post([postMeta('_thumbnail_id', '99999')]), assetData))
+ .toBeUndefined();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('assets_99999'));
+ });
+
+ it('tolerates a missing assetData map', () => {
+ expect(resolveFeaturedImageAsset(post([postMeta('_thumbnail_id', '18113')]), undefined))
+ .toBeUndefined();
+ });
+});
+
+describe('getAttachmentAltText', () => {
+ it('reads _wp_attachment_image_alt off the attachment', () => {
+ const alt = getAttachmentAltText({
+ 'wp:postmeta': [
+ postMeta('_wp_attached_file', '2025/11/retriever-and-snow-fall.jpg'),
+ postMeta('_wp_attachment_image_alt', 'A golden retriever in falling snow')
+ ]
+ });
+ expect(alt).toBe('A golden retriever in falling snow');
+ });
+
+ it('returns an empty string when the alt meta is present but blank', () => {
+ // This is the case in the ticket's own export — the alt value is an empty CDATA block.
+ expect(getAttachmentAltText({
+ 'wp:postmeta': [postMeta('_wp_attachment_image_alt', '')]
+ })).toBe('');
+ });
+
+ it('returns an empty string when there is no alt meta', () => {
+ expect(getAttachmentAltText({
+ 'wp:postmeta': [postMeta('_wp_attached_file', 'x.jpg')]
+ })).toBe('');
+ });
+
+ it('returns an empty string for an attachment with no postmeta', () => {
+ expect(getAttachmentAltText({})).toBe('');
+ });
+});
diff --git a/upload-api/migration-wordpress/libs/extractItems.ts b/upload-api/migration-wordpress/libs/extractItems.ts
index 594fe6179..b37d2818d 100644
--- a/upload-api/migration-wordpress/libs/extractItems.ts
+++ b/upload-api/migration-wordpress/libs/extractItems.ts
@@ -566,6 +566,33 @@ const extractItems = async (item: any, config: DataConfig, type: string, affix:
}
+ // Featured image — WordPress stores the post thumbnail as a `_thumbnail_id` postmeta pointing at
+ // an attachment item. Add a single file field so saveEntry can attach the already-downloaded
+ // asset. Added only when an item in this post type actually declares a featured image.
+ const hasFeaturedImage = item?.some?.((data: any) => {
+ const postMeta = Array?.isArray(data?.['wp:postmeta'])
+ ? data['wp:postmeta']
+ : (data?.['wp:postmeta'] ? [data['wp:postmeta']] : []);
+ return postMeta?.some?.(
+ (meta: any) => meta?.['wp:meta_key'] === '_thumbnail_id' && meta?.['wp:meta_value']
+ );
+ });
+ if (hasFeaturedImage && !isAllContentEmpty) {
+ CT?.push?.({
+ "isDeleted": false,
+ "uid": 'featured_image',
+ "backupFieldUid": 'featured_image',
+ "otherCmsField": '_thumbnail_id',
+ "otherCmsType": 'file',
+ "contentstackField": 'Featured Image',
+ "contentstackFieldUid": 'featured_image',
+ "contentstackFieldType": 'file',
+ "backupFieldType": 'file',
+ "advanced": {
+ "mandatory": false}
+ });
+ }
+
const filePath = path.join(contentTypeFolderPath, `${type?.toLowerCase()}.json`);
const contentType: Record = {
"status": 1,
diff --git a/upload-api/tests/unit/migration-wordpress/extractItems.featuredImage.test.ts b/upload-api/tests/unit/migration-wordpress/extractItems.featuredImage.test.ts
new file mode 100644
index 000000000..8ebd6de0c
--- /dev/null
+++ b/upload-api/tests/unit/migration-wordpress/extractItems.featuredImage.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
+
+const writeFileAsync = vi.fn();
+
+vi.mock('../../../migration-wordpress/utils/parseUtil', () => ({
+ setupWordPressBlocks: vi.fn(async () => []),
+}));
+
+vi.mock('../../../migration-wordpress/utils/helper', () => ({
+ default: {
+ writeFileAsync: (...args: any[]) => writeFileAsync(...args),
+ },
+}));
+
+vi.mock('mkdirp', () => ({ default: vi.fn() }));
+
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+import extractItems from '../../../migration-wordpress/libs/extractItems';
+
+/**
+ * extractItems re-reads the source XML off disk to pull each item's body, so point it at a real
+ * file. Only the body lookup uses it; the featured-image check reads the parsed item objects.
+ */
+const xmlFixture = `
+
+
+-
+Joint Pain & Mobility Support in Colder Weather
+When temperatures start to fall.
]]>
+18098
+
+
+
+`;
+
+let localPath: string;
+
+beforeAll(() => {
+ localPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-featured-')), 'export.xml');
+ fs.writeFileSync(localPath, xmlFixture, 'utf8');
+});
+
+const postMeta = (key: string, value: string) => ({
+ 'wp:meta_key': key,
+ 'wp:meta_value': value,
+});
+
+/** Mirrors the shape xml2js produces for a WXR item (explicitArray: false). */
+const makeItem = (overrides: Record = {}) => ({
+ title: 'Joint Pain & Mobility Support in Colder Weather',
+ 'content:encoded': 'When temperatures start to fall.
',
+ 'wp:post_id': '18098',
+ 'wp:post_type': 'post',
+ ...overrides,
+});
+
+const writtenFieldMapping = () => {
+ const payload = JSON.parse(writeFileAsync.mock.calls.at(-1)?.[1]);
+ return payload.fieldMapping;
+};
+
+const run = (items: any[]) =>
+ extractItems(items, { localPath } as any, 'post', '', [], []);
+
+describe('extractItems — featured image', () => {
+ beforeEach(() => {
+ writeFileAsync.mockClear();
+ });
+
+ it('adds a featured_image file field when an item declares _thumbnail_id', async () => {
+ await run([makeItem({ 'wp:postmeta': [postMeta('_thumbnail_id', '18113')] })]);
+
+ const featured = writtenFieldMapping().find((f: any) => f.uid === 'featured_image');
+ expect(featured).toBeDefined();
+ expect(featured.contentstackFieldType).toBe('file');
+ expect(featured.contentstackFieldUid).toBe('featured_image');
+ expect(featured.contentstackField).toBe('Featured Image');
+ expect(featured.otherCmsField).toBe('_thumbnail_id');
+ expect(featured.advanced?.mandatory).toBe(false);
+ });
+
+ it('handles a single (non-array) postmeta, as xml2js emits for one entry', async () => {
+ await run([makeItem({ 'wp:postmeta': postMeta('_thumbnail_id', '18113') })]);
+
+ expect(writtenFieldMapping().some((f: any) => f.uid === 'featured_image')).toBe(true);
+ });
+
+ it('adds the field once when only some items have a featured image', async () => {
+ await run([
+ makeItem({ 'wp:post_id': '1', 'wp:postmeta': [postMeta('_yoast_wpseo_title', 'x')] }),
+ makeItem({ 'wp:post_id': '2', 'wp:postmeta': [postMeta('_thumbnail_id', '18113')] }),
+ ]);
+
+ const featured = writtenFieldMapping().filter((f: any) => f.uid === 'featured_image');
+ expect(featured).toHaveLength(1);
+ });
+
+ it('does not add the field when no item declares a featured image', async () => {
+ await run([makeItem({ 'wp:postmeta': [postMeta('_yoast_wpseo_title', 'x')] })]);
+
+ expect(writtenFieldMapping().some((f: any) => f.uid === 'featured_image')).toBe(false);
+ });
+
+ it('does not add the field when _thumbnail_id is present but empty', async () => {
+ await run([makeItem({ 'wp:postmeta': [postMeta('_thumbnail_id', '')] })]);
+
+ expect(writtenFieldMapping().some((f: any) => f.uid === 'featured_image')).toBe(false);
+ });
+
+ it('leaves items with no postmeta at all untouched', async () => {
+ await run([makeItem()]);
+
+ const mapping = writtenFieldMapping();
+ expect(mapping.some((f: any) => f.uid === 'featured_image')).toBe(false);
+ // the pre-existing fields are still emitted
+ expect(mapping.some((f: any) => f.uid === 'title')).toBe(true);
+ expect(mapping.some((f: any) => f.uid === 'modular_blocks')).toBe(true);
+ });
+});