Skip to content

fix: render text alignment from rich-text editor end-to-end (#1201)#1396

Open
nanangdev wants to merge 3 commits into
emdash-cms:mainfrom
nanangdev:fix/1201-frontend-text-align
Open

fix: render text alignment from rich-text editor end-to-end (#1201)#1396
nanangdev wants to merge 3 commits into
emdash-cms:mainfrom
nanangdev:fix/1201-frontend-text-align

Conversation

@nanangdev

Copy link
Copy Markdown

What does this PR do?

The bot's earlier 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 carry their own copy of the same logic. That is why @kodster28's June 2 reply said alignment still didn't show on previews or published versions, and why the bot's second attempt was rejected — the converter unit tests were green in isolation, but the admin save path was unfixed.

This PR finishes #1201 end-to-end:

1. Admin save pathpackages/admin/src/components/PortableTextEditor.tsx
The inline prosemirrorToPortableText and portableTextToProsemirror now forward node.attrs?.textAlign for paragraphs and headings, and restore it on the reverse path. The local PortableTextTextBlock interface gains the textAlign?: "left" | "center" | "right" | "justify" field.

2. In-page inline editorpackages/core/src/components/InlinePortableTextEditor.tsx
Same patch shape applied to _pmToPortableText / _portableTextToPM and the local PTTextBlock interface, so visual-editing mode round-trips alignment too.

3. Public Portable Text rendererpackages/core/src/components/Block.astro + index.ts
A new EmDash Block override for astro-portabletext emits a WordPress-style has-text-align-{value} class on the rendered <p> / <h1..h6> / <blockquote> whenever the block carries textAlign. left is the editor default and intentionally does not produce a class — paragraphs/headings without explicit alignment render byte-identically to the previous output. The class allowlist is enforced via Object.hasOwn, so a hostile or hand-edited Portable Text block cannot inject arbitrary class names. The new Block is also exported from emdash/ui for callers composing custom Portable Text components who want to keep the EmDash behaviour.

In all three converters, only center, right, and justify are persisted — left and any other value are omitted, matching the omit-on-default rule the bot used in the core converter pair so existing content stays byte-for-byte unchanged.

Consolidating the three duplicated converter pairs into a single shared module is a follow-up refactor and intentionally out of scope here.

Closes #1201

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (verified via tsgo --noEmit on packages/core and packages/admin after building workspace deps)
  • pnpm lint passes (oxlint clean — 0 diagnostics on changed files)
  • pnpm test passes (targeted: 6 new + 33 existing component/converter tests green; full suite not run locally — see note below)
  • pnpm format has been run (oxfmt + prettier clean on changed files)
  • I have added/updated tests for my changes
  • User-visible strings in the admin UI are wrapped for translation — n/a (no admin UI strings added; only converter logic and a class-name attribute)
  • I have added a changeset (emdash patch — fixed-group bumps @emdash-cms/admin and the rest automatically)
  • New features link to an approved Discussion — n/a (bug fix, not a feature)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7

Screenshots / test output

Local verification on Node 24.7.0, pnpm 11.1.3:

New regression test:

RUN  v4.1.5 packages/core
 Test Files  1 passed (1)
      Tests  6 passed (6)
   Duration  1.34s

Targeted suite — components + converters (covers all three patched converter pairs and the new Block helper):

RUN  v4.1.5 packages/core
 Test Files  8 passed (8)
      Tests  39 passed (39)
   Duration  4.76s

Visual end-to-end on demos/simple after rebuilding packages/admin:

The same post that previously rendered as

<p>p kanan</p>
<p>p tengah</p>
<h2 id="heading-0">h2 kanan</h2>
<h2 id="heading-1">h2 tengah</h2>

now renders as

<p class="has-text-align-right">p kanan</p>
<p class="has-text-align-center">p tengah</p>
<h2 class="has-text-align-right" id="heading-0">h2 kanan</h2>
<h2 class="has-text-align-center" id="heading-1">h2 tengah</h2>

DB inspection (SELECT content FROM ec_posts WHERE ...) confirms the corresponding revision rows now persist textAlign: "center" | "right" on the affected blocks; left-aligned blocks remain free of the field, so existing seed content is unchanged.

Lint:

oxlint -f json --type-aware
total: 0  on touched files: 0

Typecheck:

pnpm --filter ./packages/core typecheck   # tsgo --noEmit, exit 0
pnpm --filter ./packages/admin typecheck  # tsgo --noEmit, exit 0

Note on full suite: I did not run pnpm test across the whole monorepo locally — the suite (3,761 tests in packages/core alone, plus admin/blocks/etc.) hits 5+ minutes in this sandbox and was timing out the runner. The targeted suite I did run includes every test file that touches the patched converters (tests/unit/components/inline-portable-text-*, tests/unit/converters/text-align-round-trip.test.ts, the four other converter round-trip tests, and the new portable-text-text-align-class.test.ts). CI will run the full suite.

Out-of-scope follow-up: the three duplicated prosemirrorToPortableText / portableTextToProsemirror pairs (in packages/core/src/content/converters/, packages/admin/src/components/PortableTextEditor.tsx, and packages/core/src/components/InlinePortableTextEditor.tsx) carry the same logic. This bug recurred precisely because patching one location left the other two stale. Happy to open a separate issue/PR to consolidate them behind a published emdash/converters subpath if the maintainers want to schedule it.

emdashbot Bot and others added 2 commits June 4, 2026 10:36
…ms#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 to 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 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.

Closes emdash-cms#1201
@changeset-bot

changeset-bot Bot commented Jun 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d8b3f7b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR correctly diagnoses and fixes the end-to-end text-alignment issue described in #1201. The root cause — three duplicated ProseMirror ↔ Portable Text converter pairs, only one of which was patched in an earlier attempt — is real, and the approach of patching all three pairs plus adding a frontend Block override is sound and idiomatic for EmDash. The omit-on-default rule for left is consistent across all converters, preserving existing content byte-for-byte. The Object.hasOwn class-name allowlist in textAlignClassName is a correct security choice.

I found one blocking contract issue and one cross-cutting observation:

  1. The new Block component is not exported from emdash/ui. The PR description says it is, but packages/core/src/ui.ts does not re-export it from ./components/index.js. Consumers who want to compose custom Portable Text component sets while keeping EmDash's text-align behaviour have no public import path. ui.ts already exports type Block from astro-portabletext; adding the component Block alongside it is valid TypeScript (value and type exports can share a name).

  2. Blockquote and list-item alignment are not round-tripped. This is a pre-existing limitation, not a regression introduced here. The new Block renderer does emit has-text-align-* on <blockquote>, and TipTap's TextAlign can apply to paragraphs inside list items, but none of the three converter pairs preserve textAlign on paragraphs inside blockquotes or on list-item blocks. Worth a follow-up issue since the visual gap will now be more noticeable.

The core converter round-trip tests and the textAlignClassName unit tests are well written. Lint and typecheck are clean on the changed files (unverified locally due to no node_modules, but the patterns are correct and the author provided passing results).

The PR description claimed the new Block component was exported from
emdash/ui for callers composing custom Portable Text component sets, but
ui.ts only re-exported the existing block-type components and never the
new Block style override. Add it alongside the type-only Block re-export
from astro-portabletext — value and type can share a name under
verbatimModuleSyntax.

Caught by emdashbot review on PR emdash-cms#1396.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jun 9, 2026
@nanangdev

Copy link
Copy Markdown
Author

Thanks for the careful read, @emdashbot.

1. Block export from emdash/ui — fixed in d8b3f7b. You're right, the PR description was ahead of the code. ui.ts now re-exports the Block value alongside the existing type Block re-export from astro-portabletext. They share a name in different namespaces and tsgo --noEmit is clean under verbatimModuleSyntax.

2. Blockquote and list-item alignment. Confirmed pre-existing. The TipTap TextAlign extension in PortableTextEditor.tsx:2151 is configured with types: ["heading", "paragraph"], so the toolbar can never set attrs.textAlign on a bulletList / orderedList / blockquote node directly — it can set it on the inner paragraph, but each converter's case "blockquote" and case "bulletList" | "orderedList" paths drop the inner paragraph's attrs entirely. Forwarding alignment through the wrapper structures changes the persisted block shape (no field for blockquote-level alignment exists in PortableTextTextBlock today, only on the inner paragraph blocks the lists/quotes desugar into) and is a contract decision rather than a mechanical fix, so I've left it for a follow-up issue. Happy to file one and reference back here.

The new Block renderer does already emit has-text-align-* on <blockquote>, so when the converter contract is widened the rendering side won't need a second pass.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Text alignment from rich-text editor toolbar is silently dropped in prosemirrorToPortableText

1 participant