Skip to content

feat(sdk-angular): accessible semantic block editor renderer#35855

Merged
fmontes merged 19 commits into
mainfrom
angular-blockeditor-a11y
May 28, 2026
Merged

feat(sdk-angular): accessible semantic block editor renderer#35855
fmontes merged 19 commits into
mainfrom
angular-blockeditor-a11y

Conversation

@fmontes
Copy link
Copy Markdown
Member

@fmontes fmontes commented May 27, 2026

What

Adds DotCMSBlockEditorRendererNativeComponent (<dotcms-block-editor-renderer-native>), an accessible successor to the block editor renderer that emits clean semantic HTML — <ul><li><p>…</p></li></ul> — with no custom wrapper elements between semantic tags. The original DotCMSBlockEditorRendererComponent is marked @deprecated with its output unchanged for backward compatibility.

Why

An a11y scanner flagged the existing renderer for excessive nested wrapper elements: a dispatcher element sat between <ul> and its <li> children, breaking the list → listitem relationship required by the HTML spec and assistive technology. This is a published SDK, so rather than change existing output we add a new opt-in component.

How

  • Host elements are the real semantic tags (attribute-selector hosts); recursion goes through ng-template outlets that render as comment nodes, so semantic nesting stays intact.
  • Text + marks are rendered inline as native <strong>/<u>/<a>/… with no wrapper per text node.
  • Identical public input API and customRenderers contract — migration is a one-line swap of tag and import.

Notes

  • README / MIGRATION / CHANGELOG (v1.5.4) updated; both Angular examples (CSR + SSR) migrated, with a self-contained custom-block example.
  • Verified: 206 unit tests pass, lint clean, build emits the new export.
  • Follow-up: grid/table cells still reuse the legacy item component internally (one wrapper inside <td>/grid cells, not an ARIA-strict context).

🤖 Generated with Claude Code

This PR fixes: #35852

fmontes and others added 5 commits May 27, 2026 17:10
Adds DotCMSBlockEditorRendererNativeComponent
(<dotcms-block-editor-renderer-native>), which renders Block Editor
content as clean semantic HTML (<ul><li><p>…</p></li></ul>) with no
custom wrapper elements between semantic tags. Wrapper elements between
<ul> and <li> broke the list → listitem relationship required by the
HTML spec and assistive technology, and were flagged by a11y scanners.

The native renderer makes the host element the real semantic tag
(attribute-selector hosts) and recurses through ng-template outlets that
render as comment nodes, so <li> stays a true DOM child of <ul>. It
exposes the identical public input API and customRenderers contract, so
migration is a one-line swap of the tag and import.

The original DotCMSBlockEditorRendererComponent is marked @deprecated;
its DOM output is unchanged for backward compatibility. README,
MIGRATION, and CHANGELOG (v1.5.4) document the new component and the
deprecation. Both Angular examples (CSR + SSR) updated to use it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ments

The native renderer's TEXT case reused the component-based text renderer,
which emitted a <dotcms-block-editor-renderer-text> host element per text
node and per nested mark. A run with stacked marks produced a chain of
wrapper elements around the actual <strong>/<u>/... tags.

Inline the text + marks rendering into the dispatch template via a
self-recursive #textRun ng-template (rendered as comment nodes), so a
marked run is now exactly <p><u><strong>Few</strong></u></p> with no
wrapper elements. Also binds the link mark's [class] attribute, which the
old text renderer computed but never applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a custom renderer for the built-in bulletList block to both the
angular and angular-ssr blog examples, wired into customRenderers as
`bulletList`. It renders a <ul> with hot-pink oversized bullets and
delegates each item's content back to the native renderer, demonstrating
that customRenderers (overriding a default block type) works with
DotCMSBlockEditorRendererNativeComponent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lock

The bulletList custom renderer had to re-render its children through the
native renderer, which added a wrapper element inside each <li> — the very
nesting this renderer removes — making it a poor example. Replace it with a
custom `blockquote` renderer that renders self-contained markup (a 💡
callout card) from the node's flattened text, with no child re-rendering and
no wrapper elements. This is the natural custom-block use case.

Applied to both the angular and angular-ssr blog examples.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the callout component's inline styles block with Tailwind utility
classes, matching the styling approach used across the blog examples.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels May 27, 2026
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 27, 2026

Claude finished @fmontes's task in 5m 2s —— View job


Review

A few real concerns worth a second look. None are blocking on their own; #1, #3, #4 are the ones I'd most want resolved (or explicitly accepted) before this ships as a published SDK component pitched as the a11y-correct replacement.

1. Reverse-tabnabbing still possible when author supplies rel

dotcms-block-editor-renderer-native.component.html:278-281

[attr.rel]="
    markAttrs(marks)['rel'] ||
    (markAttrs(marks)['target'] === '_blank' ? 'noopener noreferrer' : null)
"

The noopener noreferrer fallback only fires when the author supplies no rel. If the editor stores rel="nofollow" and target="_blank", the rendered <a> gets rel="nofollow" only — no noopener, no noreferrer. That's the exact reverse-tabnabbing case copilot flagged at line 282; the current fix only catches the empty-rel branch. For a published SDK, this should always force noopener (and ideally noreferrer) into the token set whenever target="_blank", regardless of what else the author put in rel. The dispatch spec at line 175-199 (should preserve an author-supplied rel even when target="_blank") actively asserts the wrong behavior — it locks in the bug.

2. <th> rendered without scope, and cellNode.type is ignored

dotcms-block-editor-renderer-native.component.html:187-224

The PR is explicitly framed as the a11y replacement, but the table dispatch produces <th> elements with no scope attribute. AT can't reliably associate body cells with header cells without scope="col" / scope="row". Worth fixing here, since the whole point of this component is a11y-correct semantic DOM.

Related: the loops slice the first row into <thead><th> and the rest into <tbody><td> regardless of cellNode.type. TipTap's table extension distinguishes tableHeader vs tableCell — that distinction is silently discarded. A table with no header row is rendered with a misleading <thead>; a table whose first row is body cells gets <th> anyway. Both behaviors are inherited from the legacy renderer (blocks/table.component.ts), but the migration to a "semantic-DOM successor" is the right moment to dispatch on cellNode.type.

3. Stale validation/edit-mode state — flagged as deferred, but it's a real correctness bug now

dotcms-block-editor-renderer-native.component.ts:78,82-93

$isInEditMode = signal(getUVEState()?.mode === UVE_MODE.EDIT);

ngOnInit() {
    const state = isValidBlocks(this.blocks() as BlockEditorNode);
    ...
    this.$blockEditorState.set(state);
}

Acknowledging this was deferred to a follow-up — but with input() + OnPush now in place, the asymmetry is sharper than in the legacy renderer:

  • blocks is now reactive; CD will re-evaluate the template when the parent rebinds it.
  • $blockEditorState is not reactive; it's frozen after ngOnInit.

Concrete consequence: a parent that does blocks = signal(undefined)blocks.set(asyncDoc) later (the canonical async load pattern with this SDK) renders the new doc through the dispatch, but $blockEditorState still says error: null from the initial undefined run — or, if the initial was an error and the new doc is valid, the error message stays. The legacy renderer had the same shape but with @Input() it at least re-ran on every input change. Deferring is fine, but the deferral note should probably acknowledge this is a regression vs. legacy, not just a missing improvement.

Same story for $isInEditMode — captured once at construction. If getUVEState() returns null at construction (e.g. UVE not yet initialized in SSR/hydration), invalid blocks in production render as nothing with no console error and no DOM trace.

4. <video> has a <track> with no src

dotcms-block-editor-renderer-native.component.html:179

<track default kind="captions" srclang="en" />

<track> requires src. As written this is invalid markup — the track is non-functional and the browser may warn. Parity with legacy, but worth removing rather than carrying forward as part of the "semantic HTML" story.

5. <img alt> falls back to empty when alt is missing

dotcms-block-editor-renderer-native.component.html:165-168

<img
    [alt]="node.attrs?.['alt']"
    [src]="node.attrs?.['src']"
    style="max-width: 100%; height: auto" />

If attrs.alt is missing, Angular renders alt="", which signals "decorative image" to AT and silently swallows the missing-alt failure that scanners would otherwise surface. For an a11y-positioned renderer, a missing alt should arguably either:

  • emit no alt attribute (so scanners flag it), or
  • in edit mode, surface a visible warning via $blockEditorState (matches how invalid blocks are handled).

Parity with legacy, but it's worth a deliberate decision rather than inheritance.

6. Example CalloutComponent (CSR + SSR) is still missing OnPush

examples/angular/src/app/dotcms/pages/blog/blog-post/customRenderers/callout/callout.component.ts:14-24
examples/angular-ssr/src/app/dotcms/pages/blog/blog-post/customRenderers/callout/callout.component.ts:14-24

oidacra raised this twice and I don't see it addressed in the comment thread or commits — neither file has changeDetection: ChangeDetectionStrategy.OnPush and they still use @Input() instead of input(). As the response thread noted, example code is copied as templates by SDK users; the default matters more here than in normal app code. Either fold this in or explicitly say it's deferred.

7. Minor: track node on the outer node list (line 13)

@for (node of content; track node) tracks by object reference. Block Editor content typically comes from immutable JSON parses, so any re-fetch produces new node references → Angular destroys and recreates the entire subtree on every update. Not a correctness bug, and the rendered tree isn't usually huge, but it negates the OnPush optimization in any "doc is refetched" flow. The table loops now track $index; the outer loop could too (or at least track by node.type + index where stable).

8. Pre-block whitespace

dotcms-block-editor-renderer-native.component.html:149-153

<pre dotCodeBlock>
    <ng-container
        *ngTemplateOutlet="..." /></pre>

<pre> preserves whitespace by spec. The component doesn't set preserveWhitespaces: true, so Angular collapses it in the source, but it's worth verifying the rendered code block doesn't show leading whitespace before the first line of code. No spec covers this — quick check in a browser would be enough.

9. Heading template is duplicated 7×

dotcms-block-editor-renderer-native.component.html:48-118

Each of @case ('1')@case ('6') and @default is the same <hN [style.text-align]="..."><ng-container *ngTemplateOutlet="..." /></hN> pattern. The @switch is needed (Angular templates can't compute element tag names from an expression), but the structure could be hoisted into a small <ng-template #heading let-tag let-content> helper or rendered through ngComponentOutlet. Not blocking — just expensive to maintain.


Clean / acceptable

  • The semantic-DOM mechanism itself (attribute-host components + ng-template/ngTemplateOutlet) is sound — the dispatch spec at dotcms-block-editor-renderer-native.dispatch.spec.ts:332-375 verifies <ul> directly contains <li> with no element wrapper, including inside <td> (line 486-533).
  • asLevel()'s allow-list regex + default-to-<h2> is a good defensive call.
  • The hrefless-<a> recursion through restMarks is correct and well-tested.
  • Style passthrough is narrowed to textAlign only — good, and the migration note in MIGRATION.md:53 is clear about it.
  • Track-by-$index on table cells closes the keyed-diff bug correctly.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Introduces DotCMSBlockEditorRendererNativeComponent, an accessible successor to the existing Angular Block Editor renderer that emits semantic HTML (e.g. <ul><li><p>…</p></li></ul>) with no custom wrapper elements between semantic tags. The legacy renderer is preserved and marked @deprecated. The component reuses existing media/table/grid/contentlet/unknown blocks and uses attribute-selector components plus ng-template/ngTemplateOutlet recursion so list-to-listitem DOM relationships are preserved for assistive technology.

Changes:

  • Add DotCMSBlockEditorRendererNativeComponent and internal attribute-selector semantic components, exported from the SDK public_api.ts.
  • Mark the existing DotCMSBlockEditorRendererComponent as deprecated; update README/MIGRATION/CHANGELOG (v1.5.4) to describe the migration.
  • Migrate the CSR and SSR Angular examples to the new component and add a self-contained CalloutComponent custom renderer demo.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
core-web/libs/sdk/angular/src/public_api.ts Export the new native renderer component from the SDK.
core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.ts New component class with identical public API to legacy and same validation flow.
core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html Template that dispatches nodes to semantic tags via ng-template/ngTemplateOutlet and renders marks as native inline tags.
core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/blocks/semantic-blocks.component.ts Internal attribute-selector components keeping semantic tags as DOM hosts.
core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.spec.ts Unit tests for init, validation, class/style passthrough, and the asLevel helper.
core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.dispatch.spec.ts Dispatch-level tests covering semantic DOM output, mark nesting, lists, headings, media, and custom renderers.
core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/dotcms-block-editor-renderer.component.ts Adds @deprecated JSDoc pointing to the native renderer.
core-web/libs/sdk/angular/README.md Documents new component; deprecation notice on legacy section.
core-web/libs/sdk/angular/MIGRATION.md Adds migration section with one-line swap diff.
core-web/libs/sdk/angular/CHANGELOG.md Adds v1.5.4 entry.
examples/angular/.../blog-post.component.{ts,html} Switches CSR example to the native component and registers a callout custom renderer.
examples/angular/.../customRenderers/callout/callout.component.ts New self-contained CSR callout custom renderer.
examples/angular-ssr/.../blog-post.component.{ts,html} Switches SSR example to the native component and registers the callout.
examples/angular-ssr/.../customRenderers/callout/callout.component.ts New SSR callout custom renderer (mirror of CSR).

Comment thread core-web/libs/sdk/angular/README.md Outdated
The DotCMSBlockEditorRendererNative input table listed types that don't
match the component: blocks is BlockEditorNode (not BlockEditorContent),
and style is string | Record<string, string> (not CSSProperties).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address code-review findings on the new native renderer's template:

- Link marks now bind href/target with `|| null` so missing attrs omit
  the attribute instead of emitting `href=""` (a self-link) / `target=""`.
- Unknown mark types recurse through `restMarks` instead of falling
  straight to text, so known marks beneath them (e.g. `[unknown, bold]`)
  are preserved.
- Paragraph and heading hosts bind `[style.text-align]` explicitly
  instead of the whole `node.attrs` object, so non-CSS attrs like
  heading `level` no longer leak onto the style attribute.

Adds dispatch tests for each case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fmontes
Copy link
Copy Markdown
Member Author

fmontes commented May 28, 2026

Thanks for the thorough pass. Addressed the three actionable template findings in 1676d04 (tests added, 209 unit tests pass, lint clean):

  • Link href="" / target="" → both now bind with || null, so missing attrs omit the attribute instead of emitting a self-link. New test asserts neither attribute is present when the link mark has no attrs.
  • Unknown mark drops downstream marks → the @default arm now recurses through restMarks(marks) when a mark is still present, so [unknown, bold] keeps its <strong>. New test covers it.
  • [style]="node.attrs" leaking non-CSS attrs<p> and <h1>–<h6> (incl. the @default heading) now bind [style.text-align]="node.attrs?.['textAlign'] || null". level and any future non-style attr no longer reach the style attribute. New test asserts text-align applies and level does not leak.

Deferring the two signal/lifecycle items as a focused follow-up rather than folding them in here:

  • Validation in ngOnInit (stale on blocks change) and $isInEditMode captured at construction — both want the same refactor: blocks = input.required<BlockEditorNode>() + a computed/effect for state and a reactive read of UVE mode. That changes the public input wiring and is worth doing deliberately with its own tests, separate from this a11y-focused PR. The behavior here matches the deprecated renderer, so it's no regression.

  • Grid/table cells still use the legacy item component — confirmed, and I'll open a follow-up issue to migrate those to the semantic path so the wrapper-between-<ul>-and-<li> is gone in those contexts too.

OnPush on the host and the dispatch-test fixtures (content: [] + forced state) noted — minor, will fold into the follow-up.

Apply the worth-fixing items from the PR review:

- Hrefless link marks no longer emit an <a>: a hrefless anchor isn't a
  link to assistive tech (no role, not focusable), so we fall back to
  recursing into the remaining marks/text instead.
- Unknown / missing heading levels render as <h2>, not <h1>: an
  unexpected <h1> in an article tanks the heading outline AT relies on.
  asLevel() now only normalizes to "1"–"6"; anything else falls through
  to the safe @default.
- DotCMSBlockEditorRendererNativeComponent declares `implements OnInit`,
  marks BLOCKS as `protected readonly`, and drops a redundant cast in
  markAttrs().
- README acknowledges that table/grid cells still go through the legacy
  block component internally (a follow-up).
- MIGRATION notes the style-passthrough behavior change: the native
  renderer only forwards `textAlign`; other inline-style attrs that the
  legacy renderer copied through `[style]="node.attrs"` are dropped.
- Deprecated-section input table updated to the current types
  (BlockEditorNode, `class` instead of `className`,
  `string | Record<string, string>` instead of CSSProperties).

Tests cover hrefless link → no <a>, inner marks surviving beneath a
dropped link, the <h2> default, and the new asLevel range checks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fmontes
Copy link
Copy Markdown
Member Author

fmontes commented May 28, 2026

Thanks for the careful review. Here's what I addressed and what I deliberately didn't, with reasoning:

Fixed in 29087f5:

  • Test Branch and Commit #2 — Style passthrough not noted in migration. Added a paragraph to MIGRATION.md: native renderer forwards only textAlign; other inline-style attrs the legacy renderer copied through [style]="node.attrs" are dropped. (Code-side was already in 1676d04.)
  • Add .gitignore #3<a> with no href. You're right that an empty <a> is worse than the old href="" self-link from an AT standpoint. The renderer now skips the element entirely for a hrefless link mark and recurses into the remaining marks/text. New tests cover no <a> and inner marks surviving beneath a dropped link.
  • wezell's issue #5 — Heading @default → <h1>. Changed to <h2>. asLevel() now only normalizes 16; anything else (including missing) falls through to the safe default. Comment in the template explains why.
  • test rwqrwqrwqr wq #6 — Table/grid still use the legacy item component. Added a "Known scope" callout to the native renderer's README section so the docs no longer read as if the a11y fix is total.
  • #7a — BLOCKSprotected readonly.
  • #7b — redundant cast in markAttrs → removed (BlockEditorMark.attrs is already Record<string, string>).
  • #7d — implements OnInit → added.
  • #7e — deprecated section's stale types → table now lists BlockEditorNode, class (not className), and string | Record<string, string> (not CSSProperties).

Deliberately not changing:

  • update with latest SVN #1 — naming drift (semantic/ folder vs native class). The folder name describes the mechanism (semantic DOM via attribute-host components — those genuinely are DotSemantic* internals), the class name describes the public API ("native semantic HTML output"). They aren't synonyms. Renaming the folder is churn that doesn't change behavior or discoverability (search hits both). Happy to revisit if it bites in practice.
  • New Issue #4<hr>/<br> inside <p>. <br> inside <p> is valid HTML. <hr> inside <p> would be malformed, but the editor schema doesn't allow horizontalRule as paragraph content, and emitting a defensive check would be guarding against an input the schema forbids. The legacy renderer had the same code path; if the schema ever changes, it should be caught upstream.

Deferred (per your #8): I'll open a follow-up issue covering OnPush, the $isInEditMode snapshot, the ngOnInit validation not reacting to blocks changes, and migrating to input.required() + computed/effect. Same issue can track the wrapper inside table/grid cells (#6 follow-up).

Verified: 211 unit tests pass, lint clean, build emits the export.

The deprecation info already lives in the component's TSDoc (and the
migration steps are in MIGRATION.md), so the duplicated README section
is just maintenance overhead. Drop it, drop its TOC entry, and replace
the cross-references in the native renderer section with a single short
note + link to MIGRATION.md. Also tightens the native section's prose
since the "Why migrate" framing no longer makes sense without the
deprecated section above it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t=_blank

- Table @for loops tracked by node.type ('tableRow'/'tableCell'), so all
  iterations shared the same key and Angular's keyed diff reused the wrong
  DOM node on updates. Switched both header and body loops to track $index.
- <a target="_blank"> with no author-supplied rel is vulnerable to
  reverse-tabnabbing. The renderer now defaults rel to 'noopener noreferrer'
  in that case; an explicit rel still wins.
- Added dispatch specs covering both fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the project's Angular standard for new components and is the
natural fit for a signal-input component. 217 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
columnSpan returned undefined when `content` had more columns than
`attrs.columns` described, rendering as `grid-column: span undefined`.
Falls back to 6 (the documented behavior) and the JSDoc now states it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A float-wrapped <figure> sets width: 50% on the wrapper, but the <img>
inside had no constraint, so wide images rendered at intrinsic size and
overlapped surrounding text. max-width: 100%; height: auto keeps the
image scaled to the wrapper while preserving its aspect ratio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@fmontes fmontes added this pull request to the merge queue May 28, 2026
Merged via the queue into main with commit 429750e May 28, 2026
35 checks passed
@fmontes fmontes deleted the angular-blockeditor-a11y branch May 28, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Block Editor SDK renderer produces excessive DOM wrapper layers (accessibility)

3 participants