← Back to the journal

Why collapse structural whitespace at the markdownToHtml output

Learn why OmniPost moved structural-whitespace cleanup to the shared markdownToHtml output: one fix now covers WeChat, Zhihu, Toutiao, Sohu, and other HTML-based platforms without breaking code blocks.

Here is the short answer: if the same Markdown eventually becomes HTML for multiple platforms, the safest place to remove structural whitespace is the shared markdownToHtml output, not an individual platform adapter. The WeChat editor exposed the bug first, but the architectural lesson is bigger than WeChat.

This change matters because it is not just a platform-specific patch. For a multi-platform publishing tool like OmniGoAI’s OmniPost, the real question is where a defensive fix belongs. If you keep it in one adapter, you only protect one path. If you move it to the shared render exit, every HTML-based downstream platform benefits automatically.

If you remember only one line, make it this one: when multiple platforms share the same HTML-generation pipeline, fix shared structural noise at the shared output. Do not wait until each platform fails in its own way.

What problem was this fix actually solving?

On the surface, it looked like a list-rendering bug. Underneath, it was a structural HTML hygiene issue.

Some editors do not treat whitespace-only text nodes between structural tags as ignorable whitespace. Instead, they reinterpret them as meaningful content nodes. That is what turned a valid-looking HTML list into a visually broken editor result.

The OmniPost commit message for this change says it clearly: v0.5.40 previously removed whitespace between list items only inside the WeChat adapter. But platforms such as Zhihu, Toutiao, and Sohu consume the same marked output, and their editor behavior is not guaranteed to match browser semantics. Instead of gambling on every platform one by one, the fix was moved upstream.

The incident details are also captured in source comments and tests. The new WeChat editor, based on ProseMirror, parsed whitespace between list items as empty list items. In a real device incident, a four-item list showed up as nine items with blank odd-numbered rows. Another recorded case turned 16 intended list items into 36 rendered li nodes, effectively expanding each list to 2n+1 items.

That makes this class of bug especially tricky:

  1. the generated HTML may still look “technically valid”;
  2. standards-compliant consumers should ignore the whitespace;
  3. rich-text editors do not always behave like browsers;
  4. the same HTML can produce different outcomes in different platform backends.

Why did the fix need to move to the markdownToHtml output?

Because markdownToHtml is the actual convergence point.

In the OmniPost codebase, multiple HTML-based adapters consume the same shared renderer from src/lib/markdown.js. Repository searches show that adapters for platforms such as CSDN, Sohu, Toutiao, WordPress, Ghost, and Zhihu all depend on markdownToHtml, rather than maintaining separate Markdown-to-HTML pipelines.

That means if the cleanup lives only in weixin.js, then:

  1. the WeChat path becomes safer;
  2. other HTML platforms still receive whitespace-heavy HTML;
  3. every future HTML adapter must rediscover and re-fix the same issue;
  4. maintenance degrades into reactive patching.

Moving the cleanup to the shared output changes the economics completely:

  1. both the marked path and the fallback path are protected;
  2. every adapter that consumes shared HTML gets the guard automatically;
  3. new HTML platforms inherit the protection by default;
  4. preview and publish flows are easier to keep aligned.

That is exactly what changed in the implementation. markdownToHtml(md) no longer returns raw fn(md) from marked; it now returns collapseStructuralWhitespace(fn(md)). The fallback renderer is wrapped in the same way.

Why is a shared fix better than a “WeChat-only” fix?

Because WeChat was only the first platform to produce a visible incident, not proof that the problem belonged only to WeChat.

The commit message is explicit about this point: initial live checks suggested that Zhihu was not currently inflating empty items, but the upstream change was still made as a pure defensive fix. That is the right engineering instinct.

A less mature team often does this instead:

  1. platform A breaks;
  2. patch platform A only;
  3. assume the other platforms are fine because they have not complained yet;
  4. accumulate inconsistent post-processing logic across adapters.

A stronger rule is the opposite: if the defect comes from a shared intermediate artifact rather than a platform-specific field, prefer fixing it in the shared layer.

This article pairs well with these two posts:

The first explains why “preview looks right” does not guarantee that a platform editor will consume the result safely. The second documents the original WeChat incident that exposed this whitespace bug.

What whitespace is actually being collapsed?

The new collapseStructuralWhitespace(html) function is not a blanket minifier. It targets whitespace specifically at structural boundaries.

The implementation mainly removes three classes of whitespace:

  1. whitespace immediately after opening tags such as <ul>, <ol>, <table>, <thead>, <tbody>, and <tr>;
  2. whitespace immediately before those container closing tags;
  3. whitespace after </li>, </tr>, </td>, </th>, </thead>, and </tbody> when another tag follows.

Those positions matter because they sit between structure-bearing tags. For compliant HTML consumers, they should already be semantically ignorable. Removing them typically does not change intended rendering, but it does reduce the chance that an editor will reinterpret them as empty content nodes.

Why does this not break code blocks or normal text?

This is the boundary that had to be proven, not merely claimed.

Whenever engineers hear “collapse whitespace,” the first fear is predictable: are we going to destroy code formatting, paragraph spacing, or meaningful line breaks? The OmniPost tests answer that directly.

The repository added two key assertions in tests/lib.test.js:

  1. the markdownToHtml output must not contain whitespace between list/table structural tags;
  2. collapseStructuralWhitespace must not alter newline text inside <pre> blocks.

That second check is crucial. Newlines inside <pre><code>...</code></pre> are not structural noise; they are part of the code payload. The test explicitly verifies that a\nb\n remains intact, while a list like:

<ul>
<li>x</li>
</ul>

is tightened to:

<ul><li>x</li></ul>

So this is not an aggressive HTML minification pass. It is a narrowly scoped structural cleanup.

Why does the WeChat adapter still keep a local guard?

Because there is still an input path that can bypass markdownToHtml: user-supplied article.html.

This is another sign that the implementation is disciplined rather than naive. After the refactor, weixin.js did not delete _collapseListWhitespace(). Instead, it turned that method into a thin wrapper around the shared collapseStructuralWhitespace(html) helper. The source comment explains why: the shared output is now protected, but WeChat still needs a post-juice safeguard for cases where a user provides raw HTML directly.

That distinction matters:

  1. shared-path problems should be fixed in the shared layer;
  2. special bypass paths should still keep adapter-level guardrails.

That is how you get both clean architecture and operational safety.

Why is this especially important in multi-platform publishing?

Because the worst maintenance outcome in multi-platform systems is not “one bug is hard to fix.” It is “the same class of bug gets fixed differently in five places.”

OmniPost is effectively a shared content pipeline:

  1. Markdown becomes an intermediate HTML artifact;
  2. multiple platform adapters consume that HTML;
  3. each platform backend has its own editor, validation model, and quirks.

When the defect belongs to the shared artifact between step 1 and step 2, the highest-leverage fix is to clean that artifact once. That turns a one-off patch into a reduction in future maintenance cost.

If you are constantly expanding beyond Zhihu, CSDN, Juejin, or CNBlogs into more HTML-consuming targets, this kind of upstream cleanup matters even more. New platforms inherit the safety property automatically instead of rediscovering the same whitespace trap later.

A reusable engineering rule from this incident

A good rule to extract from this change is:

If multiple downstream systems consume the same intermediate artifact, and the defect comes from structural noise in that artifact, prefer cleaning the artifact at its shared output instead of teaching every downstream consumer to tolerate it.

In practice, that means:

  1. decide whether the bug belongs to a shared layer or a platform layer;
  2. if a shared, semantics-safe fix exists, move the correction upward;
  3. keep local guardrails only for paths that bypass the shared layer;
  4. lock both the intended fix and the “must not break” boundaries into tests.

That is exactly what OmniPost now does: a shared helper in src/lib/markdown.js, downstream delegation from WeChat, a commit note that explains the why, and tests that pin both the cleanup behavior and the <pre> safety boundary.

FAQ

Why not keep the fix only in WeChat and wait until another platform breaks?

Because the defect came from shared HTML output, not from a WeChat-only field. Once multiple platforms consume the same markdownToHtml path, cleaning structural whitespace at the shared exit is cheaper, broader, and less fragile than waiting for repeated incidents.

Does this mean every HTML platform would have shown empty list items?

No. The commit message actually says the opposite: an initial live check on Zhihu did not show list inflation at that time. The point of the change was not “every platform is broken,” but “editor behavior is not predictable enough to leave this unguarded.”

Does collapsing whitespace change valid HTML semantics?

For compliant HTML consumers, whitespace-only nodes at structural boundaries should already be ignorable, so this cleanup is generally semantics-safe. The real risk is harming code or meaningful text, which is why the <pre> boundary is covered explicitly in tests.

Why keep _collapseListWhitespace() inside the WeChat adapter at all?

Because WeChat still supports a path where users can provide article.html directly, bypassing markdownToHtml. The adapter-level guard remains as a last safety net for that special case.

They are connected, but not identical. Preview is about making what you see match what will be submitted. This fix is about keeping the intermediate HTML structurally clean so platform editors do not reinterpret whitespace as content nodes. One is rendering fidelity; the other is structural hygiene.

Moving structural-whitespace cleanup to the markdownToHtml output is a simple change with disproportionate value: clean the shared artifact once, then let many platforms consume the cleaner result. In multi-platform publishing tools, those fixes are often more valuable than they look, because they prevent whole classes of downstream adapter drift. If you want that kind of local-first “write once, distribute reliably” workflow, start with OmniPost: <https://omnigoai.com/en/download/omnipost/>.

#OmniPost#markdownToHtml#HTML rendering#cross-posting

More from the journal

7 min

What the “Open” button does on an OmniPost account

Learn what the “Open” button on an OmniPost account actually does: it jumps into the logged-in creator backend for that account so you can verify session state, dashboards, comments, and publish results faster.

Read
9 min

What to do when the desktop is busy: resource holders and queues

When another task already owns the desktop, GoWork should not cancel parallel work by default. This article explains how resource holders, desktop queues, and concurrent orchestration fit together, and why waiting in line is safer than grabbing the mouse.

Read