Skip to content

Commit c773eb2

Browse files
author
rick
committed
fix(export): preserve ::before/::after content in WeChat computed-style 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.
1 parent 37813b2 commit c773eb2

2 files changed

Lines changed: 113 additions & 4 deletions

File tree

next/src/lib/export/__tests__/wechat.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,62 @@ describe("toWechatHtmlFromDocument", () => {
9191
);
9292
});
9393

94+
it("materializes ::before and ::after content into real DOM nodes", () => {
95+
document.body.innerHTML = `
96+
<ul>
97+
<li class="check">Item one</li>
98+
</ul>
99+
<p class="tier">Pro plan</p>
100+
`;
101+
102+
const original = window.getComputedStyle.bind(window);
103+
const stub = ((el: Element, pseudo?: string | null) => {
104+
const base = original(el);
105+
if (!pseudo) return base;
106+
const overrides: Record<string, string> = {};
107+
if (pseudo === "::before" && (el as HTMLElement).matches?.(".check")) {
108+
overrides.content = '"✓"';
109+
overrides.color = "rgb(255, 0, 0)";
110+
} else if (pseudo === "::before" && (el as HTMLElement).matches?.(".tier")) {
111+
overrides.content = '"Recommended"';
112+
overrides.color = "rgb(255, 255, 255)";
113+
} else if (pseudo === "::after" && (el as HTMLElement).matches?.(".tier")) {
114+
overrides.content = '"★"';
115+
}
116+
return new Proxy(base, {
117+
get(target, prop) {
118+
if (prop === "getPropertyValue") {
119+
return (name: string) => overrides[name] ?? target.getPropertyValue(name);
120+
}
121+
const value = (target as unknown as Record<string | symbol, unknown>)[prop];
122+
return typeof value === "function" ? (value as () => unknown).bind(target) : value;
123+
},
124+
});
125+
}) as typeof window.getComputedStyle;
126+
window.getComputedStyle = stub;
127+
128+
try {
129+
const body = parseFragment(toWechatHtmlFromDocument(document));
130+
const check = body.querySelector("li.check");
131+
const tier = body.querySelector("p.tier");
132+
133+
const checkBefore = check?.firstElementChild;
134+
expect(checkBefore?.getAttribute("data-pseudo")).toBe("::before");
135+
expect(checkBefore?.textContent).toBe("✓");
136+
expect(checkBefore?.getAttribute("style") ?? "").toContain("color: rgb(255, 0, 0)");
137+
138+
const tierBefore = tier?.firstElementChild;
139+
expect(tierBefore?.getAttribute("data-pseudo")).toBe("::before");
140+
expect(tierBefore?.textContent).toBe("Recommended");
141+
142+
const tierAfter = tier?.lastElementChild;
143+
expect(tierAfter?.getAttribute("data-pseudo")).toBe("::after");
144+
expect(tierAfter?.textContent).toBe("★");
145+
} finally {
146+
window.getComputedStyle = original as typeof window.getComputedStyle;
147+
}
148+
});
149+
94150
it("clamps oversized spacing and drops negative margins", () => {
95151
document.head.innerHTML = `
96152
<style>

next/src/lib/export/wechat.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export async function copyToWechat(fullHtml: string, renderedDoc?: Document | nu
113113
function inlineComputedTree(source: Element, clone: Element, view: Window): void {
114114
const styleText = computedStyleText(source, view);
115115
if (styleText) clone.setAttribute("style", styleText);
116+
materializePseudos(source, clone, view);
116117

117118
const sourceEls = Array.from(source.querySelectorAll("*"));
118119
const cloneEls = Array.from(clone.querySelectorAll("*"));
@@ -121,16 +122,68 @@ function inlineComputedTree(source: Element, clone: Element, view: Window): void
121122
if (!cloneEl) continue;
122123
const childStyle = computedStyleText(sourceEls[i], view);
123124
if (childStyle) cloneEl.setAttribute("style", childStyle);
125+
materializePseudos(sourceEls[i], cloneEl, view);
124126
}
125127
}
126128

127-
function computedStyleText(el: Element, view: Window, opts?: { skipMargin?: boolean }): string {
129+
/**
130+
* WeChat strips pseudo-elements entirely, and our computed-style walk only sees
131+
* real DOM nodes. Read ::before/::after from getComputedStyle and turn each into
132+
* a real <span> child so the rendered content (✓, Recommended badge, etc.) survives.
133+
*/
134+
function materializePseudos(source: Element, clone: Element, view: Window): void {
135+
const before = buildPseudoNode(source, view, "::before");
136+
if (before) clone.insertBefore(before, clone.firstChild);
137+
const after = buildPseudoNode(source, view, "::after");
138+
if (after) clone.appendChild(after);
139+
}
140+
141+
function buildPseudoNode(source: Element, view: Window, pseudo: "::before" | "::after"): HTMLElement | null {
128142
let computed: CSSStyleDeclaration;
129143
try {
130-
computed = view.getComputedStyle(el);
144+
computed = view.getComputedStyle(source, pseudo);
131145
} catch {
132-
// Cross-origin frames or detached nodes throw here. Skip silently rather than abort the export.
133-
return "";
146+
return null;
147+
}
148+
const text = unquoteContent(computed.getPropertyValue("content").trim());
149+
if (text === null) return null;
150+
151+
const span = document.createElement("span");
152+
span.setAttribute("data-pseudo", pseudo);
153+
if (text) span.textContent = text;
154+
const style = computedStyleText(source, view, { pseudoComputed: computed });
155+
if (style) span.setAttribute("style", style);
156+
return span;
157+
}
158+
159+
/**
160+
* getComputedStyle returns `content` as a CSS token: a string literal (with quotes),
161+
* `none`, `normal`, or a function like `attr()` / `counter()` / `url()`. We only
162+
* materialize string-literal contents — anything else (including the no-content
163+
* sentinels) returns null so the pseudo is skipped.
164+
*/
165+
function unquoteContent(value: string): string | null {
166+
if (!value || value === "none" || value === "normal") return null;
167+
const match = value.match(/^"((?:[^"\\]|\\.)*)"$/) ?? value.match(/^'((?:[^'\\]|\\.)*)'$/);
168+
if (!match) return null;
169+
return match[1].replace(/\\(.)/g, "$1");
170+
}
171+
172+
function computedStyleText(
173+
el: Element,
174+
view: Window,
175+
opts?: { skipMargin?: boolean; pseudoComputed?: CSSStyleDeclaration },
176+
): string {
177+
let computed: CSSStyleDeclaration;
178+
if (opts?.pseudoComputed) {
179+
computed = opts.pseudoComputed;
180+
} else {
181+
try {
182+
computed = view.getComputedStyle(el);
183+
} catch {
184+
// Cross-origin frames or detached nodes throw here. Skip silently rather than abort the export.
185+
return "";
186+
}
134187
}
135188
const styles: string[] = [];
136189

0 commit comments

Comments
 (0)