diff --git a/.changeset/fix-text-align-frontend-render.md b/.changeset/fix-text-align-frontend-render.md new file mode 100644 index 000000000..6bd21b683 --- /dev/null +++ b/.changeset/fix-text-align-frontend-render.md @@ -0,0 +1,16 @@ +--- +"emdash": patch +--- + +fix: render text alignment from rich-text editor end-to-end (#1201) + +The previous fix in `text-align-round-trip` patched only the `packages/core/src/content/converters/` pair but the rich-text editor saves through two other ProseMirror ↔ Portable Text converters that each carried their own copy of the same logic — so reporter and maintainer kept seeing alignment dropped on save: + +- `packages/admin/src/components/PortableTextEditor.tsx` (admin save path) +- `packages/core/src/components/InlinePortableTextEditor.tsx` (in-page inline editing) + +Both now forward `node.attrs?.textAlign` for paragraphs and headings (only `center`, `right`, `justify` — `left` is the editor default and is omitted so existing content stays byte-identical) and restore it on the reverse path. Each editor's local `PortableTextTextBlock` interface gained the `textAlign?: "left" | "center" | "right" | "justify"` field, mirroring the type in `packages/core/src/content/converters/types.ts`. + +The Portable Text frontend renderer now emits a WordPress-style `has-text-align-{value}` class on the rendered `

` / `` / `

` whenever the block carries `textAlign`. A new `Block` component is added under `emdash/ui` for callers composing custom Portable Text components who want to keep the EmDash behaviour. The class allowlist is enforced via `Object.hasOwn`, so a hostile or hand-edited Portable Text block cannot inject arbitrary class names. + +Consolidating the three duplicated converter pairs into a single shared module is a follow-up refactor and intentionally out of scope here. diff --git a/.changeset/text-align-round-trip.md b/.changeset/text-align-round-trip.md new file mode 100644 index 000000000..b92336ca9 --- /dev/null +++ b/.changeset/text-align-round-trip.md @@ -0,0 +1,7 @@ +--- +"emdash": patch +--- + +fix(core/converters): persist text alignment from rich-text editor through ProseMirror ↔ Portable Text round-trip (#1201) + +`prosemirrorToPortableText` now reads `node.attrs.textAlign` for paragraphs and headings and forwards it into the Portable Text block. `portableTextToProsemirror` restores it back into ProseMirror node attrs. The `PortableTextTextBlock` type gained an optional `textAlign?: "left" | "center" | "right" | "justify"` field. Left alignment is not persisted (it is TipTap's default and would bloat existing content). diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 1163b4a66..b71d7a83c 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -130,6 +130,7 @@ interface PortableTextTextBlock { level?: number; children: PortableTextSpan[]; markDefs?: PortableTextMarkDef[]; + textAlign?: "left" | "center" | "right" | "justify"; } interface PortableTextImageBlock { @@ -215,12 +216,15 @@ function convertPMNode(node: { case "paragraph": { const { children, markDefs } = convertInlineContent(node.content || []); if (children.length === 0) return null; + const ta = node.attrs?.textAlign; + const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: generateKey(), style: "normal", children, markDefs: markDefs.length > 0 ? markDefs : undefined, + ...(textAlign ? { textAlign } : {}), }; } @@ -233,12 +237,15 @@ function convertPMNode(node: { level >= 1 && level <= 6 ? (`h${level}` as PortableTextTextBlock["style"]) : ("h1" as PortableTextTextBlock["style"]); + const ta = node.attrs?.textAlign; + const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: generateKey(), style: headingStyle, children, markDefs: markDefs.length > 0 ? markDefs : undefined, + ...(textAlign ? { textAlign } : {}), }; } @@ -591,7 +598,7 @@ function convertPTBlock(block: PortableTextBlock): unknown { switch (block._type) { case "block": { if (!isTextBlock(block)) return null; - const { style = "normal", children, markDefs = [] } = block; + const { style = "normal", children, markDefs = [], textAlign } = block; const pmContent = convertPTSpans(children, markDefs); switch (style) { @@ -604,7 +611,7 @@ function convertPTBlock(block: PortableTextBlock): unknown { const level = parseInt(style.substring(1), 10); return { type: "heading", - attrs: { level }, + attrs: { level, ...(textAlign ? { textAlign } : {}) }, content: pmContent.length > 0 ? pmContent : undefined, }; } @@ -621,6 +628,7 @@ function convertPTBlock(block: PortableTextBlock): unknown { default: return { type: "paragraph", + attrs: textAlign ? { textAlign } : undefined, content: pmContent.length > 0 ? pmContent : undefined, }; } diff --git a/packages/core/src/components/Block.astro b/packages/core/src/components/Block.astro new file mode 100644 index 000000000..b6a8f1042 --- /dev/null +++ b/packages/core/src/components/Block.astro @@ -0,0 +1,75 @@ +--- +/** + * EmDash custom block override for `astro-portabletext`. + * + * Renders the same HTML as the upstream Block component (h1..h6, blockquote, + * `

` for `normal`) and additionally surfaces `textAlign` from the rich-text + * editor as a WordPress-style `has-text-align-{value}` class on the rendered + * paragraph or heading. + * + * `left` is the editor default and intentionally does not produce a class — + * paragraphs/headings without explicit alignment render exactly as they did + * before this fix, so existing content is byte-for-byte unchanged. + * + * Related: https://github.com/emdash-cms/emdash/issues/1201 + */ +import type { BlockProps } from "astro-portabletext/types"; +import { usePortableText } from "astro-portabletext/utils"; + +import { textAlignClassName } from "./portable-text-text-align.js"; + +type TextAlignedNode = BlockProps["node"] & { textAlign?: string }; +type Props = Omit & { node: TextAlignedNode }; + +const props = Astro.props as Props; +const { node, index: _index, isInline: _isInline, ...attrs } = props; + +const styleIs = (style: string) => style === node.style; + +const { getUnknownComponent } = usePortableText(node); +const UnknownStyle = getUnknownComponent(); + +// Allowlist-based; attacker-controlled PT data cannot inject arbitrary classes. +// See `./portable-text-text-align.ts`. +const alignClass = textAlignClassName(node.textAlign); +--- + +{ + styleIs("h1") ? ( +

+ +

+ ) : styleIs("h2") ? ( +

+ +

+ ) : styleIs("h3") ? ( +

+ +

+ ) : styleIs("h4") ? ( +

+ +

+ ) : styleIs("h5") ? ( +
+ +
+ ) : styleIs("h6") ? ( +
+ +
+ ) : styleIs("blockquote") ? ( +
+ +
+ ) : styleIs("normal") ? ( +

+ +

+ ) : ( + + + + ) +} diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index f4d9efca0..c818aa507 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -51,6 +51,7 @@ interface PTTextBlock { level?: number; children: PTSpan[]; markDefs?: PTMarkDef[]; + textAlign?: "left" | "center" | "right" | "justify"; } type PTBlock = PTTextBlock | { _type: string; _key: string; [key: string]: unknown }; @@ -119,12 +120,15 @@ function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null { case "paragraph": { const { children, markDefs } = convertInline(node.content || []); if (children.length === 0) return null; + const ta = node.attrs?.textAlign; + const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: k(), style: "normal", children, markDefs: markDefs.length > 0 ? markDefs : undefined, + ...(textAlign ? { textAlign } : {}), }; } case "heading": { @@ -140,12 +144,15 @@ function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null { 6: "h6", }; const headingStyle = headingStyles[level] ?? "h1"; + const ta = node.attrs?.textAlign; + const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; return { _type: "block", _key: k(), style: headingStyle, children, markDefs: markDefs.length > 0 ? markDefs : undefined, + ...(textAlign ? { textAlign } : {}), }; } case "bulletList": @@ -361,7 +368,7 @@ function portableTextToPM(blocks: PTBlock[]): JSONContent { function convertPTBlock(block: PTBlock): JSONContent | null { if (isPTTextBlock(block)) { - const { style = "normal", children, markDefs = [] } = block; + const { style = "normal", children, markDefs = [], textAlign } = block; const pmContent = convertPTSpans(children, markDefs); if (style === "blockquote") { @@ -379,12 +386,13 @@ function convertPTBlock(block: PTBlock): JSONContent | null { const level = parseInt(style.substring(1), 10); return { type: "heading", - attrs: { level }, + attrs: { level, ...(textAlign ? { textAlign } : {}) }, content: pmContent.length > 0 ? pmContent : undefined, }; } return { type: "paragraph", + attrs: textAlign ? { textAlign } : undefined, content: pmContent.length > 0 ? pmContent : undefined, }; } diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 58445176e..e65b5fe9e 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -36,6 +36,7 @@ export { default as EmDashImage } from "./EmDashImage.astro"; export { default as EmDashMedia } from "./EmDashMedia.astro"; // Portable Text block type components +export { default as Block } from "./Block.astro"; export { default as Image } from "./Image.astro"; export { default as Code } from "./Code.astro"; export { default as Embed } from "./Embed.astro"; @@ -57,6 +58,7 @@ export { default as Underline } from "./marks/Underline.astro"; export { default as StrikeThrough } from "./marks/StrikeThrough.astro"; export { default as Link } from "./marks/Link.astro"; +import BlockComponent from "./Block.astro"; import BreakComponent from "./Break.astro"; import ButtonComponent from "./Button.astro"; import ButtonsComponent from "./Buttons.astro"; @@ -77,11 +79,14 @@ import TableComponent from "./Table.astro"; * Pre-configured components for EmDash Portable Text content * * Includes renderers for: + * - Block styles: paragraph, h1..h6, blockquote — with `textAlign` honoured + * as a WordPress-style `has-text-align-{value}` class (#1201) * - Block types: image, code, embed, gallery, columns, break, htmlBlock, table, * button, buttons, cover, file, pullquote * - Marks: superscript, subscript, underline, strike-through, link */ export const emdashComponents = { + block: BlockComponent, type: { image: ImageComponent, code: CodeComponent, diff --git a/packages/core/src/components/portable-text-text-align.ts b/packages/core/src/components/portable-text-text-align.ts new file mode 100644 index 000000000..42164989b --- /dev/null +++ b/packages/core/src/components/portable-text-text-align.ts @@ -0,0 +1,38 @@ +/** + * Maps a Portable Text block's `textAlign` to a WordPress-style class name + * (`has-text-align-{value}`) for public rendering. Used by the Portable Text + * `Block` override in `./Block.astro`. + * + * `left` is the document default and intentionally does not produce a class — + * this matches the converter's omit-on-default rule (see + * `src/content/converters/prosemirror-to-portable-text.ts`) so untouched + * content stays bytewise identical and renders without an extra class. + * + * Related: https://github.com/emdash-cms/emdash/issues/1201 + */ + +type AlignClassName = "has-text-align-center" | "has-text-align-right" | "has-text-align-justify"; + +const ALIGN_CLASS_MAP: Record = { + center: "has-text-align-center", + right: "has-text-align-right", + justify: "has-text-align-justify", +}; + +/** + * Returns the CSS class for a textAlign value, or `undefined` when no class + * should be emitted (default left, missing, or unknown values). + * + * Allowlist-only by design: arbitrary strings are rejected so a hand-edited + * or imported Portable Text block cannot inject attacker-controlled class + * names into the rendered HTML. + */ +export function textAlignClassName(value: string | undefined): AlignClassName | undefined { + if (value === undefined) return undefined; + // `Object.hasOwn` (not `in`) so prototype keys like "toString" or + // "constructor" can't slip through as valid alignments. + if (Object.hasOwn(ALIGN_CLASS_MAP, value)) { + return ALIGN_CLASS_MAP[value]; + } + return undefined; +} 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..ac1338ea8 100644 --- a/packages/core/src/content/converters/portable-text-to-prosemirror.ts +++ b/packages/core/src/content/converters/portable-text-to-prosemirror.ts @@ -164,7 +164,10 @@ function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null const level = parseInt(style.substring(1), 10); return { type: "heading", - attrs: { level }, + attrs: { + level, + ...(block.textAlign ? { textAlign: block.textAlign } : {}), + }, content: content.length > 0 ? content : undefined, }; } @@ -184,6 +187,7 @@ function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null default: return { type: "paragraph", + attrs: block.textAlign ? { textAlign: block.textAlign } : undefined, content: content.length > 0 ? content : undefined, }; } 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..e7522bf06 100644 --- a/packages/core/src/content/converters/prosemirror-to-portable-text.ts +++ b/packages/core/src/content/converters/prosemirror-to-portable-text.ts @@ -106,12 +106,16 @@ function convertParagraph(node: ProseMirrorNode): PortableTextTextBlock | null { return null; } + const ta = node.attrs?.textAlign; + const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; + return { _type: "block", _key: generateKey(), style: "normal", children, markDefs: markDefs.length > 0 ? markDefs : undefined, + ...(textAlign ? { textAlign } : {}), }; } @@ -147,12 +151,16 @@ function convertHeading(node: ProseMirrorNode): PortableTextTextBlock | null { return null; } + const ta = node.attrs?.textAlign; + const textAlign = ta === "center" || ta === "right" || ta === "justify" ? ta : undefined; + return { _type: "block", _key: generateKey(), style, children, markDefs: markDefs.length > 0 ? markDefs : undefined, + ...(textAlign ? { textAlign } : {}), }; } diff --git a/packages/core/src/content/converters/types.ts b/packages/core/src/content/converters/types.ts index 9ff32abdf..fd25ed432 100644 --- a/packages/core/src/content/converters/types.ts +++ b/packages/core/src/content/converters/types.ts @@ -43,6 +43,7 @@ export interface PortableTextTextBlock { level?: number; children: PortableTextSpan[]; markDefs?: PortableTextMarkDef[]; + textAlign?: "left" | "center" | "right" | "justify"; } /** diff --git a/packages/core/src/ui.ts b/packages/core/src/ui.ts index c24d5ed89..045215eb5 100644 --- a/packages/core/src/ui.ts +++ b/packages/core/src/ui.ts @@ -47,6 +47,11 @@ export { EmDashImage as Image, // Main component (wrapper with EmDash defaults) PortableText, + // Block style override (paragraph/heading/blockquote — emits + // `has-text-align-*` class when the block carries `textAlign`). + // Shares the name with the `type Block` re-export above; the + // type and the component live in different namespaces. + Block, // Comment components Comments, CommentForm, diff --git a/packages/core/tests/unit/components/portable-text-text-align-class.test.ts b/packages/core/tests/unit/components/portable-text-text-align-class.test.ts new file mode 100644 index 000000000..c14bb06ae --- /dev/null +++ b/packages/core/tests/unit/components/portable-text-text-align-class.test.ts @@ -0,0 +1,45 @@ +/** + * Frontend rendering for #1201: text alignment from the rich-text editor + * must surface as a CSS class on the rendered paragraph/heading so the + * public site reflects what the editor showed. + * + * Convention: WordPress-style `has-text-align-{value}` so existing themes + * (especially WordPress imports) get matching styles for free. + * + * `left` is the document default and intentionally omits the class — keeps + * older Portable Text bytewise unchanged when no alignment was ever set, + * matching the converter's own omit-on-default rule from the bot fix. + */ +import { describe, expect, it } from "vitest"; + +import { textAlignClassName } from "../../../src/components/portable-text-text-align.js"; + +describe("textAlignClassName", () => { + it("returns has-text-align-center for center alignment", () => { + expect(textAlignClassName("center")).toBe("has-text-align-center"); + }); + + it("returns has-text-align-right for right alignment", () => { + expect(textAlignClassName("right")).toBe("has-text-align-right"); + }); + + it("returns has-text-align-justify for justify alignment", () => { + expect(textAlignClassName("justify")).toBe("has-text-align-justify"); + }); + + it("returns undefined for left alignment (default; no class needed)", () => { + expect(textAlignClassName("left")).toBeUndefined(); + }); + + it("returns undefined when textAlign is missing", () => { + expect(textAlignClassName(undefined)).toBeUndefined(); + }); + + it("returns undefined for unknown values to avoid emitting attacker-controlled classes", () => { + // Defence against PT blocks edited by hand or imported from a hostile source. + // Only the four documented values are class-bearing. + expect(textAlignClassName("inherit" as never)).toBeUndefined(); + expect(textAlignClassName("" as never)).toBeUndefined(); + expect(textAlignClassName("center; color:red" as never)).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/unit/converters/text-align-round-trip.test.ts b/packages/core/tests/unit/converters/text-align-round-trip.test.ts new file mode 100644 index 000000000..a93b45438 --- /dev/null +++ b/packages/core/tests/unit/converters/text-align-round-trip.test.ts @@ -0,0 +1,124 @@ +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 { PortableTextTextBlock } from "../../../src/content/converters/types.js"; + +describe("text-align round-trip (core converters)", () => { + it("preserves center alignment on paragraphs through PM → PT → PM", () => { + const pmDoc = { + type: "doc" as const, + content: [ + { + type: "paragraph", + attrs: { textAlign: "center" }, + content: [{ type: "text", text: "Centered text" }], + }, + ], + }; + + // PM → PT + const pt = prosemirrorToPortableText(pmDoc); + expect(pt).toHaveLength(1); + const block = pt[0] as PortableTextTextBlock; + expect(block._type).toBe("block"); + expect(block.style).toBe("normal"); + expect(block.textAlign).toBe("center"); + expect(block.children[0]?.text).toBe("Centered text"); + + // PT → PM + const restored = portableTextToProsemirror(pt); + expect(restored.content[0]?.type).toBe("paragraph"); + expect(restored.content[0]?.attrs?.textAlign).toBe("center"); + }); + + it("preserves right alignment on headings through PM → PT → PM", () => { + const pmDoc = { + type: "doc" as const, + content: [ + { + type: "heading", + attrs: { level: 2, textAlign: "right" }, + content: [{ type: "text", text: "Right heading" }], + }, + ], + }; + + const pt = prosemirrorToPortableText(pmDoc); + expect(pt).toHaveLength(1); + const block = pt[0] as PortableTextTextBlock; + expect(block._type).toBe("block"); + expect(block.style).toBe("h2"); + expect(block.textAlign).toBe("right"); + + const restored = portableTextToProsemirror(pt); + expect(restored.content[0]?.type).toBe("heading"); + expect(restored.content[0]?.attrs?.level).toBe(2); + expect(restored.content[0]?.attrs?.textAlign).toBe("right"); + }); + + it("does not persist 'left' alignment (TipTap default)", () => { + const pmDoc = { + type: "doc" as const, + content: [ + { + type: "paragraph", + attrs: { textAlign: "left" }, + content: [{ type: "text", text: "Left text" }], + }, + ], + }; + + const pt = prosemirrorToPortableText(pmDoc); + const block = pt[0] as PortableTextTextBlock; + expect(block.textAlign).toBeUndefined(); + }); + + it("preserves justify alignment", () => { + const pmDoc = { + type: "doc" as const, + content: [ + { + type: "paragraph", + attrs: { textAlign: "justify" }, + content: [{ type: "text", text: "Justified text" }], + }, + ], + }; + + const pt = prosemirrorToPortableText(pmDoc); + expect((pt[0] as PortableTextTextBlock).textAlign).toBe("justify"); + const restored = portableTextToProsemirror(pt); + expect(restored.content[0]?.attrs?.textAlign).toBe("justify"); + }); + + it("round-trips mixed alignment blocks", () => { + const pmDoc = { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Default" }], + }, + { + type: "heading", + attrs: { level: 1, textAlign: "center" }, + content: [{ type: "text", text: "Center title" }], + }, + { + type: "paragraph", + attrs: { textAlign: "right" }, + content: [{ type: "text", text: "Right paragraph" }], + }, + ], + }; + + const pt = prosemirrorToPortableText(pmDoc); + const restored = portableTextToProsemirror(pt); + + expect(restored.content).toHaveLength(3); + expect(restored.content[0]?.attrs?.textAlign).toBeUndefined(); + expect(restored.content[1]?.attrs?.textAlign).toBe("center"); + expect(restored.content[2]?.attrs?.textAlign).toBe("right"); + }); +});