diff --git a/errors/no-page-custom-font.md b/errors/no-page-custom-font.md index 93766e1d5d36e..51052a34422cb 100644 --- a/errors/no-page-custom-font.md +++ b/errors/no-page-custom-font.md @@ -2,7 +2,8 @@ ### Why This Error Occurred -A custom font was added to a page and not with a custom `Document`. This only adds the font to the specific page and not to the entire application. +- The custom font you're adding was added to a page - this only adds the font to the specific page and not the entire application. +- The custom font you're adding was added to a separate component within `Document` - this disables automatic font optimiztion. ### Possible Ways to Fix It @@ -35,9 +36,34 @@ class MyDocument extends Document { export default MyDocument ``` +Or as a function component: + +```js +// pages/_document.js + +import { Html, Head, Main, NextScript } from 'next/document' + +export default function Document() { + return ( + + + + + +
+ + + + ) +} +``` + ### When Not To Use It -If you have a reason to only load a font for a particular page, then you can disable this rule. +If you have a reason to only load a font for a particular page or don't care about font optimization, then you can disable this rule. ### Useful Links diff --git a/packages/eslint-plugin-next/lib/rules/no-page-custom-font.js b/packages/eslint-plugin-next/lib/rules/no-page-custom-font.js index 054d84a68c5d8..d1de7566c3973 100644 --- a/packages/eslint-plugin-next/lib/rules/no-page-custom-font.js +++ b/packages/eslint-plugin-next/lib/rules/no-page-custom-font.js @@ -1,4 +1,5 @@ const NodeAttributes = require('../utils/node-attributes.js') +const { sep, posix } = require('path') module.exports = { meta: { @@ -10,7 +11,22 @@ module.exports = { }, }, create: function (context) { + const paths = context.getFilename().split('pages') + const page = paths[paths.length - 1] + + // outside of a file within `pages`, bail + if (!page) { + return {} + } + + const is_Document = + page.startsWith(`${sep}_document`) || + page.startsWith(`${posix.sep}_document`) + let documentImportName + let localDefaultExportName + let exportDeclarationType + return { ImportDeclaration(node) { if (node.source.value === 'next/document') { @@ -22,17 +38,91 @@ module.exports = { } } }, + + ExportDefaultDeclaration(node) { + exportDeclarationType = node.declaration.type + + if (node.declaration.type === 'FunctionDeclaration') { + localDefaultExportName = node.declaration.id.name + return + } + + if ( + node.declaration.type === 'ClassDeclaration' && + node.declaration.superClass && + node.declaration.superClass.name === documentImportName + ) { + localDefaultExportName = node.declaration.id.name + } + }, + JSXOpeningElement(node) { - const documentClass = context - .getAncestors() - .find( - (ancestorNode) => - ancestorNode.type === 'ClassDeclaration' && - ancestorNode.superClass && - ancestorNode.superClass.name === documentImportName + if (node.name.name !== 'link') { + return + } + + const ancestors = context.getAncestors() + + // if `export default ` is further down within the file after the + // currently traversed component, then `localDefaultExportName` will + // still be undefined + if (!localDefaultExportName) { + // find the top level of the module + const program = ancestors.find( + (ancestor) => ancestor.type === 'Program' ) - if (documentClass || node.name.name !== 'link') { + // go over each token to find the combination of `export default ` + for (let i = 0; i <= program.tokens.length - 1; i++) { + if (localDefaultExportName) { + break + } + + const token = program.tokens[i] + + if (token.type === 'Keyword' && token.value === 'export') { + const nextToken = program.tokens[i + 1] + + if ( + nextToken && + nextToken.type === 'Keyword' && + nextToken.value === 'default' + ) { + const maybeIdentifier = program.tokens[i + 2] + + if (maybeIdentifier && maybeIdentifier.type === 'Identifier') { + localDefaultExportName = maybeIdentifier.value + } + } + } + } + } + + const parentComponent = ancestors.find((ancestor) => { + // export default class ... extends ... + if (exportDeclarationType === 'ClassDeclaration') { + return ( + ancestor.type === exportDeclarationType && + ancestor.superClass && + ancestor.superClass.name === documentImportName + ) + } + + // export default function ... + if (exportDeclarationType === 'FunctionDeclaration') { + return ( + ancestor.type === exportDeclarationType && + ancestor.id.name === localDefaultExportName + ) + } + + // function ...() {} export default ... + // class ... extends ...; export default ... + return ancestor.id && ancestor.id.name === localDefaultExportName + }) + + // file starts with _document and this is within the default export + if (is_Document && parentComponent) { return } @@ -47,10 +137,16 @@ module.exports = { hrefValue.startsWith('https://fonts.googleapis.com/css') if (isGoogleFont) { + const end = + 'This is discouraged. See https://nextjs.org/docs/messages/no-page-custom-font.' + + const message = is_Document + ? `Rendering this not inline within of Document disables font optimization. ${end}` + : `Custom fonts not added at the document level will only load for a single page. ${end}` + context.report({ node, - message: - 'Custom fonts not added at the document level will only load for a single page. This is discouraged. See https://nextjs.org/docs/messages/no-page-custom-font.', + message, }) } }, diff --git a/test/unit/eslint-plugin-next/no-page-custom-font.test.ts b/test/unit/eslint-plugin-next/no-page-custom-font.test.ts index 9887496c4fe6c..1f110c373f59c 100644 --- a/test/unit/eslint-plugin-next/no-page-custom-font.test.ts +++ b/test/unit/eslint-plugin-next/no-page-custom-font.test.ts @@ -12,10 +12,12 @@ import { RuleTester } from 'eslint' }) const ruleTester = new RuleTester() +const filename = 'pages/_document.js' + ruleTester.run('no-page-custom-font', rule, { valid: [ - `import Document, { Html, Head } from "next/document"; - + { + code: `import Document, { Html, Head } from "next/document"; class MyDocument extends Document { render() { return ( @@ -30,11 +32,11 @@ ruleTester.run('no-page-custom-font', rule, { ); } } - - export default MyDocument; - `, - `import NextDocument, { Html, Head } from "next/document"; - + export default MyDocument;`, + filename, + }, + { + code: `import NextDocument, { Html, Head } from "next/document"; class Document extends NextDocument { render() { return ( @@ -49,16 +51,70 @@ ruleTester.run('no-page-custom-font', rule, { ); } } - export default Document; `, + filename, + }, + { + code: `export default function CustomDocument() { + return ( + + + + + + ) + }`, + filename, + }, + { + code: `function CustomDocument() { + return ( + + + + + + ) + } + + export default CustomDocument; + `, + filename, + }, + { + code: ` + import Document, { Html, Head } from "next/document"; + class MyDocument { + render() { + return ( + + + + + + ); + } + } + + export default MyDocument;`, + filename, + }, ], invalid: [ { code: ` import Head from 'next/head' - export default function IndexPage() { return (
@@ -73,6 +129,7 @@ ruleTester.run('no-page-custom-font', rule, { ) } `, + filename: 'pages/index.tsx', errors: [ { message: @@ -83,29 +140,44 @@ ruleTester.run('no-page-custom-font', rule, { }, { code: ` - import Document, { Html, Head } from "next/document"; + import Head from 'next/head' - class MyDocument { - render() { - return ( - - - - - - ); - } + + function Links() { + return ( + <> + + + + ) } - - export default MyDocument;`, + + export default function IndexPage() { + return ( +
+ + + +

Hello world!

+
+ ) + } + `, + filename, errors: [ { message: - 'Custom fonts not added at the document level will only load for a single page. This is discouraged. See https://nextjs.org/docs/messages/no-page-custom-font.', - type: 'JSXOpeningElement', + 'Rendering this not inline within of Document disables font optimization. This is discouraged. See https://nextjs.org/docs/messages/no-page-custom-font.', + }, + { + message: + 'Rendering this not inline within of Document disables font optimization. This is discouraged. See https://nextjs.org/docs/messages/no-page-custom-font.', }, ], },