Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/fix-text-align-frontend-render.md
Original file line number Diff line number Diff line change
@@ -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 `<p>` / `<h1..h6>` / `<blockquote>` 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.
7 changes: 7 additions & 0 deletions .changeset/text-align-round-trip.md
Original file line number Diff line number Diff line change
@@ -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).
12 changes: 10 additions & 2 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ interface PortableTextTextBlock {
level?: number;
children: PortableTextSpan[];
markDefs?: PortableTextMarkDef[];
textAlign?: "left" | "center" | "right" | "justify";
}

interface PortableTextImageBlock {
Expand Down Expand Up @@ -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 } : {}),
};
}

Expand All @@ -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 } : {}),
};
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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,
};
}
Expand All @@ -621,6 +628,7 @@ function convertPTBlock(block: PortableTextBlock): unknown {
default:
return {
type: "paragraph",
attrs: textAlign ? { textAlign } : undefined,
content: pmContent.length > 0 ? pmContent : undefined,
};
}
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/components/Block.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
/**
* EmDash custom block override for `astro-portabletext`.
*
* Renders the same HTML as the upstream Block component (h1..h6, blockquote,
* `<p>` 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<BlockProps, "node"> & { 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") ? (
<h1 class={alignClass} {...attrs}>
<slot />
</h1>
) : styleIs("h2") ? (
<h2 class={alignClass} {...attrs}>
<slot />
</h2>
) : styleIs("h3") ? (
<h3 class={alignClass} {...attrs}>
<slot />
</h3>
) : styleIs("h4") ? (
<h4 class={alignClass} {...attrs}>
<slot />
</h4>
) : styleIs("h5") ? (
<h5 class={alignClass} {...attrs}>
<slot />
</h5>
) : styleIs("h6") ? (
<h6 class={alignClass} {...attrs}>
<slot />
</h6>
) : styleIs("blockquote") ? (
<blockquote class={alignClass} {...attrs}>
<slot />
</blockquote>
) : styleIs("normal") ? (
<p class={alignClass} {...attrs}>
<slot />
</p>
) : (
<UnknownStyle {...props}>
<slot />
</UnknownStyle>
)
}
12 changes: 10 additions & 2 deletions packages/core/src/components/InlinePortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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": {
Expand All @@ -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":
Expand Down Expand Up @@ -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") {
Expand All @@ -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,
};
}
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/components/portable-text-text-align.ts
Original file line number Diff line number Diff line change
@@ -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<string, AlignClassName> = {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand All @@ -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,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
};
}

Expand Down Expand Up @@ -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 } : {}),
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/content/converters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface PortableTextTextBlock {
level?: number;
children: PortableTextSpan[];
markDefs?: PortableTextMarkDef[];
textAlign?: "left" | "center" | "right" | "justify";
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading