Skip to content

feat: align Markdown support between webapp and mobile - #823

Merged
hanzei merged 2 commits into
masterfrom
claude/github-issue-816-zilz9t
Aug 3, 2026
Merged

feat: align Markdown support between webapp and mobile#823
hanzei merged 2 commits into
masterfrom
claude/github-issue-816-zilz9t

Conversation

@hanzei

@hanzei hanzei commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Closes #816.

The two clients rendered text notes with different libraries and had drifted: the webapp ran marked through an 11-tag DOMPurify allowlist, mobile ran markdown-it with no allowlist at all. Both now render one feature set, written down in docs/specs/markdown-rendering.md.

Behaviour

Syntax Before (webapp / mobile) Now (both)
########## tag stripped, text kept / h4–h6 headings, styled as bold body-size text
~~strike~~ markers eaten / rendered rendered
- [ ] / - [x] checkbox dropped / ☐ ☑ ☐ / ☑, non-interactive
fenced + indented code <pre> stripped, no block layout / block layout block layout
bare https://… autolinked / not linked autolinked
--- / *** dropped / hr horizontal rule
[alt](url) dropped entirely / fetched and rendered literal source, [alt](url "title")
tables mangled, header row lost / rendered literal source
raw HTML tag stripped, words kept / literal literal source (inert)
--, "…" literal / en-dash + curly quotes literal
link schemes any / any, Linking.openURL unfiltered http, https, mailto only

User-visible changes worth calling out

  • Mobile loses smart typography. Text that renders with en-dashes and curly quotes today renders literally after this. No data or API change, but people will notice.
  • Mobile no longer fetches remote images out of note content. That contradicted docs/specs/file-attachments.md ("no markdown/inline embedding" — images are a separate gallery) and meant a collaborator's note could drive a third-party request.
  • Links outside http/https/mailto are no longer followed on either client; they render as their label with nothing to tap. Mobile's link rule previously called Linking.openURL(href) with no filtering at all, so tel:, sms: and arbitrary app deep links were followed.

No API or schema change. No migration.

Two decisions that differ from the issue text

  1. ########## render as bold body text, not as literal source and not silently stripped. Both clients emit real heading elements (so the document outline survives for assistive tech) and style them down — .markdown-content :is(h4, h5, h6) in the webapp, markdownStyles.ts on mobile.
  2. Raw HTML shows its source rather than "formatting dropped, text kept" as the issue's §3 proposed. Matching the issue would have meant regex-stripping <…> out of mobile's text tokens, which is lossy; escaping in the webapp renderer instead makes both clients agree and puts raw HTML in the same bucket as images and tables. #### foo was the only other member of that category and it moved (see 1), so "formatting dropped, text kept" is now empty — every unsupported construct shows its source. The spec says so explicitly rather than leaving the category undocumented.

Also deviating on the pinned image format: the issue specified [alt](src "title"), but the leading ! is kept — [alt](src "title") — so what the reader sees is really what they typed and is not ambiguous with a link. Pinned in formatLiteralImage and used by both clients.

Structure

shared/ owns the parts that must be byte-identical, because the two implementations have no other shared code path:

  • shared/src/markdown.ts — link-scheme policy + the literal-image format.
  • shared/src/markdownCases.ts — the conformance corpus. Both unit suites assert one expectation per case id and fail if an id has none, so a case cannot be covered on one client and forgotten on the other.

The implementation traps (marked has no image tokenizer to disable and use() treats a false-returning tokenizer as "fall through to the default"; markdown-it's .disable('image') yields a live link, while .disable('table') works correctly; RNMD marks image tokens block: true) are documented in §5 of the spec and in comments at each site.

Testing

  • task check — full gate, green (1269 mobile tests, all webapp/shared/server suites).
  • task test-e2e — 299 passed, 3 skipped. webapp/e2e/tests/markdown.spec.ts gains two specs covering the newly supported syntax and the literal-source/link-scheme cases.
  • New: mobile/__tests__/markdown.test.tsx, shared/src/__tests__/markdown.test.ts; extended webapp/src/utils/__tests__/markdown.test.ts.

Screenshots

Webapp, one kitchen-sink note — top and scrolled:

Mobile screenshots were not feasible: this environment has no simulator or device to render the app on. The mobile behaviour is covered by mobile/__tests__/markdown.test.tsx, which asserts against the real parser and the real rendered component tree.


Generated by Claude Code

Text notes rendered differently in the two clients: the webapp ran marked
through an 11-tag DOMPurify allowlist while mobile ran markdown-it with no
allowlist at all, so strikethrough typed on mobile lost its formatting in the
browser, `- [x]` (the syntax list-to-text conversion emits) landed on the
webapp's weakest case, and mobile fetched arbitrary third-party URLs out of
note content.

Both clients now render one feature set, specified in
docs/specs/markdown-rendering.md:

- Supported: h1-h6 (h4-h6 as bold body text), bold/italic/strike, inline and
  block code, lists, non-interactive checkboxes, blockquotes, links, rules.
- Shown as literal source: images, tables, raw HTML.
- No smart typography, on either client.
- Link schemes restricted to http/https/mailto, so a shared note cannot drive
  navigation into `tel:`, `sms:` or an app deep link.

shared/ owns the two things that must be byte-identical (the scheme policy and
the literal-image format) plus the conformance corpus both unit suites run,
which fails if a case is covered on one client and forgotten on the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UahhmDPDzUjrngqeWeuDTw
@hanzei
hanzei marked this pull request as ready for review August 3, 2026 15:23
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cf90203e-59ba-4a65-bf38-38cd07d5cb68

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The change defines a canonical Markdown specification and shared conformance corpus. Shared utilities enforce safe link schemes and literal image formatting. Web and mobile renderers now align on supported syntax, unsupported content, typography, links, images, tables, and raw HTML. Both clients use synchronized tests, with added web end-to-end coverage and mobile parser and interaction checks.

Possibly related issues

Possibly related PRs

  • hanzei/jot#53 — Introduced the web end-to-end test suite extended by this change.
  • hanzei/jot#380 — Overlaps in web Markdown rendering, note rendering, and end-to-end tests.
  • hanzei/jot#436 — Earlier mobile Markdown changes affecting the same screen and styles.

Poem

A rabbit found Markdown paths to share,
Safe links hopping everywhere.
Tables stayed text, images stayed still,
Checkboxes smiled by parser skill.
Web and mobile now rhyme just right. 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: aligning Markdown support between the webapp and mobile clients.
Description check ✅ Passed The description directly explains the Markdown alignment, behavior changes, implementation, documentation, and test coverage.
Linked Issues check ✅ Passed The changes satisfy the coding objectives in issue #816, including shared behavior, restricted links, literal unsupported syntax, documentation, and tests.
Out of Scope Changes check ✅ Passed The changes are limited to Markdown implementation, shared utilities, documentation, styling, and related test coverage described in issue #816.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mobile/src/utils/markdown.tsx`:
- Around line 157-160: Update the press handler around Linking.openURL in the
press function to attach a rejection handler to the returned promise, preventing
unhandled rejections when the URL cannot be opened while preserving the existing
onLinkPress behavior.
- Around line 91-116: Ensure the MarkdownIt configuration around html, linkify,
and md.disable('table') prevents URL autolinking inside raw HTML text and
disabled table content, preserving those regions as inert literal text. Add
mobile coverage for URLs within displayed tags and pipe-table rows, or revert
linkifications spanning those protected regions without changing normal bare-URL
linking elsewhere.

In `@shared/src/markdown.ts`:
- Around line 24-28: Update the link parsing flow around isAllowedLinkHref so
scheme-less autolinks such as www.example.com are rejected before parser
normalization to http/https. Apply the restriction at parser time, or add
coverage and configuration ensuring marked with gfm and markdown-it with linkify
do not emit normalized links for scheme-less input; preserve acceptance of
explicitly allowed schemes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 76fcb1d1-dea8-455b-85f5-86da8cf173b4

📥 Commits

Reviewing files that changed from the base of the PR and between 02ba046 and 39da507.

📒 Files selected for processing (14)
  • CLAUDE.md
  • docs/specs/markdown-rendering.md
  • mobile/__tests__/markdown.test.tsx
  • mobile/src/screens/NoteEditorScreen.tsx
  • mobile/src/utils/markdown.tsx
  • mobile/src/utils/markdownStyles.ts
  • shared/src/__tests__/markdown.test.ts
  • shared/src/index.ts
  • shared/src/markdown.ts
  • shared/src/markdownCases.ts
  • webapp/e2e/tests/markdown.spec.ts
  • webapp/src/index.css
  • webapp/src/utils/__tests__/markdown.test.ts
  • webapp/src/utils/markdown.ts

Comment thread mobile/src/utils/markdown.tsx Outdated
Comment thread mobile/src/utils/markdown.tsx
Comment thread shared/src/markdown.ts
Three review findings, all real:

- Linking.openURL rejects when nothing can handle a URL, and the href comes
  straight out of note content, so that is a normal outcome rather than a
  crash. Attach a rejection handler (and do not log it — note content never
  goes to the log).

- linkify was reaching into regions the webapp shows as literal source: a URL
  in a table cell, or inside an href attribute, became a live link on mobile
  and stayed text in the browser. Parse tables and raw HTML instead of
  disabling them, then collapse the tokens into literal text afterwards, which
  discards the parsed contents along with the links. This also fixes an HTML
  block swallowing its Markdown on the webapp but not on mobile.

- linkify-it autolinks a bare example.com; marked's GFM autolinker needs a
  scheme or a www. prefix. Unwrap the extra links rather than turning fuzzyLink
  off, which would also drop www.example.com — one marked does link.

Corpus gains cases for each, so both clients stay pinned to the same answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UahhmDPDzUjrngqeWeuDTw
@hanzei
hanzei merged commit 40dc471 into master Aug 3, 2026
18 checks passed
@hanzei
hanzei deleted the claude/github-issue-816-zilz9t branch August 3, 2026 16:25
hanzei added a commit that referenced this pull request Aug 4, 2026
…ormatting and nesting (#827)

* feat: convert notes by the rendered Markdown subset, keeping inline formatting and nesting

Text ↔ list conversion had its own opinion of what formatting to strip,
pinned to the webapp's renderer ("It only needs to handle the syntax subset
webapp/src/utils/markdown.ts renders"). #823 widened that subset and #825
gave list items an inline subset of their own, so the premise no longer
held and the stripper was deleting formatting the destination now displays.

Conversion now follows from the render subsets instead of restating them.
Because the item subset (spec §2.1) is a strict subset of what text notes
render, the rule is mechanical: remove the block markdown an item
structurally replaces — its bullet, its checkbox, a heading prefix,
blockquote markers — and keep everything else exactly as typed. Inline
syntax survives because the item renders it. Block syntax that is not a
line prefix (a fence, `---`, a table row, an image) survives too, because
§2.1 shows it as literal source.

That deletes stripInlineFormatting outright rather than extending it to the
newly rendered syntax, and with it the `![alt](url)` → `!alt` mangling the
link regex produced. The ReDoS reasoning behind its character classes is
kept as a comment, since the hazard returns with any future regex scan of
note content.

Nesting now survives text → list. listToText has always emitted two-space
indentation for children, but the reverse threw it away, so a nested list
round-tripped through a text note came back flat. An indented line that
also carries a list marker becomes a child of the nearest preceding
top-level item. Indentation alone does not qualify: an indented line
without a marker is usually a wrapped paragraph, and re-parenting it
silently is worse than dropping nesting nobody asked for. The clients
cannot send parent_id (the item ids do not exist yet), so they send
indent_level and the server's existing buildCreateNoteItems rebuilds
parent_id — no server change was needed. Mobile mirrors that same walk
locally so an offline conversion matches its eventual replay.

listToText still emits item text unescaped, now deliberately: both sides
lex the same source with the same inline rules, so escaping would introduce
a rendering change rather than prevent one, and every item is emitted
behind a `- [ ] ` marker that keeps block syntax literal.

Documented as spec §2.2, next to the subset it derives from.

Known gap, unchanged by this commit and worth its own issue: conversion
does not check ITEM_MAX_COUNT or ITEM_TEXT_MAX_LENGTH before sending, so a
note over either cap fails with a generic "failed to convert" toast. The
webapp's paste path does guard this; the convert path does not. Keeping
inline markers makes the text-length cap marginally easier to hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qcu6eapDXfh5xsW42LL4i

* fix: strip blockquote markers on both sides of a list marker, correct round-trip claim

Review feedback on #827.

`> - [x] Child` is a quoted checklist, but the blockquote strip ran only
after the list-marker match, so the marker never matched: the item kept
"- [x]" in its text and lost the completed state. Per spec §2.1 that text
then renders as a literal checkbox beside the item's own real one, which is
the exact outcome the subset exists to prevent.

Stripping blockquotes on both sides of the marker rather than moving the
strip, because the two nest in either order: moving it alone would fix
`> - [x] a` and break `- > a`, leaving the `>` in the item text.

The spec also overclaimed the round trip. list → text → list does return
the same items, but text → list → text *normalizes* — an item has one
representation, so `# Groceries`, `* Eggs`, `1. Eggs` and `> Eggs` all come
back as task lines. And an item whose text begins with `#` or `>` loses
that prefix on the way back, since the converter cannot tell it apart from
the block markup it strips. Both are now stated in §2.2 and asserted in
tests, and the e2e comment making the same overclaim is corrected.

Verified separately that the no-escaping argument is unaffected: `# foo`,
`> quoted`, `---` and table pipes all stay literal inside a task item under
marked, while `**bold**` still renders. That claim was about rendering and
holds; only the converter's own round trip is lossy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qcu6eapDXfh5xsW42LL4i

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

Align Markdown support between webapp and mobile

2 participants