Verdict
Give every media node a way to carry its options through Markdown, so a person or an agent can write a sized, floated, captioned embed by hand.
Two shapes, both optional:
- Caption rides the standard CommonMark title slot:
.
- Layout rides an option block after the closing parenthesis:
{width=800 float=right}.
Together:
{width=800 float=right}
Emit only what differs from the default. A node nobody styled still exports as . Most documents will not change shape at all.
Two things are decided here, and the grammar is the smaller one.
The caption also needs its HTML leg back. A user captions a video, copies it, and the caption is gone. Eight of nine nodes never emit a <figcaption>, so every HTML path drops it — clipboard copy included. That is one call per node and no new syntax, and it is the first slice.
The Markdown lane leaves the shared embed degradation, while DOCX and ODT keep it. Without that split the grammar changes nothing anyone would see.
What is broken today
Measured, not read. Each number comes from driving the built package or the product path, not from reading the source.
Media options do not survive an export. Set every node to width=800 height=450 float=right margin=… display=inline-block justifyContent=center plus a caption, then serialize. Four of sixty attribute slots survive. Only video and audio keep anything, and only width and height.
The product export never reaches the media hooks at all. importMarkdown builds the right node. exportMarkdown writes a bare link:
| Input |
Node built on import |
Markdown written on export |
 |
video |
[https://…/a.mp4](https://…/a.mp4) |
 |
youtube |
[https://…](https://…) |
 |
spotify |
[https://…](https://…) |
 |
audio, both set |
[https://…/a.mp3](https://…/a.mp3) |
vimeo, soundcloud, x and loom behave the same way.
This is deliberate, and the comment says why. portableJson.ts:10-13:
Every embed is an iframe or a player that DOCX, Markdown and ODT cannot express. The src is the only part a reader can still follow.
toPortableJson rewrites all eight EMBED_NODE_TYPES into a hyperlink paragraph before serialization. So the eight renderMarkdown hooks in @docs.plus/extension-hypermultimedia are unreachable from the product export. They run in the clean-room Cypress suite and nowhere else. image is excluded on purpose and does export as a picture.
The syntax that exists today is not portable Markdown. typedMediaMarkdown.ts:79 writes size inside the destination parentheses: . CommonMark reads what follows the destination as a title, and a title must be quoted. Stock marked returns that input as literal text, not an image. It works inside docs.plus only because each node registers an inline tokenizer that runs before the standard image rule.
The caption is the <figcaption> a user types on the node. It is editable in the node view, it lives in the caption attribute, and that attribute is the source of truth. It persists through collaboration and JSON. All nine nodes carry it (captionAttribute(), caption.ts:116-131).
It survives almost no serialization. Measured on all nine nodes:
| Leg |
Result |
| Stored attribute |
kept on all nine |
HTML <figcaption> |
kept on image only |
| Markdown |
lost on all nine, image included |
| HTML written, then re-parsed |
kept on image only |
Three lines explain it:
captionAttribute().renderHTML returns {} (caption.ts:130), so the caption never becomes an HTML attribute. It can only ride a <figure> wrapper.
wrapRenderWithCaption (caption.ts:132-143) has exactly one caller: image.ts:178.
parseHTML reads a caption only when element.tagName === 'FIGURE' (caption.ts:125-128), so parsing is symmetric with rendering.
The HTML leg is disclosed in the published 2.0.0 CHANGELOG — "HTML serialization carries the caption for image only — which includes clipboard copy/paste and the toolbar Copy action". It is stated as a limit, with no reason recorded. So a user who captions a video and copies it loses the caption, and that is a more everyday path than an export.
image also carries an unused title attribute.  parses the title and discards it.
 does not round-trip. It imports, picks up the schema defaults width 640 and height 480, then exports as .
Decision
Caption gets both of its missing legs
The caption is the editable <figcaption> on the node. Fixing it is two independent changes, and neither needs a new grammar.
HTML. Call wrapRenderWithCaption from the other eight nodes, not from image alone. parseHTML already reads a <figure> on every node, so the read side needs nothing. This alone fixes clipboard copy and the toolbar Copy action.
The open question is the wrapper's effect on an iframe embed. image.ts:178 moves the block layout style onto the <figure> when a caption is present, and each embed node pins its own height. Check soundcloud and spotify first — both floor their height and neither scales with the shell.
Markdown.  maps to the caption attribute, both directions.
Valid CommonMark. Every other Markdown tool already renders the title slot as a caption or a tooltip. The attribute already exists on all nine nodes, so nothing new is stored.
Layout uses the option block
{key=value key=value} immediately after the closing parenthesis. Whitespace-separated. Quotes allowed around a value that holds a space.
The vocabulary is the one the code already defines at embedKit.ts:37-45:
width · height · display · float · clear · margin · justifyContent
An unknown key is dropped, not stored. A value that fails its type check is dropped, and the node keeps its default.
Why this shape, on evidence
An earlier draft of this RFC claimed the brace block "follows the Pandoc convention, so a model already writes it". That was asserted, not checked. It is now checked, against vendor documentation and against GitHub's live renderer.
There is no single industry convention. The leaders disagree:
| Convention |
Platforms |
| Brace attribute block |
GitLab (native), Pandoc, Quarto, Material for MkDocs, MyST, kramdown, markdown-it-attrs |
Raw HTML <img> only |
GitHub, Docusaurus, MDX, Next.js, Astro, Joplin, Typora, GitBook, Mintlify, Google's style guide |
| Pipe inside the alt text |
Obsidian |
| JSX component |
MDX, Astro |
| Nothing at all |
Notion, Bear, Craft, Microsoft Learn |
CommonMark itself defines no attribute syntax. Its generic-attributes proposal was never adopted.
GitLab already ships this exact shape, which is the strongest single precedent:
{width=100 height=100px}
A title slot and a brace block, together, on the same image, in a shipping product.
Measured through GitHub's /markdown API, which is the largest renderer we must degrade well on:
| Input |
GitHub output |
 — what we ship today |
 — the image construct collapses and the reader sees literal {width=800 float=right} |
image renders; {width=800 float=right} prints as trailing text |
{width=800 float=right} |
image renders, alt="youtube" and title="My caption" both intact; braces print as text |
 — Obsidian form |
renders cleanly with no stray text, but the pipe is swallowed into alt |
So the shape we ship today is the one that breaks. The proposal degrades to visible-but-harmless text, and keeps the caption.
Three honest costs, stated plainly:
- The brace family is not one uniform key set. kramdown needs
{:width="800"}, with a colon and quoted values. MyST writes w=100px, not width=. Only GitLab and Pandoc accept the bare {width=800} form.
- Only
width and height mean anything outside docs.plus. Pandoc turns float=right into data-float="right". The other five keys are ours alone.
- The Obsidian pipe degrades better than anything else. It renders with zero stray characters. It loses because Obsidian documents only
|WIDTH and |WIDTHxHEIGHT, so it cannot carry float, margin, a caption, or later player options.
Implementation cost does not favour any candidate. @tiptap/markdown 3.22.3 runs on marked and documents no image attribute convention at all. Every candidate needs a custom inline tokenizer. So the choice is on merit, not on effort.
Accept it from foreign Markdown
The block is read on import from any Markdown, not only from our own output. That is the point of the task. An agent writes the file, docs.plus reads it.
The isValid*Url validators keep running on src, unchanged, whatever the block carries.
Emit only what differs
An attribute equal to the node default is not written. A styled node writes only the keys the user changed.
This is what keeps an exported document readable, and it is the reason the grammar is safe to add. It also needs one explicit rule, because today's behaviour is ambiguous: compare against the schema default, not against "was it set". So a video at 640×480 writes nothing, and  starts round-tripping unchanged.
Split the Markdown lane out of embed degradation
toPortableJson serves three lanes:
markdownExport.ts:30
odtExport.ts:343
documentHtml.ts:44, which feeds DOCX
Markdown leaves. DOCX and ODT stay. Markdown can express an embed once it has a syntax; a .docx and a .odt still cannot hold an iframe.
Without this split the grammar changes nothing a user or an agent would ever see. This is the reviewable part of the proposal.
Learning curve
The target is close to zero for an end user. Two facts decide whether that is reachable.
No convention is universal, so no syntax is already known to everyone. A GitHub user has never seen a brace block. A GitLab or Pandoc user has. The honest claim is not "everyone knows this"; it is "more vendors publish this than any other form, and it explains itself when read".
The end user almost never writes it. They use the toolbar. Markdown reaches them at three moments: they read an exported file, they hand-write a size once in a while, or an agent writes a file for them. So the curve is set by three properties, in this order:
- The common case needs no syntax.
 stays exactly that. This is what "emit only what differs" buys, and it is the largest single contributor.
- The keys explain themselves.
width=800 float=right needs no lookup. |800 and ?w=800 both need one.
- A wrong guess degrades safely. Measured above: a foreign renderer still shows the media and prints the unknown keys as text.
Documentation is part of this work, not a follow-up. Nothing in docs/ teaches the Markdown media syntax today. docs/api/ holds README.md, authentication.md, quickstart.md and websocket.md, and none of them mentions Markdown. So the  form we already ship is documented only in an npm README that an end user never opens. A syntax with a good curve and no page still costs a lookup that fails.
Alternatives considered
Query parameters on the src — . No new grammar at all. Rejected: it corrupts the URL, it breaks the isValid*Url validators, and the parameters travel to the third-party host.
Inline HTML for styled media — emit <img width=… style="float:right"> when a value differs. Renders correctly in GitHub and Pandoc. Rejected: it stops being Markdown, many renderers strip it, and no HTML element renders a YouTube embed anyway.
Keep the current in-parenthesis syntax and extend it — cheapest to build. Rejected on measurement: GitHub's renderer returns it as literal text, so every file we write stays broken outside docs.plus. That defeats the goal.
The Obsidian pipe — . It degrades better than every other candidate, with zero stray characters on GitHub. Rejected: Obsidian documents only |WIDTH and |WIDTHxHEIGHT, so it cannot carry float, margin, a caption, or player options. It also collides with the node type already in the alt slot.
Constraints the design must respect
Registration order decides inline tokens. In @tiptap/markdown 3.22.3, block tokens try the next handler when one yields nothing (MarkdownManager.ts:405). Inline and mark tokens take markdownHandlers[0] with no fallback (:247, :727). A losing extension cannot yield by returning nothing. image is an inline token, so the media hooks must win by order.
A backslash escape is destroyed on import. a \* literal star imports as a literal star. Both characters vanish. Any option value that holds a Markdown character is unsafe until this is fixed, so it lands in the same work.
marked keeps its defaults. No call site passes markedOptions, so breaks stays false.
One shared MarkdownManager. Constructing one mutates a process-global marked singleton and its tokenizers accumulate. Both lanes share a lazy instance for that reason (markdownExport.ts:21-27).
The import cap is 64 KB. MAX_MARKDOWN_CHARS is 64 * 1024, and the route answers 413 above it. An option block makes every media line longer. If the cap moves, the user-facing copy at apps/webapp/src/api/documents/conversionErrors.ts:9-11 moves in the same edit.
A URL-valued option needs the same gate as src. video.poster is the case that exists today. Route it through isSafeMediaSrc, not through the plain value parser.
Two media-import defects this work should fix
Both are attribute defects on the Markdown import path, so they belong here rather than in a separate issue.
- Spotify import ignores the canonical URL and the default height. An imported track renders 352 px tall instead of 152 px, so the same track looks different depending on whether it arrived by paste or by import.
- X import stores an un-normalized
src, unlike every other X write path. Nothing breaks visually, because the read side normalizes again, but two identical embeds hold different stored values.
Suggested slices
- Caption in HTML for the other eight nodes. One call to
wrapRenderWithCaption per node. Fixes clipboard copy and the toolbar Copy action. No new syntax, no schema change, and the read side already works.
- Caption through the Markdown title slot, all nine nodes, both directions.
- Split the Markdown lane out of
toPortableJson, so the eight embed hooks become reachable. No new syntax; the existing hooks start running.
- Option block for the seven layout keys, emit-only-what-differs, in the shared
createTypedMediaMarkdownHooks factory and in the image node.
- Per-node player options under the same grammar —
controls, autoplay, loop, muted, preload, poster, and the x set. Same parser, larger vocabulary.
- A documentation page under
docs/ that teaches the syntax. Nothing there teaches it today, so this is not optional polish.
Slice 1 is the smallest change with the largest user-visible win, and it does not touch Markdown at all. Slices 1, 2 and 3 are independent of each other.
Breaking change
The five packages are published at 2.0.0.
Slice 3 changes what renderMarkdown writes for audio and video:  becomes {width=800}. An external consumer that reads our Markdown with its own parser breaks. Nothing else does — import keeps accepting the old shape.
That reads as a minor bump with a migration note, not a major, because the old input still parses. Worth a ruling before slice 3 lands.
Open questions
justifyContent or justify-content on the wire? Six of seven keys are identical in both spellings. Only this one differs. Kebab-case reads as CSS and is easier to guess; camelCase matches the stored attribute exactly. Recommendation: write kebab-case, accept both on import.
- Does
image join the same tokenizer? It uses the standard image token today, not the hm_* family. Giving it an option block means taking that token by registration order.
- Minor or major version for slice 3? See above.
Not this issue
Related work found by the same audit, filed separately: the pad turns a pasted Markdown link into a link mark instead of a hyperlink mark; a select-all Markdown paste can degrade to literal text; superscript and subscript are dropped on export and API.md does not say so; each webapp editor re-registers twelve tokenizers into the global marked.
How this was measured
- All five clean-room suites green, exit 0: 426 Cypress specs and 22 Jest tests.
extension-hypermultimedia 171/171, including markdown/markdown-round-trip.cy.ts at 26/26.
- Attribute loss measured by driving the built
dist/index.js through a real Editor with StarterKit and @tiptap/markdown 3.22.3.
- Export behaviour measured by driving
importMarkdown and exportMarkdown from apps/hocuspocus.server/src/modules/document-conversion/domain/ directly.
- 38 Markdown constructs round-tripped through that same product path: 21 unchanged, 9 normalized acceptably, 3 lossy.
- Caption measured on all nine nodes across four legs: stored attribute,
getHTML(), Markdown serialize, and HTML written then re-parsed.
- Convention survey across 30+ platforms, primary vendor documentation only, then reproduced independently through GitHub's
POST /markdown API for the four candidate shapes.
Verdict
Give every media node a way to carry its options through Markdown, so a person or an agent can write a sized, floated, captioned embed by hand.
Two shapes, both optional:
.{width=800 float=right}.Together:
Emit only what differs from the default. A node nobody styled still exports as
. Most documents will not change shape at all.Two things are decided here, and the grammar is the smaller one.
The caption also needs its HTML leg back. A user captions a video, copies it, and the caption is gone. Eight of nine nodes never emit a
<figcaption>, so every HTML path drops it — clipboard copy included. That is one call per node and no new syntax, and it is the first slice.The Markdown lane leaves the shared embed degradation, while DOCX and ODT keep it. Without that split the grammar changes nothing anyone would see.
What is broken today
Measured, not read. Each number comes from driving the built package or the product path, not from reading the source.
Media options do not survive an export. Set every node to
width=800 height=450 float=right margin=… display=inline-block justifyContent=centerplus a caption, then serialize. Four of sixty attribute slots survive. Onlyvideoandaudiokeep anything, and only width and height.The product export never reaches the media hooks at all.
importMarkdownbuilds the right node.exportMarkdownwrites a bare link:video[https://…/a.mp4](https://…/a.mp4)youtube[https://…](https://…)spotify[https://…](https://…)audio, both set[https://…/a.mp3](https://…/a.mp3)vimeo,soundcloud,xandloombehave the same way.This is deliberate, and the comment says why.
portableJson.ts:10-13:toPortableJsonrewrites all eightEMBED_NODE_TYPESinto a hyperlink paragraph before serialization. So the eightrenderMarkdownhooks in@docs.plus/extension-hypermultimediaare unreachable from the product export. They run in the clean-room Cypress suite and nowhere else.imageis excluded on purpose and does export as a picture.The syntax that exists today is not portable Markdown.
typedMediaMarkdown.ts:79writes size inside the destination parentheses:. CommonMark reads what follows the destination as a title, and a title must be quoted. Stockmarkedreturns that input as literal text, not an image. It works inside docs.plus only because each node registers an inline tokenizer that runs before the standard image rule.The caption is the
<figcaption>a user types on the node. It is editable in the node view, it lives in thecaptionattribute, and that attribute is the source of truth. It persists through collaboration and JSON. All nine nodes carry it (captionAttribute(),caption.ts:116-131).It survives almost no serialization. Measured on all nine nodes:
<figcaption>imageonlyimageincludedimageonlyThree lines explain it:
captionAttribute().renderHTMLreturns{}(caption.ts:130), so the caption never becomes an HTML attribute. It can only ride a<figure>wrapper.wrapRenderWithCaption(caption.ts:132-143) has exactly one caller:image.ts:178.parseHTMLreads a caption only whenelement.tagName === 'FIGURE'(caption.ts:125-128), so parsing is symmetric with rendering.The HTML leg is disclosed in the published
2.0.0CHANGELOG — "HTML serialization carries the caption forimageonly — which includes clipboard copy/paste and the toolbar Copy action". It is stated as a limit, with no reason recorded. So a user who captions a video and copies it loses the caption, and that is a more everyday path than an export.imagealso carries an unusedtitleattribute.parses the title and discards it.does not round-trip. It imports, picks up the schema defaultswidth640 andheight480, then exports as.Decision
Caption gets both of its missing legs
The caption is the editable
<figcaption>on the node. Fixing it is two independent changes, and neither needs a new grammar.HTML. Call
wrapRenderWithCaptionfrom the other eight nodes, not fromimagealone.parseHTMLalready reads a<figure>on every node, so the read side needs nothing. This alone fixes clipboard copy and the toolbar Copy action.The open question is the wrapper's effect on an iframe embed.
image.ts:178moves the block layout style onto the<figure>when a caption is present, and each embed node pins its own height. Checksoundcloudandspotifyfirst — both floor their height and neither scales with the shell.Markdown.
maps to thecaptionattribute, both directions.Valid CommonMark. Every other Markdown tool already renders the title slot as a caption or a tooltip. The attribute already exists on all nine nodes, so nothing new is stored.
Layout uses the option block
{key=value key=value}immediately after the closing parenthesis. Whitespace-separated. Quotes allowed around a value that holds a space.The vocabulary is the one the code already defines at
embedKit.ts:37-45:width·height·display·float·clear·margin·justifyContentAn unknown key is dropped, not stored. A value that fails its type check is dropped, and the node keeps its default.
Why this shape, on evidence
An earlier draft of this RFC claimed the brace block "follows the Pandoc convention, so a model already writes it". That was asserted, not checked. It is now checked, against vendor documentation and against GitHub's live renderer.
There is no single industry convention. The leaders disagree:
markdown-it-attrs<img>onlyCommonMark itself defines no attribute syntax. Its generic-attributes proposal was never adopted.
GitLab already ships this exact shape, which is the strongest single precedent:
A title slot and a brace block, together, on the same image, in a shipping product.
Measured through GitHub's
/markdownAPI, which is the largest renderer we must degrade well on:— what we ship today— the image construct collapses and the reader sees literal{width=800 float=right}{width=800 float=right}prints as trailing text{width=800 float=right}alt="youtube"andtitle="My caption"both intact; braces print as text— Obsidian formaltSo the shape we ship today is the one that breaks. The proposal degrades to visible-but-harmless text, and keeps the caption.
Three honest costs, stated plainly:
{:width="800"}, with a colon and quoted values. MyST writesw=100px, notwidth=. Only GitLab and Pandoc accept the bare{width=800}form.widthandheightmean anything outside docs.plus. Pandoc turnsfloat=rightintodata-float="right". The other five keys are ours alone.|WIDTHand|WIDTHxHEIGHT, so it cannot carryfloat,margin, a caption, or later player options.Implementation cost does not favour any candidate.
@tiptap/markdown3.22.3 runs onmarkedand documents no image attribute convention at all. Every candidate needs a custom inline tokenizer. So the choice is on merit, not on effort.Accept it from foreign Markdown
The block is read on import from any Markdown, not only from our own output. That is the point of the task. An agent writes the file, docs.plus reads it.
The
isValid*Urlvalidators keep running onsrc, unchanged, whatever the block carries.Emit only what differs
An attribute equal to the node default is not written. A styled node writes only the keys the user changed.
This is what keeps an exported document readable, and it is the reason the grammar is safe to add. It also needs one explicit rule, because today's behaviour is ambiguous: compare against the schema default, not against "was it set". So a
videoat 640×480 writes nothing, andstarts round-tripping unchanged.Split the Markdown lane out of embed degradation
toPortableJsonserves three lanes:markdownExport.ts:30odtExport.ts:343documentHtml.ts:44, which feeds DOCXMarkdown leaves. DOCX and ODT stay. Markdown can express an embed once it has a syntax; a
.docxand a.odtstill cannot hold an iframe.Without this split the grammar changes nothing a user or an agent would ever see. This is the reviewable part of the proposal.
Learning curve
The target is close to zero for an end user. Two facts decide whether that is reachable.
No convention is universal, so no syntax is already known to everyone. A GitHub user has never seen a brace block. A GitLab or Pandoc user has. The honest claim is not "everyone knows this"; it is "more vendors publish this than any other form, and it explains itself when read".
The end user almost never writes it. They use the toolbar. Markdown reaches them at three moments: they read an exported file, they hand-write a size once in a while, or an agent writes a file for them. So the curve is set by three properties, in this order:
stays exactly that. This is what "emit only what differs" buys, and it is the largest single contributor.width=800 float=rightneeds no lookup.|800and?w=800both need one.Documentation is part of this work, not a follow-up. Nothing in
docs/teaches the Markdown media syntax today.docs/api/holdsREADME.md,authentication.md,quickstart.mdandwebsocket.md, and none of them mentions Markdown. So theform we already ship is documented only in an npm README that an end user never opens. A syntax with a good curve and no page still costs a lookup that fails.Alternatives considered
Query parameters on the
src—. No new grammar at all. Rejected: it corrupts the URL, it breaks theisValid*Urlvalidators, and the parameters travel to the third-party host.Inline HTML for styled media — emit
<img width=… style="float:right">when a value differs. Renders correctly in GitHub and Pandoc. Rejected: it stops being Markdown, many renderers strip it, and no HTML element renders a YouTube embed anyway.Keep the current in-parenthesis syntax and extend it — cheapest to build. Rejected on measurement: GitHub's renderer returns it as literal text, so every file we write stays broken outside docs.plus. That defeats the goal.
The Obsidian pipe —
. It degrades better than every other candidate, with zero stray characters on GitHub. Rejected: Obsidian documents only|WIDTHand|WIDTHxHEIGHT, so it cannot carryfloat,margin, a caption, or player options. It also collides with the node type already in the alt slot.Constraints the design must respect
Registration order decides inline tokens. In
@tiptap/markdown3.22.3, block tokens try the next handler when one yields nothing (MarkdownManager.ts:405). Inline and mark tokens takemarkdownHandlers[0]with no fallback (:247,:727). A losing extension cannot yield by returning nothing.imageis an inline token, so the media hooks must win by order.A backslash escape is destroyed on import.
a \* literal starimports asa literal star. Both characters vanish. Any option value that holds a Markdown character is unsafe until this is fixed, so it lands in the same work.markedkeeps its defaults. No call site passesmarkedOptions, sobreaksstays false.One shared
MarkdownManager. Constructing one mutates a process-globalmarkedsingleton and its tokenizers accumulate. Both lanes share a lazy instance for that reason (markdownExport.ts:21-27).The import cap is 64 KB.
MAX_MARKDOWN_CHARSis64 * 1024, and the route answers 413 above it. An option block makes every media line longer. If the cap moves, the user-facing copy atapps/webapp/src/api/documents/conversionErrors.ts:9-11moves in the same edit.A URL-valued option needs the same gate as
src.video.posteris the case that exists today. Route it throughisSafeMediaSrc, not through the plain value parser.Two media-import defects this work should fix
Both are attribute defects on the Markdown import path, so they belong here rather than in a separate issue.
src, unlike every other X write path. Nothing breaks visually, because the read side normalizes again, but two identical embeds hold different stored values.Suggested slices
wrapRenderWithCaptionper node. Fixes clipboard copy and the toolbar Copy action. No new syntax, no schema change, and the read side already works.toPortableJson, so the eight embed hooks become reachable. No new syntax; the existing hooks start running.createTypedMediaMarkdownHooksfactory and in theimagenode.controls,autoplay,loop,muted,preload,poster, and thexset. Same parser, larger vocabulary.docs/that teaches the syntax. Nothing there teaches it today, so this is not optional polish.Slice 1 is the smallest change with the largest user-visible win, and it does not touch Markdown at all. Slices 1, 2 and 3 are independent of each other.
Breaking change
The five packages are published at
2.0.0.Slice 3 changes what
renderMarkdownwrites foraudioandvideo:becomes{width=800}. An external consumer that reads our Markdown with its own parser breaks. Nothing else does — import keeps accepting the old shape.That reads as a minor bump with a migration note, not a major, because the old input still parses. Worth a ruling before slice 3 lands.
Open questions
justifyContentorjustify-contenton the wire? Six of seven keys are identical in both spellings. Only this one differs. Kebab-case reads as CSS and is easier to guess; camelCase matches the stored attribute exactly. Recommendation: write kebab-case, accept both on import.imagejoin the same tokenizer? It uses the standardimagetoken today, not thehm_*family. Giving it an option block means taking that token by registration order.Not this issue
Related work found by the same audit, filed separately: the pad turns a pasted Markdown link into a
linkmark instead of ahyperlinkmark; a select-all Markdown paste can degrade to literal text; superscript and subscript are dropped on export andAPI.mddoes not say so; each webapp editor re-registers twelve tokenizers into the globalmarked.How this was measured
extension-hypermultimedia171/171, includingmarkdown/markdown-round-trip.cy.tsat 26/26.dist/index.jsthrough a realEditorwithStarterKitand@tiptap/markdown3.22.3.importMarkdownandexportMarkdownfromapps/hocuspocus.server/src/modules/document-conversion/domain/directly.getHTML(), Markdown serialize, and HTML written then re-parsed.POST /markdownAPI for the four candidate shapes.