Skip to content

feat(wordpress): migrate the post featured image and its alt text - #1154

Open
umesh-more-cstk wants to merge 1 commit into
devfrom
feature/wordpress-featured-image
Open

umesh-more-cstk wants to merge 1 commit into
devfrom
feature/wordpress-featured-image

Conversation

@umesh-more-cstk

Copy link
Copy Markdown
Contributor

🔗 Jira Ticket

⚠️ Needs a Jira ticket linked before review — raised from Salesforce support ticket 00062373, no Jira issue was created for it yet.


📋 PR Type

  • ✨ Feature
  • 🐛 Bug Fix
  • 🔥 Hotfix
  • ♻️ Refactor
  • 🧹 Chore / Dependency Update
  • 📝 Documentation

📝 Description

What changed?

  • extractItems now emits a featured_image file field on the generated content type, so the featured image is actually available to map at the Field Mapping step. Added only when an item in that post type declares a _thumbnail_id, so exports without featured images are unaffected.
  • saveEntry resolves that thumbnail to the downloaded asset via a new resolveFeaturedImageAsset helper. saveAsset already registers every attachment under assets_<wp:post_id>, which is exactly what _thumbnail_id holds — so it is a one-hop lookup, no URL matching needed.
  • saveAsset now prefers the attachment's _wp_attachment_image_alt postmeta for the asset description, via a new getAttachmentAltText helper. That is where WordPress keeps alt text, and it surfaces as featured_image.description on the entry.

Why?

A customer reported that their WordPress featured images were downloaded into the stack but never attached to the post entry, and that no featured image field appeared at the Field Mapping step.

Both symptoms trace to the same gap: the connector never read _thumbnail_id at all — there were zero references to it in the codebase. getAllAssets downloads every attachment item in the export, so the image file did arrive; but entries only referenced assets found inside the Gutenberg blocks of content:encoded, and a featured image lives outside the post body. The result was an uploaded, orphaned asset and a content type with nowhere to put it.

This is a standalone port of the equivalent work already on feature/wordpress-acf. That branch has a working implementation, but it also rewrites wordpress.service.ts from ~2,670 to 8,000+ lines and bundles in ACF support, a Yoast SEO group, excerpt, and status/created_at lifecycle fields. Only the featured-image slice is taken here, so the fix can ship without that surface area.

Behaviour note: the asset description fallback order changes from description → content → excerpt to alt → description → content → excerpt. Alt is only preferred when non-empty, so the existing fallbacks still apply unchanged for attachments with no alt text.


🧩 Affected Areas

  • api — Node.js backend
  • ui — React frontend
  • upload-api — Upload API server
  • docker / docker-compose
  • CI / GitHub Actions workflows
  • Environment variables / config
  • Other:

🧪 How to Test

  1. Run a WordPress migration using a WXR export where a post carries a _thumbnail_id postmeta and the referenced attachment item is present in the same file. (A "Posts only" WordPress export omits the media items — the thumbnail cannot be resolved in that case, by design.)
  2. At the Field Mapping step, confirm a Featured Image field is listed on the post content type, typed as file.
  3. Complete the migration and open a migrated post entry in the destination stack.

Expected result: the entry's featured_image field holds the migrated asset, and the asset's description carries the image's WordPress alt text when the source had any. A post with no featured image gets no featured_image value, and an export with no featured images anywhere gets no featured_image field at all.


📸 Screenshots / Recordings

Not applicable — no UI changes. The new field renders through the existing Field Mapping table.


🔗 Related PRs / Dependencies

  • Supersedes the need to merge feature/wordpress-acf for featured-image support.
  • Needs a follow-up cherry-pick to main, which is where the reporting customer is running from. dev and main differ by only ~131 lines across the touched files, so it should apply cleanly.

✅ Author Checklist

  • Branch follows naming convention: feature/, bugfix/, or hotfix/ + 5–30 lowercase chars
  • Jira ticket linked above — outstanding, see note at top
  • Self-reviewed the diff — no debug logs, commented-out code, or TODOs left in
  • .env / example.env updated if new environment variables were added — n/a, none added
  • No sensitive credentials or secrets committed
  • Existing tests pass locally — api 632 passed, upload-api 303 passed; both were green before the change too (619 / 297, the deltas being the new tests below). tsc --noEmit error count on api is unchanged at 49 pre-existing errors
  • New tests written — 19 across two new files: extractItems.featuredImage.test.ts (6, covering the field being added, single-object postmeta as xml2js emits it, added once across mixed items, and three negative cases) and wordpress.service.featuredImage.test.ts (13, covering thumbnail resolution, numeric ids, failed-download fallback, and alt-text extraction). Fixtures mirror the real shapes from the reporting export.
  • README.md / docs updated if behaviour changed — not done; flagging below
  • Talisman pre-push scan passes — not run locally

👀 Reviewer Notes

  • Placement of the schema field is gated on hasFeaturedImage && !isAllContentEmpty, matching how the existing terms and author reference fields are gated. Without the isAllContentEmpty guard, a post type whose items all have empty bodies would produce a content type holding only a file field and no title, which would not import.
  • Failed downloads are deliberately left unset rather than written as a uid — assetData only contains attachments that downloaded successfully, and a dangling asset uid would fail the entry import. The helper logs a warning naming the missing assets_<id>.
  • docs/ was not updated. The public WordPress-to-Contentstack doc walks through field mapping; it may want a line about the featured image now appearing. Happy to add that here or separately.
  • One thing worth a second opinion: alt text lands on the asset's description, not as a separate text field on the post content type. That keeps a single source of truth and matches WordPress's own model, and it is what feature/wordpress-acf does — but the customer's wording asked for "featured_image and it's alt on the post content type", which could also be read as wanting a dedicated featured_image_alt field. Easy to add if reviewers prefer that reading.

Migration v2 · Docs · Issues

🤖 Generated with Claude Code

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_<wp:post_id>`, 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.
@umesh-more-cstk
umesh-more-cstk requested a review from a team as a code owner September 21, 2026 06:56
@snyk-io

snyk-io Bot commented Sep 21, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 2 0 10 ✅ Passed
🟠 High Severity 22 399 25 ✅ Passed
🟡 Medium Severity 27 16 500 ✅ Passed
🔵 Low Severity 2 0 1000 ✅ Passed

⏱️ SLA Breach Summary

⚠️ Warning: The following vulnerabilities have exceeded their SLA thresholds (days since publication).

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 5 377 30 / 120 days ❌ Failed / ⚠️ Warning
🟡 Medium 0 0 90 / 365 days ✅ Passed
🔵 Low 0 0 180 / 365 days ✅ Passed

🟠 High Severity - SLA Breached Issues (with fixes)

Showing 5 issue(s) that have exceeded the 30-day SLA threshold:

  1. Interpretation Conflict

    • ID: SNYK-JS-UNDICI-18426065
    • Package: undici@7.28.0
    • Published: 52 days ago (SLA: 30 days)
    • CVSS Score: 8.2
    • CVE: CVE-2026-14643
  2. Information Exposure

    • ID: SNYK-JS-UNDICI-18426521
    • Package: undici@7.28.0
    • Published: 52 days ago (SLA: 30 days)
    • CVSS Score: 8.3
    • CVE: CVE-2026-13697
  3. Infinite loop

    • ID: SNYK-JS-NANOID-18506897
    • Package: nanoid@3.3.16
    • Published: 49 days ago (SLA: 30 days)
    • CVSS Score: 8.2
    • CVE: CVE-2026-67213
  4. Improper Check for Unusual or Exceptional Conditions

    • ID: SNYK-JS-SOCKETIOPARSER-18517008
    • Package: socket.io-parser@4.2.6
    • Published: 47 days ago (SLA: 30 days)
    • CVSS Score: 8.7
    • CVE: CVE-2026-69185
  5. Inefficient Algorithmic Complexity

    • ID: SNYK-JS-JSYAML-18593780
    • Package: js-yaml@4.3.0
    • Published: 43 days ago (SLA: 30 days)
    • CVSS Score: 8.7

ℹ️ Vulnerabilities Without Available Fixes (Informational Only)

The following vulnerabilities were detected but do not have fixes available (no upgrade or patch). These are excluded from failure thresholds:

  • Critical without fixes: 0
  • High without fixes: 399
  • Medium without fixes: 16
  • Low without fixes: 0

❌ BUILD FAILED - Security checks failed

Please review and fix the security vulnerabilities before merging.

@umesh-more-cstk umesh-more-cstk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review of head d6ea3d1. I traced the featured-image path end to end — extractItems → field mapping → buildSchemaTree / convertToSchemaFormatesaveEntry → entry JSON — and the mechanism holds up. 0 blockers · 1 question · 2 nits, all inline.

What checks out

  • The asset lookup is the right contract. saveAsset registers every attachment as assets_<wp:post_id> (wordpress.service.ts:1848) and _thumbnail_id holds exactly that id, so the one-hop resolve is correct. Returning the whole asset object rather than a uid is also right: the existing WordPress file case does formatted = asset (wordpress.service.ts:1082), and Drupal's file case does the same assets_${value} lookup, returns the object, and likewise leaves the field unset on a miss (drupal/entries.service.ts:442-476). The deliberate "no dangling uid" choice in the reviewer notes matches the sibling connector exactly.
  • The schema side lands. contentstackFieldType: 'file' reaches case "file" in content-type-creator.utils.ts:756; contentstackField: 'Featured Image' becomes display_name via content-type-creator.utils.ts:428; and isDeleted: false is harmless since only === true is filtered (line 136). The pushed field object carries the same key set as the canonical title field at extractItems.ts:264.
  • Placement is correct. The new block sits outside the per-item loop alongside the terms / author pushes, so it is emitted once per content type, and the !isAllContentEmpty guard matches its siblings at extractItems.ts:525/535/551. The reviewer note explaining that guard is accurate.
  • wp:postmeta does survive the XML→JSON step — the parser at upload-api/migration-wordpress/utils/helper.ts:16 applies no element whitelist, so _thumbnail_id is genuinely reachable in saveEntry.
  • Scope matches the stated Affected Areas (api + upload-api). No lockfile or generated churn, no secrets, no leftover debug code.

The disclosed behaviour change is real and slightly wider than the feature name suggests: getAttachmentAltText is consulted in saveAsset for every attachment, so alt text now wins over description / content:encoded / excerpt:encoded for all migrated assets, not only featured images. It is documented in the PR body and only applies when alt is non-empty, so I am not filing it as a finding — just flagging it for whoever signs off, since it touches assets that already migrate fine today.

Not verified

I did not run either test suite — node_modules is not installed in this environment. I can neither confirm nor dispute the stated 632 / 303 pass counts; the 19 new tests read as covering the right cases, including the single-object postmeta shape and the failed-download fallback.

Process, not code

The PR's own template flags the Jira ticket as outstanding ("Needs a Jira ticket linked before review"), and docs/ was intentionally left out. Both are the author's call — carrying them forward so they are not lost at merge. The PR is also currently behind dev.


Generated by Claude Code

entryData[uid]['author'] = authorData;
const featuredImage = resolveFeaturedImageAsset(item, assetData);
if (featuredImage) {
entryData[uid]['featured_image'] = featuredImage;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: saveEntry writes featured_image without consulting fields (the content type's fieldMapping), while the schema side only emits the field when hasFeaturedImage && !isAllContentEmpty (upload-api/migration-wordpress/libs/extractItems.ts:580). The two can disagree.

Concrete case: a post type whose items all have empty content:encoded. extractItems then skips the featured_image push (along with title/url), but if any of those items carries _thumbnail_id and the attachment downloaded, this line still puts featured_image on the entry — an entry key with no matching field in the generated content type. The same holds if the field is marked deleted at the Field Mapping step: buildFieldSchema drops isDeleted === true fields (api/src/utils/content-type-creator.utils.ts:136), but nothing filters the entry side.

The author / tags writes on the lines just above have the same shape, so this may well be pre-existing and tolerated by the importer — which is why this is a question rather than a blocker. Worth confirming an orphan key is genuinely ignored on import; if it isn't, gating this on a lookup for a featured_image entry in fields would close it.


Generated by Claude Code

(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) : "");

Copy link
Copy Markdown
Contributor Author

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]" if wp:meta_value ever arrives as an xml2js node rather than a plain string. The parser is configured with attrkey: 'attributes' / charkey: 'text' (upload-api/migration-wordpress/utils/helper.ts:16), so any element carrying an attribute becomes { text, attributes } — which is exactly why saveEntry reads tag?.text and cat?.attributes?.nicename further 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 reads assets_[object Object]. Cheap guard:

Suggested change
return typeof value === "string" ? value : (value != null ? String(value) : "");
if (typeof value === "string") return value;
const raw = value?.text ?? value;
return raw != null ? String(raw) : "";

Generated by Claude Code

"isDeleted": false,
"uid": 'featured_image',
"backupFieldUid": 'featured_image',
"otherCmsField": '_thumbnail_id',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: otherCmsField: '_thumbnail_id' becomes a user-visible label in one path. updateContentType resets a field with contentstackField: field?.otherCmsField (api/src/services/contentMapper.service.ts:1010), and contentstackField is what buildSchemaTree turns into the Contentstack display_name (api/src/utils/content-type-creator.utils.ts:428). A field mapping that goes through that reset therefore produces a field literally named _thumbnail_id in the destination stack instead of "Featured Image".

The sibling fields sidestep this by putting a human label in otherCmsField — the author push at line 551 uses 'Author' rather than dc:creator, and terms uses 'terms'. Using 'Featured Image' here would match them and keep the reset path honest; the _thumbnail_id provenance is already captured in the comment above and in backupFieldUid.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant