Skip to content

feat(export): inline computed iframe styles for WeChat paste#94

Open
TuYv wants to merge 3 commits into
nexu-io:mainfrom
TuYv:feat/wechat-computed-style-export
Open

feat(export): inline computed iframe styles for WeChat paste#94
TuYv wants to merge 3 commits into
nexu-io:mainfrom
TuYv:feat/wechat-computed-style-export

Conversation

@TuYv

@TuYv TuYv commented May 28, 2026

Copy link
Copy Markdown

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) walks getComputedStyle over 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, and wechat.test.ts pins that invariant.

Other guardrails:

  • Clamp margin / padding to 48px (≈ 8px baseline × 6 lines, comfortable mobile reading max). Poster-scale 80-120px gutters would otherwise dominate the narrow article column.
  • Drop negative margins (WeChat editor rejects them, breaks paragraph flow).
  • Skip border-style: none sides 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 — zero behaviour change for callers that haven't migrated.

<ExportMenu> (next/src/components/export-menu.tsx) now passes iframeRef.current?.contentDocument so the new exporter receives the live DOM.

Test plan

  • pnpm -F @html-anything/next test — 141 tests pass, including 3 new cases in next/src/lib/export/__tests__/wechat.test.ts:
    • inlines computed styles from the rendered preview DOM
    • drops fragile page-layout styles so WeChat paste stays in article flow
    • clamps oversized spacing and drops negative margins
  • pnpm -F @html-anything/next typecheck passes
  • Manual: paste an exported skill (any of the 78 bundled templates) into the WeChat editor; confirm class-driven Tailwind colors / fonts / spacing survive and the page reflows to the column instead of collapsing to a fixed 1080px width

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.
@lefarcen lefarcen requested a review from mrcfps May 28, 2026 02:35
@lefarcen lefarcen added size/M Medium change: 100-299 lines risk/medium Medium risk change type/feature Feature or new user-facing capability labels May 28, 2026

@mrcfps mrcfps left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TuYv Thanks for closing the Tailwind/CDN gap here — I found one blocker in the new computed-style exporter that should be fixed before merge.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

@lefarcen lefarcen added size/L Large change: 300-699 changed lines and removed size/M Medium change: 100-299 lines labels May 28, 2026
@lefarcen lefarcen requested a review from mrcfps May 28, 2026 05:57
…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.
@TuYv TuYv force-pushed the feat/wechat-computed-style-export branch from c773eb2 to f5d11b3 Compare May 28, 2026 06:06

@mrcfps mrcfps left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Comment on lines +116 to +125
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

@mrcfps mrcfps left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TuYv Thanks for the quick follow-up here — I re-ran the latest head and found one blocker still remaining in the computed-style exporter.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

Comment on lines +118 to +125
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

@lefarcen lefarcen requested a review from Eli-tangerine May 28, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/medium Medium risk change size/L Large change: 300-699 changed lines type/feature Feature or new user-facing capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants