Why a WeChat-only whitespace fix was never enough
Learn why OmniPost moved empty-list-item protection from a WeChat-only patch to the shared markdownToHtml output, while keeping adapter-level guardrails for bypass paths.
Here is the short answer: if the same Markdown eventually becomes HTML for multiple downstream platforms, fixing structural whitespace only inside the WeChat adapter is not enough. The durable fix belongs at the shared markdownToHtml output. WeChat exposed the failure first, but the defect did not fundamentally belong only to WeChat.
That is what makes this OmniPost change worth studying. The team first stopped the immediate WeChat breakage, then moved the reusable logic up into src/lib/markdown.js, and still kept a platform-level fallback for inputs that bypass the shared rendering path. For a multi-platform publishing tool like OmniGoAI’s OmniPost, that architectural move matters more than the bug itself, because it determines whether new platforms inherit a shared protection or force you to keep copying platform-specific patches forever.
If you remember only one line, make it this one: clean structural noise in shared artifacts at the shared output, then keep adapter-level logic for adapter-level exceptions.
The real question was never “should WeChat be fixed?”
At first glance the answer looks obvious. If the WeChat editor turns whitespace between list items into empty list entries, then the natural reaction is to patch WeChat and move on.
But once you trace the bug one layer upward, the actual engineering question changes:
- did the defect come from a WeChat-only field, or from a shared intermediate HTML artifact?
- if the shared artifact itself carries structural noise, should that noise be removed at the shared exit?
- after moving the fix upward, should the platform adapter still keep a local fallback?
The OmniPost codebase answers all three quite clearly.
In src/adapters/platforms/weixin.js, the WeChat adapter imports both the shared renderer and the shared whitespace helper:
import { markdownToHtml, collapseStructuralWhitespace } from '../../lib/markdown.js';
And in the normal content path it starts from:
let content = article.html || (await markdownToHtml(article.markdown));
Those two lines already tell you most of the story. WeChat is not consuming a WeChat-only Markdown pipeline. It is consuming HTML produced by the shared markdownToHtml path. Once that is true, keeping the entire fix only in weixin.js is, at best, a partial solution.
Why WeChat was the first platform to reveal the issue
Because it is one of the most sensitive import-oriented editors in the stack.
The OmniPost update note describes the incident in unusually concrete terms: the new WeChat Official Account editor interpreted whitespace between list items as empty list items, so a four-item list rendered as nine items with blank odd-numbered rows. The same protective logic was later extended to table structures.
That means the failure was not merely cosmetic. The editor had crossed the line from “rendering looks a bit odd” to “structural noise is being reinterpreted as content nodes.” Our earlier post, Why WeChat turns whitespace into empty list items, focused on the incident itself: why the website looked normal, why generic previews looked normal, and why the WeChat backend still broke.
But if you stop at “WeChat is special, so patch WeChat,” you miss the bigger lesson: WeChat exposed a structural-hygiene problem in shared HTML, not merely a WeChat-only display bug.
Why a WeChat-only patch was not enough
Because the same rendering pipeline feeds other HTML-based platforms too.
In src/lib/markdown.js, OmniPost now defines markdownToHtml(md) as a shared exit point:
export async function markdownToHtml(md) {
if (!md) return '';
const fn = await loadMarked();
if (fn) {
try {
return collapseStructuralWhitespace(fn(md));
} catch {
/* fall through to builtin */
}
}
return collapseStructuralWhitespace(fallbackMarkdown(md));
}
The most important detail is not that a helper exists. It is that both the marked path and the fallback path now flow through the same structural-whitespace cleanup. The fix has been promoted from a platform patch into a shared-artifact cleanup step.
The source comment explains the why in plain language:
markedpretty-prints with newlines around<li>and at the edges of<ol>/<ul>;- standards-compliant HTML consumers should ignore that whitespace;
- the new WeChat editor, based on ProseMirror, may instead interpret it as empty list items;
- other HTML-consuming editors, including ones on Zhihu or Toutiao, are not predictable enough to trust blindly;
- so instead of gambling platform by platform, OmniPost cleans the structure at the source.
That is why a WeChat-only fix was insufficient:
- it protects only one downstream path;
- it does not make the shared HTML cleaner for Zhihu, Sohu, Toutiao, Ghost, WordPress, or any future HTML-based target;
- every new platform would need to rediscover the same structural hazard;
- maintenance would degrade into reactive patching.
That style of maintenance feels fast in the short term and becomes expensive in the long term.
Why the shared render exit is the right first landing point
Because it maximizes both coverage and semantic safety.
First, coverage. Any adapter that consumes HTML through markdownToHtml now inherits the cleanup automatically. Once the WeChat incident exposed the flaw, the benefit was no longer limited to WeChat. The whole HTML distribution chain became safer.
Second, semantic boundaries. collapseStructuralWhitespace(html) does not remove all whitespace. It targets whitespace at structural tag boundaries. The implementation mainly cleans:
- whitespace right after opening tags like
<ul>,<ol>,<table>,<thead>,<tbody>, and<tr>; - whitespace right before those container closing tags;
- whitespace after tags like
</li>,</tr>,</td>, and</th>when another tag follows.
So this is not an HTML compressor and not an aggressive minifier. It is a narrowly scoped structural hygiene pass. For standards-compliant consumers, those whitespace-only nodes should already be semantically ignorable. Removing them usually does not change the intended output, but it does reduce the chance that an editor will reinterpret them as empty content nodes.
That is also why the related article, Why collapse structural whitespace at the markdownToHtml output, argued that this was not just “moving a WeChat patch around.” It was about cleaning a shared HTML artifact before many platforms consume it.
Then why keep a WeChat-side fallback at all?
Because some inputs can still bypass the shared exit.
This is where teams often overcorrect. Once a fix has been “moved upstream,” it is tempting to delete every downstream safeguard in the name of cleaner architecture.
OmniPost did not do that. weixin.js still imports collapseStructuralWhitespace, which tells you something important: the shared layer fixes the default path; the adapter keeps a fallback for the bypass path.
Why is that necessary? Because WeChat can accept article.html directly. If a user supplies raw HTML instead of going through article.markdown -> markdownToHtml, that content can skip the shared cleanup pass entirely.
So the durable division of labor looks like this:
- default Markdown flow: clean structure at the shared
markdownToHtmlexit; - special raw-HTML flow: apply the same structural cleanup again inside the platform adapter;
- reuse the same helper so the logic does not drift.
That is the mature design. It is neither “push everything upward” nor “keep everything local.”
Why this is such a useful example for multi-platform architecture
Because it is a textbook case of shared-layer responsibility versus adapter-layer responsibility.
Multi-platform systems often accumulate technical debt by fixing the same class of issue differently in different adapters. At first, each patch feels small. Over time, the consequences stack up:
- the same bug gets one fix for WeChat, another for Zhihu, another for Toutiao, and another for Sohu;
- one platform changes, the others silently drift;
- preview and publish begin to disagree;
- nobody on the team remembers whether the next bug should be debugged in the shared renderer or inside a specific adapter.
This OmniPost incident gives you a reusable rule instead:
If multiple downstream systems consume the same intermediate artifact, and the defect comes from structural noise in that artifact, clean it once at the shared output. Keep adapter-level fallbacks only for input paths that bypass the shared layer.
That rule is simple, but it saves a lot of repeated “patch one platform, miss three others” work.
How preview, the shared renderer, and the adapter should divide the work
These layers are related, but they do not replace one another.
What should the shared renderer do?
It should ensure structural hygiene of the intermediate HTML. Its job is to remove shared noise that downstream editors might reinterpret.
What should the platform adapter do?
It should enforce platform-specific boundaries, including:
- account state and API constraints;
- platform-only fields;
- platform-specific HTML or content rules;
- last-mile fallbacks for bypass inputs.
What should preview do?
It should help answer whether what you see is close to what will actually be submitted. Preview checks visual fidelity, not whether a downstream editor will reinterpret hidden structural whitespace as extra nodes.
So these three layers form a stack of acceptance criteria:
- the shared renderer makes the artifact cleaner;
- the adapter makes the platform boundary safer;
- preview makes visual problems visible to humans.
Remove any one of those layers, and you create a new blind spot.
A practical rule for deciding what moves upward and what stays local
A quick checklist helps.
Move the fix to the shared layer when:
- the defect comes from a shared intermediate artifact, not a platform-only field;
- the cleanup is semantics-safe for compliant downstream consumers;
- multiple platforms already consume the same artifact;
- future platforms are likely to inherit the same path.
Structural-whitespace cleanup satisfies all four.
Keep logic in the adapter when:
- the defect exists only in a platform-specific API or editor field;
- the fix needs platform context to stay safe;
- the input can bypass the shared layer;
- the behavior has no reuse value for other platforms.
The WeChat-side guard for direct article.html input fits that second category perfectly.
What this means for “write once, publish everywhere” workflows
It means the stability of a cross-posting system often depends less on how many adapter patches you have, and more on whether your shared layer is clean enough.
If you publish the same article to Zhihu, CSDN, Juejin, CNBlogs, and WeChat every day, the most economical strategy is not to wait until every platform fails in a slightly different way. It is to make the shared intermediate artifact cleaner and more predictable first. Platform adapters still matter, of course, but they should mostly carry platform-specific complexity instead of constantly compensating for shared-layer noise.
That is why this article is not merely repeating the WeChat empty-list incident, and not merely repeating the argument for a markdownToHtml cleanup. It answers a more reusable engineering question: when one platform exposes a shared defect first, do you stop at a local patch, or keep going and move the fix upward? OmniPost’s answer was the right one: stop the bleeding, move the reusable logic up, then keep a local fallback for bypass paths.
FAQ
Why not just keep the fix in weixin.js?
Because WeChat is consuming HTML produced by the shared markdownToHtml path. If the defect comes from structural noise in that shared artifact, a WeChat-only fix protects only one consumer and leaves the rest of the HTML pipeline unchanged.
Does moving the fix upward mean every other platform would have shown empty list items too?
No. The more precise claim is that other platform editors are not predictable enough to trust blindly, so a defensive cleanup at the shared layer is worth doing even before they fail visibly.
Why keep adapter-level logic after the shared layer is fixed?
Because there are still bypass inputs such as direct article.html. The shared layer covers the default path; the adapter covers the special path. Those two responsibilities complement each other.
Could this damage code blocks or meaningful text spacing?
Not if the implementation only touches whitespace at structural boundaries. OmniPost’s current approach is exactly that kind of narrow cleanup: it targets list and table structure, while leaving <pre> code-block newlines intact.
How is this different from preview?
Preview asks whether the rendered output looks close to the eventual submission. Structural-whitespace cleanup asks whether a downstream editor may reinterpret the shared HTML as extra nodes. They are connected, but they solve different problems.
Moving this whitespace fix from a WeChat-only patch into the shared markdownToHtml output, while still keeping an adapter-level fallback for bypass paths, is a high-leverage architectural move: clean the shared artifact first, then let platform adapters focus on platform-specific complexity. That is how multi-platform publishing tools stay maintainable as they grow. If you want that kind of local-first, controllable content-distribution workflow, start with OmniPost: <https://omnigoai.com/en/download/omnipost/>.