<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: frank</title>
    <description>The latest articles on DEV Community by frank (@mdfold).</description>
    <link>https://dev.to/mdfold</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4051706%2Ff7721660-f842-4411-ae4e-5036d351321f.jpg</url>
      <title>DEV Community: frank</title>
      <link>https://dev.to/mdfold</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mdfold"/>
    <language>en</language>
    <item>
      <title>Source Positions Are a Coordinate System, Not Just Line and Column</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:24:16 +0000</pubDate>
      <link>https://dev.to/mdfold/source-positions-are-a-coordinate-system-not-just-line-and-column-2epj</link>
      <guid>https://dev.to/mdfold/source-positions-are-a-coordinate-system-not-just-line-and-column-2epj</guid>
      <description>&lt;p&gt;A Markdown linter can detect the right problem and still highlight the wrong character.&lt;/p&gt;

&lt;p&gt;The usual cause is not parsing. It is a coordinate-system mismatch: one component counts UTF-8 bytes, another counts Unicode code points, and JavaScript indexes UTF-16 code units.&lt;/p&gt;

&lt;p&gt;I tested this with Node.js 25.3.0, unified 11.0.5 + remark-parse 11.0.0, commonmark.js 0.31.2, markdown-it 15.0.1 (&lt;code&gt;commonmark&lt;/code&gt; preset), and Marked 18.0.11.&lt;/p&gt;

&lt;h2&gt;
  
  
  One emoji, three valid offsets
&lt;/h2&gt;

&lt;p&gt;Consider this source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# A😀B&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The position immediately before &lt;code&gt;B&lt;/code&gt; is:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Offset&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript UTF-16 code units&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unicode code points&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8 bytes&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;# A😀B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;utf16&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codePoints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;utf16&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;utf16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;utf16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;codePoints&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// { utf16: 5, codePoints: 4, bytes: 7 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of those numbers is universally wrong. The bug appears when an API calls all of them &lt;code&gt;offset&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Positions should be half-open ranges
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/syntax-tree/unist" rel="noopener noreferrer"&gt;unist specification&lt;/a&gt; defines a position with &lt;code&gt;start&lt;/code&gt; and &lt;code&gt;end&lt;/code&gt; points. Lines and columns are one-based, offsets are zero-based, and &lt;code&gt;end&lt;/code&gt; points to the first character after the source region. Its definition of a character is a UTF-16 code unit.&lt;/p&gt;

&lt;p&gt;remark-parse produced this position for the &lt;code&gt;A😀B&lt;/code&gt; text node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes source recovery unambiguous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treating &lt;code&gt;end&lt;/code&gt; as inclusive introduces an off-by-one error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four parsers, four position capabilities
&lt;/h2&gt;

&lt;p&gt;The same input produced materially different public metadata:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parser&lt;/th&gt;
&lt;th&gt;Block positions&lt;/th&gt;
&lt;th&gt;Inline positions&lt;/th&gt;
&lt;th&gt;Absolute offset&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;remark-parse 11.0.0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;UTF-16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;commonmark.js 0.31.2&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markdown-it 15.0.1&lt;/td&gt;
&lt;td&gt;line ranges&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marked 18.0.11&lt;/td&gt;
&lt;td&gt;no standard field&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;commonmark.js reported the heading as &lt;code&gt;[[1,1],[1,6]]&lt;/code&gt;, but its &lt;code&gt;A😀B&lt;/code&gt; text node had no &lt;code&gt;sourcepos&lt;/code&gt;. markdown-it reported &lt;code&gt;[0,1]&lt;/code&gt; on the inline token, while every inline child had a null &lt;code&gt;map&lt;/code&gt;. Marked exposed &lt;code&gt;raw&lt;/code&gt;, not a unique source location.&lt;/p&gt;

&lt;p&gt;This is a capability difference, not a rendering-quality ranking. Block ranges are enough for scroll synchronization. Character-accurate quick fixes need inline ranges and explicit offsets.&lt;/p&gt;

&lt;p&gt;Do not reconstruct positions with &lt;code&gt;indexOf(token.raw)&lt;/code&gt;. Repeated text makes the result ambiguous. Accumulating &lt;code&gt;raw.length&lt;/code&gt; also breaks when a parser normalizes newlines, ignores a BOM, or merges text nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test failure syntax, not only valid syntax
&lt;/h2&gt;

&lt;p&gt;I used four fixtures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fixtures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;# Title&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;A paragraph.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;difficult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;# A😀B&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Use **e&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;u0301** and `code`.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;# Broken [link](&amp;lt;oops&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Tail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;boundary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;uFEFF# Zero&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;u200BWidth&lt;/span&gt;&lt;span class="se"&gt;\r\n\r\n&lt;/span&gt;&lt;span class="s2"&gt;End&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failed link never became a link node. remark kept the failed construct in one text node; commonmark.js split it into several text nodes; markdown-it still only identified the containing line. A diagnostic implementation cannot assume malformed syntax has the AST shape of successful syntax.&lt;/p&gt;

&lt;p&gt;The boundary fixture exposed another divergence. With a leading BOM, remark still recognized the heading, while commonmark.js parsed the first line as a paragraph. The zero-width character remained part of the text, and CRLF consumed two UTF-16 code units.&lt;/p&gt;

&lt;p&gt;When the first question is whether a real file is Markdown, plain text, or affected by encoding, this &lt;a href="https://mdfold.com/guides/what-is-a-markdown-file" rel="noopener noreferrer"&gt;Markdown file structure guide&lt;/a&gt; is the contextual checklist I use. It does not imply that parsers normalize those boundaries consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical range contract
&lt;/h2&gt;

&lt;p&gt;For a JavaScript editor, I would make the unit part of the field name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SourcePoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;offsetUtf16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;columnUtf16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SourceRange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SourcePoint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SourcePoint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// exclusive&lt;/span&gt;
  &lt;span class="nl"&gt;sourceVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a Rust or Go service returns byte offsets, call the field &lt;code&gt;offsetUtf8Bytes&lt;/code&gt; and convert against the exact same source text. &lt;code&gt;sourceVersion&lt;/code&gt; matters because every range becomes suspect after formatting or editing.&lt;/p&gt;

&lt;p&gt;For occasional lookups, scan from the start to compute line and column. For many diagnostics, precompute every line-start offset and use binary search. Decide how CRLF is counted and encode that decision in tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would regression-test
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;an emoji before the target;&lt;/li&gt;
&lt;li&gt;a decomposed character such as &lt;code&gt;e\u0301&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;LF and CRLF;&lt;/li&gt;
&lt;li&gt;a leading BOM;&lt;/li&gt;
&lt;li&gt;a zero-width character;&lt;/li&gt;
&lt;li&gt;repeated identical text;&lt;/li&gt;
&lt;li&gt;malformed links and emphasis;&lt;/li&gt;
&lt;li&gt;generated AST nodes with no source range.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The invariant is stronger than a snapshot: slicing the original source with a node's half-open offsets should reproduce that node's source spelling whenever the node genuinely came from one continuous region.&lt;/p&gt;

&lt;p&gt;Source positions are a protocol between parser, analyzer, editor, and formatter. Define the unit, range semantics, and source version before trusting the number.&lt;/p&gt;

&lt;p&gt;After a formatter rewrites the document, would you invalidate every diagnostic and reparse, or maintain a source map from the previous version?&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Snapshot Tests Lie About Markdown: Assert Structure Instead</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Wed, 02 Sep 2026 08:22:56 +0000</pubDate>
      <link>https://dev.to/mdfold/snapshot-tests-lie-about-markdown-assert-structure-instead-2d2l</link>
      <guid>https://dev.to/mdfold/snapshot-tests-lie-about-markdown-assert-structure-instead-2d2l</guid>
      <description>&lt;p&gt;A Markdown parser upgrade can pass every “does it render?” check and still change the document.&lt;/p&gt;

&lt;p&gt;The dangerous regressions are quiet: a malformed link starts auto-linking a URL, a list changes nesting, an invisible character survives normalization, or a plugin turns previously literal text into an extension node.&lt;/p&gt;

&lt;p&gt;An HTML snapshot will notice some of those changes. The problem is that it also notices quote style, optional closing tags, whitespace, attribute order, and renderer-specific formatting. Once a dependency upgrade changes hundreds of snapshots, “update all” becomes tempting—and that can approve the semantic regression with the noise.&lt;/p&gt;

&lt;p&gt;I ran a small fixed-version experiment to separate structure from serialization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;Environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 25.3.0&lt;/li&gt;
&lt;li&gt;commonmark.js 0.31.2&lt;/li&gt;
&lt;li&gt;markdown-it 15.0.0 with the &lt;code&gt;commonmark&lt;/code&gt; preset&lt;/li&gt;
&lt;li&gt;Marked 18.0.9 with default options&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The corpus deliberately includes four classes of input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;# Release&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;- parse&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;- render&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;difficult&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Read [the *nested* case](https://example.com/a_(b)).&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Broken [link](&amp;lt;https://example.com&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;edge-nul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;left&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;u0000right&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of first comparing the entire HTML string, the harness extracts the contract we actually care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;structuralFacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;h1&lt;/span&gt;&lt;span class="se"&gt;(?:\s&lt;/span&gt;&lt;span class="sr"&gt;|&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;ul&lt;/span&gt;&lt;span class="se"&gt;(?:\s&lt;/span&gt;&lt;span class="sr"&gt;|&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;li&lt;/span&gt;&lt;span class="se"&gt;(?:\s&lt;/span&gt;&lt;span class="sr"&gt;|&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;a&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+href=/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;replacement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;�&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All 12 parser-fixture assertions passed after the expected capability differences were made explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The differences were the useful result
&lt;/h2&gt;

&lt;p&gt;All three parsers produced the same structures for the normal document and the nested-emphasis link.&lt;/p&gt;

&lt;p&gt;For the unclosed link destination, commonmark.js and markdown-it emitted literal text. Marked did not create the intended Markdown link either, but its default URL auto-linking created an anchor for the URL inside the broken syntax.&lt;/p&gt;

&lt;p&gt;For the &lt;code&gt;U+0000&lt;/code&gt; case, commonmark.js and markdown-it emitted &lt;code&gt;U+FFFD&lt;/code&gt;, matching CommonMark's input-character rule. The HTML string returned by Marked still contained &lt;code&gt;U+0000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That does not make differential testing a vote. Two implementations agreeing does not establish the product contract. The correct reference depends on the layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CommonMark core: the pinned CommonMark spec examples&lt;/li&gt;
&lt;li&gt;GFM or another extension: that extension's spec and the enabled configuration&lt;/li&gt;
&lt;li&gt;product behavior: an explicit, reviewed project decision&lt;/li&gt;
&lt;li&gt;unresolved divergence: a report, not an automatically accepted snapshot&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start with upstream conformance fixtures
&lt;/h2&gt;

&lt;p&gt;The CommonMark specification repository embeds more than 500 examples that act as conformance tests. Its test tool can dump them as JSON records containing the Markdown input, expected HTML, section, and example number.&lt;/p&gt;

&lt;p&gt;That is a much stronger base than a hand-written “Markdown basics” file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;example&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;commonmarkSpec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`CommonMark example &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But conformance is not the whole product. Those fixtures do not cover your sanitizer, editor transactions, plugin ordering, export template, or platform-specific extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add one permanent fixture per real bug
&lt;/h2&gt;

&lt;p&gt;Every parser bug should leave behind:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the smallest input that reproduces it;&lt;/li&gt;
&lt;li&gt;parser, plugin, preset, and option versions;&lt;/li&gt;
&lt;li&gt;the expected structural facts;&lt;/li&gt;
&lt;li&gt;the issue or commit that explains why the expectation exists;&lt;/li&gt;
&lt;li&gt;the boundary: spec requirement, extension, or product policy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not delete that fixture when the implementation changes. If the intended behavior changes, update the contract in a reviewable change that explains why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use snapshots as evidence, not as the oracle
&lt;/h2&gt;

&lt;p&gt;Raw HTML snapshots are still useful when exact serialization is a public API. Keep them beside structural assertions.&lt;/p&gt;

&lt;p&gt;For most editor and publishing systems, stronger assertions target:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;node types and nesting;&lt;/li&gt;
&lt;li&gt;heading levels;&lt;/li&gt;
&lt;li&gt;link and image destinations;&lt;/li&gt;
&lt;li&gt;literal text preservation;&lt;/li&gt;
&lt;li&gt;source spans when diagnostics depend on them;&lt;/li&gt;
&lt;li&gt;sanitizer results after rendering;&lt;/li&gt;
&lt;li&gt;stable IDs or attributes that downstream code consumes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a table fixture is needed, I use a visual generator only to create the source, then test the generated Markdown in the target parsers. In a September 2 check, the &lt;a href="https://mdfold.com/markdown-table-generator" rel="noopener noreferrer"&gt;MDFold Markdown Table Generator&lt;/a&gt; escaped &lt;code&gt;Maya | UX&lt;/code&gt; as &lt;code&gt;Maya \| UX&lt;/code&gt;; that proves the current generator's output for this fixture, not universal table support. The same page also stayed within a 390-pixel viewport without page-level horizontal overflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Property tests need real invariants
&lt;/h2&gt;

&lt;p&gt;“Every parser must produce identical HTML” is not a valid property across Markdown dialects.&lt;/p&gt;

&lt;p&gt;Better properties include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;parsing bounded input never crashes;&lt;/li&gt;
&lt;li&gt;the same version and options produce a deterministic result;&lt;/li&gt;
&lt;li&gt;the AST contains no parent-child cycles;&lt;/li&gt;
&lt;li&gt;disabling raw HTML produces no raw-HTML nodes;&lt;/li&gt;
&lt;li&gt;bounded input size and nesting do not produce unbounded work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Property-based testing tools combine generated inputs with predicates. Record the seed for every failure, shrink it, and add the minimal result to the permanent regression corpus. Otherwise the interesting failure disappears after the random run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep pathological tests separate
&lt;/h2&gt;

&lt;p&gt;Large unmatched delimiter runs, deep brackets, nested lists, and unclosed HTML comments test complexity rather than ordinary correctness. markdown-it's own repository separates CommonMark fixtures, implementation fixtures, and pathological tests; the pathological suite uses an isolated worker and a timeout.&lt;/p&gt;

&lt;p&gt;That separation is important. Run fast semantic fixtures on every commit. Run larger complexity suites nightly or before releases, using a stable input scale and a generous budget. A noisy wall-clock threshold on shared CI is not a reliable algorithm test.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical test layout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fixtures/
  commonmark/      # upstream, versioned conformance data
  regressions/     # one directory per real bug
  extensions/      # GFM tables, task lists, footnotes...
  security/        # raw HTML, protocols, remote resources
  pathological/    # deep, long, and unclosed inputs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For dependency upgrades, generate a report with three sections: new differences, removed differences, and unclassified differences. Block the upgrade on the last section. That makes “update snapshots” a reviewed decision instead of a reflex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CommonMark 0.31.2 specification and examples: &lt;a href="https://spec.commonmark.org/0.31.2/" rel="noopener noreferrer"&gt;https://spec.commonmark.org/0.31.2/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CommonMark spec test tooling: &lt;a href="https://github.com/commonmark/commonmark-spec" rel="noopener noreferrer"&gt;https://github.com/commonmark/commonmark-spec&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;markdown-it test layout and pathological cases: &lt;a href="https://github.com/markdown-it/markdown-it/tree/master/test" rel="noopener noreferrer"&gt;https://github.com/markdown-it/markdown-it/tree/master/test&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;fast-check property model: &lt;a href="https://fast-check.dev/docs/core-blocks/properties/" rel="noopener noreferrer"&gt;https://fast-check.dev/docs/core-blocks/properties/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does your Markdown suite protect today: the document's meaning, or one renderer's current whitespace?&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Regex Cannot Sanitize Markdown: Put the Security Boundary After Parsing</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Mon, 31 Aug 2026 07:10:45 +0000</pubDate>
      <link>https://dev.to/mdfold/regex-cannot-sanitize-markdown-put-the-security-boundary-after-parsing-54pm</link>
      <guid>https://dev.to/mdfold/regex-cannot-sanitize-markdown-put-the-security-boundary-after-parsing-54pm</guid>
      <description>&lt;p&gt;Deleting &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; from Markdown with a regular expression is not an HTML security boundary. The browser eventually consumes a DOM, not the original Markdown string. A regex that matches one attribute spelling can miss another spelling the HTML parser accepts, while an aggressive replacement can damage text that was supposed to remain inside a code span.&lt;/p&gt;

&lt;p&gt;The reliable order is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Markdown source
  -&amp;gt; pinned parser
  -&amp;gt; HTML sanitizer for the real output policy
  -&amp;gt; controlled DOM sink
  -&amp;gt; CSP / Trusted Types as defense in depth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A regex that passes the easy test
&lt;/h2&gt;

&lt;p&gt;I tested this deliberately narrow cleaner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;naiveRegexClean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;script&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;script&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;on&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sr"&gt;+="&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*"/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/javascript:/gi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It removes a plain &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; and a double-quoted &lt;code&gt;onerror&lt;/code&gt;. It does not remove the same event attribute when it is unquoted or single-quoted:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;img src=x onerror="alert(1)"&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;event attribute removed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;img src=x onerror=alert(1)&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;event attribute remains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;img src=x onerror='alert(1)'&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;event attribute remains&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Replacing &lt;code&gt;javascript:&lt;/code&gt; also turns the URL into a different string. It does not prove that the resulting URL matches the application's protocol or navigation policy.&lt;/p&gt;

&lt;p&gt;The point is not that every regex is short. The point is that string matching and browser HTML parsing operate at different abstraction levels.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixed-version test
&lt;/h2&gt;

&lt;p&gt;I used Node.js 25.3.0, Marked 18.0.7, DOMPurify 3.4.12, and MDFold's current browser-side Markdown-to-HTML path. The fixture covered ordinary formatting, raw scripts, quoted and unquoted event handlers, Markdown and raw-HTML &lt;code&gt;javascript:&lt;/code&gt; links, inline code, a remote image, and a fixed-position style.&lt;/p&gt;

&lt;p&gt;Marked preserved the raw HTML in its generated output. That is expected: the Marked documentation explicitly warns that it does not sanitize output HTML.&lt;/p&gt;

&lt;p&gt;The real browser preview then sanitized the parsed HTML. It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;preserved bold text and an HTTPS link;&lt;/li&gt;
&lt;li&gt;removed the script element and event handler;&lt;/li&gt;
&lt;li&gt;removed unsafe &lt;code&gt;href&lt;/code&gt; values while preserving link text;&lt;/li&gt;
&lt;li&gt;preserved the attack-shaped string inside inline code as escaped text;&lt;/li&gt;
&lt;li&gt;preserved the remote image URL;&lt;/li&gt;
&lt;li&gt;preserved the tested &lt;code&gt;style&lt;/code&gt; attribute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more informative than checking whether an alert happened to appear. The test inspected the resulting elements, attributes, URLs, and text nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing, sanitizing, and resource policy are separate
&lt;/h2&gt;

&lt;p&gt;CommonMark defines how raw HTML participates in Markdown parsing. It does not define a web application's XSS policy. OWASP recommends output encoding when data should remain text and HTML sanitization when authors are allowed to provide HTML. DOMPurify works on a parsed, inert DOM and applies an allow-list to elements and attributes.&lt;/p&gt;

&lt;p&gt;An application that does not need raw HTML can reject HTML nodes earlier. That reduces the surface but does not answer every downstream question. Images can still cause network requests. Links can still leave the site. Plugins can still generate HTML. The final sink still owns the final policy.&lt;/p&gt;

&lt;p&gt;The two preserved values in my test show the remaining boundaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A sanitized remote image can still make a request. XSS prevention and privacy are different controls.&lt;/li&gt;
&lt;li&gt;A permitted style can still obscure content. Script safety and visual-integrity policy are different controls.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Do not modify sanitized HTML afterward
&lt;/h2&gt;

&lt;p&gt;This sequence destroys the boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dirty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USER&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;untrustedValue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every untrusted value needs the defense for the context where it is inserted. Prefer safe sinks such as &lt;code&gt;textContent&lt;/code&gt; for plain text. If a CMS or template engine reparses or mutates the HTML later, validate at that boundary too.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical review checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pin parser and sanitizer versions separately.&lt;/li&gt;
&lt;li&gt;Disable raw HTML when the product does not need it.&lt;/li&gt;
&lt;li&gt;When HTML is required, start with a narrow allow-list.&lt;/li&gt;
&lt;li&gt;Define protocol rules for &lt;code&gt;href&lt;/code&gt; and resource rules for &lt;code&gt;src&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decide whether inline styles belong in the content model.&lt;/li&gt;
&lt;li&gt;Avoid post-sanitization string concatenation.&lt;/li&gt;
&lt;li&gt;Inspect the final DOM and network behavior, not only the source string.&lt;/li&gt;
&lt;li&gt;Retest in the destination CMS, browser, and mobile layout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I reproduced the browser portion with the &lt;a href="https://mdfold.com/markdown-to-html" rel="noopener noreferrer"&gt;MDFold Markdown-to-HTML converter&lt;/a&gt;. At a 390-pixel viewport the tested preview stayed within the page width, but that result covers this fixture and current version only.&lt;/p&gt;

&lt;p&gt;The engineering question is not "which regex catches every payload?" It is "which component owns the policy for this exact output sink?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Primary sources
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://spec.commonmark.org/0.31.2/" rel="noopener noreferrer"&gt;CommonMark 0.31.2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marked.js.org/" rel="noopener noreferrer"&gt;Marked security warning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP XSS Prevention Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cure53/DOMPurify/blob/main/README.md" rel="noopener noreferrer"&gt;DOMPurify README&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>markdown</category>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Invisible Unicode Characters Can Break Markdown Without Looking Different</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Sat, 22 Aug 2026 22:44:45 +0000</pubDate>
      <link>https://dev.to/mdfold/invisible-unicode-characters-can-break-markdown-without-looking-different-2e3d</link>
      <guid>https://dev.to/mdfold/invisible-unicode-characters-can-break-markdown-without-looking-different-2e3d</guid>
      <description>&lt;p&gt;Two Markdown lines can look identical and still produce different HTML. Before blaming the parser, inspect the code points.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# heading
#​heading
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second line contains U+200B ZERO WIDTH SPACE after &lt;code&gt;#&lt;/code&gt;. CommonMark requires the ATX marker sequence to be followed by a space, tab, or line ending, so this is a paragraph rather than a heading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the invisible input observable
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codePoints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="s2"&gt;`U+&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;codePoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s1"&gt;u200Bheading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;// U+0023 U+200B U+0068 ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the source came from an unknown editor, first verify that it is actually a text file and inspect its encoding. This &lt;a href="https://mdfold.com/guides/how-to-open-a-markdown-file" rel="noopener noreferrer"&gt;guide to opening Markdown files&lt;/a&gt; is a useful checklist; the important step is inspection, not conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  What three fixed parser versions produced
&lt;/h2&gt;

&lt;p&gt;I tested Node.js 25.3.0 with Marked 18.0.7, markdown-it 14.1.0, and commonmark.js 0.31.2. These are observed outputs under default options, not claims about every Markdown platform.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Marked&lt;/th&gt;
&lt;th&gt;markdown-it&lt;/th&gt;
&lt;th&gt;commonmark.js&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;# heading&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;heading&lt;/td&gt;
&lt;td&gt;heading&lt;/td&gt;
&lt;td&gt;heading&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;leading U+FEFF before &lt;code&gt;#&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;paragraph, FEFF retained&lt;/td&gt;
&lt;td&gt;paragraph, FEFF omitted in output&lt;/td&gt;
&lt;td&gt;paragraph, FEFF omitted in output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+200B after &lt;code&gt;#&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;paragraph&lt;/td&gt;
&lt;td&gt;paragraph&lt;/td&gt;
&lt;td&gt;paragraph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+00A0 after &lt;code&gt;#&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;heading&lt;/td&gt;
&lt;td&gt;paragraph&lt;/td&gt;
&lt;td&gt;paragraph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fullwidth &lt;code&gt;＃&lt;/code&gt; or &lt;code&gt;＊&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;plain text&lt;/td&gt;
&lt;td&gt;plain text&lt;/td&gt;
&lt;td&gt;plain text&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The NBSP result is the useful warning: a character that looks like a space creates a real cross-parser difference. “Whitespace” is not one universal Markdown category.&lt;/p&gt;

&lt;h2&gt;
  
  
  A BOM is positional
&lt;/h2&gt;

&lt;p&gt;UTF-8 uses &lt;code&gt;EF BB BF&lt;/code&gt; as its optional signature. After decoding, that is U+FEFF. Unicode says a recognized initial BOM should be removed before text processing. The same code point in the middle of text is not automatically a BOM and must not be globally deleted.&lt;/p&gt;

&lt;p&gt;In the experiment, a leading U+FEFF prevented &lt;code&gt;#&lt;/code&gt; from being the first character seen by the Markdown grammar. An internal &lt;code&gt;alpha\uFEFFbeta&lt;/code&gt; remained present in all three outputs. Fix BOM handling at the decoding boundary, not with &lt;code&gt;replaceAll('\uFEFF', '')&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero width does not mean zero semantics
&lt;/h2&gt;

&lt;p&gt;U+200B is a format control used to indicate a word or line-break opportunity. Its effect depends on position:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;after &lt;code&gt;#&lt;/code&gt;, it prevents ATX-heading recognition;&lt;/li&gt;
&lt;li&gt;inside &lt;code&gt;**bo\u200Bld**&lt;/code&gt;, all three parsers still create &lt;code&gt;&amp;lt;strong&amp;gt;&lt;/code&gt;, while preserving U+200B in the text node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a blanket “remove invisible characters” rule is both too broad and too weak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look-alike punctuation is different punctuation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;＃&lt;/code&gt; is U+FF03, not U+0023. &lt;code&gt;＊&lt;/code&gt; is U+FF0A, not U+002A. All three parsers treated the fullwidth examples as ordinary text.&lt;/p&gt;

&lt;p&gt;NFKC normalization can map some compatibility characters to ASCII, but silently normalizing user source changes data. A safer editor warns, previews the proposed change, and lets the author approve it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bidirectional controls require a different review model
&lt;/h2&gt;

&lt;p&gt;UAX #9 distinguishes logical order from display order. Directional controls can change what reviewers see while parsers continue reading logical code-point order.&lt;/p&gt;

&lt;p&gt;With U+202E inside a Markdown URL, none of the three parsers simply discarded it. Marked and commonmark.js percent-encoded it; markdown-it serialized the host differently through IDNA processing. The safe review target is therefore not just the visible source. Inspect the code points, parsed &lt;code&gt;href&lt;/code&gt;, and final resolved URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical ingestion policy
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Handle an initial BOM during byte decoding.&lt;/li&gt;
&lt;li&gt;Preserve raw bytes in regression fixtures.&lt;/li&gt;
&lt;li&gt;Flag &lt;code&gt;Cf&lt;/code&gt; characters and non-ASCII look-alike punctuation near Markdown delimiters.&lt;/li&gt;
&lt;li&gt;Show code point, line, column, and Unicode name in diagnostics.&lt;/li&gt;
&lt;li&gt;Preview repairs instead of silently rewriting source.&lt;/li&gt;
&lt;li&gt;Validate links after parsing and URL resolution.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Invisible-character bugs are boundary bugs: the decoder, editor, Markdown grammar, HTML serializer, and browser may each make a different decision. Good tooling makes those decisions observable.&lt;/p&gt;

&lt;p&gt;Would you prefer an editor that automatically cleans invisible characters, or one that always preserves the source and only warns?&lt;/p&gt;

</description>
      <category>security</category>
      <category>markdown</category>
      <category>webdev</category>
      <category>unicode</category>
    </item>
    <item>
      <title>Markdown Images Are Network Requests, Not Embedded Assets</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Thu, 20 Aug 2026 06:08:34 +0000</pubDate>
      <link>https://dev.to/mdfold/markdown-images-are-network-requests-not-embedded-assets-5580</link>
      <guid>https://dev.to/mdfold/markdown-images-are-network-requests-not-embedded-assets-5580</guid>
      <description>&lt;p&gt;The line &lt;code&gt;![diagram](../assets/flow.png)&lt;/code&gt; does not embed an image. It creates an image node whose destination still has to be resolved, fetched, allowed, and rendered by another system.&lt;/p&gt;

&lt;p&gt;That distinction explains why a document can parse perfectly and still show broken images—or make network requests its author did not account for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing and URL resolution are separate stages
&lt;/h2&gt;

&lt;p&gt;I fixed the experiment to Node 25.3.0 and Marked 18.0.7 with GFM enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;![diagram](../assets/flow.png "Flow")&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;p&amp;gt;&amp;lt;img src="../assets/flow.png" alt="diagram" title="Flow"&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Marked preserves the destination. A browser or publisher later resolves it against a base URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../assets/flow.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://docs.example.test/guides/setup/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;
&lt;span class="c1"&gt;// https://docs.example.test/guides/assets/flow.png&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../assets/flow.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://docs.example.test/guides/setup.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;
&lt;span class="c1"&gt;// https://docs.example.test/assets/flow.png&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same Markdown, different request. The parser did its job in both cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six cases that need different policies
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Parser output&lt;/th&gt;
&lt;th&gt;What the host must decide&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;../assets/flow.png&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;relative &lt;code&gt;src&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;base URL and asset copy rules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/assets/logo.svg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;root-relative &lt;code&gt;src&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;which origin owns the root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://.../pixel.png?doc=42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;absolute &lt;code&gt;src&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;remote trust and request privacy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;data:image/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;data URL&lt;/td&gt;
&lt;td&gt;sanitizer and CSP policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;empty alt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;alt=""&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;decorative or accessibility defect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;empty destination&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src=""&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reject, rewrite, or host behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CommonMark defines image syntax and maps the description to alt text. It does not upload files or promise that a URL exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remote images are third-party dependencies
&lt;/h2&gt;

&lt;p&gt;When HTML contains a remote &lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt;, the browser requests that resource. The remote server observes the request; the page's Referrer Policy controls how much referring-page information is sent. Query parameters can also encode a document or campaign identifier.&lt;/p&gt;

&lt;p&gt;Treat remote images like any other external dependency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;allow only reviewed schemes and hosts;&lt;/li&gt;
&lt;li&gt;proxy or self-host assets when appropriate;&lt;/li&gt;
&lt;li&gt;set a deliberate Referrer Policy;&lt;/li&gt;
&lt;li&gt;restrict image sources with CSP &lt;code&gt;img-src&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;sanitize final HTML after AST transformations;&lt;/li&gt;
&lt;li&gt;never put sensitive identifiers in image URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Parser configuration cannot replace those controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Relative paths need a publishing contract
&lt;/h2&gt;

&lt;p&gt;A stable workflow defines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;where the Markdown file lives;&lt;/li&gt;
&lt;li&gt;what base URL the final page uses;&lt;/li&gt;
&lt;li&gt;how referenced assets are copied;&lt;/li&gt;
&lt;li&gt;whether root-relative paths are allowed;&lt;/li&gt;
&lt;li&gt;what happens when an image is missing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are debugging a real document, this &lt;a href="https://mdfold.com/guides/add-images-in-markdown" rel="noopener noreferrer"&gt;Markdown image path guide&lt;/a&gt; provides a concrete path checklist. The important step is to test the published URL, not just the editor preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data URLs trade path stability for other costs
&lt;/h2&gt;

&lt;p&gt;Data URLs avoid a separate file lookup, but they make source files larger and harder to review, prevent independent caching, and may be blocked by a sanitizer or CSP. They are reasonable for small controlled assets, not a universal fix for image portability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the final request graph
&lt;/h2&gt;

&lt;p&gt;My recommended pipeline is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;parse Markdown to an AST;&lt;/li&gt;
&lt;li&gt;inspect image destinations;&lt;/li&gt;
&lt;li&gt;resolve them against the declared publication base;&lt;/li&gt;
&lt;li&gt;validate scheme, host, path, and query;&lt;/li&gt;
&lt;li&gt;copy or rewrite managed assets;&lt;/li&gt;
&lt;li&gt;sanitize rendered HTML and apply CSP/referrer policy;&lt;/li&gt;
&lt;li&gt;open the public page and verify actual responses and layout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The AST tells you what the author referenced. URL resolution tells you where it points. A browser test tells you what really happened.&lt;/p&gt;

&lt;p&gt;Should Markdown hosts block cross-origin images by default, or preserve compatibility and require each application to opt into a stricter policy?&lt;/p&gt;

&lt;h2&gt;
  
  
  Primary references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://spec.commonmark.org/0.31.2/#images" rel="noopener noreferrer"&gt;CommonMark 0.31.2: Images&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://url.spec.whatwg.org/" rel="noopener noreferrer"&gt;WHATWG URL Standard&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img" rel="noopener noreferrer"&gt;MDN: img element&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Referrer_policy" rel="noopener noreferrer"&gt;MDN: Referrer Policy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP" rel="noopener noreferrer"&gt;MDN: Content Security Policy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>tooling</category>
      <category>markdown</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Your Markdown Parser Is Not Your XSS Boundary</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Wed, 19 Aug 2026 02:36:27 +0000</pubDate>
      <link>https://dev.to/mdfold/your-markdown-parser-is-not-your-xss-boundary-2gpk</link>
      <guid>https://dev.to/mdfold/your-markdown-parser-is-not-your-xss-boundary-2gpk</guid>
      <description>&lt;p&gt;A Markdown parser can produce exactly the right HTML and still leave your application exposed to XSS. Parsing answers what the input means. Sanitization decides which parts of that meaning are allowed to reach an HTML sink.&lt;/p&gt;

&lt;p&gt;I tested that boundary with Node.js 25.3.0, Marked 18.0.7, DOMPurify 3.4.12, and jsdom 30.0.1. The important comparison is not a screenshot. It is the HTML before and after sanitization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest useful pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;USE_PROFILES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;SANITIZE_NAMED_PROPS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This deliberately keeps two responsibilities separate. Marked parses Markdown. DOMPurify applies an allow-list to the HTML structure that will approach the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five cases that expose the boundary
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Normal content survives
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Hello&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Safe&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://example.com&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The heading and HTTPS link survive both stages. A sanitizer should preserve allowed document structure, not flatten every document to text.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Raw HTML is valid Markdown, not necessarily safe HTML
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt; &lt;span class="na"&gt;onerror=&lt;/span&gt;&lt;span class="s"&gt;"alert(1)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Marked 18.0.7 returns the element and its event attribute unchanged. DOMPurify returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"x"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser did not fail. CommonMark supports raw HTML. The unsafe step would be treating syntactic validity as authorization to insert every attribute.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. URL schemes need their own policy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;click&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;javascript:alert(1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rendered HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"javascript:alert(1)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;click&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sanitized HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;a&amp;gt;&lt;/span&gt;click&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An element allow-list alone is insufficient. URL-bearing attributes need scheme validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Code examples must not be cleaned as attacks
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;```&lt;/span&gt;&lt;span class="nl"&gt;html
&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt; &lt;span class="na"&gt;onerror=&lt;/span&gt;&lt;span class="s"&gt;"alert(1)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;```&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser escapes the payload inside &lt;code&gt;pre &amp;gt; code&lt;/code&gt;. A regex that removes attack-looking source before parsing would damage legitimate security documentation. Context has to be established first.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. XSS defenses extend beyond &lt;code&gt;script&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"attributes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"action"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;SANITIZE_NAMED_PROPS&lt;/code&gt;, the result becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"user-content-attributes"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"user-content-action"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This addresses DOM clobbering: attacker-controlled names can interfere with properties that application code expects to resolve normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put sanitization after the last unsafe transform
&lt;/h2&gt;

&lt;p&gt;A practical pipeline is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;untrusted Markdown
  -&amp;gt; parser
  -&amp;gt; Markdown/HTML AST transforms
  -&amp;gt; sanitizer
  -&amp;gt; serializer
  -&amp;gt; matching HTML sink
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;rehype-sanitize&lt;/code&gt; documentation makes the ordering rule explicit: sanitize after the last unsafe operation, because a later plugin can reintroduce unsafe properties. DOMPurify's current threat model adds another constraint: do not sanitize and then freely post-process the result. The policy and the sink must stay aligned.&lt;/p&gt;

&lt;p&gt;Turning off raw HTML is a useful reduction in attack surface, but it is not a universal sanitizer. Plugins, link protocols, generated IDs, and later transforms still deserve explicit policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I verify in a conversion workflow
&lt;/h2&gt;

&lt;p&gt;When checking &lt;a href="https://mdfold.com/markdown-to-html" rel="noopener noreferrer"&gt;Markdown to HTML&lt;/a&gt;, I separate three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did normal Markdown preserve the intended structure?&lt;/li&gt;
&lt;li&gt;Did fenced examples remain inert code?&lt;/li&gt;
&lt;li&gt;Is untrusted output safe for &lt;em&gt;this application's&lt;/em&gt; sink and policy?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two are conversion checks. The third belongs to the embedding application. A converter producing structurally correct HTML does not automatically promise that arbitrary input is safe to inject into another site's DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engineering checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Treat external Markdown as untrusted by default.&lt;/li&gt;
&lt;li&gt;Disable raw HTML where the product does not need it.&lt;/li&gt;
&lt;li&gt;Sanitize the final HTML tree with a maintained allow-list sanitizer.&lt;/li&gt;
&lt;li&gt;Model URL schemes, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, and styling capabilities explicitly.&lt;/li&gt;
&lt;li&gt;Do not run arbitrary HTML-mutating plugins after sanitization.&lt;/li&gt;
&lt;li&gt;Test AST shape, rendered HTML, and sanitized HTML separately.&lt;/li&gt;
&lt;li&gt;Pin and update sanitizer versions; security fixes are part of the boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design question I keep coming back to is this: should Markdown renderers disable raw HTML by default, or should they expose it only behind an explicit host-supplied security policy?&lt;/p&gt;

&lt;h2&gt;
  
  
  Primary sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://spec.commonmark.org/0.31.2/" rel="noopener noreferrer"&gt;CommonMark 0.31.2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/markedjs/marked/blob/master/docs/USING_ADVANCED.md" rel="noopener noreferrer"&gt;Marked advanced usage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/rehypejs/rehype-sanitize" rel="noopener noreferrer"&gt;rehype-sanitize&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cure53/DOMPurify/wiki/Security-Goals-%26-Threat-Model" rel="noopener noreferrer"&gt;DOMPurify security goals and threat model&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build a Markdown Heading Linter on the AST, Not With Regex</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Sat, 15 Aug 2026 22:13:26 +0000</pubDate>
      <link>https://dev.to/mdfold/build-a-markdown-heading-linter-on-the-ast-not-with-regex-2o8f</link>
      <guid>https://dev.to/mdfold/build-a-markdown-heading-linter-on-the-ast-not-with-regex-2o8f</guid>
      <description>&lt;p&gt;A heading linter sounds like a one-line regex:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;gm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works until a documentation page contains a fenced example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Real heading&lt;/span&gt;

&lt;span class="p"&gt;```&lt;/span&gt;&lt;span class="nl"&gt;md
&lt;/span&gt;&lt;span class="gu"&gt;## This is code, not navigation&lt;/span&gt;
&lt;span class="p"&gt;```&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regex finds two headings. A Markdown parser finds one heading and one code block.&lt;/p&gt;

&lt;p&gt;That difference is why I prefer to put document automation behind an AST boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source -&amp;gt; parser -&amp;gt; mdast -&amp;gt; transformer -&amp;gt; hast -&amp;gt; HTML
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A small, testable plugin
&lt;/h2&gt;

&lt;p&gt;For this experiment I pinned &lt;code&gt;unified@11.0.5&lt;/code&gt;, &lt;code&gt;remark-parse@11.0.0&lt;/code&gt;, &lt;code&gt;remark-rehype@11.1.2&lt;/code&gt;, &lt;code&gt;rehype-stringify@10.0.1&lt;/code&gt;, and &lt;code&gt;unist-util-visit@5.1.0&lt;/code&gt; on Node 25.3.0.&lt;/p&gt;

&lt;p&gt;The plugin has two jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;collect headings for a table of contents;&lt;/li&gt;
&lt;li&gt;report a diagnostic when heading depth jumps by more than one level.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;headingAudit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;previousDepth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;textOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousDepth&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;previousDepth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`Heading jumps from h&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;previousDepth&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to h&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;node&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nx"&gt;headings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="nx"&gt;previousDepth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headings&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser decides what a heading is. The plugin only evaluates heading nodes. This keeps syntax decisions separate from project policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four cases worth keeping in CI
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Normal structure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Guide&lt;/span&gt;

&lt;span class="gu"&gt;## Install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: two heading nodes, no diagnostic, and two TOC entries.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Inline markup and duplicate labels
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Guide&lt;/span&gt;

&lt;span class="gu"&gt;## *API* Reference&lt;/span&gt;

&lt;span class="gu"&gt;## API Reference&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first heading does not contain one flat text node. Its children include an &lt;code&gt;emphasis&lt;/code&gt; node, so a robust text extractor must walk descendants. Both visible labels normalize to the same slug; the second needs a stable suffix such as &lt;code&gt;api-reference-1&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Valid Markdown that violates a project rule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Guide&lt;/span&gt;

&lt;span class="gu"&gt;### Internals&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parsing and HTML rendering succeed, but the plugin reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Heading jumps from h1 to h3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a useful distinction. A parser error means the syntax cannot be interpreted under the configured grammar. A linter diagnostic means the syntax is valid but conflicts with a documentation policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A heading marker inside code
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Real heading&lt;/span&gt;

&lt;span class="p"&gt;```&lt;/span&gt;&lt;span class="nl"&gt;md
&lt;/span&gt;&lt;span class="gu"&gt;## Not a heading&lt;/span&gt;
&lt;span class="p"&gt;```&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The top-level mdast nodes are &lt;code&gt;heading&lt;/code&gt; and &lt;code&gt;code&lt;/code&gt;. The TOC contains only &lt;code&gt;Real heading&lt;/code&gt;. No special-case regex is needed because the parser has already resolved the context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep plugins on the right side of the bridge
&lt;/h2&gt;

&lt;p&gt;Here is the complete processing order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;unified&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remarkParse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headingAudit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remarkRehype&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addHeadingIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rehypeStringify&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;headingAudit&lt;/code&gt; expects mdast and therefore runs before &lt;code&gt;remarkRehype&lt;/code&gt;. &lt;code&gt;addHeadingIds&lt;/code&gt; writes HTML properties and therefore runs on hast after the bridge. Plugin order is part of the contract, not cosmetic configuration.&lt;/p&gt;

&lt;p&gt;When checking the same samples with a &lt;a href="https://mdfold.com/markdown-to-html" rel="noopener noreferrer"&gt;Markdown-to-HTML converter&lt;/a&gt;, I compare structural output—headings, code blocks, links—not only whether the preview looks plausible. Two pages can look similar while exposing different trees to a TOC generator, sanitizer, or editor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an AST does not solve
&lt;/h2&gt;

&lt;p&gt;An AST removes a lot of context guessing, but it does not make a pipeline automatically safe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two plugins can mutate the same nodes;&lt;/li&gt;
&lt;li&gt;generating a TOC before another plugin rewrites headings creates stale data;&lt;/li&gt;
&lt;li&gt;slug behavior is not defined by CommonMark and differs across platforms;&lt;/li&gt;
&lt;li&gt;raw HTML still needs an explicit trust and sanitization policy;&lt;/li&gt;
&lt;li&gt;syntax-extension plugins can change what inputs the parser accepts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule I use is to document every plugin's input tree, output tree, mutations, diagnostics, and ordering constraints. Then test both the tree shape and the serialized output.&lt;/p&gt;

&lt;p&gt;CommonMark 0.31.2 supports the underlying separation: block structure is resolved before inline structure. Unified and remark make that structure available as mdast and provide a plugin pipeline around it. The experiment above only claims the pinned versions and inputs listed here; it is not proof that every Markdown dialect shares the same tree or slug rules.&lt;/p&gt;

&lt;p&gt;Where would you draw the boundary between plugin freedom and syntax stability in a long-lived documentation pipeline?&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Markdown Dialects Need a Capability Matrix, Not Just a Name</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Sat, 15 Aug 2026 03:41:50 +0000</pubDate>
      <link>https://dev.to/mdfold/markdown-dialects-need-a-capability-matrix-not-just-a-name-18lg</link>
      <guid>https://dev.to/mdfold/markdown-dialects-need-a-capability-matrix-not-just-a-name-18lg</guid>
      <description>&lt;p&gt;Two tools can both claim to support “Markdown” and still disagree about tables, task lists, bare URLs, and even the HTML element used for strikethrough.&lt;/p&gt;

&lt;p&gt;That is usually not a parser bug. It is a contract problem.&lt;/p&gt;

&lt;p&gt;“Markdown support” often collapses three separate layers into one label:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a base syntax such as CommonMark;&lt;/li&gt;
&lt;li&gt;extensions such as GitHub Flavored Markdown;&lt;/li&gt;
&lt;li&gt;platform-specific configuration, sanitization, and post-processing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I ran a small differential test to make those boundaries concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed environment
&lt;/h2&gt;

&lt;p&gt;Tested on August 15, 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;commonmark&lt;/code&gt; 0.31.2&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;markdown-it&lt;/code&gt; 14.3.0, default preset, no third-party plugins&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;marked&lt;/code&gt; 18.0.7, default options&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The comparison records generated HTML, not screenshots. That distinction matters: two results can look similar while producing different DOM structures for accessibility, export, or later transforms.&lt;/p&gt;

&lt;p&gt;Minimal setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;commonmark&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;commonmark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;markdownit&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;markdown-it&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;marked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;before ~~deleted~~ after&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;commonmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Parser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;commonmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HtmlRenderer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;markdownit&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are versioned defaults, not permanent labels for the libraries. Options and plugins can change the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A pipe table is not base CommonMark
&lt;/h2&gt;

&lt;p&gt;Input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| A | B |
|---|---|
| 1 | 2 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Observed structure:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parser&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;commonmark 0.31.2&lt;/td&gt;
&lt;td&gt;One paragraph; pipes remain text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markdown-it 14.3.0&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;table&lt;/code&gt; with &lt;code&gt;thead&lt;/code&gt; and &lt;code&gt;tbody&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;marked 18.0.7&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;table&lt;/code&gt; with &lt;code&gt;thead&lt;/code&gt; and &lt;code&gt;tbody&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CommonMark 0.31.2 does not define pipe tables. GFM adds them as an extension.&lt;/p&gt;

&lt;p&gt;When I need a controlled table input for a compatibility test, I use a &lt;a href="https://mdfold.com/markdown-table-generator" rel="noopener noreferrer"&gt;Markdown table generator&lt;/a&gt; to avoid accidental delimiter mistakes, then verify the output with the actual target renderer. Generating valid source is not the same as proving platform compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A pipe inside a code span is a hard case
&lt;/h2&gt;

&lt;p&gt;Input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| A | B |
|---|---|
| &lt;span class="sb"&gt;`x|y`&lt;/span&gt; | a&lt;span class="se"&gt;\|&lt;/span&gt;b |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the tested markdown-it and Marked defaults, the unescaped pipe inside the code span still splits the row. One cell receives the opening backtick plus &lt;code&gt;x&lt;/code&gt;; the next receives &lt;code&gt;y&lt;/code&gt; plus the closing backtick, instead of one code span.&lt;/p&gt;

&lt;p&gt;GFM’s table examples require escaping that pipe even inside a code span:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| A | B |
|---|---|
| &lt;span class="sb"&gt;`x\|y`&lt;/span&gt; | a&lt;span class="se"&gt;\|&lt;/span&gt;b |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why splitting a table row with &lt;code&gt;source.split('|')&lt;/code&gt; is not a parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A table-like shape can fail correctly
&lt;/h2&gt;

&lt;p&gt;Input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| A | B |
| 1 | 2 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three parsers emitted a paragraph. Without the delimiter row, none guessed that the author intended a table.&lt;/p&gt;

&lt;p&gt;For editor diagnostics, checking for a real table node is safer than checking whether the source contains pipes.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Strikethrough has two kinds of differences
&lt;/h2&gt;

&lt;p&gt;For:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;before ~~deleted~~ after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;commonmark keeps the tildes as text;&lt;/li&gt;
&lt;li&gt;markdown-it emits &lt;code&gt;&amp;lt;s&amp;gt;&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;Marked emits &lt;code&gt;&amp;lt;del&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the boundary case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;before ~not deleted~ after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;only Marked recognized strikethrough in this test.&lt;/p&gt;

&lt;p&gt;There are two contracts here: whether the syntax is recognized, and which HTML structure represents it. Screenshot tests can miss the second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Task lists may be plain text or form controls
&lt;/h2&gt;

&lt;p&gt;Input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; [x] shipped
&lt;span class="p"&gt;-&lt;/span&gt; [ ] pending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;commonmark and the markdown-it default produced normal list items containing &lt;code&gt;[x]&lt;/code&gt; and &lt;code&gt;[ ]&lt;/code&gt;. Marked produced disabled checkbox inputs and marked the first one as checked.&lt;/p&gt;

&lt;p&gt;Whether a host then makes those boxes interactive is a UI and security decision, not something the Markdown syntax alone can decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Angle-bracket autolinks and bare URLs are different features
&lt;/h2&gt;

&lt;p&gt;All three parsers linked this CommonMark form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nv"&gt;&amp;lt;https://example.com/a_(b)&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Visit https://example.com/a_(b).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;only Marked linked the bare URL under the tested defaults. It also excluded the trailing period from the destination.&lt;/p&gt;

&lt;p&gt;markdown-it can add this behavior with &lt;code&gt;linkify&lt;/code&gt;; that option was deliberately left off so the matrix describes the default preset.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Raw HTML policy is not HTML safety
&lt;/h2&gt;

&lt;p&gt;Input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;xmp&amp;gt;&lt;/span&gt;&lt;span class="gs"&gt;**not emphasis**&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/xmp&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;commonmark and Marked preserved the &lt;code&gt;xmp&lt;/code&gt; element. markdown-it escaped the tags because raw HTML is disabled by default. All three still parsed the emphasis inside.&lt;/p&gt;

&lt;p&gt;GFM has a tag-filter extension for names such as &lt;code&gt;script&lt;/code&gt;, &lt;code&gt;style&lt;/code&gt;, &lt;code&gt;iframe&lt;/code&gt;, and &lt;code&gt;xmp&lt;/code&gt;. That filter is not a complete HTML sanitizer. Marked’s own documentation also warns that its output is not sanitized.&lt;/p&gt;

&lt;p&gt;A production pipeline therefore needs two explicit answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What HTML can the parser generate?&lt;/li&gt;
&lt;li&gt;What HTML does the sanitizer allow?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The resulting capability matrix
&lt;/h2&gt;

&lt;p&gt;For these exact versions and defaults:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;commonmark&lt;/th&gt;
&lt;th&gt;markdown-it&lt;/th&gt;
&lt;th&gt;Marked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Angle-bracket autolink&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipe tables&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;~~x~~&lt;/code&gt; strikethrough&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;~x~&lt;/code&gt; strikethrough&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task-list checkboxes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare-URL autolinking&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw HTML enabled by default&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complete HTML sanitization&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;“No” is not automatically a defect. Disabling raw HTML can be a deliberate safety boundary. markdown-it can gain more syntax through plugins. The important point is that version and configuration belong in the contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  A more useful declaration
&lt;/h2&gt;

&lt;p&gt;Instead of only storing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"flavor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gfm"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;an application could expose something closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"commonmark-0.31.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tables"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strikethrough"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"single-or-double-tilde"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"taskList"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"extendedAutolink"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tagfilter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rawHtml"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"parse-then-sanitize"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That object is testable, versionable, and much harder to misunderstand than a dialect name.&lt;/p&gt;

&lt;p&gt;The open question is whether Markdown ecosystems should standardize a machine-readable capability manifest—or whether every platform will continue documenting its dialect through examples and surprises.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
    </item>
    <item>
      <title>Why One Newline Renders Differently Across Markdown Tools</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Fri, 14 Aug 2026 03:37:51 +0000</pubDate>
      <link>https://dev.to/mdfold/why-one-newline-renders-differently-across-markdown-tools-4lb3</link>
      <guid>https://dev.to/mdfold/why-one-newline-renders-differently-across-markdown-tools-4lb3</guid>
      <description>&lt;p&gt;Press Enter once in a Markdown editor and the preview may keep the text in one paragraph. Move the same file to a chat app and every source line may become a visible line break.&lt;/p&gt;

&lt;p&gt;That is usually not a parser bug. Three separate things are being confused:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the file's line ending (&lt;code&gt;LF&lt;/code&gt;, &lt;code&gt;CRLF&lt;/code&gt;, or &lt;code&gt;CR&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;the Markdown node (&lt;code&gt;softbreak&lt;/code&gt; or hard line break), and&lt;/li&gt;
&lt;li&gt;the browser's final layout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I tested the cases below on August 14, 2026 with fixed versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;commonmark.js 0.31.2&lt;/li&gt;
&lt;li&gt;markdown-it 15.0.0&lt;/li&gt;
&lt;li&gt;Marked 18.0.9&lt;/li&gt;
&lt;li&gt;CommonMark specification 0.31.2&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One Enter is a soft break by default
&lt;/h2&gt;

&lt;p&gt;Input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;alpha
beta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three parsers produced one paragraph in their default configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;alpha
beta&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CommonMark calls the line ending a soft break. A renderer may emit it as a newline character or a space. Under the browser's usual &lt;code&gt;white-space: normal&lt;/code&gt;, that whitespace is collapsed, so the two words normally appear on one visual line unless the container wraps them.&lt;/p&gt;

&lt;p&gt;This distinction matters: a newline in generated HTML source is not necessarily a visible line break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two spaces and a backslash create hard breaks
&lt;/h2&gt;

&lt;p&gt;CommonMark supports two forms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;alpha  
beta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;alpha&lt;span class="err"&gt;\&lt;/span&gt;
beta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both produced a hard-break node and a &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt; element in all three parsers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;alpha&lt;span class="nt"&gt;&amp;lt;br&amp;gt;&lt;/span&gt;
beta&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer the backslash form when a break is semantically important. It is visible during review, while trailing spaces are easy for an editor or formatter to delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  LF, CRLF, and CR normalized to the same result
&lt;/h2&gt;

&lt;p&gt;I rendered these inputs separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alpha\nbeta
alpha\r\nbeta
alpha\rbeta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three parsers treated them as the same paragraph with a soft break by default. Converting a repository from CRLF to LF should therefore not change this Markdown meaning by itself.&lt;/p&gt;

&lt;p&gt;Trailing-space cleanup is a different story. Removing the second space from a hard break changes the document structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;breaks&lt;/code&gt; option changes platform behavior
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;breaks: true&lt;/code&gt;, markdown-it and Marked converted a normal paragraph newline to &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;Result for &lt;code&gt;alpha\nbeta&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;commonmark.js default&lt;/td&gt;
&lt;td&gt;soft break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markdown-it default&lt;/td&gt;
&lt;td&gt;soft break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;markdown-it &lt;code&gt;breaks: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marked default&lt;/td&gt;
&lt;td&gt;soft break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marked &lt;code&gt;breaks: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That option explains many "works here, breaks there" reports. A chat renderer may intentionally preserve author-entered lines, while a documentation system follows CommonMark's paragraph behavior.&lt;/p&gt;

&lt;p&gt;Treat the option as part of the platform's Markdown dialect. Recording only "we use Markdown" is not enough for a reproducible publishing pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases expose the real boundary
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One trailing space is still soft
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;alpha 
beta
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not create a hard break. The parsers differed slightly in the exact HTML whitespace they retained, but not in the resulting Markdown structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two spaces at the end of a block do not create &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;alpha  

next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CommonMark says a hard break separates inline content inside a block. It cannot occur at the end of the paragraph. commonmark.js and markdown-it removed the trailing spaces. Marked 18.0.9 retained them in the HTML string, but none of the three emitted &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The page looks the same in a browser, yet byte-for-byte HTML snapshots differ. Decide whether your tests protect semantic structure or exact serialization.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code spans use different whitespace rules
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="sb"&gt;`alpha
beta`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;code&amp;gt;&lt;/span&gt;alpha beta&lt;span class="nt"&gt;&amp;lt;/code&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The newline is normalized inside the code span. A backslash or two trailing spaces there do not become a hard break.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical testing strategy
&lt;/h2&gt;

&lt;p&gt;For a multi-output documentation pipeline, test three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;preserve the exact fixture, including visible line-ending and trailing-space notation;&lt;/li&gt;
&lt;li&gt;compare the parsed structure, especially soft-break and hard-break nodes;&lt;/li&gt;
&lt;li&gt;compare the target output and inspect it under the real CSS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the final target is fixed-layout output, I also check a representative file against the &lt;a href="https://mdfold.com/guides/markdown-to-pdf-keep-formatting" rel="noopener noreferrer"&gt;Markdown-to-PDF formatting workflow&lt;/a&gt;. That does not replace parser tests; it catches the separate class of layout differences introduced after parsing.&lt;/p&gt;

&lt;p&gt;My working rule is simple: use blank lines for paragraphs, a visible backslash for intentional inline breaks, and automatic wrapping for prose. Then lock parser versions and options wherever the same source must render consistently.&lt;/p&gt;

&lt;p&gt;Who should own line-break semantics in a shared document system: the author in the source, or the rendering environment for each destination?&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>markdown</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Markdown vs PDF: Stop Choosing One File for Every Stage</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Thu, 13 Aug 2026 06:24:26 +0000</pubDate>
      <link>https://dev.to/mdfold/markdown-vs-pdf-stop-choosing-one-file-for-every-stage-4nk2</link>
      <guid>https://dev.to/mdfold/markdown-vs-pdf-stop-choosing-one-file-for-every-stage-4nk2</guid>
      <description>&lt;p&gt;Markdown vs PDF: use one for the source and one for delivery&lt;/p&gt;

&lt;p&gt;The practical answer to &lt;strong&gt;Markdown vs PDF&lt;/strong&gt; is not that one format wins. Use Markdown while content is being written, reviewed, translated, or reused. Use PDF when the page arrangement has been checked and the recipient needs a stable delivery copy for reading, printing, signing, or archiving.&lt;/p&gt;

&lt;p&gt;For an important document, keep both: the &lt;code&gt;.md&lt;/code&gt; file is the editable source; the &lt;code&gt;.pdf&lt;/code&gt; file is a dated or versioned output. After changing the source, export and inspect a new PDF. Do not edit the two files independently and assume they still contain the same revision.&lt;/p&gt;

&lt;p&gt;This guide is for writers, developers, researchers, and small teams deciding which file to keep, share, or convert. It owns the format decision and source-to-output workflow. The separate &lt;a href="https://mdfold.com/guides/markdown-to-pdf-keep-formatting" rel="noopener noreferrer"&gt;format-preservation guide&lt;/a&gt; covers detailed fixes for headings, links, code, and tables after you have already chosen PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core difference is structured text versus page-oriented output
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://spec.commonmark.org/0.31.2/" rel="noopener noreferrer"&gt;CommonMark defines Markdown&lt;/a&gt; as a plain-text format for writing structured documents. The source uses visible markers—such as &lt;code&gt;#&lt;/code&gt; for a heading or &lt;code&gt;-&lt;/code&gt; for a list—and a renderer decides how that structure looks. A different theme can change fonts, spacing, colors, and line wrapping without changing the source words.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.loc.gov/preservation/digital/formats/fdd/fdd000030.shtml" rel="noopener noreferrer"&gt;Library of Congress PDF format description&lt;/a&gt; describes PDF as representing formatted, page-oriented documents. A PDF can contain text, images, graphics, and structure, but its defining job is to carry a composed page rather than the author's lightweight source notation.&lt;/p&gt;

&lt;p&gt;That difference leads to a useful decision table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Prefer Markdown&lt;/th&gt;
&lt;th&gt;Prefer PDF&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Revise sentences and headings repeatedly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review exact pages and line breaks&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reuse the same content on a website or in another build&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Send a print-ready handout&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inspect small text changes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Possible, but less direct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preserve a final visual snapshot&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guarantee accessibility without checking&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row matters. Plain text is not automatically an accessible published experience, and fixed pages are not automatically well tagged. Format choice is only the start of accessibility work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our same-document test shows what each format preserves
&lt;/h2&gt;

&lt;p&gt;We tested the current &lt;a href="https://mdfold.com/markdown-to-pdf" rel="noopener noreferrer"&gt;MDFold Markdown-to-PDF converter&lt;/a&gt; with a controlled 635-byte source. It contained one heading, a three-row decision table, a link, a checklist, a block quotation, and the final marker &lt;code&gt;FORMAT-END-008&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;First, we changed &lt;code&gt;SOURCE-REV-A&lt;/code&gt; to &lt;code&gt;SOURCE-REV-B&lt;/code&gt; and expanded one checklist item. The source editor and live preview both showed the new revision; the old revision disappeared from the preview. This is the Markdown advantage in concrete form: the author can change the underlying structured text directly and review the rendered result before producing a delivery file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmdfold.com%2Fimages%2Fguides%2Fmarkdown-vs-pdf-editable-source.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmdfold.com%2Fimages%2Fguides%2Fmarkdown-vs-pdf-editable-source.webp" title="The source revision changed from A to B, and the live preview immediately showed the new wording and checklist item. Captured August 7, 2026." alt="MDFold editor and preview showing revision B of the controlled Markdown document" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, we used the real PDF action and inspected the exact downloaded file. It was a valid one-page PDF of 23,268 bytes. Text extraction found &lt;code&gt;SOURCE-REV-B&lt;/code&gt;, the revised checklist wording, and &lt;code&gt;FORMAT-END-008&lt;/code&gt;. These checkpoints show that the tested output came from the edited source and reached the end of the document. The output also included an MDFold-branded converter section after the fixture. That added section is visible in the screenshot and means the current export is not a source-only handoff.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmdfold.com%2Fimages%2Fguides%2Fmarkdown-vs-pdf-fixed-output.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmdfold.com%2Fimages%2Fguides%2Fmarkdown-vs-pdf-fixed-output.webp" title="The exported one-page PDF retained revision B, the edited checklist wording, and the final marker in selectable text. Captured August 7, 2026." alt="One-page MDFold PDF containing revision B and the final verification marker" width="800" height="1131"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This test does not prove that every Markdown feature or every PDF viewer behaves identically. It proves a narrower, useful boundary: for this fixture and current converter, editing happened in the source, while the generated PDF captured that accepted revision as a one-page output with extractable text and an added branded section. Inspect whether that extra material is acceptable before using the file as a formal deliverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose Markdown while meaning and structure can still change
&lt;/h2&gt;

&lt;p&gt;Markdown is the stronger working format when people need to change the document rather than merely comment on its appearance.&lt;/p&gt;

&lt;p&gt;Use it for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Drafting and restructuring.&lt;/strong&gt; Headings, lists, quotations, code, and links remain visible in the source instead of being placed on fixed pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewing text changes.&lt;/strong&gt; Line-based tools can compare two text revisions. The official &lt;a href="https://git-scm.com/docs/git-diff" rel="noopener noreferrer"&gt;&lt;code&gt;git diff&lt;/code&gt; documentation&lt;/a&gt; describes the mechanisms Git provides for showing changes between content states. You do not need Git to edit Markdown, but plain text makes this kind of review practical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusing content.&lt;/strong&gt; The same structured source can feed a web page, documentation site, or PDF workflow. Each renderer still needs its own acceptance check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automating repeatable output.&lt;/strong&gt; A team can keep the source, rendering rules, and assets together, then rebuild the delivery format after an approved change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working with content-focused tools.&lt;/strong&gt; Search, replacement, translation, and code review operate on the actual words and markers rather than reconstructed page objects.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Markdown is not a finished visual specification. A &lt;code&gt;.md&lt;/code&gt; file does not carry a universal font, paper size, margin system, page count, or print result. CommonMark also does not define every extension that popular editors offer. Tables, task lists, equations, diagrams, and raw HTML can vary between renderers. Name the intended renderer when those features matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose PDF after the pages are ready to review and share
&lt;/h2&gt;

&lt;p&gt;PDF is the stronger delivery format when the recipient cares about the composed pages more than the source notation.&lt;/p&gt;

&lt;p&gt;Use it for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a report whose page breaks, margins, and typography have been approved;&lt;/li&gt;
&lt;li&gt;a printable handout, invoice, form, or signed record;&lt;/li&gt;
&lt;li&gt;a review copy where everyone should refer to the same page arrangement;&lt;/li&gt;
&lt;li&gt;a final snapshot that should not silently change when a website theme changes;&lt;/li&gt;
&lt;li&gt;a recipient who expects a standard document viewer rather than a Markdown renderer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;“Fixed output” does not mean “impossible to edit.” PDF software can add comments, fill fields, redact content, or modify page objects. The important distinction is that PDF is not the cleanest source for revising the original logical document. If you revise only the PDF and leave the Markdown untouched, the two versions diverge.&lt;/p&gt;

&lt;p&gt;PDF also needs final-file checks. A generated document can have clipped tables, awkward page breaks, substituted fonts, dead links, or missing structure even when the preview looked acceptable. Open the downloaded file independently and inspect the pages you will actually send.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reliable workflow keeps one source of truth
&lt;/h2&gt;

&lt;p&gt;Use this five-step handoff:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write and revise in Markdown.&lt;/strong&gt; Keep linked assets in a stable location and use a revision label for consequential documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview with the intended renderer.&lt;/strong&gt; Check headings, lists, tables, links, code, images, and any renderer-specific extensions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export a new PDF.&lt;/strong&gt; Do not overwrite a previously approved copy until the new output passes review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspect the saved PDF.&lt;/strong&gt; Confirm page count, final marker, links, text selection, table width, image presence, page breaks, and print size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label the relationship.&lt;/strong&gt; Record which source revision produced the PDF. Store the Markdown as the master and the PDF as an output unless policy requires a different recordkeeping model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A simple naming pattern prevents ambiguity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;release-notes.md                 editable source
release-notes-r3.pdf             reviewed output from revision 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a recipient edits the PDF, treat those edits as feedback to reconcile into the Markdown source. Then export revision 4. This avoids two competing masters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile viewing exposes the fixed-page tradeoff
&lt;/h2&gt;

&lt;p&gt;We repeated the product check at 390 × 844 pixels. The MDFold PDF preview stayed within the browser viewport without document-level horizontal overflow and showed the same one-page result.&lt;/p&gt;

&lt;p&gt;the PDF preview remained contained without document-level horizontal overflow; detailed reading still benefits from zoom. Captured August 7, 2026.")&lt;/p&gt;

&lt;p&gt;Containment is not the same as comfortable reading. A fixed page scaled down to a phone can make table cells and body text small. Markdown rendered as a responsive web page can reflow more naturally, but the result depends on the renderer and its styles. If phone reading is the primary use, publish a responsive version and offer PDF as a secondary download. If printing or page references are primary, retain the PDF and test zoom and navigation on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility and privacy require separate checks
&lt;/h2&gt;

&lt;p&gt;Neither file extension is an accessibility certificate. Markdown can express headings, links, lists, image descriptions, and table structure, but the published renderer must carry that meaning into an accessible experience. PDF can include document structure, yet the final file still needs correct reading order, meaningful links, alternative text, language, contrast, and keyboard behavior. Adobe's &lt;a href="https://helpx.adobe.com/acrobat/using/touch-reading-order-tool-pdfs.html" rel="noopener noreferrer"&gt;Reading Order tool guidance&lt;/a&gt; explains how page reading order, headings, form labels, alternative text, and table tagging may need to be checked and repaired; a &lt;code&gt;.pdf&lt;/code&gt; extension alone does not establish those qualities.&lt;/p&gt;

&lt;p&gt;For privacy, inspect the complete workflow rather than inferring safety from a format. In this MDFold test, we observed no request to an application upload or conversion endpoint while editing and generating the controlled document. The page still loaded ordinary application assets. This observation covers the tested route and fixture on August 7, 2026; it is not a promise about unrelated links, remote images, analytics, extensions, or other tools.&lt;/p&gt;

&lt;p&gt;If the Markdown contains remote images, opening a preview may contact those hosts. If the PDF contains sensitive material, its portability makes access control and secure delivery important. Use non-sensitive test content until the workflow is approved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know what this comparison cannot guarantee
&lt;/h2&gt;

&lt;p&gt;The controlled test used installed Google Chrome, the current MDFold converter, one 635-byte English fixture, one template, one desktop viewport, and one phone viewport. It covered a heading, table, link, checklist, quotation, and plain text. The current output added an MDFold-branded converter section after the fixture, so it is not suitable when a source-only or unbranded PDF is required. The test did not cover authenticated assets, complex images, equations, diagrams, embedded fonts, forms, signatures, encryption, archival profiles, screen-reader testing, every Markdown dialect, or every PDF viewer.&lt;/p&gt;

&lt;p&gt;Do not generalize the one-page result into universal layout fidelity. A longer or more complex document can expose different page breaks and conversion limits. Do not infer that extractable text guarantees correct semantic tagging or reading order. And do not discard the Markdown merely because the PDF passed: the source is what makes the next controlled revision straightforward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final decision checklist
&lt;/h2&gt;

&lt;p&gt;Choose &lt;strong&gt;Markdown now&lt;/strong&gt; if the document still needs substantive edits, structured reuse, translation, line-by-line review, or output in more than one format.&lt;/p&gt;

&lt;p&gt;Choose &lt;strong&gt;PDF now&lt;/strong&gt; if the content has been accepted and recipients need reviewed pages for printing, formal delivery, signatures, or stable page references.&lt;/p&gt;

&lt;p&gt;Keep &lt;strong&gt;both&lt;/strong&gt; when the document has a lifecycle: Markdown as the maintained source, PDF as a versioned delivery artifact. Before sharing, verify that the PDF was generated from the intended source revision and inspect the actual saved file.&lt;/p&gt;

&lt;p&gt;When PDF is the right next step, &lt;a href="https://mdfold.com/markdown-to-pdf" rel="noopener noreferrer"&gt;open the Markdown-to-PDF converter&lt;/a&gt;, render the source, download the result, and complete the final-file checks above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and testing basis
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://spec.commonmark.org/0.31.2/" rel="noopener noreferrer"&gt;CommonMark 0.31.2 specification&lt;/a&gt; — Markdown's plain-text structured-document model.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.loc.gov/preservation/digital/formats/fdd/fdd000030.shtml" rel="noopener noreferrer"&gt;Library of Congress: PDF format family&lt;/a&gt; — PDF's formatted, page-oriented representation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://helpx.adobe.com/acrobat/using/touch-reading-order-tool-pdfs.html" rel="noopener noreferrer"&gt;Adobe Acrobat: Reading Order tool for PDFs&lt;/a&gt; — checking and repairing reading order, tags, headings, form labels, images, and tables.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git-scm.com/docs/git-diff" rel="noopener noreferrer"&gt;Git documentation: git diff&lt;/a&gt; — text-change comparison mechanisms.&lt;/li&gt;
&lt;li&gt;MDFold product test, August 7, 2026 — controlled source edit, live preview, exact one-page PDF, extracted-text checkpoints, request observation, and 390-pixel phone review.&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>Tested PDF-to-Markdown on Searchable, Scanned, and Table-Heavy PDFs</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:36:56 +0000</pubDate>
      <link>https://dev.to/mdfold/tested-pdf-to-markdown-on-searchable-scanned-and-table-heavy-pdfs-2597</link>
      <guid>https://dev.to/mdfold/tested-pdf-to-markdown-on-searchable-scanned-and-table-heavy-pdfs-2597</guid>
      <description>&lt;p&gt;PDF-to-Markdown conversion is often described as if it were a file-format rewrite:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Read the PDF, emit Markdown, done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That model breaks as soon as the PDF contains anything more complicated than a single column of selectable text.&lt;/p&gt;

&lt;p&gt;I tested four controlled cases in a browser-based converter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;a two-page searchable PDF with headings, prose, and a table;&lt;/li&gt;
&lt;li&gt;a searchable PDF containing a grouped header, a blank cell, and a multiline row label;&lt;/li&gt;
&lt;li&gt;an image-only scanned PDF;&lt;/li&gt;
&lt;li&gt;the same kind of scan after OCR added a searchable text layer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The results were consistent, but not simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;searchable text was extracted;&lt;/li&gt;
&lt;li&gt;page boundaries remained identifiable;&lt;/li&gt;
&lt;li&gt;table values survived, but the table grid did not;&lt;/li&gt;
&lt;li&gt;the image-only scan returned no text;&lt;/li&gt;
&lt;li&gt;OCR made the scan extractable, but silently changed identifiers;&lt;/li&gt;
&lt;li&gt;none of the tested outputs became trustworthy Markdown without review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important lesson is not that PDF-to-Markdown “works” or “fails.” It is that &lt;strong&gt;text recovery, reading order, visual structure, and character accuracy are separate problems&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test setup
&lt;/h2&gt;

&lt;p&gt;I ran the current &lt;a href="https://mdfold.com/pdf-to-markdown" rel="noopener noreferrer"&gt;MDFold PDF-to-Markdown tool&lt;/a&gt; in installed Google Chrome on August 9, 2026.&lt;/p&gt;

&lt;p&gt;The controlled fixtures contained no personal or customer data.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fixture&lt;/th&gt;
&lt;th&gt;What it tested&lt;/th&gt;
&lt;th&gt;Verification method&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Two-page incident memo&lt;/td&gt;
&lt;td&gt;Searchable prose, page order, a small table, exact values&lt;/td&gt;
&lt;td&gt;Four unique checkpoints and two page markers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex regional table&lt;/td&gt;
&lt;td&gt;Grouped heading, multiline label, deliberate blank cell&lt;/td&gt;
&lt;td&gt;Nineteen labels and values plus visual comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image-only field note&lt;/td&gt;
&lt;td&gt;Behavior when no text layer exists&lt;/td&gt;
&lt;td&gt;Expected zero extracted words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCR-processed scans&lt;/td&gt;
&lt;td&gt;Recognition errors in clean and difficult pages&lt;/td&gt;
&lt;td&gt;Exact source-to-output token comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I repeated the complex table and difficult OCR tests at desktop size and at a 390 × 844 phone viewport. The extracted text matched between the two viewports, and the page itself did not overflow horizontally.&lt;/p&gt;

&lt;p&gt;This is a controlled product test, not a universal benchmark. Different PDF generators, fonts, tags, languages, rotations, and OCR engines can produce different results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 1: searchable text survived, but the table did not
&lt;/h2&gt;

&lt;p&gt;The first fixture was a two-page incident-review memo. It contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a title and section headings;&lt;/li&gt;
&lt;li&gt;case ID &lt;code&gt;IR-317&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a three-column table;&lt;/li&gt;
&lt;li&gt;the values &lt;code&gt;Table overflow&lt;/code&gt;, &lt;code&gt;12 pages&lt;/code&gt;, and &lt;code&gt;2026-08-28&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a second page with scope and ownership notes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four checkpoints appeared in the extracted output. Two page markers also appeared in the correct order.&lt;/p&gt;

&lt;p&gt;But the table did not become a Markdown pipe table. Its words were present as a flat sequence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kxxdhlvqop0x2ql1hmd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8kxxdhlvqop0x2ql1hmd.webp" alt="MDFold showing the raw extracted text from a searchable two-page PDF" width="800" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The controlled values and page order survived, but the source table was not reconstructed as Markdown table syntax. Captured August 7 and reproduced August 9, 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the first distinction a converter UI can hide:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Complete text is not the same thing as complete structure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A quick scan of the output might suggest success because every important word is visible. A program expecting &lt;code&gt;| Check | Result | Evidence |&lt;/code&gt;, however, would find no table at all.&lt;/p&gt;

&lt;p&gt;The correct next step was manual reconstruction based on the original PDF, not guessing columns from spaces in the extracted string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a searchable PDF still has no Markdown structure
&lt;/h2&gt;

&lt;p&gt;A PDF page describes positioned content. Markdown describes document structure.&lt;/p&gt;

&lt;p&gt;Those are different models.&lt;/p&gt;

&lt;p&gt;Mozilla’s PDF.js API exposes page text through &lt;code&gt;getTextContent()&lt;/code&gt;. It exposes a tagged structure tree separately through &lt;code&gt;getStructTree()&lt;/code&gt;, and that structure tree may be &lt;code&gt;null&lt;/code&gt;. Even when text extraction succeeds, the extractor may not receive a reliable semantic instruction saying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;This line is an H2.
These nine text fragments form a 3 × 3 table.
This empty region is an intentionally blank cell.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, the page may contain text fragments positioned at coordinates. The converter has to infer reading order and relationships from those fragments, unless the PDF contains usable tags or another structural layer.&lt;/p&gt;

&lt;p&gt;That inference is where apparently successful conversions become dangerous. The text may be present while its relationships are wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2: every table value survived, but the blank cell became unknowable
&lt;/h2&gt;

&lt;p&gt;The complex table fixture contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a grouped &lt;code&gt;Quarter totals&lt;/code&gt; heading;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Q1&lt;/code&gt; and &lt;code&gt;Q2&lt;/code&gt; subcolumns;&lt;/li&gt;
&lt;li&gt;a multiline &lt;code&gt;North America&lt;/code&gt; row label;&lt;/li&gt;
&lt;li&gt;a deliberately blank Q2 cell for Europe;&lt;/li&gt;
&lt;li&gt;a note explaining that blank means “not reported,” not zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The source looked like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsirc14lt5f35wli121ut.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsirc14lt5f35wli121ut.webp" alt="Controlled PDF table with grouped headers, a multiline label, and an intentional blank" width="800" height="638"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The visual grid carries meaning that is not contained in the individual words. Project-created fixture captured August 5, 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The converter retained all 19 controlled text checkpoints, including:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;North America
1,240
1,318
Reviewed
Europe
980
Missing Q2
Asia Pacific
1,105
1,207
Provisional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output still was not a table:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmehgcce29o1vfga788a0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmehgcce29o1vfga788a0.webp" alt="Flat PDF extraction containing all controlled table values but no Markdown grid" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All labels and values survived, but grouped headers, cell boundaries, and the location of the blank cell did not. Captured August 5 and reproduced August 9, 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;From the flat text alone, a cleanup script cannot safely answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether &lt;code&gt;Quarter totals&lt;/code&gt; spans two columns;&lt;/li&gt;
&lt;li&gt;whether &lt;code&gt;North America&lt;/code&gt; is one cell or two;&lt;/li&gt;
&lt;li&gt;whether Europe’s missing number belongs to Q1 or Q2;&lt;/li&gt;
&lt;li&gt;whether the blank represents zero, missing data, or a layout gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The footnote helps a human recover the intent, but it does not restore the missing cell coordinate.&lt;/p&gt;

&lt;p&gt;This is why table verification must be cell-oriented. Counting extracted words or checking that every number appears is insufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 3: the image-only scan produced zero text
&lt;/h2&gt;

&lt;p&gt;The third fixture looked like a normal one-page field note in a PDF viewer. It had headings, numbered steps, identifiers, and measurements.&lt;/p&gt;

&lt;p&gt;But the PDF contained only page pixels. There was no searchable text layer.&lt;/p&gt;

&lt;p&gt;The result in the converter was an empty output: zero words.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0ef2f99dz0smxuhkwdd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0ef2f99dz0smxuhkwdd.webp" alt="An image-only scanned PDF selected in MDFold with an empty Markdown result" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The file opened successfully, but there was no text layer to extract. Captured August 5 and reproduced August 9, 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is not evidence that the document contains no writing. It means the extraction method found no machine-readable text.&lt;/p&gt;

&lt;p&gt;OCR must happen before text-based Markdown conversion. OCRmyPDF describes this as adding an OCR text layer to a scanned PDF while retaining the original page image. Once that layer exists, a text extractor has something to read.&lt;/p&gt;

&lt;p&gt;The boundary matters: the tested MDFold workflow extracts an existing text layer. It does not perform OCR on the image-only PDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 4: OCR recovered editable text and introduced silent errors
&lt;/h2&gt;

&lt;p&gt;I processed controlled scans with local OCR, created searchable PDFs from the recognized text, and ran those PDFs through the same converter.&lt;/p&gt;

&lt;p&gt;The clear fixture produced 63 editable words. The difficult fixture produced 64.&lt;/p&gt;

&lt;p&gt;That sounds successful until the identifiers are compared exactly.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;OCR output&lt;/th&gt;
&lt;th&gt;Error type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ZX-41&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;X-41&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Character omitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;B0O-518&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;B00-518&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Letter O changed to zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;III.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;I.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Roman-numeral characters omitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OI-1058&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0I-1058&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Letter O changed to zero&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The OCR also correctly retained &lt;code&gt;Café&lt;/code&gt;, &lt;code&gt;$1,084.70&lt;/code&gt;, &lt;code&gt;2026-08-05&lt;/code&gt;, and a faint &lt;code&gt;REVIEWED 08/05&lt;/code&gt; stamp.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6b8kgkqhydohj5hl0r7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6b8kgkqhydohj5hl0r7.webp" alt="Editable OCR text containing correct prose and silent identifier changes" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;OCR made the scan searchable, but several high-risk tokens changed without an error message. Captured August 5 and reproduced August 9, 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is a more difficult failure than empty output.&lt;/p&gt;

&lt;p&gt;Empty output is visible. A plausible but incorrect asset tag can flow into a repository, search index, or LLM prompt without being noticed.&lt;/p&gt;

&lt;p&gt;The safe question is not “Did OCR produce text?” It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which characters would cause harm if one symbol changed?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For many documents, that includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;invoice totals;&lt;/li&gt;
&lt;li&gt;dates and times;&lt;/li&gt;
&lt;li&gt;serial numbers;&lt;/li&gt;
&lt;li&gt;case IDs;&lt;/li&gt;
&lt;li&gt;chemical units;&lt;/li&gt;
&lt;li&gt;version strings;&lt;/li&gt;
&lt;li&gt;negative signs and decimal separators;&lt;/li&gt;
&lt;li&gt;names containing accents;&lt;/li&gt;
&lt;li&gt;checkboxes, list numbers, and status labels.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A six-step verification workflow
&lt;/h2&gt;

&lt;p&gt;The following process worked better than treating conversion as one action.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Classify the source before converting
&lt;/h3&gt;

&lt;p&gt;Try selecting text in the PDF viewer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If normal text can be selected, begin with text extraction.&lt;/li&gt;
&lt;li&gt;If only a rectangular image region can be selected, plan an OCR step.&lt;/li&gt;
&lt;li&gt;If the file mixes searchable and scanned pages, test both page types.&lt;/li&gt;
&lt;li&gt;If tables, columns, figures, or equations carry meaning, plan visual comparison even when the text is searchable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Add checkpoints to controlled tests
&lt;/h3&gt;

&lt;p&gt;For a production pipeline, create a representative fixture with unique values at the beginning, middle, and end.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"IR-317"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Table overflow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12 pages"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-28"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expectedPages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expectedTables"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking only the opening paragraph will not catch a missing final page or an interrupted reading order.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Separate text assertions from structure assertions
&lt;/h3&gt;

&lt;p&gt;These are different tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;requiredValues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;| Check | Result | Evidence |&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my searchable fixture, the first assertion passed and the second failed. Reporting a single “conversion passed” result would have hidden the table failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Rebuild only structure you can prove
&lt;/h3&gt;

&lt;p&gt;Do not infer a table from word order alone when a blank cell or merged header exists.&lt;/p&gt;

&lt;p&gt;Open the original PDF beside the Markdown and reconstruct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;heading levels;&lt;/li&gt;
&lt;li&gt;list boundaries;&lt;/li&gt;
&lt;li&gt;table rows and columns;&lt;/li&gt;
&lt;li&gt;figure captions;&lt;/li&gt;
&lt;li&gt;page-sensitive notes;&lt;/li&gt;
&lt;li&gt;code indentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the structure cannot be proven, keep the content as clearly labelled text or choose a layout-aware extraction tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Verify exact high-risk tokens
&lt;/h3&gt;

&lt;p&gt;Create a small change ledger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Source | Extracted | Decision |
| :--- | :--- | :--- |
| B0O-518 | B00-518 | Correct from source image |
| OI-1058 | 0I-1058 | Correct from source image |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this before polishing headings. Semantic cleanup is wasted if the underlying identifiers are wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Retain provenance
&lt;/h3&gt;

&lt;p&gt;Keep the PDF until review is complete. Page markers in Markdown can help a reviewer return to the source page.&lt;/p&gt;

&lt;p&gt;For high-risk workflows, store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the source filename or document ID;&lt;/li&gt;
&lt;li&gt;conversion date and tool version;&lt;/li&gt;
&lt;li&gt;page boundaries;&lt;/li&gt;
&lt;li&gt;OCR engine and language when applicable;&lt;/li&gt;
&lt;li&gt;manual corrections;&lt;/li&gt;
&lt;li&gt;unresolved uncertainties.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Markdown is easier to edit, which is useful. It also makes undocumented corrections easier to hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the method by failure mode
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source condition&lt;/th&gt;
&lt;th&gt;Reasonable first method&lt;/th&gt;
&lt;th&gt;Do not assume&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple searchable prose&lt;/td&gt;
&lt;td&gt;Local text extraction&lt;/td&gt;
&lt;td&gt;Heading levels are correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Searchable PDF with columns&lt;/td&gt;
&lt;td&gt;Layout-aware extraction and reading-order review&lt;/td&gt;
&lt;td&gt;Text sequence matches visual order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simple table&lt;/td&gt;
&lt;td&gt;Text extraction plus manual cell verification&lt;/td&gt;
&lt;td&gt;Spaces reconstruct a grid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merged or nested table&lt;/td&gt;
&lt;td&gt;Table-specific extraction or manual reconstruction&lt;/td&gt;
&lt;td&gt;Blank cells and spans survive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image-only scan&lt;/td&gt;
&lt;td&gt;OCR first, then text extraction&lt;/td&gt;
&lt;td&gt;Opening the PDF means text exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed searchable and scanned pages&lt;/td&gt;
&lt;td&gt;Per-page detection with OCR fallback&lt;/td&gt;
&lt;td&gt;One method handled every page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Equations or diagrams&lt;/td&gt;
&lt;td&gt;Visual or specialist extraction&lt;/td&gt;
&lt;td&gt;Extracted characters preserve meaning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The best converter is not always the one that produces the prettiest Markdown. It is the one whose failure modes you can detect and whose output you can verify for the document in front of you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy is a property of the whole pipeline
&lt;/h2&gt;

&lt;p&gt;During the current MDFold tests, I observed no request carrying the PDF to an application conversion endpoint. The extraction happened in the browser.&lt;/p&gt;

&lt;p&gt;That is a narrow observation, not a claim that the page is offline. Ordinary analytics requests were still present, and remote page assets can still require a network connection.&lt;/p&gt;

&lt;p&gt;The OCR step was separate. I used a local operating-system OCR engine for the controlled scans. A hosted OCR service would have a different data path, retention policy, and risk profile.&lt;/p&gt;

&lt;p&gt;Before processing sensitive material, inspect every stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PDF storage
  → OCR engine, if needed
  → text extractor
  → cleanup editor
  → repository, search index, or AI system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A local converter cannot make a later cloud OCR or AI upload local.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this test does not prove
&lt;/h2&gt;

&lt;p&gt;The fixtures were small, English-language documents created for testing. They covered two pages, a grouped table, an intentional blank cell, clean and difficult scans, and a phone-sized browser view.&lt;/p&gt;

&lt;p&gt;They did not cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;encrypted PDFs;&lt;/li&gt;
&lt;li&gt;handwritten pages;&lt;/li&gt;
&lt;li&gt;right-to-left scripts;&lt;/li&gt;
&lt;li&gt;vertical writing;&lt;/li&gt;
&lt;li&gt;mathematical notation;&lt;/li&gt;
&lt;li&gt;multi-page tables with repeated headers;&lt;/li&gt;
&lt;li&gt;tagged-PDF accessibility quality;&lt;/li&gt;
&lt;li&gt;embedded files or interactive forms;&lt;/li&gt;
&lt;li&gt;every OCR engine or PDF producer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test also does not prove that browser-local extraction is more accurate than a hosted or AI-based service. It proves the observed boundary of one current workflow.&lt;/p&gt;

&lt;p&gt;If you want the deeper OCR cleanup procedure, I documented the exact-token method in a separate &lt;a href="https://mdfold.com/guides/scanned-pdf-to-markdown" rel="noopener noreferrer"&gt;scanned-PDF-to-Markdown guide&lt;/a&gt;. For table-specific reconstruction, the &lt;a href="https://mdfold.com/guides/pdf-tables-to-markdown" rel="noopener noreferrer"&gt;PDF table recovery guide&lt;/a&gt; includes the controlled blank-cell example.&lt;/p&gt;

&lt;h2&gt;
  
  
  The acceptance test matters more than the conversion button
&lt;/h2&gt;

&lt;p&gt;After these tests, I no longer describe PDF-to-Markdown as a single conversion.&lt;/p&gt;

&lt;p&gt;It is a pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;classify the PDF
  → recover text
  → verify reading order
  → reconstruct proven structure
  → compare exact values
  → retain provenance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the searchable fixtures, text recovery succeeded while table recovery failed. For the image-only scan, text recovery could not begin until OCR created a layer. For the OCR fixtures, extraction succeeded while character verification still found silent errors.&lt;/p&gt;

&lt;p&gt;That leads to a more useful definition of success:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Markdown is acceptable only when every important fact and relationship can be traced back to the source PDF.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you have built a PDF ingestion or RAG pipeline, which failure has caused more damage in practice: missing text, wrong reading order, flattened tables, or plausible OCR errors?&lt;/p&gt;

&lt;h2&gt;
  
  
  Focused FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can a searchable PDF still produce bad Markdown?
&lt;/h3&gt;

&lt;p&gt;Yes. Searchable text proves that characters can be extracted; it does not prove correct heading levels, reading order, table cells, columns, or figure relationships. Compare the Markdown with the visible PDF structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a scanned PDF return an empty result?
&lt;/h3&gt;

&lt;p&gt;An image-only scan has page pixels but no machine-readable text layer. Run OCR first, then verify the recognized text before rebuilding Markdown structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does OCR make the converted Markdown accurate?
&lt;/h3&gt;

&lt;p&gt;Not automatically. In this controlled test, OCR made the scans editable but silently changed four identifiers or list tokens. Exact values still need source comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should I test a PDF-to-Markdown pipeline?
&lt;/h3&gt;

&lt;p&gt;Use representative fixtures with unique checkpoints, assert text and structure separately, include one image-only and one table-heavy case, compare high-risk tokens exactly, and retain page-level provenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib-PDFPageProxy.html" rel="noopener noreferrer"&gt;PDF.js &lt;code&gt;PDFPageProxy&lt;/code&gt; API&lt;/a&gt; — separate text-content and structure-tree access.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ocrmypdf.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;OCRmyPDF documentation&lt;/a&gt; — adding searchable OCR text layers to scanned PDFs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tesseract-ocr.github.io/tessdoc/ImproveQuality.html" rel="noopener noreferrer"&gt;Tesseract: Improving OCR output quality&lt;/a&gt; — scan quality, segmentation, rotation, and recognition factors.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.w3.org/WAI/WCAG21/Techniques/pdf/PDF6" rel="noopener noreferrer"&gt;W3C PDF6&lt;/a&gt; — table structure in tagged PDF documents.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.github.com/gfm/#tables-extension-" rel="noopener noreferrer"&gt;GitHub Flavored Markdown table extension&lt;/a&gt; — the target pipe-table structure used in the asse&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>I Tested What a Browser-Based DOCX-to-Markdown Converter Actually Preserves</title>
      <dc:creator>frank</dc:creator>
      <pubDate>Sun, 02 Aug 2026 05:59:41 +0000</pubDate>
      <link>https://dev.to/mdfold/i-tested-what-a-browser-based-docx-to-markdown-converter-actually-preserves-199n</link>
      <guid>https://dev.to/mdfold/i-tested-what-a-browser-based-docx-to-markdown-converter-actually-preserves-199n</guid>
      <description>&lt;p&gt;“Preserves formatting” is one of the least useful promises a document converter can make.&lt;/p&gt;

&lt;p&gt;Which formatting?&lt;/p&gt;

&lt;p&gt;A Word document can contain semantic heading styles, font sizes, colors, numbered lists, floating objects, page headers, merged table cells, tracked changes, comments, and embedded images. Markdown has no direct representation for many of those things.&lt;/p&gt;

&lt;p&gt;So I stopped asking whether DOCX-to-Markdown conversion “works” and tested a narrower question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which document structures survive, which become plain text, and which require a deliberate cleanup decision?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I created two controlled &lt;code&gt;.docx&lt;/code&gt; files and converted them with &lt;a href="https://mdfold.com/word-to-markdown" rel="noopener noreferrer"&gt;MDFold’s browser-based Word to Markdown tool&lt;/a&gt;. The first represented a well-structured technical document. The second deliberately used features that are easy to misinterpret or awkward to express in Markdown.&lt;/p&gt;

&lt;p&gt;The result was not “everything preserved” or “formatting lost.” It was much more useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;real Word heading styles became Markdown headings;&lt;/li&gt;
&lt;li&gt;bold, italic, links, bullets, and numbered lists survived;&lt;/li&gt;
&lt;li&gt;a large bold paragraph stayed bold instead of becoming a heading;&lt;/li&gt;
&lt;li&gt;table text survived, but table structure did not;&lt;/li&gt;
&lt;li&gt;headers and footers were omitted;&lt;/li&gt;
&lt;li&gt;an embedded raster image became a self-contained data URI;&lt;/li&gt;
&lt;li&gt;page layout, fonts, and visual positioning were not reproduced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the behavior this article explains and turns into a repeatable workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  DOCX and Markdown describe different things
&lt;/h2&gt;

&lt;p&gt;DOCX can describe both document meaning and page presentation. Markdown mostly describes lightweight document structure.&lt;/p&gt;

&lt;p&gt;Microsoft’s explanation of WordprocessingML shows that a Word document is composed of structured elements such as paragraphs and runs, with styles and properties attached to them. A paragraph can be a real &lt;code&gt;Heading 1&lt;/code&gt;, or it can be an ordinary paragraph that merely looks like a title because someone made it 22-point bold text.&lt;/p&gt;

&lt;p&gt;Those two paragraphs may look almost identical in Word. They are not equivalent to a converter.&lt;/p&gt;

&lt;p&gt;The conversion pipeline I tested uses &lt;a href="https://github.com/mwilliamson/mammoth.js/" rel="noopener noreferrer"&gt;Mammoth&lt;/a&gt; to interpret DOCX structure as HTML, then converts that HTML into Markdown. Mammoth’s own documentation is unusually honest about the boundary: DOCX and HTML have a large structural mismatch, and conversion works best when the Word document uses styles for their intended meaning.&lt;/p&gt;

&lt;p&gt;That gives us the first rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prepare Word for structural conversion, not visual imitation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your real goal is to reproduce page margins, floating text boxes, fonts, headers, or exact pagination, Markdown is the wrong destination. Use PDF for a fixed result or keep the document in DOCX for collaborative editing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The controlled test documents
&lt;/h2&gt;

&lt;p&gt;I used generated files rather than a convenient hand-picked customer document. This made every expected result explicit and kept personal information out of the screenshots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test A: semantic structure
&lt;/h3&gt;

&lt;p&gt;The representative file contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a real Word &lt;code&gt;Heading 1&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a real &lt;code&gt;Heading 2&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;one sentence containing bold and italic runs;&lt;/li&gt;
&lt;li&gt;an external hyperlink with descriptive text;&lt;/li&gt;
&lt;li&gt;two bullet items;&lt;/li&gt;
&lt;li&gt;two numbered steps;&lt;/li&gt;
&lt;li&gt;a distinctive final checkpoint sentence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The expected Markdown was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# DOCX Conversion Field Test&lt;/span&gt;

&lt;span class="gu"&gt;## Release checklist&lt;/span&gt;

The &lt;span class="gs"&gt;**owner**&lt;/span&gt; reviews the _generated Markdown_ before publishing.

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Open the release runbook&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://example.com/runbook&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Verify the title
&lt;span class="p"&gt;-&lt;/span&gt; Verify the final paragraph
&lt;span class="p"&gt;
1.&lt;/span&gt; Convert the DOCX
&lt;span class="p"&gt;2.&lt;/span&gt; Review the Markdown

FINAL CHECKPOINT: names, numbers, and links still match.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Test B: features that need judgment
&lt;/h3&gt;

&lt;p&gt;The difficult file contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a visually large, bold title that was still a normal Word paragraph;&lt;/li&gt;
&lt;li&gt;underlined text;&lt;/li&gt;
&lt;li&gt;a two-column table;&lt;/li&gt;
&lt;li&gt;an embedded PNG with alternative text;&lt;/li&gt;
&lt;li&gt;a page header and footer;&lt;/li&gt;
&lt;li&gt;a distinctive final checkpoint sentence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This second file was designed to prevent a false sense of success. A converter can return non-empty Markdown while still losing the distinction that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The well-structured document converted cleanly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f2imdooi5r22ocyxnk3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4f2imdooi5r22ocyxnk3.png" alt="MDFold Word to Markdown output preserving two heading levels, emphasis, a link, bullet items, numbered steps, and a final checkpoint" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;The representative file preserved every checkpoint we asserted: heading levels, emphasis, link destination, both list types, and the final sentence.
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;The generated Markdown was not character-for-character identical to my hand-written expectation. The converter used extra spaces after list markers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt;   Verify the title
&lt;span class="p"&gt;-&lt;/span&gt;   Verify the final paragraph
&lt;span class="p"&gt;
1.&lt;/span&gt;  Convert the DOCX
&lt;span class="p"&gt;2.&lt;/span&gt;  Review the Markdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That difference is harmless in common Markdown renderers. It is also a useful reminder not to confuse stylistic normalization with information loss.&lt;/p&gt;

&lt;p&gt;The important checks were structural:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Word input&lt;/th&gt;
&lt;th&gt;Observed Markdown&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Heading 1 style&lt;/td&gt;
&lt;td&gt;&lt;code&gt;# DOCX Conversion Field Test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heading 2 style&lt;/td&gt;
&lt;td&gt;&lt;code&gt;## Release checklist&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bold run&lt;/td&gt;
&lt;td&gt;&lt;code&gt;**owner**&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Italic run&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_generated Markdown_&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External hyperlink&lt;/td&gt;
&lt;td&gt;Markdown link with the same destination&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bullet list&lt;/td&gt;
&lt;td&gt;Hyphen list&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Numbered list&lt;/td&gt;
&lt;td&gt;Ordered Markdown list&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final checkpoint&lt;/td&gt;
&lt;td&gt;Same sentence in the result&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the kind of Word document that converts well: the author used real structure and kept the layout linear.&lt;/p&gt;

&lt;h2&gt;
  
  
  A title that only looks like a title stays ordinary text
&lt;/h2&gt;

&lt;p&gt;The difficult document began with &lt;code&gt;VISUAL-ONLY TITLE&lt;/code&gt; in a large, bold font. It looked like a heading in Word, but it was not assigned a heading style.&lt;/p&gt;

&lt;p&gt;The observed Markdown was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**VISUAL-ONLY TITLE**&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is correct behavior.&lt;/p&gt;

&lt;p&gt;Automatically turning every short bold paragraph into a heading would create false structure in labels, warnings, captions, and emphasized sentences. A converter should prefer the document’s semantic style over a guess based on appearance.&lt;/p&gt;

&lt;p&gt;Before conversion, open Word’s Navigation pane. If an intended section title does not appear in the document outline, apply the correct Heading style. Do not merely increase the font size.&lt;/p&gt;

&lt;p&gt;Microsoft’s &lt;a href="https://learn.microsoft.com/en-us/office/open-xml/word/how-to-apply-a-style-to-a-paragraph-in-a-word-processing-document" rel="noopener noreferrer"&gt;Open XML paragraph-style documentation&lt;/a&gt; supports this distinction: paragraph style is an explicit document property, separate from the runs that carry text and visual formatting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table text survived, but the table did not
&lt;/h2&gt;

&lt;p&gt;This was the most important limitation in the test.&lt;/p&gt;

&lt;p&gt;The DOCX table contained:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;Ready&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The observed output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Owner

Status

Platform

Ready
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The words survived. The row-and-column relationship did not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xg54wckzmj0ksxddtnq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xg54wckzmj0ksxddtnq.png" alt="MDFold output showing a visual-only title as bold text, flattened table cells, a data-URI image, and the final checkpoint" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;The difficult file produced valid Markdown text, but valid is not the same as structurally complete. The table must be rebuilt and checked against the source.
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;This matters because a flattened table can silently change meaning. Four values in the correct order may still be unusable when the reader cannot tell which header belongs to which value.&lt;/p&gt;

&lt;p&gt;For a small table, rebuild it manually and compare every cell with Word. For a large table, use a converter that explicitly supports GitHub Flavored Markdown tables, export the data separately as CSV, or use &lt;a href="https://mdfold.com/markdown-table-generator" rel="noopener noreferrer"&gt;MDFold’s Markdown table generator&lt;/a&gt; after verifying the source values.&lt;/p&gt;

&lt;p&gt;Do not trust the presence of all words as proof that the table survived.&lt;/p&gt;

&lt;h2&gt;
  
  
  Embedded images can survive in an inconvenient form
&lt;/h2&gt;

&lt;p&gt;The test PNG became Markdown with a &lt;code&gt;data:&lt;/code&gt; URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;A generated status marker&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;data:image/png;base64,...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes the image self-contained: no separate file is required. It can also make the Markdown enormous, create unreadable diffs, and fail in publishing systems that block data URLs.&lt;/p&gt;

&lt;p&gt;For a repository or documentation site, my preferred workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;extract the image into an assets directory;&lt;/li&gt;
&lt;li&gt;give it a stable, descriptive filename;&lt;/li&gt;
&lt;li&gt;replace the data URI with a relative path;&lt;/li&gt;
&lt;li&gt;preserve or improve the alt text;&lt;/li&gt;
&lt;li&gt;preview the page in the actual publishing system.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;Green deployment status marker&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;./assets/deployment-status.png&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mammoth documents inline data URIs as its default image behavior. Other converters may drop images, produce placeholders, or extract separate files. “Images supported” does not tell you which of those contracts a tool provides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headers and footers were omitted—and that may be desirable
&lt;/h2&gt;

&lt;p&gt;The difficult DOCX used both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CONFIDENTIAL DRAFT HEADER
PAGE FOOTER — INTERNAL REVIEW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither appeared in the Markdown.&lt;/p&gt;

&lt;p&gt;For a web article, this is often desirable. Repeating page furniture is not part of the article’s logical reading flow. For a policy, contract, or controlled document, however, the missing classification, revision, or page note may be important.&lt;/p&gt;

&lt;p&gt;The right question is not “did the header survive?” It is “does any information in the header need a new structural home?”&lt;/p&gt;

&lt;p&gt;Move essential revision data into front matter, a document metadata block, or the opening section before conversion. Do not rely on page headers to become Markdown automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Underline is ambiguous in Markdown
&lt;/h2&gt;

&lt;p&gt;The underlined sentence returned as plain text.&lt;/p&gt;

&lt;p&gt;Markdown has no universal underline syntax, and underlining is often confused with hyperlinks on the web. Mammoth’s default mapping intentionally ignores underline rather than inventing a meaning.&lt;/p&gt;

&lt;p&gt;Decide what the underline meant in the source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;emphasis → use &lt;code&gt;*italic*&lt;/code&gt; or &lt;code&gt;**bold**&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a section label → use a real heading;&lt;/li&gt;
&lt;li&gt;an insertion → write explicit editorial text;&lt;/li&gt;
&lt;li&gt;a link → create a real hyperlink;&lt;/li&gt;
&lt;li&gt;decoration → remove it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conversion cannot make that editorial decision safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Convert DOCX to Markdown in a reviewable workflow
&lt;/h2&gt;

&lt;p&gt;Here is the process I would use for a real document.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Prepare the Word file
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Save legacy &lt;code&gt;.doc&lt;/code&gt; files as &lt;code&gt;.docx&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;Apply Heading styles to actual section headings.&lt;/li&gt;
&lt;li&gt;Accept or reject tracked changes.&lt;/li&gt;
&lt;li&gt;Resolve comments that should not become published content.&lt;/li&gt;
&lt;li&gt;Move essential header or footer information into the document body.&lt;/li&gt;
&lt;li&gt;Simplify merged tables where possible.&lt;/li&gt;
&lt;li&gt;Add useful alternative text to meaningful images.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Convert the DOCX
&lt;/h3&gt;

&lt;p&gt;Open &lt;a href="https://mdfold.com/word-to-markdown" rel="noopener noreferrer"&gt;MDFold Word to Markdown&lt;/a&gt;, choose the &lt;code&gt;.docx&lt;/code&gt; file, and wait for editable Markdown to appear. In the version tested on August 2, 2026, the conversion ran in the browser and did not require a document upload endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Check high-risk structures first
&lt;/h3&gt;

&lt;p&gt;Do not begin by proofreading ordinary paragraphs. Check the features most likely to change meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;H1, H2, and H3 hierarchy;&lt;/li&gt;
&lt;li&gt;first and final paragraphs;&lt;/li&gt;
&lt;li&gt;ordered-list numbering;&lt;/li&gt;
&lt;li&gt;link destinations;&lt;/li&gt;
&lt;li&gt;every table;&lt;/li&gt;
&lt;li&gt;image references and alt text;&lt;/li&gt;
&lt;li&gt;footnotes, comments, headers, and footers;&lt;/li&gt;
&lt;li&gt;names, dates, percentages, and negative numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Repair the Markdown for its destination
&lt;/h3&gt;

&lt;p&gt;Different destinations support different Markdown extensions. GitHub Flavored Markdown supports pipe tables, while strict CommonMark does not define the same table extension. A data URI or HTML fragment may work in one renderer and fail in another.&lt;/p&gt;

&lt;p&gt;Preview the result where it will actually live: GitHub, a documentation generator, a CMS, an Obsidian vault, or another target system.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Download and verify the final &lt;code&gt;.md&lt;/code&gt; file
&lt;/h3&gt;

&lt;p&gt;Open the downloaded file rather than assuming the editor state and saved artifact are identical. Search for the final checkpoint, inspect the Markdown source, and render it once in the target environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The phone-sized workflow worked, but desktop is better for review
&lt;/h2&gt;

&lt;p&gt;I repeated the representative conversion at a 390 × 844 viewport. The file opened, the expected final checkpoint was present, the result remained editable, and the page had no document-level horizontal overflow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgo6sc2f6xhvnvqmcafon.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgo6sc2f6xhvnvqmcafon.png" alt="Phone-sized MDFold Word to Markdown result showing headings, emphasis, a link, lists, and download controls" width="390" height="844"&gt;&lt;/a&gt;&lt;/p&gt;&lt;br&gt;Phone conversion worked in the test. A larger screen is still the better place to compare tables, long links, and image data.
  &lt;p&gt;&lt;/p&gt;

&lt;p&gt;Mobile support is useful for an urgent check. It does not remove the need for a careful desktop review when the source contains complex structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use a different conversion path
&lt;/h2&gt;

&lt;p&gt;Use this browser workflow for ordinary &lt;code&gt;.docx&lt;/code&gt; documents that are mostly headings, paragraphs, emphasis, lists, and links.&lt;/p&gt;

&lt;p&gt;Consider &lt;a href="https://pandoc.org/MANUAL.html" rel="noopener noreferrer"&gt;Pandoc&lt;/a&gt; or another document pipeline when you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;batch conversion;&lt;/li&gt;
&lt;li&gt;repeatable command-line builds;&lt;/li&gt;
&lt;li&gt;separate media extraction;&lt;/li&gt;
&lt;li&gt;custom style mapping;&lt;/li&gt;
&lt;li&gt;footnotes, citations, or richer table handling;&lt;/li&gt;
&lt;li&gt;a conversion process that can be reviewed and versioned as code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep Word or export to PDF when the requirement is visual fidelity rather than portable structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy is part of the conversion choice
&lt;/h2&gt;

&lt;p&gt;For the tested MDFold workflow, the DOCX was read by browser-side code and the Markdown appeared locally in the editor. That is narrower than claiming that every website conversion is private.&lt;/p&gt;

&lt;p&gt;If you use another converter, determine whether the file is uploaded, retained, used for model processing, or logged. This matters for contracts, unpublished research, internal runbooks, customer data, and regulated material.&lt;/p&gt;

&lt;p&gt;Local conversion still depends on the device and browser you trust. Extensions, shared computers, managed devices, backups, and downloaded files remain part of the security boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical definition of “preserved”
&lt;/h2&gt;

&lt;p&gt;After this test, I would not describe DOCX-to-Markdown quality with one percentage or one checkbox.&lt;/p&gt;

&lt;p&gt;I would ask five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is every important sentence present?&lt;/li&gt;
&lt;li&gt;Is the heading hierarchy still correct?&lt;/li&gt;
&lt;li&gt;Are relationships—lists, links, tables, notes—still explicit?&lt;/li&gt;
&lt;li&gt;Are images usable in the target system?&lt;/li&gt;
&lt;li&gt;Did any page-level information need to be moved into document structure?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A converter can automate syntax. It cannot decide what a visual convention meant to the author.&lt;/p&gt;

&lt;p&gt;That is why DOCX-to-Markdown conversion should be treated as a small content migration, not a file-extension swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  DOCX-to-Markdown FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I convert an old &lt;code&gt;.doc&lt;/code&gt; file directly?
&lt;/h3&gt;

&lt;p&gt;Not with the MDFold workflow tested here. Save the legacy file as &lt;code&gt;.docx&lt;/code&gt; in Word, LibreOffice, or another compatible editor, then convert the new copy. Keep the original until you have checked the Markdown.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did my Word heading become ordinary text?
&lt;/h3&gt;

&lt;p&gt;The paragraph probably looked like a heading without using a Word Heading style. Apply the correct semantic style and convert again; do not rely on font size or bold text alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I keep embedded images as base64 data?
&lt;/h3&gt;

&lt;p&gt;Only when the destination supports data URLs and a self-contained file matters more than readable source. For repositories and documentation sites, extracted image files with stable relative paths usually produce smaller, reviewable Markdown.&lt;/p&gt;

&lt;p&gt;If you have a difficult Word document, try the &lt;a href="https://mdfold.com/word-to-markdown" rel="noopener noreferrer"&gt;browser-based converter&lt;/a&gt; and share one reproducible feature that did not map cleanly. A merged table, floating text box, tracked-change sequence, or image-heavy page is more useful feedback than “formatting broke.”&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
