1+ /* eslint-disable complexity */
12import { decode , encode } from '@jridgewell/sourcemap-codec' ;
3+ import path from 'node:path' ;
24import type { OutputFile , Plugin } from 'esbuild' ;
35
46export interface InjectCSSPluginOptions {
5- getCSSText ?: ( ( source : OutputFile , cssFiles : OutputFile [ ] ) => string | undefined | void ) | undefined ;
7+ ignoreCSSEntries ?: string [ ] ;
68 stylesPlaceholder : string ;
79}
810
@@ -20,63 +22,241 @@ function updateMappings(encoded: string, startIndex: number, offset: number) {
2022 return encode ( mappings ) ;
2123}
2224
23- export default function injectCSSPlugin ( { getCSSText, stylesPlaceholder } : InjectCSSPluginOptions ) : Plugin {
24- if ( ! stylesPlaceholder ) {
25- throw new Error ( 'inject-css-plugin: no placeholder for styles provided' ) ;
25+ type Metafile = {
26+ outputs : Record <
27+ string ,
28+ {
29+ entryPoint ?: string ;
30+ imports ?: Array < {
31+ path : string ;
32+ kind ?: string ;
33+ external ?: boolean ;
34+ } > ;
35+ }
36+ > ;
37+ } ;
38+
39+ export function mapOutputsToRootOutputs ( metafile : Metafile ) : {
40+ roots : string [ ] ;
41+ outputToRoots : Map < string , readonly string [ ] > ;
42+ } {
43+ const outputs = metafile . outputs ?? { } ;
44+ const outFiles = Object . keys ( outputs ) ;
45+
46+ const rootSet = new Set < string > ( ) ;
47+ for ( const outKey of outFiles ) {
48+ // eslint-disable-next-line security/detect-object-injection
49+ if ( outputs [ outKey ] ?. entryPoint ) {
50+ rootSet . add ( outKey ) ;
51+ }
2652 }
53+ const roots = [ ...rootSet ] . sort ( ) ;
54+
55+ const outputKeySet = new Set ( outFiles ) ;
56+
57+ const adj = new Map < string , string [ ] > ( ) ;
58+ for ( const outKey of outFiles ) {
59+ // eslint-disable-next-line security/detect-object-injection
60+ const imps = outputs [ outKey ] ?. imports ?? [ ] ;
61+ const list : string [ ] = [ ] ;
62+
63+ for ( const imp of imps ) {
64+ if ( ! imp || imp . external ) {
65+ continue ;
66+ }
67+
68+ const raw = imp . path ;
69+ let target : string | null = null ;
70+
71+ if ( outputKeySet . has ( raw ) ) {
72+ target = raw ;
73+ } else {
74+ const resolved = path . posix . normalize ( path . posix . resolve ( path . posix . dirname ( outKey ) , raw ) ) ;
75+ if ( outputKeySet . has ( resolved ) ) {
76+ target = resolved ;
77+ }
78+ }
2779
28- getCSSText =
29- getCSSText ||
30- ( ( source , cssFiles ) => {
31- const entryName = source . path . replace ( / ( \. j s | \. m j s ) $ / u, '' ) ;
32- const css = cssFiles . find ( f => f . path . replace ( / ( \. c s s ) $ / u, '' ) === entryName ) ;
80+ if ( target ) {
81+ list . push ( target ) ;
82+ }
83+ }
84+
85+ adj . set ( outKey , list ) ;
86+ }
87+
88+ const outToRootSet = new Map < string , Set < string > > ( ) ;
89+ for ( const outKey of outFiles ) {
90+ outToRootSet . set ( outKey , new Set ( ) ) ;
91+ }
92+
93+ for ( const rootKey of roots ) {
94+ const stack : string [ ] = [ rootKey ] ;
95+ const seen = new Set < string > ( ) ;
96+
97+ while ( stack . length ) {
98+ const cur = stack . pop ( ) ! ;
99+ if ( seen . has ( cur ) ) {
100+ continue ;
101+ }
102+ seen . add ( cur ) ;
103+
104+ outToRootSet . get ( cur ) ?. add ( rootKey ) ;
105+
106+ const nexts = adj . get ( cur ) ;
107+ if ( ! nexts || nexts . length === 0 ) {
108+ continue ;
109+ }
110+
111+ for ( const n of nexts ) {
112+ if ( ! seen . has ( n ) ) {
113+ stack . push ( n ) ;
114+ }
115+ }
116+ }
117+ }
118+
119+ const outputToRoots : Map < string , readonly string [ ] > = new Map ( ) ;
120+ for ( const outKey of outFiles ) {
121+ const s = outToRootSet . get ( outKey ) ?? new Set < string > ( ) ;
122+ outputToRoots . set ( outKey , Object . freeze ( [ ...s ] . sort ( ) ) ) ;
123+ }
124+
125+ return { roots, outputToRoots } ;
126+ }
33127
34- return css ?. text ;
35- } ) ;
128+ function findOutputKeyForFile ( filePath : string , outputToRoots : Map < string , readonly string [ ] > ) : string | undefined {
129+ const fp = path . normalize ( filePath ) ;
130+
131+ // output keys in esbuild metafile are relative e.g. "dist/chunk-XYZ.js"
132+ for ( const outKey of outputToRoots . keys ( ) ) {
133+ const k1 = path . normalize ( outKey ) ; // "dist/chunk-XYZ.js"
134+ const k2 = path . normalize ( path . join ( path . sep , outKey ) ) ; // "/dist/chunk-XYZ.js"
135+ if ( fp . endsWith ( k1 ) || fp . endsWith ( k2 ) ) {
136+ return outKey ;
137+ }
138+ }
139+
140+ return undefined ;
141+ }
142+
143+ function diffSets < K > ( self : Set < K > , other : Set < K > ) : Set < K > {
144+ const result = new Set < K > ( ) ;
145+ for ( const element of self ) {
146+ if ( ! other . has ( element ) ) {
147+ result . add ( element ) ;
148+ }
149+ }
150+ return result ;
151+ }
152+
153+ export default function injectCSSPlugin ( { ignoreCSSEntries, stylesPlaceholder } : InjectCSSPluginOptions ) : Plugin {
154+ if ( ! stylesPlaceholder ) {
155+ throw new Error ( 'inject-css-plugin: no placeholder for styles provided' ) ;
156+ }
36157
37158 const stylesPlaceholderQuoted = JSON . stringify ( stylesPlaceholder ) ;
38159
160+ const ignoreCSSEntriesSet = new Set < string > ( ignoreCSSEntries ) ;
161+
39162 return {
40163 name : `inject-css-plugin(${ stylesPlaceholder } )` ,
41164 setup ( build ) {
42- build . onEnd ( ( { outputFiles = [ ] } ) => {
165+ if ( build . initialOptions . metafile ) {
166+ build . initialOptions . metafile = true ;
167+ }
168+
169+ build . onEnd ( ( { outputFiles = [ ] , metafile } ) => {
43170 const cssFiles = outputFiles . filter ( ( { path } ) => path . match ( / ( \. c s s ) $ / u) ) ;
171+ const jsFiles = outputFiles . filter ( ( { path } ) => path . match ( / ( \. j s | \. m j s ) $ / u) ) ;
172+
173+ const jsToCssMap = new Map (
174+ cssFiles
175+ . map ( cssFile => {
176+ const jsFilePath = jsFiles . find (
177+ jsFile => jsFile . path . replace ( / ( \. j s | \. m j s ) $ / u, '' ) === cssFile . path . replace ( / ( \. c s s ) $ / u, '' )
178+ ) ?. path ;
179+ if ( ! jsFilePath ) {
180+ return ;
181+ }
182+ return [ jsFilePath , cssFile ] as const ;
183+ } )
184+ . filter ( ( entry ) : entry is readonly [ string , OutputFile ] => entry !== undefined )
185+ ) ;
186+
187+ if ( ! metafile ) {
188+ throw new Error ( 'inject-css-plugin: metafile is required for proper CSS injection' ) ;
189+ }
190+
191+ const { outputToRoots } = mapOutputsToRootOutputs ( metafile ) ;
44192
45193 for ( const file of outputFiles ) {
46194 if ( file . path . match ( / ( \. j s | \. m j s ) $ / u) ) {
47- const cssText = getCSSText ( file , cssFiles ) ;
48195 const jsText = file ?. text ;
49196
50- if ( cssText && jsText ?. includes ( stylesPlaceholderQuoted ) ) {
51- const index = jsText . indexOf ( stylesPlaceholderQuoted ) ;
52- const map = outputFiles . find ( f => f . path . replace ( / ( \. m a p ) $ / u, '' ) === file . path ) ;
197+ const shouldProccess = jsText ?. includes ( stylesPlaceholderQuoted ) ;
53198
54- const updatedJsText = [
55- jsText . slice ( 0 , index ) ,
56- JSON . stringify ( cssText ) ,
57- jsText . slice ( index + stylesPlaceholderQuoted . length )
58- ] . join ( '' ) ;
199+ if ( ! shouldProccess ) {
200+ continue ;
201+ }
59202
60- file . contents = Buffer . from ( updatedJsText ) ;
203+ const outKey = findOutputKeyForFile ( file . path , outputToRoots ) ;
204+ const owners = ( outKey && outputToRoots . get ( outKey ) ) || [ ] ;
205+ const cssFilesMap = new Map (
206+ owners
207+ ?. map ( owner => {
208+ const cssFile = jsToCssMap . get ( path . join ( process . cwd ( ) , owner ) ) ;
209+ if ( ! cssFile ) {
210+ return ;
211+ }
212+ const cssKey = cssFile ? path . relative ( process . cwd ( ) , cssFile . path ) : undefined ;
213+ return [ cssKey , cssFile ] as const ;
214+ } )
215+ . filter ( ( entry ) : entry is readonly [ string , OutputFile ] => entry !== undefined )
216+ ) ;
61217
62- // eslint-disable-next-line no-magic-numbers
63- if ( updatedJsText . indexOf ( stylesPlaceholder ) !== - 1 ) {
64- throw new Error (
65- `Duplicate placeholders are not supported.\nFound ${ stylesPlaceholder } in ${ file . path } .`
66- ) ;
67- }
218+ const cssCandidateKeys = diffSets ( new Set ( cssFilesMap . keys ( ) ) , ignoreCSSEntriesSet ) ;
68219
69- if ( map ) {
70- const parsed = JSON . parse ( map . text ) ;
220+ if ( cssCandidateKeys . size !== 1 ) {
221+ throw new Error (
222+ `inject-css-plugin: unable to uniquely determine CSS for ${ outKey } . Found CSS entries: \n[\n${ [
223+ ...cssCandidateKeys
224+ ]
225+ . map ( entry => ` '${ entry } '` )
226+ . join ( ',\n' ) } \n]\n Add the appropriate CSS file to ignoreCSSEntries to fix this issue.`
227+ ) ;
228+ }
71229
72- parsed . mappings = updateMappings (
73- parsed . mappings ,
74- index ,
75- cssText . length - stylesPlaceholderQuoted . length
76- ) ;
230+ const cssText = cssFilesMap . get ( Array . from ( cssCandidateKeys ) . at ( 0 ) ! ) ?. text ;
77231
78- map . contents = Buffer . from ( JSON . stringify ( parsed ) ) ;
79- }
232+ if ( ! cssText ) {
233+ throw new Error (
234+ `inject-css-plugin: unable to find CSS text for ${ outKey } .${ ignoreCSSEntries ? '\n The following entries were ignored:\n' + ignoreCSSEntries . map ( entry => ` '${ entry } '` ) . join ( '\n' ) : '' } `
235+ ) ;
236+ }
237+
238+ const index = jsText . indexOf ( stylesPlaceholderQuoted ) ;
239+ const map = outputFiles . find ( f => f . path . replace ( / ( \. m a p ) $ / u, '' ) === file . path ) ;
240+
241+ const updatedJsText = [
242+ jsText . slice ( 0 , index ) ,
243+ JSON . stringify ( cssText ) ,
244+ jsText . slice ( index + stylesPlaceholderQuoted . length )
245+ ] . join ( '' ) ;
246+
247+ file . contents = Buffer . from ( updatedJsText ) ;
248+
249+ // eslint-disable-next-line no-magic-numbers
250+ if ( updatedJsText . indexOf ( stylesPlaceholder ) !== - 1 ) {
251+ throw new Error ( `Duplicate placeholders are not supported.\nFound ${ stylesPlaceholder } in ${ file . path } .` ) ;
252+ }
253+
254+ if ( map ) {
255+ const parsed = JSON . parse ( map . text ) ;
256+
257+ parsed . mappings = updateMappings ( parsed . mappings , index , cssText . length - stylesPlaceholderQuoted . length ) ;
258+
259+ map . contents = Buffer . from ( JSON . stringify ( parsed ) ) ;
80260 }
81261 }
82262 }
0 commit comments