From 551d5d5c870aaa3d59c6a90d109ed2a60517ff69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Tue, 12 May 2026 16:39:29 +0200 Subject: [PATCH 01/12] docs: add replaceFileUrls design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-12-replace-file-urls-design.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-replace-file-urls-design.md diff --git a/docs/superpowers/specs/2026-05-12-replace-file-urls-design.md b/docs/superpowers/specs/2026-05-12-replace-file-urls-design.md new file mode 100644 index 00000000..48e46b5f --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-replace-file-urls-design.md @@ -0,0 +1,121 @@ +# Replace file URLs in CMS entries (`replaceFileUrls`) + +**Date:** 2026-05-12 +**Status:** Approved + +## Problem + +When transferring data between two Webiny environments (e.g. prod → dev), file URLs embedded in CMS entries remain pointing at the source environment. These appear in two field types: + +- **`file` fields** — store a plain string URL, e.g. `"https://d2dwjqx9moweeo.cloudfront.net/files/abc123/image.png"` +- **`rich-text` fields** — store a JSON object with a `state` key (stringified lexical JSON) and an `html` key (HTML string); the URL appears verbatim in both the `src` attribute of `wby-image` nodes and in the rendered HTML + +## Solution + +Add an optional `fileUrls` field to `MigrationConfig`. Export a `replaceFileUrls(config)` transformer from the public API. If `config.fileUrls` is absent the transformer is a noop. If present, it walks all CMS entry fields via `visitFields()` and replaces every occurrence of the source URL with the target URL. + +## Design + +### Config schema — `shared.schema.ts` + +Add an optional top-level field to the shared config schema: + +```typescript +fileUrls: z.object({ + source: z.string().min(1), + target: z.string().min(1), +}).optional() +``` + +Users read from `.env` in their `config.ts`: + +```typescript +createConfig({ + fileUrls: { + source: fromEnv("SOURCE_FILE_URL"), + target: fromEnv("TARGET_FILE_URL"), + }, + // ... +}) +``` + +Both `source` and `target` must be non-empty strings when the field is present. Validation is Zod's — invalid values throw at `createConfig` time before any transfer runs. + +### Transformer — `src/transformers/cms/replaceFileUrls.ts` + +```typescript +export const replaceFileUrls = (config: MigrationConfig.Interface): Transformer.Interface => { + if (!config.fileUrls) { + return createDdbTransformer("replaceFileUrls", () => { /* noop */ }); + } + + const { source, target } = config.fileUrls; + + return createDdbTransformer("replaceFileUrls", async ctx => { + const data = ctx.record.data; + if (!data?.modelId || !data?.values) { + return; + } + const model = ctx.modelProvider.getModel(data.modelId); + if (!model) { + return; + } + + await visitFields(data.values, model.fields, (values, field, value) => { + if (field.type === "file" && typeof value === "string") { + values[field.storageId] = value.replaceAll(source, target); + return; + } + + if (field.type === "rich-text" && value && typeof value === "object") { + const rt = value as { state?: string; html?: string }; + if (typeof rt.state === "string") { + rt.state = rt.state.replaceAll(source, target); + } + if (typeof rt.html === "string") { + rt.html = rt.html.replaceAll(source, target); + } + } + }); + }); +}; +``` + +**Field handling:** + +- `file` — value is a plain string; `replaceAll` returns the replaced string, assigned back to `values[field.storageId]`. +- `rich-text` — value is an object `{ state: string, html: string }`. The URL appears verbatim in both. `replaceAll` on the strings in-place (mutating the object properties directly, since `visitFields` passes values by reference). No lexical tree parsing needed — the URL is a literal substring in both representations. +- All other field types — skipped. + +`visitFields` handles nested objects and dynamic zones recursively, so URLs embedded inside `object` fields or dynamic zone templates are reached automatically. + +### Public API — `src/index.ts` + +```typescript +export { replaceFileUrls } from "~/transformers/cms/replaceFileUrls.ts"; +``` + +### Preset usage + +In a preset, after `wrapInData` has run (so `record.data.values` is present): + +```typescript +builder.use(replaceFileUrls(config)) +``` + +If `config.fileUrls` is absent the call is harmless — the noop transformer adds no overhead. Users who never set `SOURCE_FILE_URL` / `TARGET_FILE_URL` see no change in behavior. + +## Files changed + +| File | Change | +|---|---| +| `src/features/MigrationConfig/schemas/shared.schema.ts` | Add optional `fileUrls` to config schema | +| `src/transformers/cms/replaceFileUrls.ts` | New transformer | +| `src/index.ts` | Export `replaceFileUrls` | +| `src/presets/v5-to-v6-ddb.ts` | Add `.use(replaceFileUrls(config))` to CMS entry pipelines | + +## Trade-offs + +- **`replaceAll` vs lexical tree walk** — `replaceAll` on the raw strings is simpler and change-resilient. A node-walk would be precise but fragile to lexical format evolution. Since the URL appears verbatim in both `state` and `html`, string replacement is correct and safe. +- **Source URL as prefix vs exact match** — `replaceAll` replaces every occurrence anywhere in the string. If the source URL appears as a substring inside a longer URL, it will still be replaced. This is the correct behavior for the CDN domain replacement case (`https://d2dwjqx9moweeo.cloudfront.net` → `https://d2new.cloudfront.net`). +- **Noop path** — Returning a named transformer even in the noop case keeps the transformer list consistent across runs (name appears in logs regardless of whether it does work). A zero-cost alternative would be to not register it at all, but that would require conditional `.use()` calls at the call site. From d2503128d3b05871e10798d694b3a916d1e64f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 09:00:19 +0200 Subject: [PATCH 02/12] docs: add replaceFileUrls implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-13-replace-file-urls.md | 574 ++++++++++++++++++ 1 file changed, 574 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-replace-file-urls.md diff --git a/docs/superpowers/plans/2026-05-13-replace-file-urls.md b/docs/superpowers/plans/2026-05-13-replace-file-urls.md new file mode 100644 index 00000000..8cc265ca --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-replace-file-urls.md @@ -0,0 +1,574 @@ +# Replace File URLs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an optional `fileUrls` config field and a `replaceFileUrls(config)` transformer that replaces source CDN URLs with target CDN URLs in all `file` and `rich-text` CMS entry fields. + +**Architecture:** The Zod schema gains an optional `fileUrls: { source, target }` top-level field. A new `replaceFileUrls(config)` factory returns a noop transformer when `config.fileUrls` is absent, or a transformer that walks every CMS entry field via `visitFields()` and replaces the source URL string with the target URL string. The transformer is exported from the public API and wired into the `cmsEntries` pipeline in `v5-to-v6-ddb`. + +**Tech Stack:** TypeScript, Zod, `visitFields` field walker, `ctx.compressionHandler` for rich-text decompression/recompression. + +--- + +## File map + +| Action | File | +|--------|------| +| Modify | `src/features/MigrationConfig/schemas/unified.schema.ts` | +| Create | `src/transformers/cms/replaceFileUrls.ts` | +| Create | `__tests__/transformers/cms/replaceFileUrls.test.ts` | +| Modify | `src/index.ts` | +| Modify | `src/presets/v5-to-v6-ddb.ts` | + +--- + +## Task 1: Add `fileUrls` to the unified config schema + +**Files:** +- Modify: `src/features/MigrationConfig/schemas/unified.schema.ts` + +- [ ] **Step 1: Add the `fileUrls` field to `unifiedTransferInputSchema`** + +In `src/features/MigrationConfig/schemas/unified.schema.ts`, add the optional field to the schema object. The field must come after `debug` to keep the schema alphabetically grouped with other optional top-level keys: + +```typescript +export const unifiedTransferInputSchema = z + .object({ + source: sourceSchema, + target: targetSchema, + pipeline: pipelineSettingsSchema, + tuning: tuningSchema, + debug: debugSettingsSchema, + fileUrls: z + .object({ + source: trimmedString(), + target: trimmedString() + }) + .optional() + }) + .superRefine(/* unchanged */); +``` + +`trimmedString()` is already imported from `./shared.schema.ts` — no new import needed. + +- [ ] **Step 2: Run type-check to confirm no breakage** + +```bash +yarn ts-check +``` + +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/features/MigrationConfig/schemas/unified.schema.ts +git commit -m "feat: add fileUrls to unified config schema" +``` + +--- + +## Task 2: Write the `replaceFileUrls` transformer + +**Files:** +- Create: `src/transformers/cms/replaceFileUrls.ts` +- Create: `__tests__/transformers/cms/replaceFileUrls.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `__tests__/transformers/cms/replaceFileUrls.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { replaceFileUrls } from "~/transformers/cms/replaceFileUrls.ts"; +import { makeFakeBaseContext } from "../fakeContext.ts"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.ts"; + +const SOURCE = "https://old.cdn.com"; +const TARGET = "https://new.cdn.com"; + +function makeConfig(fileUrls?: { source: string; target: string }): MigrationConfig.Interface { + return { fileUrls } as unknown as MigrationConfig.Interface; +} + +function makeCompressionHandler() { + return { + compress: async (data: unknown) => ({ + compression: "gzip", + value: JSON.stringify(data) + }), + decompress: async (compressed: unknown) => + JSON.parse((compressed as { value: string }).value) + }; +} + +function makeModelProvider(fields: { id: string; fieldId: string; storageId: string; type: string; multipleValues?: boolean; settings?: unknown }[]) { + return { + getModel(_modelId: string) { + return { modelId: "test", fields }; + } + }; +} + +describe("replaceFileUrls", () => { + it("is a noop when config.fileUrls is absent", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "page", + values: { "file@hero": `${SOURCE}/image.png` } + } + }, + { modelProvider: makeModelProvider([{ id: "hero", fieldId: "hero", storageId: "file@hero", type: "file" }]) } + ); + + await replaceFileUrls(makeConfig())(ctx); + + const values = (ctx.record.data as Record).values as Record; + expect(values["file@hero"]).toBe(`${SOURCE}/image.png`); + }); + + it("replaces URL in a single file field", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "page", + values: { "file@hero": `${SOURCE}/files/abc/photo.png` } + } + }, + { modelProvider: makeModelProvider([{ id: "hero", fieldId: "hero", storageId: "file@hero", type: "file" }]) } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record; + expect(values["file@hero"]).toBe(`${TARGET}/files/abc/photo.png`); + }); + + it("replaces URLs in a multi-value file field (array of strings)", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "gallery", + values: { + "file@images": [ + `${SOURCE}/files/a/img1.png`, + `${SOURCE}/files/b/img2.png` + ] + } + } + }, + { + modelProvider: makeModelProvider([ + { id: "images", fieldId: "images", storageId: "file@images", type: "file", multipleValues: true } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record; + expect(values["file@images"]).toEqual([ + `${TARGET}/files/a/img1.png`, + `${TARGET}/files/b/img2.png` + ]); + }); + + it("replaces URL inside compressed rich-text (state + html)", async () => { + const compressionHandler = makeCompressionHandler(); + const rawRichText = { + state: `{"root":{"children":[{"src":"${SOURCE}/files/abc/img.png","type":"wby-image"}]}}`, + html: `` + }; + const compressed = await compressionHandler.compress(rawRichText); + + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "article", + values: { "rich-text@body": compressed } + } + }, + { + modelProvider: makeModelProvider([ + { id: "body", fieldId: "body", storageId: "rich-text@body", type: "rich-text" } + ]), + compressionHandler + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record; + const decompressed = await compressionHandler.decompress(values["rich-text@body"]) as { state: string; html: string }; + expect(decompressed.state).toContain(`${TARGET}/files/abc/img.png`); + expect(decompressed.html).toContain(`${TARGET}/files/abc/img.png`); + expect(decompressed.state).not.toContain(SOURCE); + expect(decompressed.html).not.toContain(SOURCE); + }); + + it("replaces URL in raw (uncompressed) rich-text { state, html }", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "article", + values: { + "rich-text@body": { + state: `{"src":"${SOURCE}/img.png"}`, + html: `` + } + } + } + }, + { + modelProvider: makeModelProvider([ + { id: "body", fieldId: "body", storageId: "rich-text@body", type: "rich-text" } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record; + const rt = values["rich-text@body"] as { state: string; html: string }; + expect(rt.state).toBe(`{"src":"${TARGET}/img.png"}`); + expect(rt.html).toBe(``); + }); + + it("does not modify fields of other types", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "page", + values: { "text@title": `${SOURCE}/some/path` } + } + }, + { modelProvider: makeModelProvider([{ id: "title", fieldId: "title", storageId: "text@title", type: "text" }]) } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record; + expect(values["text@title"]).toBe(`${SOURCE}/some/path`); + }); + + it("replaces URL in a file field nested inside an object field", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "hero", + values: { + "object@block": { + "file@image": `${SOURCE}/files/abc/img.png` + } + } + } + }, + { + modelProvider: makeModelProvider([ + { + id: "block", + fieldId: "block", + storageId: "object@block", + type: "object", + settings: { + fields: [ + { id: "image", fieldId: "image", storageId: "file@image", type: "file" } + ] + } + } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record; + const block = values["object@block"] as Record; + expect(block["file@image"]).toBe(`${TARGET}/files/abc/img.png`); + }); + + it("returns early when record has no data envelope", async () => { + const ctx = makeFakeBaseContext( + { PK: "T#root#CMS#CME#abc", SK: "L", TYPE: "cms.entry.l" }, + { modelProvider: { getModel: () => { throw new Error("should not be called"); } } } + ); + + await expect(replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx)).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +yarn test __tests__/transformers/cms/replaceFileUrls.test.ts +``` + +Expected: FAIL — `Cannot find module '~/transformers/cms/replaceFileUrls.ts'` + +- [ ] **Step 3: Implement `replaceFileUrls`** + +Create `src/transformers/cms/replaceFileUrls.ts`: + +```typescript +import { createTransformer } from "~/transformers/createTransformer.ts"; +import type { BaseTransformContext } from "~/features/TransformContext/abstractions/BaseTransformContext.ts"; +import type { BaseRecord } from "~/domain/transform/types/records.ts"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.ts"; +import { visitFields } from "./fieldVisitor.ts"; + +export function replaceFileUrls( + config: MigrationConfig.Interface +) { + if (!config.fileUrls) { + return createTransformer>( + "replaceFileUrls", + () => {} + ); + } + + const { source, target } = config.fileUrls; + + return createTransformer>( + "replaceFileUrls", + async ctx => { + const data = ctx.record.data as Record | undefined; + if (!data) { + return; + } + + const modelId = data.modelId; + if (!modelId) { + return; + } + + const model = ctx.modelProvider.getModel(modelId as string); + if (!model) { + return; + } + + const values = data.values; + if (!values || typeof values !== "object") { + return; + } + + await visitFields( + values as Record, + model.fields, + async (fieldValues, field, value) => { + if (field.type === "file") { + if (Array.isArray(value)) { + fieldValues[field.storageId] = (value as unknown[]).map(v => + typeof v === "string" ? v.replaceAll(source, target) : v + ); + } else if (typeof value === "string") { + fieldValues[field.storageId] = value.replaceAll(source, target); + } + return; + } + + if (field.type === "rich-text" && value && typeof value === "object") { + if ("compression" in (value as object)) { + const decompressed = (await ctx.compressionHandler.decompress(value)) as { + state?: string; + html?: string; + }; + if (typeof decompressed.state === "string") { + decompressed.state = decompressed.state.replaceAll(source, target); + } + if (typeof decompressed.html === "string") { + decompressed.html = decompressed.html.replaceAll(source, target); + } + fieldValues[field.storageId] = await ctx.compressionHandler.compress(decompressed); + } else { + const rt = value as { state?: string; html?: string }; + if (typeof rt.state === "string") { + rt.state = rt.state.replaceAll(source, target); + } + if (typeof rt.html === "string") { + rt.html = rt.html.replaceAll(source, target); + } + } + } + } + ); + } + ); +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +yarn test __tests__/transformers/cms/replaceFileUrls.test.ts +``` + +Expected: all 8 tests PASS. + +- [ ] **Step 5: Run full suite and type-check** + +```bash +yarn ts-check && yarn test:coverage +``` + +Expected: 0 type errors, all tests green, coverage thresholds met. + +- [ ] **Step 6: Commit** + +```bash +git add src/transformers/cms/replaceFileUrls.ts __tests__/transformers/cms/replaceFileUrls.test.ts +git commit -m "feat: add replaceFileUrls transformer" +``` + +--- + +## Task 3: Export from the public API + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Add the export** + +In `src/index.ts`, find the `copyFileToTarget` export line (currently the only file-manager transformer exported): + +```typescript +export { copyFileToTarget } from "./transformers/file-manager/copyFileToTarget.ts"; +``` + +Add the new export immediately after it: + +```typescript +export { copyFileToTarget } from "./transformers/file-manager/copyFileToTarget.ts"; +export { replaceFileUrls } from "./transformers/cms/replaceFileUrls.ts"; +``` + +- [ ] **Step 2: Run type-check** + +```bash +yarn ts-check +``` + +Expected: 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/index.ts +git commit -m "feat: export replaceFileUrls from public API" +``` + +--- + +## Task 4: Wire into the `v5-to-v6-ddb` preset + +**Files:** +- Modify: `src/presets/v5-to-v6-ddb.ts` + +- [ ] **Step 1: Import `replaceFileUrls`** + +In `src/presets/v5-to-v6-ddb.ts`, add to the existing transformer imports block: + +```typescript +import { + addGsiTenant, + addLiveField, + auditLogTransformers, + cmsEntryTransformers, + createMetadata, + extractImageMetadata, + groupsToRoles, + migrateFileManagerSettings, + migrateMailerSettings, + removeAttributes, + removeLocale, + renameFieldAttributes, + replaceFileUrls, + transformModelGroup, + transformPermissions, + updateFlpIds, + wrapInData +} from "~/transformers/index.ts"; +``` + +- [ ] **Step 2: Add `replaceFileUrls` to the CMS entries pipeline** + +Find the `cmsEntries` pipeline build (currently ends with `.use(addLiveField).build()`): + +```typescript +const cmsEntries = factory + .create({ + name: "CmsEntries", + scanner: DdbScanner, + processors: [DdbProcessor] + }) + .filter(createFilter(isCmsEntry)) + .use(cmsEntryTransformers) + .use(addLiveField) + .use(replaceFileUrls(config)) + .build(); +``` + +- [ ] **Step 3: Export `replaceFileUrls` from the transformers barrel** + +Check `src/transformers/index.ts` — add `replaceFileUrls` to the cms exports if not already there. Find the cms transformer exports and add: + +```typescript +export { replaceFileUrls } from "./cms/replaceFileUrls.ts"; +``` + +- [ ] **Step 4: Run type-check and full test suite** + +```bash +yarn ts-check && yarn test:coverage +``` + +Expected: 0 errors, all tests green. + +- [ ] **Step 5: Run format check** + +```bash +yarn format:fix +``` + +Expected: no changes (or only whitespace — commit any formatting changes). + +- [ ] **Step 6: Commit** + +```bash +git add src/presets/v5-to-v6-ddb.ts src/transformers/index.ts +git commit -m "feat: wire replaceFileUrls into v5-to-v6-ddb CmsEntries pipeline" +``` + +--- + +## Final verification + +- [ ] **Run all checks** + +```bash +yarn format:fix && yarn ts-check && yarn test:coverage && yarn lint && yarn check:imports +``` + +Expected: all clean. From 155e29ff6905204dffe599126876e1566ce59d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 09:55:15 +0200 Subject: [PATCH 03/12] feat: add fileUrls to unified config schema --- src/features/MigrationConfig/schemas/unified.schema.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/features/MigrationConfig/schemas/unified.schema.ts b/src/features/MigrationConfig/schemas/unified.schema.ts index a8d874dd..fc583620 100644 --- a/src/features/MigrationConfig/schemas/unified.schema.ts +++ b/src/features/MigrationConfig/schemas/unified.schema.ts @@ -54,7 +54,13 @@ export const unifiedTransferInputSchema = z target: targetSchema, pipeline: pipelineSettingsSchema, tuning: tuningSchema, - debug: debugSettingsSchema + debug: debugSettingsSchema, + fileUrls: z + .object({ + source: trimmedString(), + target: trimmedString() + }) + .optional() }) .superRefine((data, ctx) => { if (data.source.s3.bucket === data.target.s3.bucket) { From ebf500230dc1aff012eb895ebdb5080c4996dcaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 09:59:36 +0200 Subject: [PATCH 04/12] feat: add replaceFileUrls transformer Co-Authored-By: Claude Sonnet 4.6 --- .../transformers/cms/replaceFileUrls.test.ts | 300 ++++++++++++++++++ src/transformers/cms/replaceFileUrls.ts | 85 +++++ 2 files changed, 385 insertions(+) create mode 100644 __tests__/transformers/cms/replaceFileUrls.test.ts create mode 100644 src/transformers/cms/replaceFileUrls.ts diff --git a/__tests__/transformers/cms/replaceFileUrls.test.ts b/__tests__/transformers/cms/replaceFileUrls.test.ts new file mode 100644 index 00000000..fda48f38 --- /dev/null +++ b/__tests__/transformers/cms/replaceFileUrls.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect } from "vitest"; +import { replaceFileUrls } from "~/transformers/cms/replaceFileUrls.ts"; +import { makeFakeBaseContext } from "../fakeContext.ts"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.ts"; + +const SOURCE = "https://old.cdn.com"; +const TARGET = "https://new.cdn.com"; + +function makeConfig(fileUrls?: { source: string; target: string }): MigrationConfig.Interface { + return { fileUrls } as unknown as MigrationConfig.Interface; +} + +function makeCompressionHandler() { + return { + compress: async (data: unknown) => ({ + compression: "gzip", + value: JSON.stringify(data) + }), + decompress: async (compressed: unknown) => + JSON.parse((compressed as { value: string }).value) + }; +} + +function makeModelProvider( + fields: { + id: string; + fieldId: string; + storageId: string; + type: string; + multipleValues?: boolean; + settings?: unknown; + }[] +) { + return { + getModel(_modelId: string) { + return { modelId: "test", fields }; + } + }; +} + +describe("replaceFileUrls", () => { + it("is a noop when config.fileUrls is absent", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "page", + values: { "file@hero": `${SOURCE}/image.png` } + } + }, + { + modelProvider: makeModelProvider([ + { id: "hero", fieldId: "hero", storageId: "file@hero", type: "file" } + ]) + } + ); + + await replaceFileUrls(makeConfig())(ctx); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + expect(values["file@hero"]).toBe(`${SOURCE}/image.png`); + }); + + it("replaces URL in a single file field", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "page", + values: { "file@hero": `${SOURCE}/files/abc/photo.png` } + } + }, + { + modelProvider: makeModelProvider([ + { id: "hero", fieldId: "hero", storageId: "file@hero", type: "file" } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + expect(values["file@hero"]).toBe(`${TARGET}/files/abc/photo.png`); + }); + + it("replaces URLs in a multi-value file field (array of strings)", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "gallery", + values: { + "file@images": [`${SOURCE}/files/a/img1.png`, `${SOURCE}/files/b/img2.png`] + } + } + }, + { + modelProvider: makeModelProvider([ + { + id: "images", + fieldId: "images", + storageId: "file@images", + type: "file", + multipleValues: true + } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + expect(values["file@images"]).toEqual([ + `${TARGET}/files/a/img1.png`, + `${TARGET}/files/b/img2.png` + ]); + }); + + it("replaces URL inside compressed rich-text (state + html)", async () => { + const compressionHandler = makeCompressionHandler(); + const rawRichText = { + state: `{"root":{"children":[{"src":"${SOURCE}/files/abc/img.png","type":"wby-image"}]}}`, + html: `` + }; + const compressed = await compressionHandler.compress(rawRichText); + + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "article", + values: { "rich-text@body": compressed } + } + }, + { + modelProvider: makeModelProvider([ + { id: "body", fieldId: "body", storageId: "rich-text@body", type: "rich-text" } + ]), + compressionHandler + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + const decompressed = (await compressionHandler.decompress(values["rich-text@body"])) as { + state: string; + html: string; + }; + expect(decompressed.state).toContain(`${TARGET}/files/abc/img.png`); + expect(decompressed.html).toContain(`${TARGET}/files/abc/img.png`); + expect(decompressed.state).not.toContain(SOURCE); + expect(decompressed.html).not.toContain(SOURCE); + }); + + it("replaces URL in raw (uncompressed) rich-text { state, html }", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "article", + values: { + "rich-text@body": { + state: `{"src":"${SOURCE}/img.png"}`, + html: `` + } + } + } + }, + { + modelProvider: makeModelProvider([ + { id: "body", fieldId: "body", storageId: "rich-text@body", type: "rich-text" } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + const rt = values["rich-text@body"] as { state: string; html: string }; + expect(rt.state).toBe(`{"src":"${TARGET}/img.png"}`); + expect(rt.html).toBe(``); + }); + + it("does not modify fields of other types", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "page", + values: { "text@title": `${SOURCE}/some/path` } + } + }, + { + modelProvider: makeModelProvider([ + { id: "title", fieldId: "title", storageId: "text@title", type: "text" } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + expect(values["text@title"]).toBe(`${SOURCE}/some/path`); + }); + + it("replaces URL in a file field nested inside an object field", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { + modelId: "hero", + values: { + "object@block": { + "file@image": `${SOURCE}/files/abc/img.png` + } + } + } + }, + { + modelProvider: makeModelProvider([ + { + id: "block", + fieldId: "block", + storageId: "object@block", + type: "object", + settings: { + fields: [ + { + id: "image", + fieldId: "image", + storageId: "file@image", + type: "file" + } + ] + } + } + ]) + } + ); + + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + const block = values["object@block"] as Record; + expect(block["file@image"]).toBe(`${TARGET}/files/abc/img.png`); + }); + + it("returns early when record has no data envelope", async () => { + const ctx = makeFakeBaseContext( + { PK: "T#root#CMS#CME#abc", SK: "L", TYPE: "cms.entry.l" }, + { + modelProvider: { + getModel: () => { + throw new Error("should not be called"); + } + } + } + ); + + await expect( + replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx) + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/transformers/cms/replaceFileUrls.ts b/src/transformers/cms/replaceFileUrls.ts new file mode 100644 index 00000000..a5291934 --- /dev/null +++ b/src/transformers/cms/replaceFileUrls.ts @@ -0,0 +1,85 @@ +import { createTransformer } from "~/transformers/createTransformer.ts"; +import type { BaseTransformContext } from "~/features/TransformContext/abstractions/BaseTransformContext.ts"; +import type { BaseRecord } from "~/domain/transform/types/records.ts"; +import type { MigrationConfig } from "~/features/MigrationConfig/index.ts"; +import { visitFields } from "./fieldVisitor.ts"; + +export function replaceFileUrls(config: MigrationConfig.Interface) { + if (!config.fileUrls) { + return createTransformer>( + "replaceFileUrls", + () => {} + ); + } + + const { source, target } = config.fileUrls; + + return createTransformer>( + "replaceFileUrls", + async ctx => { + const data = ctx.record.data as Record | undefined; + if (!data) { + return; + } + + const modelId = data.modelId; + if (!modelId) { + return; + } + + const model = ctx.modelProvider.getModel(modelId as string); + if (!model) { + return; + } + + const values = data.values; + if (!values || typeof values !== "object") { + return; + } + + await visitFields( + values as Record, + model.fields, + async (fieldValues, field, value) => { + if (field.type === "file") { + if (Array.isArray(value)) { + fieldValues[field.storageId] = (value as unknown[]).map(v => + typeof v === "string" ? v.replaceAll(source, target) : v + ); + } else if (typeof value === "string") { + fieldValues[field.storageId] = value.replaceAll(source, target); + } + return; + } + + if (field.type === "rich-text" && value && typeof value === "object") { + if ("compression" in (value as object)) { + const decompressed = (await ctx.compressionHandler.decompress( + value + )) as { + state?: string; + html?: string; + }; + if (typeof decompressed.state === "string") { + decompressed.state = decompressed.state.replaceAll(source, target); + } + if (typeof decompressed.html === "string") { + decompressed.html = decompressed.html.replaceAll(source, target); + } + fieldValues[field.storageId] = + await ctx.compressionHandler.compress(decompressed); + } else { + const rt = value as { state?: string; html?: string }; + if (typeof rt.state === "string") { + rt.state = rt.state.replaceAll(source, target); + } + if (typeof rt.html === "string") { + rt.html = rt.html.replaceAll(source, target); + } + } + } + } + ); + } + ); +} From 67cb6823ff2ca0e553473068f7e69b048ba1e9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:05:15 +0200 Subject: [PATCH 05/12] fix: address code review issues in replaceFileUrls Co-Authored-By: Claude Sonnet 4.6 --- .../transformers/cms/replaceFileUrls.test.ts | 61 +++++++++++++++++++ src/transformers/cms/replaceFileUrls.ts | 20 +++--- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/__tests__/transformers/cms/replaceFileUrls.test.ts b/__tests__/transformers/cms/replaceFileUrls.test.ts index fda48f38..f25337ce 100644 --- a/__tests__/transformers/cms/replaceFileUrls.test.ts +++ b/__tests__/transformers/cms/replaceFileUrls.test.ts @@ -297,4 +297,65 @@ describe("replaceFileUrls", () => { replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx) ).resolves.toBeUndefined(); }); + + it("returns early when record.data has no modelId", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { values: { "file@hero": `${SOURCE}/image.png` } } + }, + { + modelProvider: { + getModel: () => { + throw new Error("should not be called"); + } + } + } + ); + + await expect( + replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx) + ).resolves.toBeUndefined(); + }); + + it("returns early when modelProvider does not know the model", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { modelId: "unknown", values: { "file@hero": `${SOURCE}/image.png` } } + }, + { modelProvider: { getModel: (_modelId: string) => null } } + ); + + const values = (ctx.record.data as Record).values as Record< + string, + unknown + >; + await replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx); + expect(values["file@hero"]).toBe(`${SOURCE}/image.png`); + }); + + it("returns early when entry has no values object", async () => { + const ctx = makeFakeBaseContext( + { + PK: "T#root#CMS#CME#abc", + SK: "L", + TYPE: "cms.entry.l", + data: { modelId: "page" } + }, + { + modelProvider: makeModelProvider([ + { id: "hero", fieldId: "hero", storageId: "file@hero", type: "file" } + ]) + } + ); + + await expect( + replaceFileUrls(makeConfig({ source: SOURCE, target: TARGET }))(ctx) + ).resolves.toBeUndefined(); + }); }); diff --git a/src/transformers/cms/replaceFileUrls.ts b/src/transformers/cms/replaceFileUrls.ts index a5291934..563fd013 100644 --- a/src/transformers/cms/replaceFileUrls.ts +++ b/src/transformers/cms/replaceFileUrls.ts @@ -4,6 +4,11 @@ import type { BaseRecord } from "~/domain/transform/types/records.ts"; import type { MigrationConfig } from "~/features/MigrationConfig/index.ts"; import { visitFields } from "./fieldVisitor.ts"; +interface IRichTextBody { + state?: string; + html?: string; +} + export function replaceFileUrls(config: MigrationConfig.Interface) { if (!config.fileUrls) { return createTransformer>( @@ -37,6 +42,8 @@ export function replaceFileUrls(config: MigrationConfig.Interface) { return; } + const { compressionHandler } = ctx; + await visitFields( values as Record, model.fields, @@ -53,13 +60,10 @@ export function replaceFileUrls(config: MigrationConfig.Interface) { } if (field.type === "rich-text" && value && typeof value === "object") { - if ("compression" in (value as object)) { - const decompressed = (await ctx.compressionHandler.decompress( + if ("compression" in (value as object) && "value" in (value as object)) { + const decompressed = (await compressionHandler.decompress( value - )) as { - state?: string; - html?: string; - }; + )) as IRichTextBody; if (typeof decompressed.state === "string") { decompressed.state = decompressed.state.replaceAll(source, target); } @@ -67,9 +71,9 @@ export function replaceFileUrls(config: MigrationConfig.Interface) { decompressed.html = decompressed.html.replaceAll(source, target); } fieldValues[field.storageId] = - await ctx.compressionHandler.compress(decompressed); + await compressionHandler.compress(decompressed); } else { - const rt = value as { state?: string; html?: string }; + const rt = value as IRichTextBody; if (typeof rt.state === "string") { rt.state = rt.state.replaceAll(source, target); } From 46a9815c243b73e358d8ddd2ed82d2f0ec760773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:08:32 +0200 Subject: [PATCH 06/12] fix: collapse noop guard and barrel export for replaceFileUrls Co-Authored-By: Claude Sonnet 4.6 --- src/transformers/cms/index.ts | 1 + src/transformers/cms/replaceFileUrls.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/transformers/cms/index.ts b/src/transformers/cms/index.ts index e00ce471..e8b40de8 100644 --- a/src/transformers/cms/index.ts +++ b/src/transformers/cms/index.ts @@ -2,6 +2,7 @@ export { addLiveField } from "./addLiveField.ts"; export { fixCmePk } from "./fixCmePk.ts"; export { removeFolderRevision } from "./removeFolderRevision.ts"; export { renameFieldAttributes } from "./renameFieldAttributes.ts"; +export { replaceFileUrls } from "./replaceFileUrls.ts"; export { transformModelGroup } from "./transformModelGroup.ts"; export { fixBrokenStorageKeys } from "./fixBrokenStorageKeys.ts"; export { transformRichText } from "./transformRichText.ts"; diff --git a/src/transformers/cms/replaceFileUrls.ts b/src/transformers/cms/replaceFileUrls.ts index 563fd013..3ab01efe 100644 --- a/src/transformers/cms/replaceFileUrls.ts +++ b/src/transformers/cms/replaceFileUrls.ts @@ -10,18 +10,14 @@ interface IRichTextBody { } export function replaceFileUrls(config: MigrationConfig.Interface) { - if (!config.fileUrls) { - return createTransformer>( - "replaceFileUrls", - () => {} - ); - } - - const { source, target } = config.fileUrls; - return createTransformer>( "replaceFileUrls", async ctx => { + if (!config.fileUrls) { + return; + } + const { source, target } = config.fileUrls; + const data = ctx.record.data as Record | undefined; if (!data) { return; @@ -73,6 +69,7 @@ export function replaceFileUrls(config: MigrationConfig.Interface) { fieldValues[field.storageId] = await compressionHandler.compress(decompressed); } else { + // rt is a reference to the value already in fieldValues — mutation propagates without re-assignment const rt = value as IRichTextBody; if (typeof rt.state === "string") { rt.state = rt.state.replaceAll(source, target); From 5a05fa06be0a9c34eb16ce14405a04ef86f799e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:09:31 +0200 Subject: [PATCH 07/12] feat: export replaceFileUrls from public API --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index cc12f255..352be2e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export { createOsTransformer } from "./transformers/createOsTransformer.ts"; // Built-in transformers — ready-made for common patterns in custom presets. export { copyFileToTarget } from "./transformers/file-manager/copyFileToTarget.ts"; +export { replaceFileUrls } from "./transformers/cms/replaceFileUrls.ts"; // Pipeline factories export { createFilter, type Filter } from "./domain/pipeline/Filter.ts"; From a8864018943d3eb1cc63cc6899dc41e8d76cee70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:11:27 +0200 Subject: [PATCH 08/12] feat: wire replaceFileUrls into v5-to-v6-ddb CmsEntries pipeline --- src/presets/v5-to-v6-ddb.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/presets/v5-to-v6-ddb.ts b/src/presets/v5-to-v6-ddb.ts index f4a401bc..eecfb135 100644 --- a/src/presets/v5-to-v6-ddb.ts +++ b/src/presets/v5-to-v6-ddb.ts @@ -34,6 +34,7 @@ import { removeAttributes, removeLocale, renameFieldAttributes, + replaceFileUrls, transformModelGroup, transformPermissions, updateFlpIds, @@ -269,6 +270,7 @@ export default createTransferPreset({ .filter(createFilter(isCmsEntry)) .use(cmsEntryTransformers) .use(addLiveField) + .use(replaceFileUrls(config)) .build(); // ======================================================================== From 2cd5c1af9f81388b5ea13fa7d6fe1345b81da6a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:16:48 +0200 Subject: [PATCH 09/12] fix: extract fileUrlsSchema, add replaceFileUrls to OS preset, remove redundant cast Co-Authored-By: Claude Sonnet 4.6 --- .../MigrationConfig/schemas/unified.schema.ts | 14 ++++++++------ src/presets/v5-to-v6-os.ts | 7 +++++-- src/transformers/cms/replaceFileUrls.ts | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/features/MigrationConfig/schemas/unified.schema.ts b/src/features/MigrationConfig/schemas/unified.schema.ts index fc583620..936ead0a 100644 --- a/src/features/MigrationConfig/schemas/unified.schema.ts +++ b/src/features/MigrationConfig/schemas/unified.schema.ts @@ -33,6 +33,13 @@ const sourceSchema = z.object({ opensearch: opensearchSourceSchema.nullable().optional() }); +const fileUrlsSchema = z + .object({ + source: trimmedString(), + target: trimmedString() + }) + .optional(); + const targetSchema = z.object({ region: trimmedString(), credentials: credentialsOrProviderSchema, @@ -55,12 +62,7 @@ export const unifiedTransferInputSchema = z pipeline: pipelineSettingsSchema, tuning: tuningSchema, debug: debugSettingsSchema, - fileUrls: z - .object({ - source: trimmedString(), - target: trimmedString() - }) - .optional() + fileUrls: fileUrlsSchema }) .superRefine((data, ctx) => { if (data.source.s3.bucket === data.target.s3.bucket) { diff --git a/src/presets/v5-to-v6-os.ts b/src/presets/v5-to-v6-os.ts index 14f1438d..56923f84 100644 --- a/src/presets/v5-to-v6-os.ts +++ b/src/presets/v5-to-v6-os.ts @@ -1,6 +1,7 @@ import { createTransferPreset } from "~/utils/createTransferPreset.ts"; import { OsScanner } from "~/features/OsScanner/index.ts"; import { OsProcessor } from "~/features/OsProcessor/index.ts"; +import { MigrationConfig } from "~/features/MigrationConfig/index.ts"; import { createFilter } from "~/domain/pipeline/Filter.ts"; import { isAcoSearchRecord, @@ -9,12 +10,13 @@ import { isOsBackgroundTask, isOsMailerSettings } from "~/domain/transform/filters.ts"; -import { addLiveField, osCmsEntryTransformers } from "~/transformers/index.ts"; +import { addLiveField, osCmsEntryTransformers, replaceFileUrls } from "~/transformers/index.ts"; export default createTransferPreset({ name: "v5-to-v6-os", description: "Webiny v5 to v6 migration — OpenSearch DDB table.", - configure({ runner, pipelineBuilderFactory: factory }): void { + configure({ runner, pipelineBuilderFactory: factory, container }): void { + const config = container.resolve(MigrationConfig); const acoSearchRecords = factory .create({ name: "AcoSearchRecords", @@ -82,6 +84,7 @@ export default createTransferPreset({ .filter(createFilter(isCmsEntry)) .use(osCmsEntryTransformers) .use(addLiveField) + .use(replaceFileUrls(config)) .build(); // ======================================================================== diff --git a/src/transformers/cms/replaceFileUrls.ts b/src/transformers/cms/replaceFileUrls.ts index 3ab01efe..610f63fd 100644 --- a/src/transformers/cms/replaceFileUrls.ts +++ b/src/transformers/cms/replaceFileUrls.ts @@ -46,7 +46,7 @@ export function replaceFileUrls(config: MigrationConfig.Interface) { async (fieldValues, field, value) => { if (field.type === "file") { if (Array.isArray(value)) { - fieldValues[field.storageId] = (value as unknown[]).map(v => + fieldValues[field.storageId] = value.map(v => typeof v === "string" ? v.replaceAll(source, target) : v ); } else if (typeof value === "string") { From 655798e3ca64f40afb65b750ebd16e9b61fcfaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:30:16 +0200 Subject: [PATCH 10/12] refactor: extract module-level helpers in replaceFileUrls for readability Co-Authored-By: Claude Sonnet 4.6 --- src/transformers/cms/replaceFileUrls.ts | 104 ++++++++++++++++-------- 1 file changed, 69 insertions(+), 35 deletions(-) diff --git a/src/transformers/cms/replaceFileUrls.ts b/src/transformers/cms/replaceFileUrls.ts index 610f63fd..f7cc22d6 100644 --- a/src/transformers/cms/replaceFileUrls.ts +++ b/src/transformers/cms/replaceFileUrls.ts @@ -2,6 +2,7 @@ import { createTransformer } from "~/transformers/createTransformer.ts"; import type { BaseTransformContext } from "~/features/TransformContext/abstractions/BaseTransformContext.ts"; import type { BaseRecord } from "~/domain/transform/types/records.ts"; import type { MigrationConfig } from "~/features/MigrationConfig/index.ts"; +import type { CompressionHandler } from "@webiny/utils/exports/api.js"; import { visitFields } from "./fieldVisitor.ts"; interface IRichTextBody { @@ -9,6 +10,66 @@ interface IRichTextBody { html?: string; } +interface IVisitParams { + fieldValues: Record; + field: { type: string; storageId: string }; + value: unknown; + source: string; + target: string; + compressionHandler: CompressionHandler.Interface; +} + +function replaceUrls(str: string, source: string, target: string): string { + return str.replaceAll(source, target); +} + +function replaceRichTextUrls(rt: IRichTextBody, source: string, target: string): void { + if (typeof rt.state === "string") { + rt.state = replaceUrls(rt.state, source, target); + } + if (typeof rt.html === "string") { + rt.html = replaceUrls(rt.html, source, target); + } +} + +function isCompressedValue(value: object): boolean { + return "compression" in value && "value" in value; +} + +function replaceFileField(params: IVisitParams): void { + const { fieldValues, field, value, source, target } = params; + if (Array.isArray(value)) { + fieldValues[field.storageId] = value.map(v => + typeof v === "string" ? replaceUrls(v, source, target) : v + ); + } else if (typeof value === "string") { + fieldValues[field.storageId] = replaceUrls(value, source, target); + } +} + +async function replaceRichTextField(params: IVisitParams): Promise { + const { fieldValues, field, value, source, target, compressionHandler } = params; + if (!value || typeof value !== "object") { + return; + } + if (isCompressedValue(value as object)) { + const rt = (await compressionHandler.decompress(value)) as IRichTextBody; + replaceRichTextUrls(rt, source, target); + fieldValues[field.storageId] = await compressionHandler.compress(rt); + } else { + // value is a reference already in fieldValues — mutation propagates without re-assignment + replaceRichTextUrls(value as IRichTextBody, source, target); + } +} + +async function visitFieldUrls(params: IVisitParams): Promise { + if (params.field.type === "file") { + replaceFileField(params); + } else if (params.field.type === "rich-text") { + await replaceRichTextField(params); + } +} + export function replaceFileUrls(config: MigrationConfig.Interface) { return createTransformer>( "replaceFileUrls", @@ -44,41 +105,14 @@ export function replaceFileUrls(config: MigrationConfig.Interface) { values as Record, model.fields, async (fieldValues, field, value) => { - if (field.type === "file") { - if (Array.isArray(value)) { - fieldValues[field.storageId] = value.map(v => - typeof v === "string" ? v.replaceAll(source, target) : v - ); - } else if (typeof value === "string") { - fieldValues[field.storageId] = value.replaceAll(source, target); - } - return; - } - - if (field.type === "rich-text" && value && typeof value === "object") { - if ("compression" in (value as object) && "value" in (value as object)) { - const decompressed = (await compressionHandler.decompress( - value - )) as IRichTextBody; - if (typeof decompressed.state === "string") { - decompressed.state = decompressed.state.replaceAll(source, target); - } - if (typeof decompressed.html === "string") { - decompressed.html = decompressed.html.replaceAll(source, target); - } - fieldValues[field.storageId] = - await compressionHandler.compress(decompressed); - } else { - // rt is a reference to the value already in fieldValues — mutation propagates without re-assignment - const rt = value as IRichTextBody; - if (typeof rt.state === "string") { - rt.state = rt.state.replaceAll(source, target); - } - if (typeof rt.html === "string") { - rt.html = rt.html.replaceAll(source, target); - } - } - } + await visitFieldUrls({ + fieldValues, + field, + value, + source, + target, + compressionHandler + }); } ); } From 56e446b99c290ab3d1090c34236ec7dd3782d671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:34:01 +0200 Subject: [PATCH 11/12] fix: guard on both source and target in replaceFileUrls Co-Authored-By: Claude Sonnet 4.6 --- src/transformers/cms/replaceFileUrls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transformers/cms/replaceFileUrls.ts b/src/transformers/cms/replaceFileUrls.ts index f7cc22d6..ba33ac9a 100644 --- a/src/transformers/cms/replaceFileUrls.ts +++ b/src/transformers/cms/replaceFileUrls.ts @@ -74,7 +74,7 @@ export function replaceFileUrls(config: MigrationConfig.Interface) { return createTransformer>( "replaceFileUrls", async ctx => { - if (!config.fileUrls) { + if (!config.fileUrls?.target || !config.fileUrls.source) { return; } const { source, target } = config.fileUrls; From a067d9acbbfa8bee694fcb9a35339f94c5964c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= Date: Wed, 13 May 2026 10:47:10 +0200 Subject: [PATCH 12/12] chore: update dependencies --- package.json | 2 +- yarn.lock | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 4bcb40bc..ea86dffe 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@aws-sdk/lib-dynamodb": "^3.1045.0", "@faker-js/faker": "^10.4.0", "@smithy/util-stream": "^4.6.1", - "@types/jsdom": "^28.0.2", + "@types/jsdom": "^28.0.3", "@types/node": "^24.12.4", "@types/yargs": "^17.0.35", "@vitest/coverage-v8": "4.1.6", diff --git a/yarn.lock b/yarn.lock index e6c9cd9e..ee31c14e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7410,15 +7410,15 @@ __metadata: languageName: node linkType: hard -"@types/jsdom@npm:^28.0.2": - version: 28.0.2 - resolution: "@types/jsdom@npm:28.0.2" +"@types/jsdom@npm:^28.0.3": + version: 28.0.3 + resolution: "@types/jsdom@npm:28.0.3" dependencies: "@types/node": "npm:*" "@types/tough-cookie": "npm:*" parse5: "npm:^8.0.0" undici-types: "npm:^7.21.0" - checksum: 10c0/c2ed63eedf494d714556b7b537e459f1339154489c1dbc7e0996b1fc94f981be590b2fa1c2122db11bf7818c8df9e212bf55a0efd7ce5d16a9751de6cc581220 + checksum: 10c0/08b1cd61ee3e9610676be3c68a782a94667b86a5f73b8a262095d05f84c9e864fc11b25ae53450cd519a0abd46c202906a735bd61aa176257a981964bc5b1166 languageName: node linkType: hard @@ -7935,7 +7935,7 @@ __metadata: "@inquirer/prompts": "npm:^8.4.3" "@opensearch-project/opensearch": "npm:^3.6.0" "@smithy/util-stream": "npm:^4.6.1" - "@types/jsdom": "npm:^28.0.2" + "@types/jsdom": "npm:^28.0.3" "@types/node": "npm:^24.12.4" "@types/yargs": "npm:^17.0.35" "@vitest/coverage-v8": "npm:4.1.6" @@ -9845,9 +9845,9 @@ __metadata: linkType: hard "electron-to-chromium@npm:^1.5.328": - version: 1.5.353 - resolution: "electron-to-chromium@npm:1.5.353" - checksum: 10c0/a5481023e4056d8773b5ccd646906123d82f2821466b26b521b96c9645d0714c5be6a5af792ec8477b904a5fcfe3794bfc45d2acbf8b306f4693842fb85a7cd4 + version: 1.5.354 + resolution: "electron-to-chromium@npm:1.5.354" + checksum: 10c0/0ac7980f5e4973f4f806241ec241394f1e723d40b743de13f64b8a93f7dad32ef459fdee7e760f4865ff06dfc09de5493d81b4a4cb8bf1c1241efebca3cd47f6 languageName: node linkType: hard @@ -12567,9 +12567,9 @@ __metadata: linkType: hard "node-releases@npm:^2.0.36": - version: 2.0.38 - resolution: "node-releases@npm:2.0.38" - checksum: 10c0/db9909234ed750c5b9d0075f83214cd16b76370b54eab50e3554f3ba939ba7ac39f3aca2ddf93471ae8553dbde2ea9354b0ae380c9cff1f8e53b55e414903413 + version: 2.0.44 + resolution: "node-releases@npm:2.0.44" + checksum: 10c0/004337ee9c0c455e81fdfc85cc8a585e89932d28338cd35a4ddba21ef2b68ff20cf06097544e7859c1868579d8529ed230802b127be5357c153e267079e8d851 languageName: node linkType: hard @@ -13557,8 +13557,8 @@ __metadata: linkType: hard "protobufjs@npm:^7.3.0, protobufjs@npm:^7.5.5": - version: 7.5.7 - resolution: "protobufjs@npm:7.5.7" + version: 7.5.8 + resolution: "protobufjs@npm:7.5.8" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" @@ -13572,7 +13572,7 @@ __metadata: "@protobufjs/utf8": "npm:^1.1.1" "@types/node": "npm:>=13.7.0" long: "npm:^5.0.0" - checksum: 10c0/432b30edf06c689ca591372812b57a7cda3d5325311b69369e3bb15afef80ccc5dd021cc7e92d72e55e8e0f5665abf87c32e26601319ab9576707f846777842e + checksum: 10c0/25968259084a634e035f865febd5a31f75bdaee4fda6fba4e14c57019024e7cada859b470eb69350f430fbd0d509c805249b9fe8a6c44a57a0b484a1ef685751 languageName: node linkType: hard @@ -15961,8 +15961,8 @@ __metadata: linkType: hard "ws@npm:^8.18.3": - version: 8.20.0 - resolution: "ws@npm:8.20.0" + version: 8.20.1 + resolution: "ws@npm:8.20.1" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -15971,7 +15971,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 + checksum: 10c0/ce162433218399cdedeb76fd33363d4d86a7d910058d4e3c679dce08cea65d6da6b39f11baa4d7808d024cf46ed88f6a05c17611621aaad8fc5e62edacc30c5d languageName: node linkType: hard