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);
+ });
+});