Skip to content

Render a supported subset of inline HTML tags instead of dropping them - #181

Open
Vincent Peng (vincent-peng) wants to merge 3 commits into
microsoft:mainfrom
vincent-peng:inline-html-subset
Open

Vincent Peng (vincent-peng) wants to merge 3 commits into
microsoft:mainfrom
vincent-peng:inline-html-subset

Conversation

@vincent-peng

Copy link
Copy Markdown
Contributor

Summary

InlineHTML nodes had no InlineConvertible conformance, so the entire node — including text inside formatting tags — was dropped during conversion (a <kbd>Ctrl</kbd> rendered as a ). This adds an InlineHTMLRewriter post-parse pass (same seam as the existing speculative rewriters) that maps a focused, GitHub-relevant inline subset onto existing AST nodes:

Tag Result
<br>, </br> LineBreak
<b> / <strong> Strong
<i> / <em> Emphasis
<s> / <del> / <strike> Strikethrough
<u> / <ins> underline (InlineAttributes)
<mark> highlight (InlineAttributes)
<sub> / <sup> baseline-shifted text (InlineAttributes + MDFont.scaled)
<code> / <kbd> / <samp> / <tt> InlineCode
<a href="…"> Link (quote-aware attribute scan; entity-decoded; data: rejected; data-href not matched)
<wbr> zero-width space
<!-- … --> dropped

Design notes:

  • Unsupported or unmatched tags render as literal text — nothing is silently dropped. Unclosed supported tags apply their formatting to the rest of the container, which matches how browsers and GitHub treat them.
  • Runs after partial emphasis/table rewrites and before ImageBlockRewriter; formatting wrappers split around Image nodes so paragraph-level image extraction still works.
  • Constrained containers (Link, Image, InlineAttributes) degrade non-recurring children to plain text to respect swift-markdown structural invariants.
  • Bounded: recursion capped at depth 64 and tag-pair scans are bounded, so pathological unclosed-tag runs can't recurse or rescan unboundedly.
  • HTMLBlock nodes become literal-source paragraphs; comments, declarations (<!…>), and processing instructions (<?…>) are dropped.
  • InlineAttributes.convert renders underline/highlight/subscript/superscript via the existing attributed-string pipeline; MDFont.scaled(by:) works on UIKit and AppKit.

Resolves #177

Validation

  • make lint — 0 violations (strict)
  • make test — full suite passes (150 package tests + 9 ParagraphView tests), including a new InlineHTMLRewriterTests suite (28 tests) exercising the full parse → rewrite → renderable → attributed-string path: every supported tag, comments/declarations, unsupported/unknown/unclosed tags, nested formatting, links and href edge cases (quoted/unquoted/uppercase/data-href/entities), image-in-wrapper splitting, nested-link degradation, deep-nesting bounds, and documents with no HTML (no-op passthrough).
  • make build-sample — builds clean

OSS readiness

  • No public API change; always-on behavior matches how GitHub renders inline HTML. README's supported-syntax list updated.
  • Malformed/adversarial input is bounded and falls back to literal text rather than recursing or dropping content.

Inline raw-HTML nodes (InlineHTML) were previously dropped during inline
conversion because they don't conform to InlineConvertible. This adds an
AST-level InlineHTMLRewriter that maps a supported formatting subset onto
equivalent Markdown nodes or InlineAttributes carriers before conversion:

- <br> -> line break, <wbr> -> zero-width space
- <b>/<strong> -> strong, <i>/<em> -> emphasis
- <s>/<del>/<strike> -> strikethrough
- <u>/<ins>, <mark>, <sub>/<sup> -> InlineAttributes, now rendered by a
  new InlineConvertible conformance (underline, highlight, baseline offset)
- <code>/<kbd>/<samp>/<tt> -> inline code
- <a href> -> link

HTML comments are dropped. Unmatched, unsupported, or malformed tags keep
their source as literal text so documents degrade gracefully. An open tag
with no matching close applies to the rest of the inline container, which
keeps mid-stream documents sensible while a tag is still arriving.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Follow-ups from review:

- Bound tag-matching recursion at 64 pairs of nesting; deeper input emits
  literally instead of recursing quadratically / overflowing the stack.
- Convert HTMLBlock nodes (standalone-line tags, cmark block type 7) to
  literal paragraphs so their text is never silently dropped.
- Replace the href regex with a quote-aware scanner: matches only on a
  whitespace boundary (no data-href/xlink:href false positives), skips
  quoted attribute values, and accepts case-insensitive HREF.
- Decode common named/numeric entities in href values (&amp;, &quot;, …).
- Flatten InlineAttributes inside <a> to their children so the link still
  forms; split underline/highlight/sub/sup runs around non-recurring
  children (Link, Image) instead of going fully literal.
- Use InlineCode.code when flattening nested inline code inside <code>.
- Accept the full cmark whitespace set inside tags; map </br> to a line
  break like browsers do.
- Require a key boundary in the InlineAttributes enabled-key regex.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- visitHTMLBlock now drops comment/PI/declaration blocks instead of
  surfacing them as visible text, matching inline comment handling.
- Images inside <b>/<i>/<s> wrappers split the run so Image nodes reach
  paragraph level where ImageBlockRewriter can hoist them — previously
  an image inside a bold tag was silently dropped at conversion.
- Link/Image/InlineAttributes children are filtered back to
  RecurringInlineMarkup (text-preserved fallback) so rewritten content
  stays within swift-markdown's structural rules.
- Depth-cap fallback path now drops comments consistently.
- enabledKey regex requires a word boundary after 'true'; README lists
  <strike> and clarifies comment handling.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@vincent-peng
Vincent Peng (vincent-peng) requested review from a team and a balanced review from Copilot October 8, 2026 13:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vincent-peng

Copy link
Copy Markdown
Contributor Author

Independent verification: PASS (verified against the real artifact by an agent that did not write the code, on detached worktree 6873ea9)

  • swiftlint --strict: 0 violations, 0 serious in 142 files
  • Package tests: 150 executed, 0 failures — InlineHTMLRewriterTests 31/31 covering tag mappings, comments/declarations, unsupported/unclosed tags, href edge cases, image splitting, nesting bounds
  • make build-sample: ** BUILD SUCCEEDED **
  • Notes: pre-existing warnings only, in files untouched by this diff

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: render common inline HTML tags

2 participants