7fba48ef70
<!-- ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [x] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [x] Make sure the linting passes by running `pnpm lint` - [ ] The examples guidelines are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing.md#adding-examples) - [ ] --> ## Feature - [x] Implements https://github.com/vercel/next.js/discussions/40112 - [x] Integration tests added Adds a new option to the current font optimization to enable experimental font size adjust The new `optimizeFonts` config will be ``` optimizeFonts: { inlineFonts: true, experimentalAdjustFallbacks: false, }, ``` To enable the feature, set `experimentalAdjustFallbacks: true` `optimizeFonts: false` will disable the entire feature (including inlining google font definition) Co-authored-by: JJ Kasper <jj@jjsweb.site>
273 lines
8.6 KiB
TypeScript
273 lines
8.6 KiB
TypeScript
import {
|
|
webpack,
|
|
BasicEvaluatedExpression,
|
|
sources,
|
|
} from 'next/dist/compiled/webpack/webpack'
|
|
import {
|
|
getFontDefinitionFromNetwork,
|
|
getFontOverrideCss,
|
|
FontManifest,
|
|
} from '../../../server/font-utils'
|
|
import postcss from 'postcss'
|
|
import minifier from 'next/dist/compiled/cssnano-simple'
|
|
import {
|
|
FONT_MANIFEST,
|
|
OPTIMIZED_FONT_PROVIDERS,
|
|
} from '../../../shared/lib/constants'
|
|
import * as Log from '../../output/log'
|
|
|
|
function minifyCss(css: string): Promise<string> {
|
|
return postcss([
|
|
minifier(
|
|
{
|
|
excludeAll: true,
|
|
discardComments: true,
|
|
normalizeWhitespace: { exclude: false },
|
|
},
|
|
postcss
|
|
),
|
|
])
|
|
.process(css, { from: undefined })
|
|
.then((res) => res.css)
|
|
}
|
|
|
|
function isNodeCreatingLinkElement(node: any) {
|
|
const callee = node.callee as any
|
|
if (callee.type !== 'Identifier') {
|
|
return false
|
|
}
|
|
const componentNode = node.arguments[0] as any
|
|
if (componentNode.type !== 'Literal') {
|
|
return false
|
|
}
|
|
// React has pragma: _jsx.
|
|
// Next has pragma: __jsx.
|
|
return (
|
|
(callee.name === '_jsx' || callee.name === '__jsx') &&
|
|
componentNode.value === 'link'
|
|
)
|
|
}
|
|
|
|
export class FontStylesheetGatheringPlugin {
|
|
compiler?: webpack.Compiler
|
|
gatheredStylesheets: Array<string> = []
|
|
manifestContent: FontManifest = []
|
|
isLikeServerless: boolean
|
|
adjustFontFallbacks?: boolean
|
|
|
|
constructor({
|
|
isLikeServerless,
|
|
adjustFontFallbacks,
|
|
}: {
|
|
isLikeServerless: boolean
|
|
adjustFontFallbacks?: boolean
|
|
}) {
|
|
this.isLikeServerless = isLikeServerless
|
|
this.adjustFontFallbacks = adjustFontFallbacks
|
|
}
|
|
|
|
private parserHandler = (
|
|
factory: ReturnType<webpack.Compiler['createNormalModuleFactory']>
|
|
): void => {
|
|
const JS_TYPES = ['auto', 'esm', 'dynamic']
|
|
// Do an extra walk per module and add interested visitors to the walk.
|
|
for (const type of JS_TYPES) {
|
|
factory.hooks.parser
|
|
.for('javascript/' + type)
|
|
.tap(this.constructor.name, (parser: any) => {
|
|
/**
|
|
* Webpack fun facts:
|
|
* `parser.hooks.call.for` cannot catch calls for user defined identifiers like `__jsx`
|
|
* it can only detect calls for native objects like `window`, `this`, `eval` etc.
|
|
* In order to be able to catch calls of variables like `__jsx`, first we need to catch them as
|
|
* Identifier and then return `BasicEvaluatedExpression` whose `id` and `type` webpack matches to
|
|
* invoke hook for call.
|
|
* See: https://github.com/webpack/webpack/blob/webpack-4/lib/Parser.js#L1931-L1932.
|
|
*/
|
|
parser.hooks.evaluate
|
|
.for('Identifier')
|
|
.tap(this.constructor.name, (node: any) => {
|
|
// We will only optimize fonts from first party code.
|
|
if (parser?.state?.module?.resource.includes('node_modules')) {
|
|
return
|
|
}
|
|
let result
|
|
if (node.name === '_jsx' || node.name === '__jsx') {
|
|
result = new BasicEvaluatedExpression()
|
|
// @ts-ignore
|
|
result.setRange(node.range)
|
|
result.setExpression(node)
|
|
result.setIdentifier(node.name)
|
|
|
|
// This was added in webpack 5.
|
|
result.getMembers = () => []
|
|
}
|
|
return result
|
|
})
|
|
|
|
const jsxNodeHandler = (node: any) => {
|
|
if (node.arguments.length !== 2) {
|
|
// A font link tag has only two arguments rel=stylesheet and href='...'
|
|
return
|
|
}
|
|
if (!isNodeCreatingLinkElement(node)) {
|
|
return
|
|
}
|
|
|
|
// node.arguments[0] is the name of the tag and [1] are the props.
|
|
const arg1 = node.arguments[1]
|
|
|
|
const propsNode =
|
|
arg1.type === 'ObjectExpression' ? (arg1 as any) : undefined
|
|
const props: { [key: string]: string } = {}
|
|
if (propsNode) {
|
|
propsNode.properties.forEach((prop: any) => {
|
|
if (prop.type !== 'Property') {
|
|
return
|
|
}
|
|
if (
|
|
prop.key.type === 'Identifier' &&
|
|
prop.value.type === 'Literal'
|
|
) {
|
|
props[prop.key.name] = prop.value.value as string
|
|
}
|
|
})
|
|
}
|
|
|
|
if (
|
|
!props.rel ||
|
|
props.rel !== 'stylesheet' ||
|
|
!props.href ||
|
|
!OPTIMIZED_FONT_PROVIDERS.some(({ url }) =>
|
|
props.href.startsWith(url)
|
|
)
|
|
) {
|
|
return false
|
|
}
|
|
|
|
this.gatheredStylesheets.push(props.href)
|
|
|
|
const buildInfo = parser?.state?.module?.buildInfo
|
|
|
|
if (buildInfo) {
|
|
buildInfo.valueDependencies.set(
|
|
FONT_MANIFEST,
|
|
this.gatheredStylesheets
|
|
)
|
|
}
|
|
}
|
|
|
|
// React JSX transform:
|
|
parser.hooks.call
|
|
.for('_jsx')
|
|
.tap(this.constructor.name, jsxNodeHandler)
|
|
// Next.js JSX transform:
|
|
parser.hooks.call
|
|
.for('__jsx')
|
|
.tap(this.constructor.name, jsxNodeHandler)
|
|
// New React JSX transform:
|
|
parser.hooks.call
|
|
.for('imported var')
|
|
.tap(this.constructor.name, jsxNodeHandler)
|
|
})
|
|
}
|
|
}
|
|
|
|
public apply(compiler: webpack.Compiler) {
|
|
this.compiler = compiler
|
|
compiler.hooks.normalModuleFactory.tap(
|
|
this.constructor.name,
|
|
this.parserHandler
|
|
)
|
|
compiler.hooks.make.tapAsync(this.constructor.name, (compilation, cb) => {
|
|
if (this.isLikeServerless) {
|
|
/**
|
|
* Inline font manifest for serverless case only.
|
|
* For target: server drive the manifest through physical file and less of webpack magic.
|
|
*/
|
|
const mainTemplate = compilation.mainTemplate
|
|
mainTemplate.hooks.requireExtensions.tap(
|
|
this.constructor.name,
|
|
(source: string) => {
|
|
return `${source}
|
|
// Font manifest declaration
|
|
__webpack_require__.__NEXT_FONT_MANIFEST__ = ${JSON.stringify(
|
|
this.manifestContent
|
|
)};
|
|
// Enable feature:
|
|
process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true);`
|
|
}
|
|
)
|
|
}
|
|
compilation.hooks.finishModules.tapAsync(
|
|
this.constructor.name,
|
|
async (modules: any, modulesFinished: Function) => {
|
|
let fontStylesheets = this.gatheredStylesheets
|
|
|
|
const fontUrls = new Set<string>()
|
|
modules.forEach((module: any) => {
|
|
const fontDependencies =
|
|
module?.buildInfo?.valueDependencies?.get(FONT_MANIFEST)
|
|
if (fontDependencies) {
|
|
fontDependencies.forEach((v: string) => fontUrls.add(v))
|
|
}
|
|
})
|
|
|
|
fontStylesheets = Array.from(fontUrls)
|
|
|
|
const fontDefinitionPromises = fontStylesheets.map((url) =>
|
|
getFontDefinitionFromNetwork(url)
|
|
)
|
|
|
|
this.manifestContent = []
|
|
for (let promiseIndex in fontDefinitionPromises) {
|
|
let css = await fontDefinitionPromises[promiseIndex]
|
|
|
|
if (this.adjustFontFallbacks) {
|
|
css += getFontOverrideCss(fontStylesheets[promiseIndex], css)
|
|
}
|
|
|
|
if (css) {
|
|
try {
|
|
const content = await minifyCss(css)
|
|
this.manifestContent.push({
|
|
url: fontStylesheets[promiseIndex],
|
|
content,
|
|
})
|
|
} catch (err) {
|
|
Log.warn(
|
|
`Failed to minify the stylesheet for ${fontStylesheets[promiseIndex]}. Skipped optimizing this font.`
|
|
)
|
|
console.error(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// @ts-expect-error invalid assets type
|
|
compilation.assets[FONT_MANIFEST] = new sources.RawSource(
|
|
JSON.stringify(this.manifestContent, null, ' ')
|
|
)
|
|
|
|
modulesFinished()
|
|
}
|
|
)
|
|
cb()
|
|
})
|
|
|
|
compiler.hooks.make.tap(this.constructor.name, (compilation) => {
|
|
// @ts-ignore TODO: Remove ignore when webpack 5 is stable
|
|
compilation.hooks.processAssets.tap(
|
|
{
|
|
name: this.constructor.name,
|
|
// @ts-ignore TODO: Remove ignore when webpack 5 is stable
|
|
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
|
|
},
|
|
(assets: any) => {
|
|
assets['../' + FONT_MANIFEST] = new sources.RawSource(
|
|
JSON.stringify(this.manifestContent, null, ' ')
|
|
)
|
|
}
|
|
)
|
|
})
|
|
}
|
|
}
|