-
Notifications
You must be signed in to change notification settings - Fork 10
feat(wordpress): migrate the post featured image and its alt text #1154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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_<wp:post_id>`, | ||
| * 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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: Concrete case: a post type whose items all have empty The Generated by Claude Code |
||
| } | ||
| 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"] || | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(''); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: The sibling fields sidestep this by putting a human label in Generated by Claude Code |
||
| "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<string, any> = { | ||
| "status": 1, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = `<?xml version="1.0" encoding="UTF-8" ?> | ||
| <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wp="http://wordpress.org/export/1.2/"> | ||
| <channel> | ||
| <item> | ||
| <title>Joint Pain & Mobility Support in Colder Weather</title> | ||
| <content:encoded><![CDATA[<p class="wp-block-paragraph">When temperatures start to fall.</p>]]></content:encoded> | ||
| <wp:post_id>18098</wp:post_id> | ||
| <wp:post_type><![CDATA[post]]></wp:post_type> | ||
| </item> | ||
| </channel> | ||
| </rss>`; | ||
|
|
||
| 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<string, any> = {}) => ({ | ||
| title: 'Joint Pain & Mobility Support in Colder Weather', | ||
| 'content:encoded': '<p class="wp-block-paragraph">When temperatures start to fall.</p>', | ||
| '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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
String(value)yields"[object Object]"ifwp:meta_valueever arrives as an xml2js node rather than a plain string. The parser is configured withattrkey: 'attributes'/charkey: 'text'(upload-api/migration-wordpress/utils/helper.ts:16), so any element carrying an attribute becomes{ text, attributes }β which is exactly whysaveEntryreadstag?.textandcat?.attributes?.nicenamefurther down this file.Standard WXR writes
<wp:meta_value>with no attributes, so this is unlikely rather than broken. It just fails quietly if it does happen: the lookup misses and the warning readsassets_[object Object]. Cheap guard:Generated by Claude Code