Skip to content

Commit b879fa2

Browse files
committed
Improve css root lookup
1 parent 0e412e5 commit b879fa2

File tree

7 files changed

+234
-50
lines changed

7 files changed

+234
-50
lines changed

packages/bundle/esbuild.static.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const BASE_CONFIG = {
8383
plugins: [
8484
cssPlugin,
8585
injectCSSPlugin({
86-
getCSSText: (_source, cssFiles) => cssFiles.find(({ path }) => path.endsWith('botframework-webchat.css'))?.text,
86+
ignoreCSSEntries: ['static/botframework-webchat/component.css'],
8787
stylesPlaceholder: bundleStyleContentPlaceholder
8888
}),
8989
{

packages/bundle/tsup.config.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ const commonConfig = applyConfig(config => ({
4747
// The way `microsoft-cognitiveservices-speech-sdk` imported the `uuid` package (in their `Guid.js`) is causing esbuild/tsup to proxy require() into __require() for dynamic loading.
4848
// Webpack 4 cannot statically analyze the code and failed with error "Critical dependency: require function is used in a way in which dependencies cannot be statically extracted".
4949
'uuid'
50+
],
51+
esbuildPlugins: [
52+
...(config.esbuildPlugins ?? []),
53+
injectCSSPlugin({
54+
ignoreCSSEntries: ['dist/botframework-webchat.component.css'],
55+
stylesPlaceholder: bundleStyleContentPlaceholder,
56+
})
5057
]
5158
}));
5259

@@ -64,14 +71,7 @@ export default defineConfig([
6471
'webchat-es5': './src/boot/iife/webchat-es5.ts',
6572
'webchat-minimal': './src/boot/iife/webchat-minimal.ts'
6673
},
67-
esbuildPlugins: [
68-
...(commonConfig.esbuildPlugins ?? []),
69-
injectCSSPlugin({
70-
stylesPlaceholder: bundleStyleContentPlaceholder,
71-
getCSSText: (_source, cssFiles) => cssFiles.find(({ path }) => path.endsWith('botframework-webchat.css'))?.text
72-
}),
73-
resolveReact
74-
],
74+
esbuildPlugins: [...(commonConfig.esbuildPlugins ?? []), resolveReact],
7575
format: 'iife',
7676
outExtension() {
7777
return { js: '.js' };

packages/component/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,10 @@
7676
"homepage": "https://github.com/microsoft/BotFramework-WebChat/tree/main/packages/component#readme",
7777
"scripts": {
7878
"build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post",
79-
"build:post": "npm run build:post:dtsroll && npm run build:post:validate:css && npm run build:post:validate:dts",
79+
"build:post": "npm run build:post:dtsroll && npm run build:post:validate",
8080
"build:post:dtsroll": "dtsroll ./dist/*.d.*",
81+
"build:post:validate": "npm run build:post:validate:css && npm run build:post:validate:inject-css && npm run build:post:validate:dts",
82+
"build:post:validate:inject-css": "vg ast-check css-inject dist/*.js dist/*.mjs static/*.js",
8183
"build:post:validate:css": "vg ast-check lightning-css ./dist/*.css",
8284
"build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*",
8385
"build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch",

packages/component/tsup.config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ const commonConfig = applyConfig(config => ({
1919
injectCSSPlugin({
2020
// esbuild does not fully support CSS code splitting, every entry point has its own CSS file.
2121
// Related to https://github.com/evanw/esbuild/issues/608.
22-
getCSSText: (_source, cssFiles) =>
23-
cssFiles.find(({ path }) => path.endsWith('botframework-webchat-component.component.css'))?.text,
22+
ignoreCSSEntries: ['dist/botframework-webchat-component.component.css'],
2423
stylesPlaceholder: componentStyleContentPlaceholder
2524
}),
2625
injectCSSPlugin({ stylesPlaceholder: decoratorStyleContentPlaceholder })

packages/fluent-theme/esbuild.static.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const config = {
5151
format: 'esm',
5252
loader: { '.js': 'jsx' },
5353
minify: true,
54+
metafile: true,
5455
outdir: resolve(fileURLToPath(import.meta.url), `../static/`),
5556
platform: 'browser',
5657
plugins: [

packages/fluent-theme/tsup.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ export default defineConfig([
102102
entry: { 'botframework-webchat-fluent-theme.production.min': './src/bundle.ts' },
103103
esbuildPlugins: [
104104
...(config.esbuildPlugins ?? []),
105-
injectCSSPlugin({ stylesPlaceholder: fluentStyleContentPlaceholder }),
105+
injectCSSPlugin({
106+
stylesPlaceholder: fluentStyleContentPlaceholder
107+
}),
106108
umdResolvePlugin
107109
],
108110
format: 'iife',

packages/styles/src/build/private/injectCSSPlugin.ts

Lines changed: 217 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
/* eslint-disable complexity */
12
import { decode, encode } from '@jridgewell/sourcemap-codec';
3+
import path from 'node:path';
24
import type { OutputFile, Plugin } from 'esbuild';
35

46
export 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(/(\.js|\.mjs)$/u, '');
32-
const css = cssFiles.find(f => f.path.replace(/(\.css)$/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(/(\.css)$/u));
171+
const jsFiles = outputFiles.filter(({ path }) => path.match(/(\.js|\.mjs)$/u));
172+
173+
const jsToCssMap = new Map(
174+
cssFiles
175+
.map(cssFile => {
176+
const jsFilePath = jsFiles.find(
177+
jsFile => jsFile.path.replace(/(\.js|\.mjs)$/u, '') === cssFile.path.replace(/(\.css)$/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(/(\.js|\.mjs)$/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(/(\.map)$/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(/(\.map)$/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

Comments
 (0)