Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Breaking changes in this release:
- New debug API, by [@compulim](https://github.com/compulim) in PR [#5663](https://github.com/microsoft/BotFramework-WebChat/pull/5663) and PR [#5664](https://github.com/microsoft/BotFramework-WebChat/pull/5664), see [`DEBUGGING.md`](docs/DEBUGGING.md) for more
- Debug into element: open <kbd>F12</kbd>, select the subject in Element pane, type `$0.webChat.debugger`
- Breakpoint: open <kbd>F12</kbd>, select the subject in Element pane, type `$0.webChat.breakpoint.incomingActivity`
- The `botframework-webchat` package now uses CSS modules for styling purposes, in PR [#5666](https://github.com/microsoft/BotFramework-WebChat/pull/5666), by [@OEvgeny](https://github.com/OEvgeny)
- The `botframework-webchat` package now uses CSS modules for styling purposes, in PR [#5666](https://github.com/microsoft/BotFramework-WebChat/pull/5666), in PR [#5677](https://github.com/microsoft/BotFramework-WebChat/pull/5677) by [@OEvgeny](https://github.com/OEvgeny)
- 👷🏻 Added `npm run build-browser` script for building test harness package only, in PR [#5667](https://github.com/microsoft/BotFramework-WebChat/pull/5667), by [@compulim](https://github.com/compulim)

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate:dts",
"build:post:dtsroll": "dtsroll ./dist/*.d.*",
"build:post:validate:dts": "if grep -q -P '@msinternal\\/' dist/*.d.* 2>/dev/null; then echo \"Error: dist/*.d.* is not compiled by dtsroll\" >&2; exit 1; fi",
"build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*",
"build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch && npm run build:pre:globalize",
"build:pre:globalize": "node scripts/createPrecompiledGlobalize.mjs",
"build:pre:local-dependencies": "../../scripts/npm/build-local-dependencies.sh",
Expand Down
2 changes: 1 addition & 1 deletion packages/bundle/esbuild.static.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const BASE_CONFIG = {
plugins: [
cssPlugin,
injectCSSPlugin({
getCSSText: (_source, cssFiles) => cssFiles.find(({ path }) => path.endsWith('botframework-webchat.css'))?.text,
ignoreCSSEntries: ['static/botframework-webchat/component.css'],
stylesPlaceholder: bundleStyleContentPlaceholder
}),
{
Expand Down
7 changes: 5 additions & 2 deletions packages/bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,12 @@
},
"scripts": {
"build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate:dts",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate",
"build:post:dtsroll": "dtsroll ./dist/*.d.*",
"build:post:validate:dts": "if grep -q -P '@msinternal\\/' dist/*.d.* 2>/dev/null; then echo \"Error: dist/*.d.* is not compiled by dtsroll\" >&2; exit 1; fi",
"build:post:validate": "npm run build:post:validate:css && npm run build:post:validate:inject-css && npm run build:post:validate:dts",
"build:post:validate:css": "vg ast-check lightning-css ./dist/*.css",
"build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*",
"build:post:validate:inject-css": "vg ast-check css-inject dist/*.js dist/*.mjs static/*.js",
"build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch",
"build:pre:local-dependencies": "../../scripts/npm/build-local-dependencies.sh",
"build:pre:watch": "../../scripts/npm/build-watch.sh",
Expand Down
13 changes: 8 additions & 5 deletions packages/bundle/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ const commonConfig = applyConfig(config => ({
// 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.
// 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".
'uuid'
],
esbuildPlugins: [
...(config.esbuildPlugins ?? []),
injectCSSPlugin({
ignoreCSSEntries: ['dist/botframework-webchat.component.css'],
stylesPlaceholder: bundleStyleContentPlaceholder,
})
]
}));

Expand All @@ -64,11 +71,7 @@ export default defineConfig([
'webchat-es5': './src/boot/iife/webchat-es5.ts',
'webchat-minimal': './src/boot/iife/webchat-minimal.ts'
},
esbuildPlugins: [
...(commonConfig.esbuildPlugins ?? []),
injectCSSPlugin({ stylesPlaceholder: bundleStyleContentPlaceholder }),
resolveReact
],
esbuildPlugins: [...(commonConfig.esbuildPlugins ?? []), resolveReact],
format: 'iife',
outExtension() {
return { js: '.js' };
Expand Down
4 changes: 3 additions & 1 deletion packages/component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@
"homepage": "https://github.com/microsoft/BotFramework-WebChat/tree/main/packages/component#readme",
"scripts": {
"build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate:css && npm run build:post:validate:dts",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate",
"build:post:dtsroll": "dtsroll ./dist/*.d.*",
"build:post:validate": "npm run build:post:validate:css && npm run build:post:validate:inject-css && npm run build:post:validate:dts",
"build:post:validate:css": "vg ast-check lightning-css ./dist/*.css",
"build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*",
"build:post:validate:inject-css": "vg ast-check css-inject dist/*.js dist/*.mjs",
"build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch",
"build:pre:local-dependencies": "../../scripts/npm/build-local-dependencies.sh",
"build:pre:watch": "../../scripts/npm/build-watch.sh",
Expand Down
3 changes: 1 addition & 2 deletions packages/component/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ const commonConfig = applyConfig(config => ({
injectCSSPlugin({
// esbuild does not fully support CSS code splitting, every entry point has its own CSS file.
// Related to https://github.com/evanw/esbuild/issues/608.
getCSSText: (_source, cssFiles) =>
cssFiles.find(({ path }) => path.endsWith('botframework-webchat-component.component.css'))?.text,
ignoreCSSEntries: ['dist/botframework-webchat-component.component.css'],
stylesPlaceholder: componentStyleContentPlaceholder
}),
injectCSSPlugin({ stylesPlaceholder: decoratorStyleContentPlaceholder })
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate:dts",
"build:post:dtsroll": "dtsroll ./dist/*.d.*",
"build:post:validate:dts": "if grep -q -P '@msinternal\\/' dist/*.d.* 2>/dev/null; then echo \"Error: dist/*.d.* is not compiled by dtsroll\" >&2; exit 1; fi",
"build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*",
"build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch",
"build:pre:local-dependencies": "../../scripts/npm/build-local-dependencies.sh",
"build:pre:watch": "../../scripts/npm/build-watch.sh",
Expand Down
1 change: 1 addition & 0 deletions packages/fluent-theme/esbuild.static.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const config = {
format: 'esm',
loader: { '.js': 'jsx' },
minify: true,
metafile: true,
outdir: resolve(fileURLToPath(import.meta.url), `../static/`),
platform: 'browser',
plugins: [
Expand Down
3 changes: 2 additions & 1 deletion packages/fluent-theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@
"build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate",
"build:post:dtsroll": "dtsroll ./dist/*.d.*",
"build:post:validate": "npm run build:post:validate:css && npm run build:post:validate:dts",
"build:post:validate": "npm run build:post:validate:css && npm run build:post:validate:inject-css && npm run build:post:validate:dts",
"build:post:validate:css": "vg ast-check lightning-css ./dist/*.css",
"build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*",
"build:post:validate:inject-css": "vg ast-check css-inject dist/*.js dist/*.mjs static/*.js",
"build:pre": "npm run build:pre:local-dependencies && npm run build:pre:watch",
"build:pre:local-dependencies": "../../scripts/npm/build-local-dependencies.sh",
"build:pre:watch": "../../scripts/npm/build-watch.sh",
Expand Down
4 changes: 3 additions & 1 deletion packages/fluent-theme/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export default defineConfig([
entry: { 'botframework-webchat-fluent-theme.production.min': './src/bundle.ts' },
esbuildPlugins: [
...(config.esbuildPlugins ?? []),
injectCSSPlugin({ stylesPlaceholder: fluentStyleContentPlaceholder }),
injectCSSPlugin({
stylesPlaceholder: fluentStyleContentPlaceholder
}),
umdResolvePlugin
],
format: 'iife',
Expand Down
215 changes: 178 additions & 37 deletions packages/styles/src/build/private/injectCSSPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { decode, encode } from '@jridgewell/sourcemap-codec';
import path from 'node:path';
import type { OutputFile, Plugin } from 'esbuild';

export interface InjectCSSPluginOptions {
getCSSText?: ((source: OutputFile, cssFiles: OutputFile[]) => string | undefined | void) | undefined;
ignoreCSSEntries?: string[];
stylesPlaceholder: string;
}

Expand All @@ -20,63 +21,203 @@ function updateMappings(encoded: string, startIndex: number, offset: number) {
return encode(mappings);
}

export default function injectCSSPlugin({ getCSSText, stylesPlaceholder }: InjectCSSPluginOptions): Plugin {
if (!stylesPlaceholder) {
throw new Error('inject-css-plugin: no placeholder for styles provided');
type Metafile = {
outputs: Record<
string,
{
entryPoint?: string;
imports?: Array<{ path: string; kind?: string; external?: boolean }>;
}
>;
};

function mapOutputsToRootOutputs(metafile: Metafile) {
const outputs = metafile.outputs || {};
const allFiles = new Set(Object.keys(outputs));

const roots: string[] = [];
const graph = new Map<string, string[]>();

for (const [file, meta] of Object.entries(outputs)) {
if (meta.entryPoint) {
roots.push(file);
}

const edges: string[] = [];
const imports = meta.imports || [];

for (const imp of imports) {
if (imp.external) {
continue;
}

if (allFiles.has(imp.path)) {
edges.push(imp.path);
} else {
const resolved = path.posix.normalize(path.posix.resolve(path.posix.dirname(file), imp.path));
if (allFiles.has(resolved)) {
edges.push(resolved);
}
}
}
graph.set(file, edges);
}

getCSSText =
getCSSText ||
((source, cssFiles) => {
const entryName = source.path.replace(/(\.js|\.mjs)$/u, '');
const css = cssFiles.find(f => f.path.replace(/(\.css)$/u, '') === entryName);
roots.sort();

return css?.text;
});
const outputToRoots = new Map<string, Set<string>>();

for (const file of allFiles) {
outputToRoots.set(file, new Set());
}

for (const root of roots) {
const stack = [root];
const visited = new Set<string>();

while (stack.length > 0) {
const node = stack.pop()!;

if (visited.has(node)) {
continue;
}
visited.add(node);

outputToRoots.get(node)?.add(root);

const children = graph.get(node);
if (children) {
stack.push(...children);
}
}
}

const result = new Map<string, readonly string[]>();
for (const [file, rootSet] of outputToRoots) {
result.set(file, Object.freeze([...rootSet].sort()));
}

return { roots, outputToRoots: result };
}

function findOutputKeyForFile(filePath: string, outputToRoots: Map<string, readonly string[]>): string | undefined {
const fp = path.normalize(filePath);

// output keys in esbuild metafile are relative e.g. "dist/chunk-XYZ.js"
for (const outKey of outputToRoots.keys()) {
const k1 = path.normalize(outKey); // "dist/chunk-XYZ.js"
const k2 = path.normalize(path.join(path.sep, outKey)); // "/dist/chunk-XYZ.js"
if (fp.endsWith(k1) || fp.endsWith(k2)) {
return outKey;
}
}

return undefined;
}

export default function injectCSSPlugin({ ignoreCSSEntries, stylesPlaceholder }: InjectCSSPluginOptions): Plugin {
if (!stylesPlaceholder) {
throw new Error('inject-css-plugin: no placeholder for styles provided');
}

const stylesPlaceholderQuoted = JSON.stringify(stylesPlaceholder);

const ignoreCSSEntriesSet = new Set<string>(ignoreCSSEntries);

return {
name: `inject-css-plugin(${stylesPlaceholder})`,
setup(build) {
build.onEnd(({ outputFiles = [] }) => {
build.onEnd(({ outputFiles = [], metafile }) => {
const cssFiles = outputFiles.filter(({ path }) => path.match(/(\.css)$/u));
const jsFiles = outputFiles.filter(({ path }) => path.match(/(\.js|\.mjs)$/u));

const jsToCssMap = new Map(
cssFiles
.map(cssFile => {
const jsFilePath = jsFiles.find(
jsFile => jsFile.path.replace(/(\.js|\.mjs)$/u, '') === cssFile.path.replace(/(\.css)$/u, '')
)?.path;
if (!jsFilePath) {
return;
}
return [jsFilePath, cssFile] as const;
})
.filter((entry): entry is readonly [string, OutputFile] => entry !== undefined)
);

if (!metafile) {
throw new Error('inject-css-plugin: metafile is required for proper CSS injection');
}

const { outputToRoots } = mapOutputsToRootOutputs(metafile);

for (const file of outputFiles) {
if (file.path.match(/(\.js|\.mjs)$/u)) {
const cssText = getCSSText(file, cssFiles);
const jsText = file?.text;

if (cssText && jsText?.includes(stylesPlaceholderQuoted)) {
const index = jsText.indexOf(stylesPlaceholderQuoted);
const map = outputFiles.find(f => f.path.replace(/(\.map)$/u, '') === file.path);
const shouldProccess = jsText?.includes(stylesPlaceholderQuoted);

const updatedJsText = [
jsText.slice(0, index),
JSON.stringify(cssText),
jsText.slice(index + stylesPlaceholderQuoted.length)
].join('');
if (!shouldProccess) {
continue;
}

file.contents = Buffer.from(updatedJsText);
const outKey = findOutputKeyForFile(file.path, outputToRoots);
const owners = (outKey && outputToRoots.get(outKey)) || [];
const cssFilesMap = new Map(
owners
?.map(owner => {
const cssFile = jsToCssMap.get(path.join(process.cwd(), owner));
if (!cssFile) {
return;
}
const cssKey = cssFile ? path.relative(process.cwd(), cssFile.path) : undefined;
return [cssKey, cssFile] as const;
})
.filter((entry): entry is readonly [string, OutputFile] => entry !== undefined)
);

const cssCandidateKeys = new Set(cssFilesMap.keys()).difference(ignoreCSSEntriesSet);

if (cssCandidateKeys.size !== 1) {
throw new Error(
`inject-css-plugin: unable to uniquely determine CSS for ${outKey}. Found CSS entries: \n[\n${[
...cssCandidateKeys
]
.map(entry => ` '${entry}'`)
.join(',\n')}\n]\n Add the appropriate CSS file to ignoreCSSEntries to fix this issue.`
);
}

// eslint-disable-next-line no-magic-numbers
if (updatedJsText.indexOf(stylesPlaceholder) !== -1) {
throw new Error(
`Duplicate placeholders are not supported.\nFound ${stylesPlaceholder} in ${file.path}.`
);
}
const cssText = cssFilesMap.get(Array.from(cssCandidateKeys).at(0)!)?.text;

if (map) {
const parsed = JSON.parse(map.text);
if (!cssText) {
throw new Error(
`inject-css-plugin: unable to find CSS text for ${outKey}.${ignoreCSSEntries ? '\n The following entries were ignored:\n' + ignoreCSSEntries.map(entry => ` '${entry}'`).join('\n') : ''}`
);
}

parsed.mappings = updateMappings(
parsed.mappings,
index,
cssText.length - stylesPlaceholderQuoted.length
);
const index = jsText.indexOf(stylesPlaceholderQuoted);
const map = outputFiles.find(f => f.path.replace(/(\.map)$/u, '') === file.path);

map.contents = Buffer.from(JSON.stringify(parsed));
}
const updatedJsText = [
jsText.slice(0, index),
JSON.stringify(cssText),
jsText.slice(index + stylesPlaceholderQuoted.length)
].join('');

file.contents = Buffer.from(updatedJsText);

// eslint-disable-next-line no-magic-numbers
if (updatedJsText.indexOf(stylesPlaceholder) !== -1) {
throw new Error(`Duplicate placeholders are not supported.\nFound ${stylesPlaceholder} in ${file.path}.`);
}

if (map) {
const parsed = JSON.parse(map.text);

parsed.mappings = updateMappings(parsed.mappings, index, cssText.length - stylesPlaceholderQuoted.length);

map.contents = Buffer.from(JSON.stringify(parsed));
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions packages/vibe-grep/src/rules/css-inject.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---

# This rule checks that CSS is injected properly by verifying there are no placeholders

id: css-inject-placeholder
language: JavaScript
rule:
kind: string
regex: "\\@\\-\\-[A-Z\\-]+\\-\\-\\@"

description: "Ensure CSS placeholders are replaced during bundling"
message: "Found CSS placeholder. Ensure CSS is properly injected during bundling."
severity: error
args: [dist/*.js dist/*.mjs static/*.js]
Loading