@@ -113,6 +113,7 @@ export async function copyToWechat(fullHtml: string, renderedDoc?: Document | nu
113113function 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