From f6d9d3f4426bb81d5b2bfb18db6e3cdc5b45e1eb Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Sun, 21 Jun 2026 21:58:27 -0500 Subject: [PATCH 1/3] fix: preserve image alignment through editor round-trip and render it (#1404) Image blocks imported from WordPress/Gutenberg carry a horizontal alignment (left/right/center/wide/full), but the Portable Text <-> ProseMirror round-trip dropped it, so re-saving an imported post flattened every aligned image to the default. - core + admin converters now carry `alignment` both directions - ImageNode TipTap schema declares the `alignment` attribute - Image.astro renders `emdash-image--align-*` with matching CSS - round-trip regression tests in core and admin Co-Authored-By: Claude Opus 4.8 --- .changeset/image-alignment-round-trip.md | 6 ++ .../src/components/PortableTextEditor.tsx | 3 + .../admin/src/components/editor/ImageNode.tsx | 4 ++ .../PortableTextEditor.imageAlignment.test.ts | 46 +++++++++++++++ packages/core/src/components/Image.astro | 28 ++++++++- .../portable-text-to-prosemirror.ts | 4 ++ .../prosemirror-to-portable-text.ts | 14 +++++ packages/core/src/content/converters/types.ts | 2 + .../image-alignment-round-trip.test.ts | 59 +++++++++++++++++++ 9 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 .changeset/image-alignment-round-trip.md create mode 100644 packages/admin/tests/components/PortableTextEditor.imageAlignment.test.ts create mode 100644 packages/core/tests/unit/converters/image-alignment-round-trip.test.ts diff --git a/.changeset/image-alignment-round-trip.md b/.changeset/image-alignment-round-trip.md new file mode 100644 index 000000000..b51f73a0f --- /dev/null +++ b/.changeset/image-alignment-round-trip.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Preserve image alignment through the editor and render it on the published page (#1404). Images imported from WordPress/Gutenberg carry a horizontal alignment (`left`, `right`, `center`, `wide`, `full`), but that value was dropped the moment the post was opened in the editor — the Portable Text ↔ ProseMirror round-trip silently discarded it, so re-saving an imported post flattened every aligned image to the default. The alignment now survives the round-trip in both the core and admin converters and the image node schema, and the core `Image.astro` component renders it with matching CSS so aligned images display as authored. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 418d8f79b..a1d8c8a33 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -142,6 +142,7 @@ interface PortableTextImageBlock { height?: number; displayWidth?: number; displayHeight?: number; + alignment?: "left" | "center" | "right" | "wide" | "full"; } interface PortableTextCodeBlock { @@ -312,6 +313,7 @@ function convertPMNode(node: { height: attrNum(attrs.height), displayWidth: attrNum(attrs.displayWidth), displayHeight: attrNum(attrs.displayHeight), + alignment: attrStr(attrs.alignment) as PortableTextImageBlock["alignment"], }; } @@ -654,6 +656,7 @@ function convertPTBlock(block: PortableTextBlock): unknown { height: imageBlock.height, displayWidth: imageBlock.displayWidth, displayHeight: imageBlock.displayHeight, + alignment: imageBlock.alignment, }, }; } diff --git a/packages/admin/src/components/editor/ImageNode.tsx b/packages/admin/src/components/editor/ImageNode.tsx index e90d7fac7..f70a34f0e 100644 --- a/packages/admin/src/components/editor/ImageNode.tsx +++ b/packages/admin/src/components/editor/ImageNode.tsx @@ -325,6 +325,10 @@ export const ImageExtension = Node.create({ displayHeight: { default: null, }, + /** Horizontal alignment (e.g. preserved from a WordPress/Gutenberg import). */ + alignment: { + default: null, + }, }; }, diff --git a/packages/admin/tests/components/PortableTextEditor.imageAlignment.test.ts b/packages/admin/tests/components/PortableTextEditor.imageAlignment.test.ts new file mode 100644 index 000000000..2243f6bd4 --- /dev/null +++ b/packages/admin/tests/components/PortableTextEditor.imageAlignment.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; + +import { + _portableTextToProsemirror, + _prosemirrorToPortableText, +} from "../../src/components/PortableTextEditor"; + +type ImageBlock = { + _type: "image"; + asset: { _ref: string; url?: string }; + alignment?: string; +}; + +function isImageBlock(b: unknown): b is ImageBlock { + return typeof b === "object" && b !== null && (b as { _type?: unknown })._type === "image"; +} + +describe("PortableTextEditor image alignment round-trip", () => { + it("preserves alignment PT → PM → PT (regression for #1404)", () => { + const block = { + _type: "image" as const, + _key: "img1", + asset: { _ref: "media1", url: "/file/media1" }, + alt: "Imported image", + alignment: "wide" as const, + }; + + const pm = _portableTextToProsemirror([block]); + const imageNode = pm.content.find((n) => n.type === "image"); + expect(imageNode?.attrs?.alignment).toBe("wide"); + + const pt = _prosemirrorToPortableText(pm).find(isImageBlock); + expect(pt?.alignment).toBe("wide"); + }); + + it("omits alignment when unset", () => { + const block = { + _type: "image" as const, + _key: "img2", + asset: { _ref: "media2", url: "/file/media2" }, + alt: "Plain image", + }; + const pt = _prosemirrorToPortableText(_portableTextToProsemirror([block])).find(isImageBlock); + expect(pt?.alignment).toBeUndefined(); + }); +}); diff --git a/packages/core/src/components/Image.astro b/packages/core/src/components/Image.astro index 9f2e452f1..e7d96a337 100644 --- a/packages/core/src/components/Image.astro +++ b/packages/core/src/components/Image.astro @@ -38,6 +38,8 @@ export interface Props { displayWidth?: number; /** Display height for this instance (overrides original) */ displayHeight?: number; + /** Horizontal alignment (e.g. captured from a WordPress/Gutenberg import). */ + alignment?: "left" | "center" | "right" | "wide" | "full"; }; } @@ -63,7 +65,7 @@ if (!node?.asset) { return null; } -const { asset, alt = "", caption, width, height, displayWidth, displayHeight } = node; +const { asset, alt = "", caption, width, height, displayWidth, displayHeight, alignment } = node; // Calculate aspect ratio from original dimensions const aspectRatio = width && height ? width / height : undefined; @@ -168,7 +170,7 @@ const baseStyle = aspectRatio const imgStyle = placeholderStyle ? `${baseStyle} ${placeholderStyle}` : baseStyle; --- -
+
diff --git a/packages/core/src/content/converters/portable-text-to-prosemirror.ts b/packages/core/src/content/converters/portable-text-to-prosemirror.ts index b8ad91e2f..25299083b 100644 --- a/packages/core/src/content/converters/portable-text-to-prosemirror.ts +++ b/packages/core/src/content/converters/portable-text-to-prosemirror.ts @@ -410,6 +410,7 @@ function convertImage(block: PortableTextImageBlock): ProseMirrorNode { height: block.height, displayWidth: block.displayWidth, displayHeight: block.displayHeight, + alignment: block.alignment, }, }; } @@ -434,6 +435,8 @@ function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode { "displayHeight" in block && typeof block.displayHeight === "number" ? block.displayHeight : undefined; + const alignment = + "alignment" in block && typeof block.alignment === "string" ? block.alignment : undefined; return { type: "image", attrs: { @@ -446,6 +449,7 @@ function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode { height, displayWidth, displayHeight, + alignment, }, }; } diff --git a/packages/core/src/content/converters/prosemirror-to-portable-text.ts b/packages/core/src/content/converters/prosemirror-to-portable-text.ts index 918cb3d12..930369ce7 100644 --- a/packages/core/src/content/converters/prosemirror-to-portable-text.ts +++ b/packages/core/src/content/converters/prosemirror-to-portable-text.ts @@ -287,6 +287,19 @@ function convertHtmlBlock(node: ProseMirrorNode): PortableTextHtmlBlock { /** * Convert image to Portable Text */ +const VALID_IMAGE_ALIGNMENTS = ["left", "center", "right", "wide", "full"] as const; +type ImageAlignment = (typeof VALID_IMAGE_ALIGNMENTS)[number]; + +function isImageAlignment(value: unknown): value is ImageAlignment { + return typeof value === "string" && (VALID_IMAGE_ALIGNMENTS as readonly string[]).includes(value); +} + +/** Read a valid image alignment off a ProseMirror image node, if present. */ +function extractImageAlignment(node: ProseMirrorNode): ImageAlignment | undefined { + const value = node.attrs?.alignment; + return isImageAlignment(value) ? value : undefined; +} + function convertImage(node: ProseMirrorNode): PortableTextImageBlock { const attrs = node.attrs; const provider = typeof attrs?.provider === "string" ? attrs.provider : undefined; @@ -316,6 +329,7 @@ function convertImage(node: ProseMirrorNode): PortableTextImageBlock { height: height || undefined, displayWidth: displayWidth || undefined, displayHeight: displayHeight || undefined, + alignment: extractImageAlignment(node), }; } diff --git a/packages/core/src/content/converters/types.ts b/packages/core/src/content/converters/types.ts index 9ff32abdf..bfa564a46 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -67,6 +67,8 @@ export interface PortableTextImageBlock { displayWidth?: number; /** Display height for this instance (overrides original) */ displayHeight?: number; + /** Horizontal alignment (e.g. captured from a WordPress/Gutenberg import). */ + alignment?: "left" | "center" | "right" | "wide" | "full"; } /** diff --git a/packages/core/tests/unit/converters/image-alignment-round-trip.test.ts b/packages/core/tests/unit/converters/image-alignment-round-trip.test.ts new file mode 100644 index 000000000..d4eab5c5d --- /dev/null +++ b/packages/core/tests/unit/converters/image-alignment-round-trip.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; + +import { portableTextToProsemirror } from "../../../src/content/converters/portable-text-to-prosemirror.js"; +import { prosemirrorToPortableText } from "../../../src/content/converters/prosemirror-to-portable-text.js"; +import type { + ProseMirrorDocument, + PortableTextImageBlock, +} from "../../../src/content/converters/types.js"; + +describe("Image alignment round-trip (core converters)", () => { + it("preserves image alignment through PT → PM → PT", () => { + const imageBlock: PortableTextImageBlock = { + _type: "image", + _key: "img001", + asset: { _ref: "media123", url: "/file/media123" }, + alt: "An aligned image", + alignment: "wide", + }; + + // PT → PM + const pm = portableTextToProsemirror([imageBlock]); + expect(pm.content[0].type).toBe("image"); + expect(pm.content[0].attrs?.alignment).toBe("wide"); + + // PM → PT + const pt = prosemirrorToPortableText(pm); + const restored = pt[0] as PortableTextImageBlock; + expect(restored._type).toBe("image"); + expect(restored.alignment).toBe("wide"); + }); + + it("preserves each supported alignment value", () => { + for (const alignment of ["left", "center", "right", "wide", "full"] as const) { + const doc: ProseMirrorDocument = { + type: "doc", + content: [{ type: "image", attrs: { src: "/x.jpg", alt: "x", alignment } }], + }; + const pt = prosemirrorToPortableText(doc); + expect((pt[0] as PortableTextImageBlock).alignment).toBe(alignment); + } + }); + + it("omits alignment when unset", () => { + const doc: ProseMirrorDocument = { + type: "doc", + content: [{ type: "image", attrs: { src: "/x.jpg", alt: "x" } }], + }; + const pt = prosemirrorToPortableText(doc); + expect((pt[0] as PortableTextImageBlock).alignment).toBeUndefined(); + + const block: PortableTextImageBlock = { + _type: "image", + _key: "img002", + asset: { _ref: "m", url: "/file/m" }, + }; + const pm = portableTextToProsemirror([block]); + expect(pm.content[0].attrs?.alignment).toBeUndefined(); + }); +}); From 59dba7fa7f32ae7735336fa41bd9f12c7b8bd419 Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Mon, 22 Jun 2026 11:36:37 -0500 Subject: [PATCH 2/3] docs(changeset): rewrite image-alignment notes as user-facing release notes Drop internal mechanics (converters, image node schema, Image.astro) per AGENTS.md changeset guidance, addressing review feedback on #1576. Co-Authored-By: Claude Opus 4.8 --- .changeset/image-alignment-round-trip.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/image-alignment-round-trip.md b/.changeset/image-alignment-round-trip.md index b51f73a0f..9258dfa83 100644 --- a/.changeset/image-alignment-round-trip.md +++ b/.changeset/image-alignment-round-trip.md @@ -3,4 +3,4 @@ "@emdash-cms/admin": patch --- -Preserve image alignment through the editor and render it on the published page (#1404). Images imported from WordPress/Gutenberg carry a horizontal alignment (`left`, `right`, `center`, `wide`, `full`), but that value was dropped the moment the post was opened in the editor — the Portable Text ↔ ProseMirror round-trip silently discarded it, so re-saving an imported post flattened every aligned image to the default. The alignment now survives the round-trip in both the core and admin converters and the image node schema, and the core `Image.astro` component renders it with matching CSS so aligned images display as authored. +Preserve image alignment on posts imported from WordPress/Gutenberg (#1404). Imported images carry a horizontal alignment (`left`, `right`, `center`, `wide`, `full`), but opening an imported post in the editor and re-saving used to flatten every aligned image back to the default. Alignment now survives editing, and aligned images display as authored on the published page. From baa6a060d82c24d55a06833b9873eb69e7c0be17 Mon Sep 17 00:00:00 2001 From: "Marcus (bug-testing)" Date: Tue, 23 Jun 2026 13:26:36 -0500 Subject: [PATCH 3/3] ci: re-trigger CI (flaky api-tokens E2E, unrelated to this change) Co-Authored-By: Claude Opus 4.8