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
11 changes: 11 additions & 0 deletions packages/beasties-webpack-plugin/build.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineBuildConfig } from 'unbuild'

export default defineBuildConfig({
declaration: 'compatible',
externals: ['webpack'],
rollup: {
dts: {
respectExternal: false,
},
},
})
24 changes: 12 additions & 12 deletions packages/beasties-webpack-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@
"performance"
],
"exports": {
"import": "./dist/beasties-webpack-plugin.mjs",
"require": "./dist/beasties-webpack-plugin.js",
"default": "./dist/beasties-webpack-plugin.mjs"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
}
},
"main": "dist/beasties-webpack-plugin.js",
"module": "dist/beasties-webpack-plugin.mjs",
"source": "src/index.js",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist",
"src"
Expand All @@ -48,7 +51,7 @@
"node": "^20.0.0 || >=22.0.0"
},
"scripts": {
"build": "microbundle --target node --no-sourcemap -f cjs,esm",
"build": "unbuild",
"docs": "documentation readme src -q --no-markdown-toc -a public -s Usage --sort-order alpha",
"prepack": "npm run -s build"
},
Expand All @@ -62,19 +65,16 @@
},
"dependencies": {
"beasties": "workspace:*",
"minimatch": "^10.0.1",
"webpack-log": "^3.0.2",
"webpack-sources": "^3.2.3"
"minimatch": "^10.0.1"
},
"devDependencies": {
"@types/jsdom": "27.0.0",
"@types/webpack-sources": "3.2.3",
"css-loader": "7.1.2",
"documentation": "14.0.3",
"file-loader": "6.2.0",
"html-webpack-plugin": "5.6.6",
"microbundle": "0.15.1",
"mini-css-extract-plugin": "2.10.0",
"unbuild": "3.6.1",
"webpack": "5.104.1"
}
}
63 changes: 63 additions & 0 deletions packages/beasties-webpack-plugin/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Options } from 'beasties'
import type { Compiler } from 'webpack'
import Beasties from 'beasties'

/**
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/

/**
* Create a Beasties plugin instance with the given options.
* @public
* @param {Options} options Options to control how Beasties inlines CSS. See https://github.com/danielroe/beasties#usage
* @example
* // webpack.config.js
* module.exports = {
* plugins: [
* new Beasties({
* // Outputs: <link rel="preload" onload="this.rel='stylesheet'">
* preload: 'swap',
*
* // Don't inline critical font-face rules, but preload the font URLs:
* preloadFonts: true
* })
* ]
* }
*/
declare class BeastiesWebpackPlugin extends Beasties {
constructor(options: Options)
/**
* Invoked by Webpack during plugin initialization
*/
apply(compiler: Compiler): void
/**
* Given href, find the corresponding CSS asset
*/
getCssAsset(href: string, style: Node): Promise<string | undefined>
/**
* Check if the stylesheet should be inlined
*/
override checkInlineThreshold(link: Node, style: Node, sheet: string): boolean
/**
* Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`)
*/
embedAdditionalStylesheet(document: Document): Promise<void>
/**
* Prune the source CSS files
*/
override pruneSource(style: Node, before: string, sheetInverse: string): boolean
}

export = BeastiesWebpackPlugin
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
* the License.
*/

import type { Options } from 'beasties'
import type HtmlWebpackPlugin from 'html-webpack-plugin'
import type { Compilation, Compiler, OutputFileSystem, sources } from 'webpack'
import { createRequire } from 'node:module'
import path from 'node:path'
import Beasties from 'beasties'
import { minimatch } from 'minimatch'
import log from 'webpack-log'
import sources from 'webpack-sources'
import { tap } from './util'

const $require
Expand All @@ -32,8 +33,6 @@ const $require
// Used to annotate this plugin's hooks in Tappable invocations
const PLUGIN_NAME = 'beasties-webpack-plugin'

/** @typedef {import('beasties').Options} Options */

/**
* Create a Beasties plugin instance with the given options.
* @public
Expand All @@ -53,49 +52,62 @@ const PLUGIN_NAME = 'beasties-webpack-plugin'
* }
*/
export default class BeastiesWebpackPlugin extends Beasties {
constructor(options) {
declare compilation: Compilation
declare compiler: Compiler
declare fs: OutputFileSystem
declare logger: Required<NonNullable<Options['logger']>>
declare options: Options & Required<Pick<Options, 'logLevel' | 'path' | 'publicPath' | 'reduceInlineStyles' | 'pruneSource' | 'additionalStylesheets'>> & { allowRules: Array<string | RegExp> }
constructor(options: Options) {
super(options)

// TODO: Remove webpack-log
this.logger = log({
name: 'Beasties',
unique: true,
level: this.options.logLevel,
})
}

/**
* Invoked by Webpack during plugin initialization
*/
apply(compiler) {
apply(compiler: Compiler) {
this.compiler = compiler
this.logger = Object.assign(compiler.getInfrastructureLogger(PLUGIN_NAME), {
silent(_: string): void { },
})
// hook into the compiler to get a Compilation instance...
tap(compiler, 'compilation', PLUGIN_NAME, false, (compilation) => {
let htmlPluginHooks
tap(compiler, 'compilation', PLUGIN_NAME, false, (compilation: Compilation) => {
let htmlPluginHooks: HtmlWebpackPlugin.Hooks | undefined

this.options.path = compiler.options.output.path
this.options.publicPath = compiler.options.output.publicPath
this.options.path = compiler.options.output.path!
this.options.publicPath
// from html-webpack-plugin
= compiler.options.output.publicPath || typeof compiler.options.output.publicPath === 'function'
? compilation.getAssetPath(compiler.options.output.publicPath!, compilation)
: compiler.options.output.publicPath!
Comment on lines +77 to +81
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify the publicPath logic for function types.

The condition on line 79 checks typeof compiler.options.output.publicPath === 'function' after the || operator, which means if publicPath is falsy, the function check won't prevent the assignment on line 82. This could result in undefined being assigned to this.options.publicPath.


🏁 Script executed:

#!/bin/bash
# Search for how publicPath is used elsewhere in webpack configurations
rg -n "output\.publicPath" --type=ts -C3 -g '!node_modules'

Length of output: 2153


Fix the publicPath assignment logic to handle undefined and function types correctly.

The review comment is correct. The condition compiler.options.output.publicPath || typeof compiler.options.output.publicPath === 'function' evaluates to false when publicPath is falsy (undefined/null), causing the else branch to assign compiler.options.output.publicPath!, which results in undefined being assigned to this.options.publicPath.

The logic should check for function type first, or restructure to prevent undefined assignment:

typeof compiler.options.output.publicPath === 'function' || compiler.options.output.publicPath
  ? compilation.getAssetPath(compiler.options.output.publicPath!, compilation)
  : compiler.options.output.publicPath || ''

This ensures functions are handled and undefined defaults to an empty string, matching the defensive pattern used downstream at line 171.

🤖 Prompt for AI Agents
In packages/beasties-webpack-plugin/src/index.ts around lines 77 to 81, the
publicPath assignment incorrectly treats falsy values before checking for
function types, letting undefined be assigned; change the condition to check for
a function first and ensure a safe default (e.g., empty string) when publicPath
is undefined so that functions are resolved via compilation.getAssetPath(...)
and non-function undefined values fall back to ''. Update the ternary to
evaluate typeof compiler.options.output.publicPath === 'function' ||
compiler.options.output.publicPath first, call compilation.getAssetPath(...)
when true, and otherwise assign a safe default (empty string) to
this.options.publicPath.


const hasHtmlPlugin = compilation.options.plugins.find(
p => p.constructor && p.constructor.name === 'HtmlWebpackPlugin',
const hasHtmlPlugin = compilation.options.plugins.some(
p => p?.constructor?.name === 'HtmlWebpackPlugin',
)
try {
htmlPluginHooks = $require('html-webpack-plugin').getHooks(compilation)
}
catch {}

const handleHtmlPluginData = (htmlPluginData, callback) => {
this.fs = compilation.outputFileSystem
/**
* @param {{html: string; outputName: string; plugin: HtmlWebpackPlugin}} htmlPluginData
* @param callback
*/
const handleHtmlPluginData = (
htmlPluginData: { html: string, outputName: string, plugin: HtmlWebpackPlugin },
callback: (err?: null | Error, content?: { html: string, outputName: string, plugin: HtmlWebpackPlugin }) => void,
) => {
this.fs = compiler.outputFileSystem!
this.compilation = compilation
this.process(htmlPluginData.html)
.then((html) => {
callback(null, { html })
callback(null, { ...htmlPluginData, html })
})
.catch(callback)
}

// get an "after" hook into html-webpack-plugin's HTML generation.
if (
compilation.hooks
// @ts-expect-error - compat html-webpack-plugin 3.x
&& compilation.hooks.htmlWebpackPluginAfterHtmlProcessing
) {
tap(
Expand All @@ -116,11 +128,11 @@ export default class BeastiesWebpackPlugin extends Beasties {
'optimize-assets',
PLUGIN_NAME,
true,
(assets, callback) => {
this.fs = compilation.outputFileSystem
(assets: /* CompilationAssets */{ [id: string]: sources.Source }, callback: (err?: null | Error) => void) => {
this.fs = compiler.outputFileSystem!
this.compilation = compilation

let htmlAssetName
let htmlAssetName: string | undefined
for (const name in assets) {
if (name.match(/\.html$/)) {
htmlAssetName = name
Expand All @@ -130,13 +142,13 @@ export default class BeastiesWebpackPlugin extends Beasties {
if (!htmlAssetName) {
return callback(new Error('Could not find HTML asset.'))
}
const html = assets[htmlAssetName].source()
const html = assets[htmlAssetName]!.source()
if (!html)
return callback(new Error('Empty HTML asset.'))

this.process(String(html))
.then((html) => {
assets[htmlAssetName] = new sources.RawSource(html)
assets[htmlAssetName] = new compiler.webpack.sources.RawSource(html)
callback()
})
.catch(callback)
Expand All @@ -149,7 +161,7 @@ export default class BeastiesWebpackPlugin extends Beasties {
/**
* Given href, find the corresponding CSS asset
*/
async getCssAsset(href, style) {
override async getCssAsset(href: string, style: BeastiesStyleElement): Promise<string | undefined> {
const outputPath = this.options.path
const publicPath = this.options.publicPath

Expand Down Expand Up @@ -194,16 +206,19 @@ export default class BeastiesWebpackPlugin extends Beasties {
style.$$assetName = relativePath
// style.$$assets = this.compilation.assets;

return sheet
return sheet.toString()
}

checkInlineThreshold(link, style, sheet) {
/**
* Check if the stylesheet should be inlined
*/
override checkInlineThreshold(link: Node, style: BeastiesStyleElement, sheet: string): boolean {
const inlined = super.checkInlineThreshold(link, style, sheet)

if (inlined) {
const asset = style.$$asset
if (asset) {
delete this.compilation.assets[style.$$assetName]
this.compilation.deleteAsset(style.$$assetName)
}
else {
this.logger.warn(
Expand All @@ -218,9 +233,9 @@ export default class BeastiesWebpackPlugin extends Beasties {
/**
* Inline the stylesheets from options.additionalStylesheets (assuming it passes `options.filter`)
*/
async embedAdditionalStylesheet(document) {
const styleSheetsIncluded = [];
(this.options.additionalStylesheets || []).forEach((cssFile) => {
async embedAdditionalStylesheet(document: Document) {
const styleSheetsIncluded: string[] = [];
(this.options.additionalStylesheets || []).forEach((cssFile: string) => {
if (styleSheetsIncluded.includes(cssFile)) {
return undefined
}
Expand All @@ -229,9 +244,9 @@ export default class BeastiesWebpackPlugin extends Beasties {
file => minimatch(file, cssFile),
)
for (const asset of webpackCssAssets) {
const style = document.createElement('style')
const style = document.createElement('style') as BeastiesStyleElement
style.$$external = true
style.textContent = this.compilation.assets[asset].source()
style.textContent = this.compilation.assets[asset]!.source().toString()
document.head.appendChild(style)
}
})
Expand All @@ -240,7 +255,7 @@ export default class BeastiesWebpackPlugin extends Beasties {
/**
* Prune the source CSS files
*/
pruneSource(style, before, sheetInverse) {
override pruneSource(style: BeastiesStyleElement, before: string, sheetInverse: string): boolean {
const isStyleInlined = super.pruneSource(style, before, sheetInverse)
const asset = style.$$asset
const name = style.$$name
Expand All @@ -250,10 +265,10 @@ export default class BeastiesWebpackPlugin extends Beasties {
const minSize = this.options.minimumExternalSize
if (minSize && sheetInverse.length < minSize) {
// delete the webpack asset:
delete this.compilation.assets[style.$$assetName]
this.compilation.deleteAsset(style.$$assetName)
return true
}
this.compilation.assets[style.$$assetName] = new sources.SourceMapSource(sheetInverse, style.$$assetName, before)
this.compilation.assets[style.$$assetName] = new this.compiler.webpack.sources.SourceMapSource(sheetInverse, style.$$assetName, before)
}
else {
this.logger.warn(
Expand All @@ -264,3 +279,10 @@ export default class BeastiesWebpackPlugin extends Beasties {
return isStyleInlined
}
}

interface BeastiesStyleElement extends HTMLStyleElement {
$$name: string
$$asset: sources.Source | undefined
$$assetName: string
$$external: boolean
}
9 changes: 0 additions & 9 deletions packages/beasties-webpack-plugin/src/util.js

This file was deleted.

13 changes: 13 additions & 0 deletions packages/beasties-webpack-plugin/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Compilation, Compiler } from 'webpack'

export function tap(inst: Compiler | Compilation, hook: string, pluginName: string, async: boolean, callback: (...rest: any[]) => void): void {
if (inst.hooks) {
const camel = hook.replace(/-([a-z])/g, (s, i) => i.toUpperCase())
// @ts-expect-error - hooks call
inst.hooks[camel][async ? 'tapAsync' : 'tap'](pluginName, callback)
}
else {
// @ts-expect-error - webpack3
inst.plugin(hook, callback)
}
}
2 changes: 1 addition & 1 deletion packages/beasties-webpack-plugin/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function compile(entry: string, configDecorator: (config: webpack.Configu
return reject(err)
const info = stats!.toJson()
if (stats?.hasErrors()) {
return reject(info.errors?.join('\n'))
return reject(info.errors?.[0]?.details)
}
resolve(info)
})
Expand Down
Loading