feat(export): inline computed iframe styles for WeChat paste#94
Conversation
The juice-only path missed class-driven styles (Tailwind via CDN generates them at runtime in the preview iframe, where juice can't reach). New `toWechatHtmlFromDocument(renderedDoc)` walks `getComputedStyle` on the live DOM and inlines a 24-prop visual whitelist — color, font-*, line/letter spacing, background-*, border, border-radius, box-shadow, text-shadow, opacity, list-style, … Deliberately drops layout props (position / display / flex-* / grid-* / width / height / gap). Poster-scale grids would collapse in WeChat's ~375-540px article column anyway — letting the column reflow content as a single stream is the right behaviour. - Clamp margin/padding to 48px (≈8px baseline × 6 lines, comfortable mobile reading max). Negative margins dropped. - Border sides with computed style `none` are skipped to keep the inline blob short. - `getComputedStyle` wrapped in try/catch for cross-origin / detached node safety. - `copyToWechat(html, renderedDoc?)` falls back to the legacy juice path when no renderedDoc is passed. 3 vitest cases (wechat.test.ts) cover: inline of visual props, drop of layout props, and spacing clamp + negative-margin drop.
| for (const child of Array.from(body.childNodes)) { | ||
| const clone = child.cloneNode(true); | ||
| if (child.nodeType === Node.ELEMENT_NODE && clone.nodeType === Node.ELEMENT_NODE) { | ||
| inlineComputedTree(child as Element, clone as Element, view); |
There was a problem hiding this comment.
This new path no longer preserves ::before / ::after content. toWechatHtmlFromDocument() only clones real DOM nodes and then walks querySelectorAll("*"), so any generated content disappears before export. The old branch just above still calls juice.inlineContent(..., { inlinePseudoElements: true }), which is why WeChat exports previously kept pseudo-element badges/text. That regression is user-visible in templates we already ship, for example next/src/lib/templates/skills/pricing-page/example.html (li::before { content: "✓" }) and next/src/lib/templates/skills/saas-landing/example.html (.tier.featured::before { content: "Recommended" }): exporting those through the live iframe will now drop the checkmarks/labels entirely. Please preserve pseudo-elements in this branch too (for example by running a juice pass after cloning, or by materializing ::before / ::after from getComputedStyle(el, pseudo) when content is present) and add a regression test that proves pseudo-element content survives the WeChat export.
…le export
toWechatHtmlFromDocument only cloned real DOM nodes, dropping CSS-generated
pseudo content (e.g. li::before { content: "✓" }, .tier.featured::before
{ content: "Recommended" }) that the old juice path used to inline. Read
::before/::after via getComputedStyle on each source element and materialize
string-literal content as a real <span data-pseudo="::before|::after"> child
so the exported HTML keeps the badges and bullets WeChat receives.
c773eb2 to
f5d11b3
Compare
mrcfps
left a comment
There was a problem hiding this comment.
@TuYv Thanks for the fast pseudo-element follow-up — I re-reviewed the latest head and found one remaining blocker in the new traversal logic that can mis-style exported content when generated content is present.
🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.| materializePseudos(source, clone, view); | ||
|
|
||
| const sourceEls = Array.from(source.querySelectorAll("*")); | ||
| const cloneEls = Array.from(clone.querySelectorAll("*")); | ||
| for (let i = 0; i < sourceEls.length; i++) { | ||
| const cloneEl = cloneEls[i]; | ||
| if (!cloneEl) continue; | ||
| const childStyle = computedStyleText(sourceEls[i], view); | ||
| if (childStyle) cloneEl.setAttribute("style", childStyle); | ||
| materializePseudos(sourceEls[i], cloneEl, view); |
There was a problem hiding this comment.
Materializing pseudos before you build cloneEls breaks the source/clone index mapping in this loop. sourceEls only contains real descendants, but clone.querySelectorAll("*") now includes every injected data-pseudo span too, so as soon as a parent or earlier sibling has ::before/::after content, sourceEls[i] no longer corresponds to cloneEls[i]. A concrete repro is <div class="card"><span class="label">Hello</span></div> with .card::before { content: "Badge" } and .label { color: red }: this code applies the .label style to the injected pseudo span and leaves the real .label unstyled in the exported HTML. That is a visible regression in the main export path. Please walk the source and clone trees in lockstep (for example recurse over each child pair instead of zipping two global querySelectorAll() lists after mutation), and add a regression test with a pseudo-bearing ancestor plus a styled descendant so this mapping cannot drift again.
| const sourceEls = Array.from(source.querySelectorAll("*")); | ||
| const cloneEls = Array.from(clone.querySelectorAll("*")); | ||
| for (let i = 0; i < sourceEls.length; i++) { | ||
| const cloneEl = cloneEls[i]; | ||
| if (!cloneEl) continue; | ||
| const childStyle = computedStyleText(sourceEls[i], view); | ||
| if (childStyle) cloneEl.setAttribute("style", childStyle); | ||
| materializePseudos(sourceEls[i], cloneEl, view); |
There was a problem hiding this comment.
Materializing pseudos before building cloneEls still breaks the source/clone mapping in this traversal. source.querySelectorAll("*") only returns real descendants, but clone.querySelectorAll("*") now includes every injected data-pseudo span, so the indexes diverge as soon as an earlier element has ::before or ::after content. I reproduced that on this head with <div class="card"><span class="label">Hello</span></div> plus .card::before { content: "Badge" } and .label { color: red }: the exported HTML applies the red style to the injected pseudo span and leaves the real .label unstyled. That is a main-path correctness regression for any template that mixes generated content with styled descendants. Please walk the source and clone trees in lockstep (for example recurse over each child pair, or materialize pseudos only after a pair has been matched) and add a regression test with a pseudo-bearing ancestor plus a styled descendant so this alignment cannot drift again.
Summary
Closes the Tailwind-CDN blind spot in WeChat export. The old juice-only path missed class-driven styles because Tailwind via CDN generates them at runtime in the preview iframe — beyond juice's static reach.
New
toWechatHtmlFromDocument(renderedDoc)walksgetComputedStyleover every element of the live preview DOM and inlines a 24-prop visual whitelist (color, font-, line/letter spacing, background-, border, border-radius, box-shadow, text-shadow, opacity, list-style, …).Deliberately drops layout props (
position/display/flex-*/grid-*/width/height/gap). Poster-scale grids would collapse in WeChat's ~375-540px article column anyway — letting the column reflow content as a single stream is the right behaviour, andwechat.test.tspins that invariant.Other guardrails:
margin/paddingto 48px (≈ 8px baseline × 6 lines, comfortable mobile reading max). Poster-scale 80-120px gutters would otherwise dominate the narrow article column.border-style: nonesides to keep the inline blob short.getComputedStylewrapped in try/catch for cross-origin / detached node safety.copyToWechat(html, renderedDoc?)falls back to the legacy juice path when norenderedDocis passed — zero behaviour change for callers that haven't migrated.<ExportMenu>(next/src/components/export-menu.tsx) now passesiframeRef.current?.contentDocumentso the new exporter receives the live DOM.Test plan
pnpm -F @html-anything/next test— 141 tests pass, including 3 new cases innext/src/lib/export/__tests__/wechat.test.ts:inlines computed styles from the rendered preview DOMdrops fragile page-layout styles so WeChat paste stays in article flowclamps oversized spacing and drops negative marginspnpm -F @html-anything/next typecheckpasses