first commit
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled

This commit is contained in:
Arian Tron
2026-03-10 19:37:31 +03:30
commit 61f56f997c
27684 changed files with 2784175 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import path from 'path'
import os from 'os'
import {
throwTurbopackInternalError,
TurbopackInternalError,
} from 'next/dist/shared/lib/turbopack/internal-error'
import { Telemetry } from 'next/dist/telemetry/storage'
import { setGlobal } from 'next/dist/trace'
import { traceGlobals } from 'next/dist/trace/shared'
describe('TurbopackInternalError', () => {
it('sends a telemetry event when throwTurbopackInternalError() is called', async () => {
const oldTelemetry = traceGlobals.get('telemetry')
try {
const distDir = path.join(os.tmpdir(), 'next-telemetry')
const telemetry = new Telemetry({ distDir })
setGlobal('telemetry', telemetry)
const submitRecord = jest
// @ts-ignore
.spyOn(telemetry, 'submitRecord')
// @ts-ignore
.mockImplementation(() => Promise.resolve())
let internalError = null
try {
throwTurbopackInternalError(null, {
message: 'test error',
anonymizedLocation: 'file.rs:120:1',
})
} catch (err) {
internalError = err
}
expect(internalError).toBeInstanceOf(TurbopackInternalError)
expect(submitRecord).toHaveBeenCalledWith({
eventName: 'NEXT_ERROR_THROWN',
payload: {
errorCode: 'TurbopackInternalError',
location: 'file.rs:120:1',
},
})
} finally {
setGlobal('telemetry', oldTelemetry)
}
})
})

View File

@@ -0,0 +1,77 @@
import { acceptLanguage } from 'next/dist/server/accept-header'
describe('acceptLanguage', () => {
it('parses the header', () => {
const language = acceptLanguage('da, en-GB, en')
expect(language).toEqual('da')
})
it('respects weights', () => {
const language = acceptLanguage('en;q=0.6, en-GB;q=0.8')
expect(language).toEqual('en-gb')
})
it('returns an empty string with header is empty', () => {
const language = acceptLanguage('')
expect(language).toEqual('')
})
it('returns empty string if header is missing', () => {
const language = acceptLanguage()
expect(language).toEqual('')
})
it('ignores an empty preferences array', () => {
const language = acceptLanguage('da, en-GB, en', [])
expect(language).toEqual('da')
})
it('returns empty string if none of the preferences match', () => {
const language = acceptLanguage('da, en-GB, en', ['es'])
expect(language).toEqual('')
})
it('returns first preference if header has * and is unmatched', () => {
const language = acceptLanguage('da, en-GB, *', ['en-US'])
expect(language).toEqual('en-US')
})
it('returns first found preference that header includes', () => {
const language = acceptLanguage('da, en-GB, en', ['en-US', 'en-GB'])
expect(language).toEqual('en-US')
})
it('returns preference with highest order when equal weigths', () => {
expect(acceptLanguage('da, en, en-GB', ['en', 'en-GB'])).toEqual('en')
expect(acceptLanguage('da, en, en-GB', ['en-GB', 'en'])).toEqual('en-GB')
expect(acceptLanguage('en, en-GB, en-US')).toEqual('en')
})
it('return language with heighest weight', () => {
const language = acceptLanguage('da;q=0.5, en;q=1', ['da', 'en'])
expect(language).toEqual('en')
})
it('ignores preference case when matching', () => {
const language = acceptLanguage('da, en-GB, en-us', ['en-gb', 'en-us']) // en-GB vs en-gb
expect(language).toEqual('en-gb')
})
it('returns language using range match', () => {
expect(acceptLanguage('da', ['da-DK'])).toEqual('da-DK')
expect(acceptLanguage('en-US, en', ['en-GB', 'en-US'])).toEqual('en-GB')
expect(acceptLanguage('da, en', ['da-DK', 'en-GB'])).toEqual('da-DK')
expect(acceptLanguage('en, da', ['da-DK', 'en-GB'])).toEqual('da-DK')
expect(acceptLanguage('en, da', ['en', 'en-GB'])).toEqual('en')
expect(acceptLanguage('da, en-GB', ['da-DK', 'en-GB'])).toEqual('da-DK')
expect(acceptLanguage('en, en-GB', ['en-US', 'en-GB', 'da-DK'])).toEqual(
'en-US'
)
})
it('explicit preference overrides range match', () => {
expect(acceptLanguage('da, en-GB', ['da-DK', 'en-GB', 'da'])).toEqual(
'en-GB'
)
})
})

View File

@@ -0,0 +1,31 @@
/* eslint-env jest */
import { transformSync } from '@babel/core'
const babel = (code) =>
transformSync(code, {
filename: 'page.tsx',
presets: ['@babel/preset-typescript'],
plugins: [require('next/dist/build/babel/plugins/next-page-config')],
babelrc: false,
configFile: false,
sourceType: 'module',
compact: true,
caller: {
name: 'tests',
isDev: false,
},
} as any).code
describe('babel plugin (next-page-config)', () => {
test('export config with type annotation', () => {
const output = babel('export const config: PageConfig = {};')
expect(output).toMatch(`export const config={};`)
})
test('export config with AsExpression', () => {
const output = babel('export const config = {} as PageConfig;')
expect(output).toMatch('export const config={};')
})
})

View File

@@ -0,0 +1,540 @@
/* eslint-env jest */
import { transform } from '@babel/core'
const trim = (s) => s.join('\n').trim().replace(/^\s+/gm, '')
// avoid generating __source annotations in JSX during testing:
const NODE_ENV = process.env.NODE_ENV
;(process.env as any).NODE_ENV = 'production'
const plugin = require('next/dist/build/babel/plugins/next-ssg-transform')
;(process.env as any).NODE_ENV = NODE_ENV
const babel = (code, esm = true, pluginOptions = {}) =>
transform(code, {
filename: 'noop.js',
presets: [['@babel/preset-react', { development: false, pragma: '__jsx' }]],
plugins: [[plugin, pluginOptions]],
babelrc: false,
configFile: false,
sourceType: 'module',
compact: true,
caller: {
name: 'tests',
supportsStaticESM: esm,
},
}).code
describe('babel plugin (next-ssg-transform)', () => {
describe('getStaticProps support', () => {
it('should remove separate named export specifiers', () => {
const output = babel(trim`
export { getStaticPaths } from '.'
export { a as getStaticProps } from '.'
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove combined named export specifiers', () => {
const output = babel(trim`
export { getStaticPaths, a as getStaticProps } from '.'
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should retain extra named export specifiers', () => {
const output = babel(trim`
export { getStaticPaths, a as getStaticProps, foo, bar as baz } from '.'
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export{foo,bar as baz}from'.';export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove named export function declarations', () => {
const output = babel(trim`
export function getStaticPaths() {
return []
}
export function getStaticProps() {
return { props: {} }
}
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove named export function declarations (async)', () => {
const output = babel(trim`
export async function getStaticPaths() {
return []
}
export async function getStaticProps() {
return { props: {} }
}
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should not remove extra named export function declarations', () => {
const output = babel(trim`
export function getStaticProps() {
return { props: {} }
}
export function Noop() {}
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export function Noop(){}export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove named export variable declarations', () => {
const output = babel(trim`
export const getStaticPaths = () => {
return []
}
export const getStaticProps = function() {
return { props: {} }
}
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove named export variable declarations (async)', () => {
const output = babel(trim`
export const getStaticPaths = async () => {
return []
}
export const getStaticProps = async function() {
return { props: {} }
}
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should not remove extra named export variable declarations', () => {
const output = babel(trim`
export const getStaticPaths = () => {
return []
}, foo = 2
export const getStaticProps = function() {
return { props: {} }
}
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export const foo=2;export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove re-exported variable declarations', () => {
const output = babel(trim`
const getStaticPaths = () => {
return []
}
export { getStaticPaths }
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove re-exported variable declarations (safe)', () => {
const output = babel(trim`
const getStaticPaths = () => {
return []
}, a = 2
export { getStaticPaths }
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"const a=2;export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should remove re-exported function declarations', () => {
const output = babel(trim`
function getStaticPaths() {
return []
}
export { getStaticPaths }
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should not crash for class declarations', () => {
const output = babel(trim`
function getStaticPaths() {
return []
}
export { getStaticPaths }
export class MyClass {}
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export class MyClass{}export default function Test(){return __jsx("div",null);}"`
)
})
it(`should remove re-exported function declarations' dependents (variables, functions, imports)`, () => {
const output = babel(trim`
import keep_me from 'hello'
import {keep_me2} from 'hello2'
import * as keep_me3 from 'hello3'
import drop_me from 'bla'
import { drop_me2 } from 'foo'
import { drop_me3, but_not_me } from 'bar'
import * as remove_mua from 'hehe'
var leave_me_alone = 1;
function dont_bug_me_either() {}
const inceptionVar = 'hahaa';
var var1 = 1;
let var2 = 2;
const var3 = inceptionVar + remove_mua;
function inception1() {var2;drop_me2;}
function abc() {}
const b = function() {var3;drop_me3;};
const b2 = function apples() {};
const bla = () => {inception1};
function getStaticProps() {
abc();
drop_me;
b;
b2;
bla();
return { props: {var1} }
}
export { getStaticProps }
export default function Test() {
return <div />
}
`)
expect(output).toMatchInlineSnapshot(
`"import keep_me from'hello';import{keep_me2}from'hello2';import*as keep_me3 from'hello3';import{but_not_me}from'bar';var leave_me_alone=1;function dont_bug_me_either(){}export var __N_SSG=true;export default function Test(){return __jsx("div",null);}"`
)
})
it('should not mix up bindings', () => {
const output = babel(trim`
function Function1() {
return {
a: function bug(a) {
return 2;
}
};
}
function Function2() {
var bug = 1;
return { bug };
}
export { getStaticProps } from 'a'
`)
expect(output).toMatchInlineSnapshot(
`"function Function1(){return{a:function bug(a){return 2;}};}function Function2(){var bug=1;return{bug};}"`
)
})
it('should support class exports', () => {
const output = babel(trim`
export function getStaticProps() {
return { props: {} }
}
export default class Test extends React.Component {
render() {
return <div />
}
}
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default class Test extends React.Component{render(){return __jsx("div",null);}}"`
)
})
it('should support class exports 2', () => {
const output = babel(trim`
export function getStaticProps() {
return { props: {} }
}
class Test extends React.Component {
render() {
return <div />
}
}
export default Test;
`)
expect(output).toMatchInlineSnapshot(
`"class Test extends React.Component{render(){return __jsx("div",null);}}export var __N_SSG=true;export default Test;"`
)
})
it('should support export { _ as default }', () => {
const output = babel(trim`
export function getStaticProps() {
return { props: {} }
}
function El() {
return <div />
}
export { El as default }
`)
expect(output).toMatchInlineSnapshot(
`"function El(){return __jsx("div",null);}export var __N_SSG=true;export{El as default};"`
)
})
it('should support export { _ as default } with other specifiers', () => {
const output = babel(trim`
export function getStaticProps() {
return { props: {} }
}
function El() {
return <div />
}
const a = 5
export { El as default, a }
`)
expect(output).toMatchInlineSnapshot(
`"function El(){return __jsx("div",null);}const a=5;export var __N_SSG=true;export{El as default,a};"`
)
})
it('should support export { _ as default } with a class', () => {
const output = babel(trim`
export function getStaticProps() {
return { props: {} }
}
class El extends React.Component {
render() {
return <div />
}
}
const a = 5
export { El as default, a }
`)
expect(output).toMatchInlineSnapshot(
`"class El extends React.Component{render(){return __jsx("div",null);}}const a=5;export var __N_SSG=true;export{El as default,a};"`
)
})
it('should support full re-export', () => {
const output = babel(trim`
export { getStaticProps, default } from 'a'
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export{default}from'a';"`
)
})
it('should support babel-style memoized function', () => {
const output = babel(trim`
function fn() {
fn = function () {};
return fn.apply(this, arguments);
}
export function getStaticProps() {
fn;
}
export default function Home() { return <div />; }
`)
expect(output).toMatchInlineSnapshot(
`"export var __N_SSG=true;export default function Home(){return __jsx("div",null);}"`
)
})
it('destructuring assignment (object)', () => {
const output = babel(trim`
import fs from 'fs';
import other from 'other';
const {readFile, readdir, access: foo} = fs.promises;
const {a,b, cat: bar,...rem} = other;
export async function getStaticProps() {
readFile;
readdir;
foo;
b;
cat;
rem;
}
export default function Home() { return <div />; }
`)
expect(output).toMatchInlineSnapshot(
`"import other from'other';const{a,cat:bar}=other;export var __N_SSG=true;export default function Home(){return __jsx("div",null);}"`
)
})
it('destructuring assignment (array)', () => {
const output = babel(trim`
import fs from 'fs';
import other from 'other';
const [a, b, ...rest]= fs.promises;
const [foo, bar] = other;
export async function getStaticProps() {
a;
b;
rest;
bar;
}
export default function Home() { return <div />; }
`)
expect(output).toMatchInlineSnapshot(
`"import other from'other';const[foo]=other;export var __N_SSG=true;export default function Home(){return __jsx("div",null);}"`
)
})
it('errors for incorrect mix of functions', () => {
expect(() =>
babel(trim`
export function getStaticProps() {}
export function getServerSideProps() {}
`)
).toThrow(
`You can not use getStaticProps or getStaticPaths with getServerSideProps. To use SSG, please remove getServerSideProps`
)
expect(() =>
babel(trim`
export function getServerSideProps() {}
export function getStaticProps() {}
`)
).toThrow(
`You can not use getStaticProps or getStaticPaths with getServerSideProps. To use SSG, please remove getServerSideProps`
)
expect(() =>
babel(trim`
export function getStaticPaths() {}
export function getServerSideProps() {}
`)
).toThrow(
`You can not use getStaticProps or getStaticPaths with getServerSideProps. To use SSG, please remove getServerSideProps`
)
expect(() =>
babel(trim`
export function getServerSideProps() {}
export function getStaticPaths() {}
`)
).toThrow(
`You can not use getStaticProps or getStaticPaths with getServerSideProps. To use SSG, please remove getServerSideProps`
)
})
})
})

View File

@@ -0,0 +1,27 @@
import { warnOnce } from 'next/dist/build/output/log'
describe('build/output/log', () => {
it('warnOnce', () => {
const original = console.warn
try {
const messages = []
console.warn = (m: any) => messages.push(m)
warnOnce('test')
expect(messages.length).toEqual(1)
warnOnce('test again')
expect(messages.length).toEqual(2)
warnOnce('test', 'more')
expect(messages.length).toEqual(3)
warnOnce('test')
expect(messages.length).toEqual(3)
warnOnce('test again')
expect(messages.length).toEqual(3)
warnOnce('test', 'more')
expect(messages.length).toEqual(3)
warnOnce('test', 'should', 'add', 'another')
expect(messages.length).toEqual(4)
} finally {
console.warn = original
}
})
})

View File

@@ -0,0 +1,29 @@
/* eslint-env jest */
import { createClientRouterFilter } from 'next/dist/lib/create-client-router-filter'
import { BloomFilter } from 'next/dist/shared/lib/bloom-filter'
describe('createClientRouterFilter', () => {
it('creates a filter that does not collide with wildly different path names', () => {
const { staticFilter, dynamicFilter } = createClientRouterFilter(
['/_not-found', '/a/[lang]/corporate', '/a/[lang]/gift'], // Routes are based on BOTM's app router migration project.
[]
)
const staticFilterInstance = new BloomFilter(
staticFilter.numItems,
staticFilter.errorRate
)
staticFilterInstance.import(staticFilter)
const dynamicFilterInstance = new BloomFilter(
dynamicFilter.numItems,
dynamicFilter.errorRate
)
dynamicFilterInstance.import(dynamicFilter)
expect(
staticFilterInstance.contains(
'/all-hardcovers/no-one-can-know-1511?category=current-features'
)
).toBe(false)
})
})

View File

@@ -0,0 +1,43 @@
import postcss from 'postcss'
import mod from 'next/dist/compiled/cssnano-simple/index'
import css from '../noop-template'
describe('basic test', () => {
test('should minify css', async () => {
const input = css`
p {
color: yellow;
}
`
const res = await postcss([mod()]).process(input, {
from: 'input.css',
to: 'output.css',
})
expect(res.css).toBe('p{color:#ff0}')
})
test('should be able to declare layer names', async () => {
// @layer b is equivlaent to @layer b {}
const input = css`
@layer b {
._5-enzrfpb:lang(ar) {
font-family: myriad-arabic;
}
}
@layer b;
`
const res = await postcss([mod()]).process(input, {
from: 'input.css',
to: 'output.css',
})
expect(res.css).toBe(
'@layer b{._5-enzrfpb:lang(ar){font-family:myriad-arabic}}@layer b;'
)
})
})

View File

@@ -0,0 +1,71 @@
import postcss from 'postcss'
import mod from 'next/dist/compiled/cssnano-simple/index'
import css from '../noop-template'
describe('exclude all test', () => {
test('should not transform css', async () => {
const input = css`
p {
/* test */
color: yellow;
}
`
const res = await postcss([mod({ excludeAll: true })]).process(input, {
from: 'input.css',
to: 'output.css',
})
expect(res.css).toBe(input)
})
test('should strip comments and spaces from css', async () => {
const input = css`
p {
/* test */
color: yellow;
}
.empty {
}
`
const res = await postcss([
mod({
excludeAll: true,
discardComments: { removeAll: true },
normalizeWhitespace: { exclude: false },
}),
]).process(input, {
from: 'input.css',
to: 'output.css',
})
expect(res.css).toMatchInlineSnapshot(`"p{color:yellow}.empty{}"`)
})
test('should enable rule with empty object', async () => {
const input = css`
p {
/* test */
color: yellow;
}
.empty {
}
`
const res = await postcss([
mod({
excludeAll: true,
discardComments: { removeAll: true },
normalizeWhitespace: { exclude: false },
discardEmpty: {},
}),
]).process(input, {
from: 'input.css',
to: 'output.css',
})
expect(res.css).toMatchInlineSnapshot(`"p{color:yellow}"`)
})
})

View File

@@ -0,0 +1,40 @@
import postcss from 'postcss'
import mod from 'next/dist/compiled/cssnano-simple'
import css from '../noop-template'
describe('accepts plugin configuration', () => {
test('should not remove all comments', async () => {
const input = css`
p {
/*! heading */
color: yellow;
}
`
const res = await postcss([mod({ discardComments: {} })]).process(input, {
from: 'input.css',
to: 'output.css',
})
expect(res.css).not.toBe('p{color:#ff0}')
})
test('should remove all comments', async () => {
const input = css`
p {
/*! heading */
color: yellow;
}
`
const res = await postcss([
mod({ discardComments: { removeAll: true } }),
]).process(input, {
from: 'input.css',
to: 'output.css',
})
expect(res.css).toBe('p{color:#ff0}')
})
})

View File

@@ -0,0 +1,7 @@
export default function noop(strings: TemplateStringsArray, ...keys: string[]) {
const lastIndex = strings.length - 1
return (
strings.slice(0, lastIndex).reduce((p, s, i) => p + s + keys[i], '') +
strings[lastIndex]
)
}

View File

@@ -0,0 +1,249 @@
import { join } from 'path'
import { execSync } from 'child_process'
import { getEslintConfigSnapshot } from '../utils'
describe('eslint-config-next/core-web-vitals', () => {
it('should match expected resolved configuration', () => {
const eslintConfigAfterSetupJSON = execSync(
// Pass explicit absolute path to not get affected by the root eslint config.
`pnpm eslint --config ${join(__dirname, 'eslint.config.mjs')} --print-config ${join(__dirname, 'test.js')}`,
{
cwd: __dirname,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
}
)
const { settings, languageOptions, ...eslintConfigAfterSetup } = JSON.parse(
eslintConfigAfterSetupJSON
)
expect({
parser: languageOptions.parser,
settings,
}).toEqual({
parser: expect.stringContaining('eslint-config-next'),
settings: {
'import/parsers': expect.any(Object),
'import/resolver': expect.any(Object),
react: {
version: 'detect',
},
},
})
expect(getEslintConfigSnapshot(eslintConfigAfterSetup))
.toMatchInlineSnapshot(`
{
"language": "@/js",
"linterOptions": {
"reportUnusedDisableDirectives": 1,
},
"plugins": [
"@",
"react",
"react-hooks:eslint-plugin-react-hooks@7.0.0",
"import",
"jsx-a11y:eslint-plugin-jsx-a11y@6.10.2",
"@next/next:@next/eslint-plugin-next",
],
"rules": {
"@next/next/google-font-display": [
1,
],
"@next/next/google-font-preconnect": [
1,
],
"@next/next/inline-script-id": [
2,
],
"@next/next/next-script-for-ga": [
1,
],
"@next/next/no-assign-module-variable": [
2,
],
"@next/next/no-async-client-component": [
1,
],
"@next/next/no-before-interactive-script-outside-document": [
1,
],
"@next/next/no-css-tags": [
1,
],
"@next/next/no-document-import-in-page": [
2,
],
"@next/next/no-duplicate-head": [
2,
],
"@next/next/no-head-element": [
1,
],
"@next/next/no-head-import-in-document": [
2,
],
"@next/next/no-html-link-for-pages": [
2,
],
"@next/next/no-img-element": [
1,
],
"@next/next/no-page-custom-font": [
1,
],
"@next/next/no-script-component-in-head": [
2,
],
"@next/next/no-styled-jsx-in-document": [
1,
],
"@next/next/no-sync-scripts": [
2,
],
"@next/next/no-title-in-document-head": [
1,
],
"@next/next/no-typos": [
1,
],
"@next/next/no-unwanted-polyfillio": [
1,
],
"import/no-anonymous-default-export": [
1,
],
"jsx-a11y/alt-text": [
1,
{
"elements": [
"img",
],
"img": [
"Image",
],
},
],
"jsx-a11y/aria-props": [
1,
],
"jsx-a11y/aria-proptypes": [
1,
],
"jsx-a11y/aria-unsupported-elements": [
1,
],
"jsx-a11y/role-has-required-aria-props": [
1,
],
"jsx-a11y/role-supports-aria-props": [
1,
],
"react-hooks/component-hook-factories": [
2,
],
"react-hooks/config": [
2,
],
"react-hooks/error-boundaries": [
2,
],
"react-hooks/exhaustive-deps": [
1,
],
"react-hooks/gating": [
2,
],
"react-hooks/globals": [
2,
],
"react-hooks/immutability": [
2,
],
"react-hooks/incompatible-library": [
1,
],
"react-hooks/preserve-manual-memoization": [
2,
],
"react-hooks/purity": [
2,
],
"react-hooks/refs": [
2,
],
"react-hooks/rules-of-hooks": [
2,
],
"react-hooks/set-state-in-effect": [
2,
],
"react-hooks/set-state-in-render": [
2,
],
"react-hooks/static-components": [
2,
],
"react-hooks/unsupported-syntax": [
1,
],
"react-hooks/use-memo": [
2,
],
"react/display-name": [
2,
],
"react/jsx-key": [
2,
],
"react/jsx-no-comment-textnodes": [
2,
],
"react/jsx-no-duplicate-props": [
2,
],
"react/jsx-no-undef": [
2,
],
"react/jsx-uses-react": [
2,
],
"react/jsx-uses-vars": [
2,
],
"react/no-children-prop": [
2,
],
"react/no-danger-with-children": [
2,
],
"react/no-deprecated": [
2,
],
"react/no-direct-mutation-state": [
2,
],
"react/no-find-dom-node": [
2,
],
"react/no-is-mounted": [
2,
],
"react/no-render-return-value": [
2,
],
"react/no-string-refs": [
2,
],
"react/no-unescaped-entities": [
2,
],
"react/require-render-return": [
2,
],
},
}
`)
})
})

View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'eslint/config'
import eslintCoreWebVitals from 'eslint-config-next/core-web-vitals'
const eslintConfig = defineConfig(eslintCoreWebVitals)
export default eslintConfig

View File

@@ -0,0 +1,4 @@
// Dummy file for testing eslint-config-next core-web-vitals configuration
export default function Test() {
return <div>Hello World</div>
}

View File

@@ -0,0 +1,250 @@
import { join } from 'path'
import { execSync } from 'child_process'
import { getEslintConfigSnapshot } from '../utils'
describe('eslint-config-next', () => {
it('should match expected resolved configuration', () => {
const eslintConfigAfterSetupJSON = execSync(
// Pass explicit absolute path to not get affected by the root eslint config.
`pnpm eslint --config ${join(__dirname, 'eslint.config.mjs')} --print-config ${join(__dirname, 'test.js')}`,
{
cwd: __dirname,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
}
)
const { settings, languageOptions, ...eslintConfigAfterSetup } = JSON.parse(
eslintConfigAfterSetupJSON
)
expect({
parser: languageOptions.parser,
settings,
}).toEqual({
// parser: require.resolve('eslint-config-next')
parser: expect.stringContaining('eslint-config-next'),
settings: {
'import/parsers': expect.any(Object),
'import/resolver': expect.any(Object),
react: {
version: 'detect',
},
},
})
expect(getEslintConfigSnapshot(eslintConfigAfterSetup))
.toMatchInlineSnapshot(`
{
"language": "@/js",
"linterOptions": {
"reportUnusedDisableDirectives": 1,
},
"plugins": [
"@",
"react",
"react-hooks:eslint-plugin-react-hooks@7.0.0",
"import",
"jsx-a11y:eslint-plugin-jsx-a11y@6.10.2",
"@next/next:@next/eslint-plugin-next",
],
"rules": {
"@next/next/google-font-display": [
1,
],
"@next/next/google-font-preconnect": [
1,
],
"@next/next/inline-script-id": [
2,
],
"@next/next/next-script-for-ga": [
1,
],
"@next/next/no-assign-module-variable": [
2,
],
"@next/next/no-async-client-component": [
1,
],
"@next/next/no-before-interactive-script-outside-document": [
1,
],
"@next/next/no-css-tags": [
1,
],
"@next/next/no-document-import-in-page": [
2,
],
"@next/next/no-duplicate-head": [
2,
],
"@next/next/no-head-element": [
1,
],
"@next/next/no-head-import-in-document": [
2,
],
"@next/next/no-html-link-for-pages": [
1,
],
"@next/next/no-img-element": [
1,
],
"@next/next/no-page-custom-font": [
1,
],
"@next/next/no-script-component-in-head": [
2,
],
"@next/next/no-styled-jsx-in-document": [
1,
],
"@next/next/no-sync-scripts": [
1,
],
"@next/next/no-title-in-document-head": [
1,
],
"@next/next/no-typos": [
1,
],
"@next/next/no-unwanted-polyfillio": [
1,
],
"import/no-anonymous-default-export": [
1,
],
"jsx-a11y/alt-text": [
1,
{
"elements": [
"img",
],
"img": [
"Image",
],
},
],
"jsx-a11y/aria-props": [
1,
],
"jsx-a11y/aria-proptypes": [
1,
],
"jsx-a11y/aria-unsupported-elements": [
1,
],
"jsx-a11y/role-has-required-aria-props": [
1,
],
"jsx-a11y/role-supports-aria-props": [
1,
],
"react-hooks/component-hook-factories": [
2,
],
"react-hooks/config": [
2,
],
"react-hooks/error-boundaries": [
2,
],
"react-hooks/exhaustive-deps": [
1,
],
"react-hooks/gating": [
2,
],
"react-hooks/globals": [
2,
],
"react-hooks/immutability": [
2,
],
"react-hooks/incompatible-library": [
1,
],
"react-hooks/preserve-manual-memoization": [
2,
],
"react-hooks/purity": [
2,
],
"react-hooks/refs": [
2,
],
"react-hooks/rules-of-hooks": [
2,
],
"react-hooks/set-state-in-effect": [
2,
],
"react-hooks/set-state-in-render": [
2,
],
"react-hooks/static-components": [
2,
],
"react-hooks/unsupported-syntax": [
1,
],
"react-hooks/use-memo": [
2,
],
"react/display-name": [
2,
],
"react/jsx-key": [
2,
],
"react/jsx-no-comment-textnodes": [
2,
],
"react/jsx-no-duplicate-props": [
2,
],
"react/jsx-no-undef": [
2,
],
"react/jsx-uses-react": [
2,
],
"react/jsx-uses-vars": [
2,
],
"react/no-children-prop": [
2,
],
"react/no-danger-with-children": [
2,
],
"react/no-deprecated": [
2,
],
"react/no-direct-mutation-state": [
2,
],
"react/no-find-dom-node": [
2,
],
"react/no-is-mounted": [
2,
],
"react/no-render-return-value": [
2,
],
"react/no-string-refs": [
2,
],
"react/no-unescaped-entities": [
2,
],
"react/require-render-return": [
2,
],
},
}
`)
})
})

View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'eslint/config'
import eslintNext from 'eslint-config-next'
const eslintConfig = defineConfig(eslintNext)
export default eslintConfig

View File

@@ -0,0 +1,4 @@
// Dummy file for testing eslint-config-next base configuration
export default function Test() {
return <div>Hello World</div>
}

View File

@@ -0,0 +1,124 @@
import { join } from 'path'
import { execSync } from 'child_process'
import { getEslintConfigSnapshot } from '../utils'
describe('eslint-config-next/typescript', () => {
it('should match expected resolved configuration', () => {
const eslintConfigAfterSetupJSON = execSync(
// Pass explicit absolute path to not get affected by the root eslint config.
`pnpm eslint --config ${join(__dirname, 'eslint.config.mjs')} --print-config ${join(__dirname, 'test.tsx')}`,
{
cwd: __dirname,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
}
)
const { languageOptions, ...eslintConfigAfterSetup } = JSON.parse(
eslintConfigAfterSetupJSON
)
expect({
parser: languageOptions.parser,
}).toEqual({
parser: expect.stringContaining('typescript-eslint'),
})
expect(getEslintConfigSnapshot(eslintConfigAfterSetup))
.toMatchInlineSnapshot(`
{
"language": "@/js",
"linterOptions": {
"reportUnusedDisableDirectives": 1,
},
"plugins": [
"@",
"@typescript-eslint:@typescript-eslint/eslint-plugin@8.46.0",
],
"rules": {
"@typescript-eslint/ban-ts-comment": [
2,
],
"@typescript-eslint/no-array-constructor": [
2,
],
"@typescript-eslint/no-duplicate-enum-values": [
2,
],
"@typescript-eslint/no-empty-object-type": [
2,
],
"@typescript-eslint/no-explicit-any": [
2,
],
"@typescript-eslint/no-extra-non-null-assertion": [
2,
],
"@typescript-eslint/no-misused-new": [
2,
],
"@typescript-eslint/no-namespace": [
2,
],
"@typescript-eslint/no-non-null-asserted-optional-chain": [
2,
],
"@typescript-eslint/no-require-imports": [
2,
],
"@typescript-eslint/no-this-alias": [
2,
],
"@typescript-eslint/no-unnecessary-type-constraint": [
2,
],
"@typescript-eslint/no-unsafe-declaration-merging": [
2,
],
"@typescript-eslint/no-unsafe-function-type": [
2,
],
"@typescript-eslint/no-unused-expressions": [
1,
{
"allowShortCircuit": false,
"allowTaggedTemplates": false,
"allowTernary": false,
},
],
"@typescript-eslint/no-unused-vars": [
1,
],
"@typescript-eslint/no-wrapper-object-types": [
2,
],
"@typescript-eslint/prefer-as-const": [
2,
],
"@typescript-eslint/prefer-namespace-keyword": [
2,
],
"@typescript-eslint/triple-slash-reference": [
2,
],
"no-var": [
2,
],
"prefer-const": [
2,
{
"destructuring": "any",
"ignoreReadBeforeAssign": false,
},
],
"prefer-rest-params": [
2,
],
"prefer-spread": [
2,
],
},
}
`)
})
})

View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'eslint/config'
import eslintTypescript from 'eslint-config-next/typescript'
const eslintConfig = defineConfig(eslintTypescript)
export default eslintConfig

View File

@@ -0,0 +1,8 @@
// Dummy file for testing eslint-config-next typescript configuration
interface Props {
message: string
}
export default function Test({ message }: Props) {
return <div>{message}</div>
}

View File

@@ -0,0 +1,19 @@
/**
* Rules being turned off (i.e. remove from snapshot) would be breaking change (requires removal of eslint-disable directive)
* Rules being added that are turned off would not be a breaking change (no eslint-disable directive required)
* Rules being added with a severity would be a breaking change (requires addition of eslint-disable directive)
*/
export function getEslintConfigSnapshot(eslintConfig: any) {
return {
...eslintConfig,
rules: Object.fromEntries(
Object.entries(eslintConfig.rules).filter(
([, config]: [ruleName: string, config: [severity: unknown]]) => {
const [severity] = config
return severity !== 0 && severity !== 'off'
}
)
),
}
}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1,178 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['google-font-display']
const tests = {
valid: [
`import Head from "next/head";
export default Test = () => {
return (
<Head>
<link href={test} rel="test" />
<link
href={process.env.NEXT_PUBLIC_CANONICAL_URL}
rel="canonical"
/>
<link
href={new URL("../public/favicon.ico", import.meta.url).toString()}
rel="icon"
/>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=optional"
rel="stylesheet"
/>
</Head>
);
};
`,
`import Document, { Html, Head } from "next/document";
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css?family=Krona+One&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
);
}
}
export default MyDocument;
`,
`import Document, { Html, Head } from "next/document";
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css?family=Krona+One&display=swap"
rel="stylesheet"
crossOrigin=""
/>
</Head>
</Html>
);
}
}
export default MyDocument;
`,
],
invalid: [
{
code: `import Head from "next/head";
export default Test = () => {
return (
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One"
rel="stylesheet"
/>
</Head>
);
};
`,
errors: [
{
message:
'A font-display parameter is missing (adding `&display=optional` is recommended). See: https://nextjs.org/docs/messages/google-font-display',
type: 'JSXOpeningElement',
},
],
},
{
code: `import Head from "next/head";
export default Test = () => {
return (
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=block"
rel="stylesheet"
/>
</Head>
);
};
`,
errors: [
{
message:
'Block is not recommended. See: https://nextjs.org/docs/messages/google-font-display',
type: 'JSXOpeningElement',
},
],
},
{
code: `import Head from "next/head";
export default Test = () => {
return (
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=auto"
rel="stylesheet"
/>
</Head>
);
};
`,
errors: [
{
message:
'Auto is not recommended. See: https://nextjs.org/docs/messages/google-font-display',
type: 'JSXOpeningElement',
},
],
},
{
code: `import Head from "next/head";
export default Test = () => {
return (
<Head>
<link
href="https://fonts.googleapis.com/css2?display=fallback&family=Krona+One"
rel="stylesheet"
/>
</Head>
);
};
`,
errors: [
{
message:
'Fallback is not recommended. See: https://nextjs.org/docs/messages/google-font-display',
type: 'JSXOpeningElement',
},
],
},
],
}
describe('google-font-display', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,73 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['google-font-preconnect']
const tests = {
valid: [
`export const Test = () => (
<div>
<link rel="preconnect" href="https://fonts.gstatic.com"/>
<link
href={process.env.NEXT_PUBLIC_CANONICAL_URL}
rel="canonical"
/>
<link
href={new URL("../public/favicon.ico", import.meta.url).toString()}
rel="icon"
/>
</div>
)
`,
],
invalid: [
{
code: `
export const Test = () => (
<div>
<link href="https://fonts.gstatic.com"/>
</div>
)
`,
errors: [
{
message:
'`rel="preconnect"` is missing from Google Font. See: https://nextjs.org/docs/messages/google-font-preconnect',
type: 'JSXOpeningElement',
},
],
},
{
code: `
export const Test = () => (
<div>
<link rel="preload" href="https://fonts.gstatic.com"/>
</div>
)
`,
errors: [
{
message:
'`rel="preconnect"` is missing from Google Font. See: https://nextjs.org/docs/messages/google-font-preconnect',
type: 'JSXOpeningElement',
},
],
},
],
}
describe('google-font-preconnect', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,59 @@
import { basename } from 'path'
import glob from 'glob'
import index from '@next/eslint-plugin-next'
const getRuleNameFromRulePath = (path) => basename(path, '.js')
const rulePaths = glob.sync('packages/eslint-plugin-next/dist/rules/*js', {
absolute: true,
})
describe('@next/eslint-plugin-next index', () => {
it('should include all defined rules and no extra / undefined rules', () => {
const rules = rulePaths.map((rulePath) => getRuleNameFromRulePath(rulePath))
expect(index.rules).toContainAllKeys(rules)
})
it('should have meta information', () => {
expect(index.meta).toBeDefined()
expect(index.meta.name).toBe('@next/eslint-plugin-next')
})
it('should have proper flat config structure for recommended', () => {
const config = index.configs.recommended
expect(config.name).toBe('next/recommended')
expect(config.rules).toBeDefined()
})
it('should have proper flat config structure for core-web-vitals', () => {
const config = index.configs['core-web-vitals']
expect(config.name).toBe('next/core-web-vitals')
expect(config.rules).toBeDefined()
})
it('should have legacy recommended config', () => {
const config = index.configs['recommended-legacy']
expect(config.plugins).toContain('@next/next')
expect(config.rules).toBeDefined()
})
it('should have legacy core-web-vitals config', () => {
const config = index.configs['core-web-vitals-legacy']
expect(config.plugins).toContain('@next/next')
expect(config.extends).toContain('plugin:@next/next/recommended-legacy')
expect(config.rules).toBeDefined()
})
rulePaths.forEach((rulePath) => {
let rule = require(rulePath)
rule = rule.default ?? rule
const ruleName = getRuleNameFromRulePath(rulePath)
const { recommended = false } = rule.meta.docs
it(`${ruleName}: recommend should be \`${recommended}\``, () => {
expect(`@next/next/${ruleName}` in index.configs.recommended.rules).toBe(
recommended
)
})
})
})

View File

@@ -0,0 +1,193 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['inline-script-id']
const errorMessage =
'`next/script` components with inline content must specify an `id` attribute. See: https://nextjs.org/docs/messages/inline-script-id'
const tests = {
valid: [
{
code: `import Script from 'next/script';
export default function TestPage() {
return (
<Script id="test-script">
{\`console.log('Hello world');\`}
</Script>
)
}`,
},
{
code: `import Script from 'next/script';
export default function TestPage() {
return (
<Script
id="test-script"
dangerouslySetInnerHTML={{
__html: \`console.log('Hello world');\`
}}
/>
)
}`,
},
{
code: `import Script from 'next/script';
export default function TestPage() {
return (
<Script src="https://example.com" />
)
}`,
},
{
code: `import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript id="test-script">
{\`console.log('Hello world');\`}
</MyScript>
)
}`,
},
{
code: `import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript
id="test-script"
dangerouslySetInnerHTML={{
__html: \`console.log('Hello world');\`
}}
/>
)
}`,
},
{
code: `import Script from 'next/script';
export default function TestPage() {
return (
<Script {...{ strategy: "lazyOnload" }} id={"test-script"}>
{\`console.log('Hello world');\`}
</Script>
)
}`,
},
{
code: `import Script from 'next/script';
export default function TestPage() {
return (
<Script {...{ strategy: "lazyOnload", id: "test-script" }}>
{\`console.log('Hello world');\`}
</Script>
)
}`,
},
{
code: `import Script from 'next/script';
const spread = { strategy: "lazyOnload" }
export default function TestPage() {
return (
<Script {...spread} id={"test-script"}>
{\`console.log('Hello world');\`}
</Script>
)
}`,
},
],
invalid: [
{
code: `import Script from 'next/script';
export default function TestPage() {
return (
<Script>
{\`console.log('Hello world');\`}
</Script>
)
}`,
errors: [
{
message: errorMessage,
type: 'JSXElement',
},
],
},
{
code: `import Script from 'next/script';
export default function TestPage() {
return (
<Script
dangerouslySetInnerHTML={{
__html: \`console.log('Hello world');\`
}}
/>
)
}`,
errors: [
{
message: errorMessage,
type: 'JSXElement',
},
],
},
{
code: `import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript>
{\`console.log('Hello world');\`}
</MyScript>
)
}`,
errors: [
{
message: errorMessage,
type: 'JSXElement',
},
],
},
{
code: `import MyScript from 'next/script';
export default function TestPage() {
return (
<MyScript
dangerouslySetInnerHTML={{
__html: \`console.log('Hello world');\`
}}
/>
)
}`,
errors: [
{
message: errorMessage,
type: 'JSXElement',
},
],
},
],
}
describe('inline-script-id', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,218 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['next-script-for-ga']
const url = 'https://nextjs.org/docs/messages/next-script-for-ga'
const ERROR_MSG_GOOGLE_ANALYTICS = `Prefer \`GoogleAnalytics\` component from \`@next/third-parties/google\` when using the inline script for Google Analytics. See: ${url}`
const ERROR_MSG_GOOGLE_TAG_MANAGER = `Prefer \`GoogleTagManager\` component from \`@next/third-parties/google\` when using the inline script for Google Tag Manager. See: ${url}`
const tests = {
valid: [
`import Script from 'next/script'
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="lazyOnload"
/>
<Script id="google-analytics">
{\`
window.dataLayer = window.dataLayer || [];
function gtag(){window.dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
\`}
</Script>
</div>
);
}
}`,
`import Script from 'next/script'
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<Script id="google-analytics">
{\`(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
})\`}
</Script>
</div>
);
}
}`,
`import Script from 'next/script'
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<Script id="google-analytics">
{\`window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
})\`}
</Script>
</div>
);
}
}`,
`export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script dangerouslySetInnerHTML={{}} />
</div>
);
}
}`,
],
invalid: [
{
code: `
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script async src='https://www.googletagmanager.com/gtag/js?id=$\{GA_TRACKING_ID}' />
<script
dangerouslySetInnerHTML={{
__html: \`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '\${GA_TRACKING_ID}', {
page_path: window.location.pathname,
});
\`,
}}/>
</div>
);
}
}`,
errors: [
{
message: ERROR_MSG_GOOGLE_TAG_MANAGER,
type: 'JSXOpeningElement',
},
],
},
{
code: `
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script dangerouslySetInnerHTML={{
__html: \`
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
\`,
}}/>
</div>
);
}
}`,
errors: [
{
message: ERROR_MSG_GOOGLE_ANALYTICS,
type: 'JSXOpeningElement',
},
],
},
{
code: `
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script dangerouslySetInnerHTML={{
__html: \`
window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
ga('create', 'UA-XXXXX-Y', 'auto');
ga('send', 'pageview');
\`,
}}/>
<script async src='https://www.google-analytics.com/analytics.js'></script>
</div>
);
}
}`,
errors: [
{
message: ERROR_MSG_GOOGLE_ANALYTICS,
type: 'JSXOpeningElement',
},
],
},
{
code: `
export class Blah extends Head {
createGoogleAnalyticsMarkup() {
return {
__html: \`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-148481588-2');\`,
};
}
render() {
return (
<div>
<h1>Hello title</h1>
<script dangerouslySetInnerHTML={this.createGoogleAnalyticsMarkup()} />
<script async src='https://www.google-analytics.com/analytics.js'></script>
</div>
);
}
}`,
errors: [
{
message: ERROR_MSG_GOOGLE_ANALYTICS,
type: 'JSXOpeningElement',
},
],
},
],
}
describe('next-script-for-ga', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,48 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-assign-module-variable']
const tests = {
valid: [
`
let myModule = {};
export default function MyComponent() {
return <></>
}
`,
],
invalid: [
{
code: `
let module = {};
export default function MyComponent() {
return <></>
}
`,
errors: [
{
message:
'Do not assign to the variable `module`. See: https://nextjs.org/docs/messages/no-assign-module-variable',
},
],
},
],
}
describe('no-assign-module-variable', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,132 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-async-client-component']
const message =
'Prevent Client Components from being async functions. See: https://nextjs.org/docs/messages/no-async-client-component'
const tests = {
valid: [
`
// single line
export default async function MyComponent() {
return <></>
}
`,
`
// single line capitalization
"use client"
export default async function myFunction() {
return ''
}
`,
`
// multiple line
async function MyComponent() {
return <></>
}
export default MyComponent
`,
`
// multiple line capitalization
"use client"
async function myFunction() {
return ''
}
export default myFunction
`,
`
// arrow function
"use client"
const myFunction = () => {
return ''
}
export default myFunction
`,
],
invalid: [
{
code: `
// single line
"use client"
export default async function MyComponent() {
return <></>
}
`,
errors: [{ message }],
},
{
code: `
// single line capitalization
"use client"
export default async function MyFunction() {
return ''
}
`,
errors: [{ message }],
},
{
code: `
// multiple line
"use client"
async function MyComponent() {
return <></>
}
export default MyComponent
`,
errors: [{ message }],
},
{
code: `
// multiple line capitalization
"use client"
async function MyFunction() {
return ''
}
export default MyFunction
`,
errors: [{ message }],
},
{
code: `
// arrow function
"use client"
const MyFunction = async () => {
return '123'
}
export default MyFunction
`,
errors: [{ message }],
},
],
}
describe('no-async-client-component', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,295 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-before-interactive-script-outside-document']
const message =
"`next/script`'s `beforeInteractive` strategy should not be used outside of `pages/_document.js`. See: https://nextjs.org/docs/messages/no-before-interactive-script-outside-document"
const tests = {
valid: [
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import Script from 'next/script'
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<meta charSet="utf-8" />
</Head>
<body>
<Main />
<NextScript />
<Script
id="scriptBeforeInteractive"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy="beforeInteractive"
></Script>
</body>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.js',
},
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import ScriptComponent from 'next/script'
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<meta charSet="utf-8" />
</Head>
<body>
<Main />
<NextScript />
<ScriptComponent
id="scriptBeforeInteractive"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy="beforeInteractive"
></ScriptComponent>
</body>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.tsx',
},
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import ScriptComponent from 'next/script'
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<meta charSet="utf-8" />
</Head>
<body>
<Main />
<NextScript />
<ScriptComponent
id="scriptBeforeInteractive"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
></ScriptComponent>
</body>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.tsx',
},
{
code: `
import Script from "next/script";
export default function Index() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename: '/Users/user_name/projects/project-name/app/layout.tsx',
},
{
code: `
import Script from "next/script";
export default function test() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename: 'C:\\Users\\username\\projects\\project-name\\app\\layout.tsx',
},
{
code: `
import Script from "next/script";
export default function Index() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename: '/Users/user_name/projects/project-name/src/app/layout.tsx',
},
{
code: `
import Script from "next/script";
export default function test() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename:
'C:\\Users\\username\\projects\\project-name\\src\\app\\layout.tsx',
},
],
invalid: [
{
code: `
import Head from "next/head";
import Script from "next/script";
export default function Index() {
return (
<Script
id="scriptBeforeInteractive"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy="beforeInteractive"
></Script>
);
}`,
filename: 'pages/index.js',
errors: [{ message }],
},
{
code: `
import Head from "next/head";
import Script from "next/script";
export default function Index() {
return (
<Script
id="scriptBeforeInteractive"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy="beforeInteractive"
></Script>
);
}`,
filename: 'components/outside-known-dirs.js',
errors: [{ message }],
},
{
code: `
import Script from "next/script";
export default function Index() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename: '/Users/user_name/projects/project-name/pages/layout.tsx',
errors: [{ message }],
},
{
code: `
import Script from "next/script";
export default function Index() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename:
'C:\\Users\\username\\projects\\project-name\\pages\\layout.tsx',
errors: [{ message }],
},
{
code: `
import Script from "next/script";
export default function Index() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename: '/Users/user_name/projects/project-name/src/pages/layout.tsx',
errors: [{ message }],
},
{
code: `
import Script from "next/script";
export default function test() {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<Script
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive"
strategy='beforeInteractive'
/>
</html>
);
}`,
filename:
'C:\\Users\\username\\projects\\project-name\\src\\pages\\layout.tsx',
errors: [{ message }],
},
],
}
describe('no-before-interactive-script-outside-document', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,110 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-css-tags']
const message =
'Do not include stylesheets manually. See: https://nextjs.org/docs/messages/no-css-tags'
const tests = {
valid: [
`import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
</div>
);
}
}`,
`import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<link href="https://fonts.googleapis.com/css?family=Open+Sans&display=swap" rel="stylesheet" />
</div>
);
}
}`,
`import {Head} from 'next/document';
export class Blah extends Head {
render(props) {
return (
<div>
<h1>Hello title</h1>
<link {...props} />
</div>
);
}
}`,
`import {Head} from 'next/document';
export class Blah extends Head {
render(props) {
return (
<div>
<h1>Hello title</h1>
<link rel="stylesheet" {...props} />
</div>
);
}
}`,
],
invalid: [
{
code: `
import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<link href="/_next/static/css/styles.css" rel="stylesheet" />
</div>
);
}
}`,
errors: [
{
message,
type: 'JSXOpeningElement',
},
],
},
{
code: `
<div>
<link href="/_next/static/css/styles.css" rel="stylesheet" />
</div>`,
errors: [
{
message,
type: 'JSXOpeningElement',
},
],
},
],
}
describe('no-css-tags', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,124 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-document-import-in-page']
const tests = {
valid: [
{
code: `import Document from "next/document"
export default class MyDocument extends Document {
render() {
return (
<Html>
</Html>
);
}
}
`,
filename: 'pages/_document.js',
},
{
code: `import Document from "next/document"
export default class MyDocument extends Document {
render() {
return (
<Html>
</Html>
);
}
}
`,
filename: 'pages/_document.page.tsx',
},
{
code: `import NDocument from "next/document"
export default class Document extends NDocument {
render() {
return (
<Html>
</Html>
);
}
}
`,
filename: 'pages/_document/index.js',
},
{
code: `import NDocument from "next/document"
export default class Document extends NDocument {
render() {
return (
<Html>
</Html>
);
}
}
`,
filename: 'pages/_document/index.tsx',
},
{
code: `import Document from "next/document"
export default class MyDocument extends Document {
render() {
return (
<Html>
</Html>
);
}
}
`,
filename: 'pagesapp/src/pages/_document.js',
},
],
invalid: [
{
code: `import Document from "next/document"
export const Test = () => <p>Test</p>
`,
filename: 'components/test.js',
errors: [
{
message:
'`<Document />` from `next/document` should not be imported outside of `pages/_document.js`. See: https://nextjs.org/docs/messages/no-document-import-in-page',
type: 'ImportDeclaration',
},
],
},
{
code: `import Document from "next/document"
export const Test = () => <p>Test</p>
`,
filename: 'pages/test.js',
errors: [
{
message:
'`<Document />` from `next/document` should not be imported outside of `pages/_document.js`. See: https://nextjs.org/docs/messages/no-document-import-in-page',
type: 'ImportDeclaration',
},
],
},
],
}
describe('no-document-import-in-page', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,146 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-duplicate-head']
const message =
'Do not include multiple instances of `<Head/>`. See: https://nextjs.org/docs/messages/no-duplicate-head'
const tests = {
valid: [
{
code: `import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
static async getInitialProps(ctx) {
//...
}
render() {
return (
<Html>
<Head/>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.js',
},
{
code: `import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<meta charSet="utf-8" />
<link
href="https://fonts.googleapis.com/css2?family=Sarabun:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.tsx',
},
],
invalid: [
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import Head from 'next/head'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<Head />
<Head />
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.js',
errors: [
{
message,
type: 'JSXElement',
},
{
message,
type: 'JSXElement',
},
],
},
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import Head from 'next/head'
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<meta charSet="utf-8" />
<link
href="https://fonts.googleapis.com/css2?family=Sarabun:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
<Head>
<script
dangerouslySetInnerHTML={{
__html: '',
}}
/>
</Head>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.page.tsx',
errors: [
{
message,
type: 'JSXElement',
},
],
},
],
}
describe('no-duplicate-head', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,124 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-head-element']
const message =
'Do not use `<head>` element. Use `<Head />` from `next/head` instead. See: https://nextjs.org/docs/messages/no-head-element'
const tests = {
valid: [
{
code: `import Head from 'next/head';
export class MyComponent {
render() {
return (
<div>
<Head>
<title>My page title</title>
</Head>
</div>
);
}
}
`,
filename: 'pages/index.js',
},
{
code: `import Head from 'next/head';
export class MyComponent {
render() {
return (
<div>
<Head>
<title>My page title</title>
</Head>
</div>
);
}
}
`,
filename: 'pages/index.tsx',
},
{
code: `
export default function Layout({ children }) {
return (
<html>
<head>
<title>layout</title>
</head>
<body>{children}</body>
</html>
);
}
`,
filename: './app/layout.js',
},
],
invalid: [
{
code: `
export class MyComponent {
render() {
return (
<div>
<head>
<title>My page title</title>
</head>
</div>
);
}
}`,
filename: './pages/index.js',
errors: [
{
message,
type: 'JSXOpeningElement',
},
],
},
{
code: `import Head from 'next/head';
export class MyComponent {
render() {
return (
<div>
<head>
<title>My page title</title>
</head>
<Head>
<title>My page title</title>
</Head>
</div>
);
}
}`,
filename: 'pages/index.ts',
errors: [
{
message,
type: 'JSXOpeningElement',
},
],
},
],
}
describe('no-head-element', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,182 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-head-import-in-document']
const tests = {
valid: [
{
code: `import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
static async getInitialProps(ctx) {
//...
}
render() {
return (
<Html>
<Head>
</Head>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.tsx',
},
{
code: `import Head from "next/head";
export default function IndexPage() {
return (
<Head>
<title>My page title</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
);
}
`,
filename: 'pages/index.tsx',
},
],
invalid: [
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import Head from 'next/head'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.js',
errors: [
{
message:
'`next/head` should not be imported in `pages/_document.js`. Use `<Head />` from `next/document` instead. See: https://nextjs.org/docs/messages/no-head-import-in-document',
type: 'ImportDeclaration',
},
],
},
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import Head from 'next/head'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document.page.tsx',
errors: [
{
message:
'`next/head` should not be imported in `pages/_document.page.tsx`. Use `<Head />` from `next/document` instead. See: https://nextjs.org/docs/messages/no-head-import-in-document',
type: 'ImportDeclaration',
},
],
},
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import Head from 'next/head'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document/index.js',
errors: [
{
message:
'`next/head` should not be imported in `pages/_document/index.js`. Use `<Head />` from `next/document` instead. See: https://nextjs.org/docs/messages/no-head-import-in-document',
type: 'ImportDeclaration',
},
],
},
{
code: `
import Document, { Html, Main, NextScript } from 'next/document'
import Head from 'next/head'
class MyDocument extends Document {
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument
`,
filename: 'pages/_document/index.tsx',
errors: [
{
message:
'`next/head` should not be imported in `pages/_document/index.tsx`. Use `<Head />` from `next/document` instead. See: https://nextjs.org/docs/messages/no-head-import-in-document',
type: 'ImportDeclaration',
},
],
},
],
}
describe('no-head-import-in-document', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,498 @@
/* eslint-env jest */
import { Linter } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
import assert from 'assert'
import path from 'path'
const NextESLintRule = rules['no-html-link-for-pages']
const withCustomPagesDir = path.join(__dirname, 'with-custom-pages-dir')
const withNestedPagesDir = path.join(__dirname, 'with-nested-pages-dir')
const withoutPagesDir = path.join(__dirname, 'without-pages-dir')
const withAppDir = path.join(__dirname, 'with-app-dir')
const linters = {
withoutPages: new Linter({
cwd: withoutPagesDir,
configType: 'eslintrc',
}),
withApp: new Linter({
cwd: withAppDir,
configType: 'eslintrc',
}),
withNestedPages: new Linter({
cwd: withNestedPagesDir,
configType: 'eslintrc',
}),
withCustomPages: new Linter({
cwd: withCustomPagesDir,
configType: 'eslintrc',
}),
}
const linterConfig: any = {
rules: {
'no-html-link-for-pages': [2],
},
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
modules: true,
jsx: true,
},
},
}
const linterConfigWithCustomDirectory: any = {
...linterConfig,
rules: {
'no-html-link-for-pages': [
2,
path.join(withCustomPagesDir, 'custom-pages'),
],
},
}
const linterConfigWithMultipleDirectories = {
...linterConfig,
rules: {
'no-html-link-for-pages': [
2,
[
path.join(withCustomPagesDir, 'custom-pages'),
path.join(withCustomPagesDir, 'custom-pages/list'),
],
],
},
}
const linterConfigWithNestedContentRootDirDirectory = {
...linterConfig,
settings: {
next: {
rootDir: path.join(withNestedPagesDir, 'demos/with-nextjs'),
},
},
}
for (const linter of Object.values(linters)) {
linter.defineRules({
'no-html-link-for-pages': NextESLintRule,
})
}
const validCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<Link href='/'>
<a>Homepage</a>
</Link>
<h1>Hello title</h1>
</div>
);
}
}
`
const validAnchorCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='#heading'>Homepage</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const validExternalLinkCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='https://google.com/'>Homepage</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const validDownloadLinkCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/static-file.csv' download>Download</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const validTargetBlankLinkCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a target="_blank" href='/new-tab'>New Tab</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const validPublicFile = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/presentation.pdf'>View PDF</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const invalidStaticCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/'>Homepage</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const invalidDynamicCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/list/foo/bar'>Homepage</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const secondInvalidDynamicCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/list/foo/'>Homepage</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const thirdInvalidDynamicCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/list/lorem-ipsum/'>Homepage</a>
<h1>Hello title</h1>
</div>
);
}
}
`
const validInterceptedRouteCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<Link href='/photo/1/'>Photo</Link>
<h1>Hello title</h1>
</div>
);
}
}
`
const invalidInterceptedRouteCode = `
import Link from 'next/link';
export class Blah extends Head {
render() {
return (
<div>
<a href='/photo/1/'>Photo</a>
<h1>Hello title</h1>
</div>
);
}
}
`
describe('no-html-link-for-pages', function () {
it('does not print warning when there are "pages" or "app" directories with rootDir in context settings', function () {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
linters.withNestedPages.verify(
validCode,
linterConfigWithNestedContentRootDirDirectory,
{ filename: 'foo.js' }
)
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('prints warning when there are no "pages" or "app" directories', function () {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
linters.withoutPages.verify(validCode, linterConfig, {
filename: 'foo.js',
})
expect(consoleSpy).toHaveBeenCalledWith(
`Pages directory cannot be found at ${path.join(
withoutPagesDir,
'pages'
)} or ${path.join(
withoutPagesDir,
'src',
'pages'
)}. If using a custom path, please configure with the \`no-html-link-for-pages\` rule in your eslint config file.`
)
consoleSpy.mockRestore()
})
it('does not print warning when there is "app" directory and no "pages" directory', function () {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
linters.withApp.verify(validCode, linterConfig, {
filename: 'foo.js',
})
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('valid link element', function () {
const report = linters.withCustomPages.verify(
validCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('valid link element with multiple directories', function () {
const report = linters.withCustomPages.verify(
validCode,
linterConfigWithMultipleDirectories,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('valid anchor element', function () {
const report = linters.withCustomPages.verify(
validAnchorCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('valid external link element', function () {
const report = linters.withCustomPages.verify(
validExternalLinkCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('valid download link element', function () {
const report = linters.withCustomPages.verify(
validDownloadLinkCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('valid target="_blank" link element', function () {
const report = linters.withCustomPages.verify(
validTargetBlankLinkCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('valid public file link element', function () {
const report = linters.withCustomPages.verify(
validPublicFile,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('invalid static route', function () {
const [report] = linters.withCustomPages.verify(
invalidStaticCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.notEqual(report, undefined, 'No lint errors found.')
assert.equal(
report.message,
'Do not use an `<a>` element to navigate to `/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
})
it('invalid dynamic route', function () {
const [report] = linters.withCustomPages.verify(
invalidDynamicCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.notEqual(report, undefined, 'No lint errors found.')
assert.equal(
report.message,
'Do not use an `<a>` element to navigate to `/list/foo/bar/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
const [secondReport] = linters.withCustomPages.verify(
secondInvalidDynamicCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.notEqual(secondReport, undefined, 'No lint errors found.')
assert.equal(
secondReport.message,
'Do not use an `<a>` element to navigate to `/list/foo/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
const [thirdReport] = linters.withCustomPages.verify(
thirdInvalidDynamicCode,
linterConfigWithCustomDirectory,
{ filename: 'foo.js' }
)
assert.notEqual(thirdReport, undefined, 'No lint errors found.')
assert.equal(
thirdReport.message,
'Do not use an `<a>` element to navigate to `/list/lorem-ipsum/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
})
it('valid link element with appDir', function () {
const report = linters.withApp.verify(validCode, linterConfig, {
filename: 'foo.js',
})
assert.deepEqual(report, [])
})
it('valid link element with multiple directories with appDir', function () {
const report = linters.withApp.verify(validCode, linterConfig, {
filename: 'foo.js',
})
assert.deepEqual(report, [])
})
it('valid anchor element with appDir', function () {
const report = linters.withApp.verify(validAnchorCode, linterConfig, {
filename: 'foo.js',
})
assert.deepEqual(report, [])
})
it('valid external link element with appDir', function () {
const report = linters.withApp.verify(validExternalLinkCode, linterConfig, {
filename: 'foo.js',
})
assert.deepEqual(report, [])
})
it('valid download link element with appDir', function () {
const report = linters.withApp.verify(validDownloadLinkCode, linterConfig, {
filename: 'foo.js',
})
assert.deepEqual(report, [])
})
it('valid target="_blank" link element with appDir', function () {
const report = linters.withApp.verify(
validTargetBlankLinkCode,
linterConfig,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('valid public file link element with appDir', function () {
const report = linters.withApp.verify(validPublicFile, linterConfig, {
filename: 'foo.js',
})
assert.deepEqual(report, [])
})
it('invalid static route with appDir', function () {
const [report] = linters.withApp.verify(invalidStaticCode, linterConfig, {
filename: 'foo.js',
})
assert.notEqual(report, undefined, 'No lint errors found.')
assert.equal(
report.message,
'Do not use an `<a>` element to navigate to `/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
})
it('invalid dynamic route with appDir', function () {
const [report] = linters.withApp.verify(invalidDynamicCode, linterConfig, {
filename: 'foo.js',
})
assert.notEqual(report, undefined, 'No lint errors found.')
assert.equal(
report.message,
'Do not use an `<a>` element to navigate to `/list/foo/bar/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
const [secondReport] = linters.withApp.verify(
secondInvalidDynamicCode,
linterConfig,
{ filename: 'foo.js' }
)
assert.notEqual(secondReport, undefined, 'No lint errors found.')
assert.equal(
secondReport.message,
'Do not use an `<a>` element to navigate to `/list/foo/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
const [thirdReport] = linters.withApp.verify(
thirdInvalidDynamicCode,
linterConfig,
{ filename: 'foo.js' }
)
assert.notEqual(thirdReport, undefined, 'No lint errors found.')
assert.equal(
thirdReport.message,
'Do not use an `<a>` element to navigate to `/list/lorem-ipsum/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
})
it('valid intercepted route with appDir', function () {
const report = linters.withApp.verify(
validInterceptedRouteCode,
linterConfig,
{ filename: 'foo.js' }
)
assert.deepEqual(report, [])
})
it('invalid intercepted route with appDir', function () {
const [report] = linters.withApp.verify(
invalidInterceptedRouteCode,
linterConfig,
{ filename: 'foo.js' }
)
assert.notEqual(report, undefined, 'No lint errors found.')
assert.equal(
report.message,
'Do not use an `<a>` element to navigate to `/photo/1/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages'
)
})
})

View File

@@ -0,0 +1,170 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-img-element']
const message =
'Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element'
const tests = {
valid: [
`import { Image } from 'next/image';
export class MyComponent {
render() {
return (
<div>
<Image
src="/test.png"
alt="Test picture"
width={500}
height={500}
/>
</div>
);
}
}`,
`export class MyComponent {
render() {
return (
<picture>
<img
src="/test.png"
alt="Test picture"
width={500}
height={500}
/>
</picture>
);
}
}`,
`export class MyComponent {
render() {
return (
<div>
<picture>
<source media="(min-width:650px)" srcset="/test.jpg"/>
<img
src="/test.png"
alt="Test picture"
style="width:auto;"
/>
</picture>
</div>
);
}
}`,
{
code: `\
import { ImageResponse } from "next/og";
export default function icon() {
return new ImageResponse(
(
<img
alt="avatar"
style={{ borderRadius: "100%" }}
width="100%"
height="100%"
src="https://example.com/image.png"
/>
)
);
}
`,
filename: `src/app/icon.js`,
},
{
code: `\
import { ImageResponse } from "next/og";
export default function Image() {
return new ImageResponse(
(
<img
alt="avatar"
style={{ borderRadius: "100%" }}
width="100%"
height="100%"
src="https://example.com/image.png"
/>
)
);
}
`,
filename: `app/opengraph-image.tsx`,
},
],
invalid: [
{
code: `
export class MyComponent {
render() {
return (
<div>
<img
src="/test.png"
alt="Test picture"
width={500}
height={500}
/>
</div>
);
}
}`,
errors: [{ message, type: 'JSXOpeningElement' }],
},
{
code: `
export class MyComponent {
render() {
return (
<img
src="/test.png"
alt="Test picture"
width={500}
height={500}
/>
);
}
}`,
errors: [{ message, type: 'JSXOpeningElement' }],
},
{
code: `\
import { ImageResponse } from "next/og";
export default function Image() {
return new ImageResponse(
(
<img
alt="avatar"
style={{ borderRadius: "100%" }}
width="100%"
height="100%"
src="https://example.com/image.png"
/>
)
);
}
`,
filename: `some/non-metadata-route-image.tsx`,
errors: [{ message, type: 'JSXOpeningElement' }],
},
],
}
describe('no-img-element', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,206 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-page-custom-font']
const filename = 'pages/_document.js'
const tests = {
valid: [
{
code: `import Document, { Html, Head } from "next/document";
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
);
}
}
export default MyDocument;`,
filename,
},
{
code: `import NextDocument, { Html, Head } from "next/document";
class Document extends NextDocument {
render() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
);
}
}
export default Document;
`,
filename,
},
{
code: `export default function CustomDocument() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
)
}`,
filename,
},
{
code: `function CustomDocument() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
)
}
export default CustomDocument;
`,
filename,
},
{
code: `
import Document, { Html, Head } from "next/document";
class MyDocument {
render() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
);
}
}
export default MyDocument;`,
filename,
},
{
code: `export default function() {
return (
<Html>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Krona+One&display=swap"
rel="stylesheet"
/>
</Head>
</Html>
)
}`,
filename,
},
],
invalid: [
{
code: `
import Head from 'next/head'
export default function IndexPage() {
return (
<div>
<Head>
<link
href="https://fonts.googleapis.com/css2?family=Inter"
rel="stylesheet"
/>
</Head>
<p>Hello world!</p>
</div>
)
}
`,
filename: 'pages/index.tsx',
errors: [
{
message:
'Custom fonts not added in `pages/_document.js` will only load for a single page. This is discouraged. See: https://nextjs.org/docs/messages/no-page-custom-font',
type: 'JSXOpeningElement',
},
],
},
{
code: `
import Head from 'next/head'
function Links() {
return (
<>
<link
href="https://fonts.googleapis.com/css2?family=Inter"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Open+Sans"
rel="stylesheet"
/>
</>
)
}
export default function IndexPage() {
return (
<div>
<Head>
<Links />
</Head>
<p>Hello world!</p>
</div>
)
}
`,
filename,
errors: [
{
message:
'Using `<link />` outside of `<Head>` will disable automatic font optimization. This is discouraged. See: https://nextjs.org/docs/messages/no-page-custom-font',
},
{
message:
'Using `<link />` outside of `<Head>` will disable automatic font optimization. This is discouraged. See: https://nextjs.org/docs/messages/no-page-custom-font',
},
],
},
],
}
describe('no-page-custom-font', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,56 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-script-component-in-head']
const message =
'`next/script` should not be used in `next/head` component. Move `<Script />` outside of `<Head>` instead. See: https://nextjs.org/docs/messages/no-script-component-in-head'
const tests = {
valid: [
`import Script from "next/script";
const Head = ({children}) => children
export default function Index() {
return (
<Head>
<Script></Script>
</Head>
);
}
`,
],
invalid: [
{
code: `
import Head from "next/head";
import Script from "next/script";
export default function Index() {
return (
<Head>
<Script></Script>
</Head>
);
}`,
filename: 'pages/index.js',
errors: [{ message }],
},
],
}
describe('no-script-component-in-head', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,130 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-styled-jsx-in-document']
const tests = {
valid: [
{
filename: 'pages/_document.js',
code: `import Document, { Html, Head, Main, NextScript } from 'next/document'
export class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}`,
},
{
filename: 'pages/_document.js',
code: `import Document, { Html, Head, Main, NextScript } from 'next/document'
export class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render() {
return (
<Html>
<Head />
<style>{"\
body{\
color:red;\
}\
"}</style>
<style {...{nonce: '123' }}></style>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}`,
},
{
filename: 'pages/index.js',
code: `
export default function Page() {
return (
<>
<p>Hello world</p>
<style jsx>{\`
p {
color: orange;
}
\`}</style>
</>
)
}
`,
},
],
invalid: [
{
filename: 'pages/_document.js',
code: `
import Document, { Html, Head, Main, NextScript } from 'next/document'
export class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render() {
return (
<Html>
<Head />
<style jsx>{"\
body{\
color:red;\
}\
"}</style>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}`,
errors: [
{
message: `\`styled-jsx\` should not be used in \`pages/_document.js\`. See: https://nextjs.org/docs/messages/no-styled-jsx-in-document`,
},
],
},
],
}
describe('no-styled-jsx-in-document', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,86 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-sync-scripts']
const message =
'Synchronous scripts should not be used. See: https://nextjs.org/docs/messages/no-sync-scripts'
const tests = {
valid: [
`import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script src='https://blah.com' async></script>
</div>
);
}
}`,
`import {Head} from 'next/document';
export class Blah extends Head {
render(props) {
return (
<div>
<h1>Hello title</h1>
<script {...props} ></script>
</div>
);
}
}`,
],
invalid: [
{
code: `
import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script src='https://blah.com'></script>
</div>
);
}
}`,
errors: [{ message, type: 'JSXOpeningElement' }],
},
{
code: `
import {Head} from 'next/document';
export class Blah extends Head {
render(props) {
return (
<div>
<h1>Hello title</h1>
<script src={props.src}></script>
</div>
);
}
}`,
errors: [{ message, type: 'JSXOpeningElement' }],
},
],
}
describe('no-sync-scripts', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,75 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-title-in-document-head']
const tests = {
valid: [
`import Head from "next/head";
class Test {
render() {
return (
<Head>
<title>My page title</title>
</Head>
);
}
}`,
`import Document, { Html, Head } from "next/document";
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
</Head>
</Html>
);
}
}
export default MyDocument;
`,
],
invalid: [
{
code: `
import { Head } from "next/document";
class Test {
render() {
return (
<Head>
<title>My page title</title>
</Head>
);
}
}`,
errors: [
{
message:
'Do not use `<title>` element with `<Head />` component from `next/document`. Titles should defined at the page-level using `<Head />` from `next/head` instead. See: https://nextjs.org/docs/messages/no-title-in-document-head',
type: 'JSXElement',
},
],
},
],
}
describe('no-title-in-document-head', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,135 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-typos']
const tests = {
valid: [
`
export default function Page() {
return <div></div>;
}
export const getStaticPaths = async () => {};
export const getStaticProps = async () => {};
`,
`
export default function Page() {
return <div></div>;
}
export const getServerSideProps = async () => {};
`,
`
export default function Page() {
return <div></div>;
}
export async function getStaticPaths() {};
export async function getStaticProps() {};
`,
`
export default function Page() {
return <div></div>;
}
export async function getServerSideProps() {};
`,
// detect only typo that is one operation away from the correct one
`
export default function Page() {
return <div></div>;
}
export async function getServerSidePropsss() {};
`,
`
export default function Page() {
return <div></div>;
}
export async function getstatisPath() {};
`,
],
invalid: [
{
code: `
export default function Page() {
return <div></div>;
}
export const getStaticpaths = async () => {};
export const getStaticProps = async () => {};
`,
filename: 'pages/index.js',
errors: [
{
message: 'getStaticpaths may be a typo. Did you mean getStaticPaths?',
type: 'ExportNamedDeclaration',
},
],
},
{
code: `
export default function Page() {
return <div></div>;
}
export async function getStaticPathss(){};
export async function getStaticPropss(){};
`,
filename: 'pages/index.js',
errors: [
{
message:
'getStaticPathss may be a typo. Did you mean getStaticPaths?',
type: 'ExportNamedDeclaration',
},
{
message:
'getStaticPropss may be a typo. Did you mean getStaticProps?',
type: 'ExportNamedDeclaration',
},
],
},
{
code: `
export default function Page() {
return <div></div>;
}
export async function getServurSideProps(){};
`,
filename: 'pages/index.js',
errors: [
{
message:
'getServurSideProps may be a typo. Did you mean getServerSideProps?',
type: 'ExportNamedDeclaration',
},
],
},
{
code: `
export default function Page() {
return <div></div>;
}
export const getServurSideProps = () => {};
`,
filename: 'pages/index.js',
errors: [
{
message:
'getServurSideProps may be a typo. Did you mean getServerSideProps?',
type: 'ExportNamedDeclaration',
},
],
},
],
}
describe('no-typos', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1,151 @@
import { RuleTester } from 'eslint'
import { rules } from '@next/eslint-plugin-next'
const NextESLintRule = rules['no-unwanted-polyfillio']
const tests = {
valid: [
`import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script src='https://polyfill.io/v3/polyfill.min.js?features=AbortController'></script>
</div>
);
}
}`,
`import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script src='https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver'></script>
</div>
);
}
}`,
`import Script from 'next/script';
export function MyApp({ Component, pageProps }) {
return (
<div>
<Component {...pageProps} />
<Script src='https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver' />
</div>
);
}`,
`import Script from 'next/script';
export function MyApp({ Component, pageProps }) {
return (
<div>
<Component {...pageProps} />
<Script src='https://polyfill-fastly.io/v3/polyfill.min.js?features=IntersectionObserver' />
</div>
);
}`,
],
invalid: [
{
code: `import {Head} from 'next/document';
export class Blah extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script src='https://polyfill.io/v3/polyfill.min.js?features=WeakSet%2CPromise%2CPromise.prototype.finally%2Ces2015%2Ces5%2Ces6'></script>
</div>
);
}
}`,
errors: [
{
message:
'No duplicate polyfills from Polyfill.io are allowed. WeakSet, Promise, Promise.prototype.finally, es2015, es5, es6 are already shipped with Next.js. See: https://nextjs.org/docs/messages/no-unwanted-polyfillio',
type: 'JSXOpeningElement',
},
],
},
{
code: `
export class Blah {
render() {
return (
<div>
<h1>Hello title</h1>
<script src='https://polyfill.io/v3/polyfill.min.js?features=Array.prototype.copyWithin'></script>
</div>
);
}
}`,
errors: [
{
message:
'No duplicate polyfills from Polyfill.io are allowed. Array.prototype.copyWithin is already shipped with Next.js. See: https://nextjs.org/docs/messages/no-unwanted-polyfillio',
type: 'JSXOpeningElement',
},
],
},
{
code: `import NextScript from 'next/script';
export function MyApp({ Component, pageProps }) {
return (
<div>
<Component {...pageProps} />
<NextScript src='https://polyfill.io/v3/polyfill.min.js?features=Array.prototype.copyWithin' />
</div>
);
}`,
errors: [
{
message:
'No duplicate polyfills from Polyfill.io are allowed. Array.prototype.copyWithin is already shipped with Next.js. See: https://nextjs.org/docs/messages/no-unwanted-polyfillio',
type: 'JSXOpeningElement',
},
],
},
{
code: `import {Head} from 'next/document';
export class ES2019Features extends Head {
render() {
return (
<div>
<h1>Hello title</h1>
<script src='https://polyfill.io/v3/polyfill.min.js?features=Object.fromEntries'></script>
</div>
);
}
}`,
errors: [
{
message:
'No duplicate polyfills from Polyfill.io are allowed. Object.fromEntries is already shipped with Next.js. See: https://nextjs.org/docs/messages/no-unwanted-polyfillio',
},
],
},
],
}
describe('no-unwanted-polyfillio', () => {
new RuleTester({
languageOptions: {
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
jsx: true,
},
},
},
}).run('eslint', NextESLintRule, tests)
})

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1,5 @@
import { notFound } from 'next/navigation'
export default function Default() {
notFound()
}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1 @@
export default () => {}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import { renderToString } from 'react-dom/server'
import * as nextRouter from 'next/router'
import { Foo } from './fixture'
// @ts-expect-error
jest.spyOn(nextRouter, 'useRouter').mockReturnValue({
pathname: 'Hello',
})
test('mock the interpolated modules should work', () => {
expect(renderToString(<Foo />)).toBe(`<div>Hello</div>`)
})

View File

@@ -0,0 +1,8 @@
import React from 'react'
import { useRouter } from 'next/router'
export const Foo = () => {
const router = useRouter()
return <div>{router.pathname}</div>
}

5
test/unit/example.txt Normal file
View File

@@ -0,0 +1,5 @@
describe('{{name}}', () => {
it('should work', async () => {
expect(typeof 'hello').toBe('string')
})
})

View File

@@ -0,0 +1,51 @@
/* eslint-env jest */
import { findConfig } from 'next/dist/lib/find-config'
import { join } from 'path'
const fixtureDir = join(__dirname, 'fixtures')
describe('find config', () => {
it('should resolve rc.json', async () => {
const config = await findConfig(join(fixtureDir, 'config-json'), 'test')
expect(config).toEqual({ foo: 'bar' })
})
it('should resolve rc.js', async () => {
const config = await findConfig(join(fixtureDir, 'config-js'), 'test')
expect(config).toEqual({ foo: 'bar' })
})
it('should resolve .config.json', async () => {
const config = await findConfig(
join(fixtureDir, 'config-long-json'),
'test'
)
expect(config).toEqual({ foo: 'bar' })
})
it('should resolve .config.js', async () => {
const config = await findConfig(join(fixtureDir, 'config-long-js'), 'test')
expect(config).toEqual({ foo: 'bar' })
})
it('should resolve .config.cjs', async () => {
const config = await findConfig(join(fixtureDir, 'config-long-cjs'), 'test')
expect(config).toEqual({ foo: 'bar' })
})
it('should resolve package.json', async () => {
const config = await findConfig(
join(fixtureDir, 'config-package-json'),
'test'
)
expect(config).toEqual({ foo: 'bar' })
})
it('should resolve down', async () => {
const config = await findConfig(
join(fixtureDir, 'config-down/one/two/three/'),
'test'
)
expect(config).toEqual({ foo: 'bar' })
})
})

View File

@@ -0,0 +1,87 @@
/* eslint-env jest */
import {
findPageFile,
createValidFileMatcher,
} from 'next/dist/server/lib/find-page-file'
import { normalizePagePath } from 'next/dist/shared/lib/page-path/normalize-page-path'
import { join } from 'path'
const resolveDataDir = join(__dirname, 'isolated', '_resolvedata')
const dirWithPages = join(resolveDataDir, 'readdir', 'pages')
describe('findPageFile', () => {
it('should work', async () => {
const pagePath = normalizePagePath('/nav/about')
const result = await findPageFile(
dirWithPages,
pagePath,
['jsx', 'js'],
false
)
expect(result).toMatch(/^[\\/]nav[\\/]about\.js/)
})
it('should work with nested index.js', async () => {
const pagePath = normalizePagePath('/nested')
const result = await findPageFile(
dirWithPages,
pagePath,
['jsx', 'js'],
false
)
expect(result).toMatch(/^[\\/]nested[\\/]index\.js/)
})
it('should prefer prefered.js before preferred/index.js', async () => {
const pagePath = normalizePagePath('/prefered')
const result = await findPageFile(
dirWithPages,
pagePath,
['jsx', 'js'],
false
)
expect(result).toMatch(/^[\\/]prefered\.js/)
})
})
describe('createPageFileMatcher', () => {
describe('isAppRouterPage', () => {
const pageExtensions = ['tsx', 'ts', 'jsx', 'js']
const fileMatcher = createValidFileMatcher(pageExtensions, '')
it('should determine either server or client component page file as leaf node page', () => {
expect(fileMatcher.isAppRouterPage('page.js')).toBe(true)
expect(fileMatcher.isAppRouterPage('./page.js')).toBe(true)
expect(fileMatcher.isAppRouterPage('./page.jsx')).toBe(true)
expect(fileMatcher.isAppRouterPage('/page.ts')).toBe(true)
expect(fileMatcher.isAppRouterPage('/path/page.tsx')).toBe(true)
expect(fileMatcher.isAppRouterPage('\\path\\page.tsx')).toBe(true)
expect(fileMatcher.isAppRouterPage('.\\page.jsx')).toBe(true)
expect(fileMatcher.isAppRouterPage('\\page.js')).toBe(true)
})
it('should determine other files under layout routes as non leaf node', () => {
expect(fileMatcher.isAppRouterPage('./not-a-page.js')).toBe(false)
expect(fileMatcher.isAppRouterPage('not-a-page.js')).toBe(false)
expect(fileMatcher.isAppRouterPage('./page.component.jsx')).toBe(false)
expect(fileMatcher.isAppRouterPage('layout.js')).toBe(false)
expect(fileMatcher.isAppRouterPage('page')).toBe(false)
})
})
describe('isMetadataRouteFile', () => {
it('should determine top level metadata routes', () => {
const pageExtensions = ['tsx', 'ts', 'jsx', 'js']
const fileMatcher = createValidFileMatcher(pageExtensions, 'app')
expect(fileMatcher.isMetadataFile('app/route.js')).toBe(false)
expect(fileMatcher.isMetadataFile('app/page.js')).toBe(false)
expect(fileMatcher.isMetadataFile('pages/index.js')).toBe(false)
expect(fileMatcher.isMetadataFile('app/robots.txt')).toBe(true)
expect(fileMatcher.isMetadataFile('app/path/robots.txt')).toBe(false)
expect(fileMatcher.isMetadataFile('app/sitemap.xml')).toBe(true)
expect(fileMatcher.isMetadataFile('app/path/sitemap.xml')).toBe(true)
})
})
})

View File

@@ -0,0 +1 @@
{ "foo": "bar" }

View File

@@ -0,0 +1 @@
module.exports = { foo: 'bar' }

View File

@@ -0,0 +1 @@
{ "foo": "bar" }

View File

@@ -0,0 +1 @@
module.exports = { foo: 'bar' }

View File

@@ -0,0 +1 @@
module.exports = { foo: 'bar' }

View File

@@ -0,0 +1 @@
{ "foo": "bar" }

View File

@@ -0,0 +1,5 @@
{
"test": {
"foo": "bar"
}
}

View File

@@ -0,0 +1,7 @@
export default function Edge() {
return 'edge'
}
export const config = {
runtime: 'experimental-edge',
}

View File

@@ -0,0 +1,5 @@
export { getStaticProps } from '../lib/utils'
export default function ImportGsp({ gsp }) {
return `import-${gsp}`
}

View File

@@ -0,0 +1,9 @@
export default function Fallback() {
return null
}
export async function getStaticProps() {
return {
props: {},
}
}

View File

@@ -0,0 +1,11 @@
export default function Nodejs() {
return 'nodejs'
}
export function getServerSideProps() {
return { props: {} }
}
export const config = {
runtime: 'nodejs',
}

View File

@@ -0,0 +1,7 @@
export default function Nodejs() {
return 'nodejs'
}
export const config = {
runtime: 'nodejs',
}

View File

@@ -0,0 +1,12 @@
export default function Nodejs() {
return 'nodejs'
}
// export an identifier instead of function
export const getServerSideProps = async () => {
return { props: {} }
}
export const config = {
runtime: 'experimental-edge',
}

View File

@@ -0,0 +1,3 @@
export default function Static() {
return 'static'
}

View File

@@ -0,0 +1,5 @@
import React from 'react'
export default function Hello() {
return <div>hello</div>
}

View File

@@ -0,0 +1,54 @@
/* eslint-env jest */
import { getFilesInDir } from 'next/dist/lib/get-files-in-dir'
import { join } from 'path'
import fs from 'fs-extra'
const testDir = join(__dirname, 'get-files-in-dir-test')
const srcDir = join(testDir, 'src')
const setupTestDir = async () => {
const paths = [
'.hidden',
'file',
'folder1/file1',
'folder1/file2',
'link',
'linkfolder',
]
await fs.ensureDir(testDir)
// create src directory structure
await fs.ensureDir(srcDir)
await fs.outputFile(join(srcDir, '.hidden'), 'hidden')
await fs.outputFile(join(srcDir, 'file'), 'file')
await fs.outputFile(join(srcDir, 'folder1', 'file1'), 'file1')
await fs.outputFile(join(srcDir, 'folder1', 'file2'), 'file2')
await fs.ensureSymlink(join(srcDir, 'file'), join(srcDir, 'link'))
await fs.ensureSymlink(join(srcDir, 'link'), join(srcDir, 'link-level-2'))
await fs.ensureSymlink(
join(srcDir, 'link-level-2'),
join(srcDir, 'link-level-3')
)
await fs.ensureSymlink(join(srcDir, 'folder1'), join(srcDir, 'linkfolder'))
return paths
}
describe('getFilesInDir', () => {
if (process.platform === 'win32') {
it('should skip on windows to avoid symlink issues', () => {})
return
}
afterAll(() => fs.remove(testDir))
it('should work', async () => {
await fs.remove(testDir)
await setupTestDir()
expect(await getFilesInDir(srcDir)).toEqual(
new Set(['.hidden', 'file', 'link', 'link-level-2', 'link-level-3'])
)
})
})

View File

@@ -0,0 +1,5 @@
describe('get-project-dir', () => {
it('should not start dev server on require', async () => {
require('next/dist/lib/get-project-dir')
})
})

View File

@@ -0,0 +1,30 @@
/* eslint-env jest */
import { Component } from 'react'
import { getDisplayName } from 'next/dist/shared/lib/utils'
describe('getDisplayName', () => {
it('gets the proper display name of a component', () => {
class ComponentOne extends Component {
render() {
return null
}
}
class ComponentTwo extends Component {
static displayName = 'CustomDisplayName'
render() {
return null
}
}
function FunctionalComponent() {
return null
}
expect(getDisplayName(ComponentOne)).toBe('ComponentOne')
expect(getDisplayName(ComponentTwo)).toBe('CustomDisplayName')
expect(getDisplayName(FunctionalComponent)).toBe('FunctionalComponent')
expect(getDisplayName(() => null)).toBe('Unknown')
expect(getDisplayName('div' as any)).toBe('div')
})
})

View File

@@ -0,0 +1,59 @@
/* eslint-env jest */
// These tests are based on https://github.com/zertosh/htmlescape/blob/3e6cf0614dd0f778fd0131e69070b77282150c15/test/htmlescape-test.js
// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE
import { htmlEscapeJsonString } from 'next/dist/server/htmlescape'
import vm from 'vm'
describe('htmlescape', () => {
test('with angle brackets should escape', () => {
const evilObj = { evil: '<script></script>' }
expect(htmlEscapeJsonString(JSON.stringify(evilObj))).toBe(
'{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}'
)
})
test('with angle brackets should parse back', () => {
const evilObj = { evil: '<script></script>' }
expect(
JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj)))
).toMatchObject(evilObj)
})
test('with ampersands should escape', () => {
const evilObj = { evil: '&' }
expect(htmlEscapeJsonString(JSON.stringify(evilObj))).toBe(
'{"evil":"\\u0026"}'
)
})
test('with ampersands should parse back', () => {
const evilObj = { evil: '&' }
expect(
JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj)))
).toMatchObject(evilObj)
})
test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should escape', () => {
const evilObj = { evil: '\u2028\u2029' }
expect(htmlEscapeJsonString(JSON.stringify(evilObj))).toBe(
'{"evil":"\\u2028\\u2029"}'
)
})
test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should parse back', () => {
const evilObj = { evil: '\u2028\u2029' }
expect(
JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj)))
).toMatchObject(evilObj)
})
test('escaped line terminators should work', () => {
expect(() => {
vm.runInNewContext(
'(' +
htmlEscapeJsonString(JSON.stringify({ evil: '\u2028\u2029' })) +
')'
)
}).not.toThrow()
})
})

View File

@@ -0,0 +1,133 @@
/* eslint-env jest */
import { detectContentType } from 'next/dist/server/image-optimizer'
import { readFile } from 'fs-extra'
import { join } from 'path'
const getImage = (filepath) => readFile(join(__dirname, filepath))
describe.each([false, true])(
'detectContentType with imgOptSkipMetadata: %s',
(imgOptSkipMetadata) => {
it('should return null for empty buffer', async () => {
expect(await detectContentType(Buffer.alloc(0), imgOptSkipMetadata)).toBe(
null
)
})
it('should return null for unrecognized buffer', async () => {
expect(
await detectContentType(
Buffer.from([0xa, 0xb, 0xc]),
imgOptSkipMetadata
)
).toBe(null)
})
it('should return jpg', async () => {
const buffer = await getImage('./images/test.jpg')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/jpeg'
)
})
it('should return png', async () => {
const buffer = await getImage('./images/test.png')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/png'
)
})
it('should return webp', async () => {
const buffer = await getImage('./images/animated.webp')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/webp'
)
})
it('should return svg', async () => {
const buffer = await getImage('./images/test.svg')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/svg+xml'
)
})
it('should return svg for inline svg', async () => {
const buffer = await getImage('./images/test-inline.svg')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/svg+xml'
)
})
it('should return svg when starts with space', async () => {
const buffer = Buffer.from(
' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
)
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/svg+xml'
)
})
it('should return svg when starts with newline', async () => {
const buffer = Buffer.from(
'\n<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
)
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/svg+xml'
)
})
it('should return svg when starts with tab', async () => {
const buffer = Buffer.from(
'\t<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>'
)
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/svg+xml'
)
})
it('should return avif', async () => {
const buffer = await getImage('./images/test.avif')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/avif'
)
})
it('should return icon', async () => {
const buffer = await getImage('./images/test.ico')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/x-icon'
)
})
it('should return icns', async () => {
const buffer = await getImage('./images/test.icns')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/x-icns'
)
})
it('should return jxl', async () => {
const buffer = await getImage('./images/test.jxl')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/jxl'
)
})
it('should return jp2', async () => {
const buffer = await getImage('./images/test.jp2')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/jp2'
)
})
it('should return heic', async () => {
const buffer = await getImage('./images/test.heic')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/heic'
)
})
it('should return pdf', async () => {
const buffer = await getImage('./images/test.pdf')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'application/pdf'
)
})
it('should return tiff', async () => {
const buffer = await getImage('./images/test.tiff')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/tiff'
)
})
it('should return bmp', async () => {
const buffer = await getImage('./images/test.bmp')
expect(await detectContentType(buffer, imgOptSkipMetadata)).toBe(
'image/bmp'
)
})
}
)

View File

@@ -0,0 +1,36 @@
/* eslint-env jest */
import { extractEtag, getImageEtag } from 'next/dist/server/image-optimizer'
import { readFile } from 'fs-extra'
import { join } from 'path'
describe('extractEtag', () => {
it('should return base64url encoded etag if etag is provided', () => {
const etag = 'sample-etag'
const result = extractEtag(etag, Buffer.from(''))
expect(result).toEqual('c2FtcGxlLWV0YWc')
})
it('should not return weak etag identifier if etag is provided', () => {
const etag = 'W/"sample-etag"'
const result = extractEtag(etag, Buffer.from(''))
expect(result).toEqual('Vy8ic2FtcGxlLWV0YWci')
})
it('should call getImageEtag and return its result if etag is null', async () => {
const buffer = await readFile(join(__dirname, './images/test.jpg'))
const res = extractEtag(null, buffer)
expect(res).toBe(getImageEtag(buffer))
})
it('should call getImageEtag and return its result if etag is undefined', async () => {
const buffer = await readFile(join(__dirname, './images/test.jpg'))
const res = extractEtag(undefined, buffer)
expect(res).toBe(getImageEtag(buffer))
})
it('should call getImageEtag and return its result if etag is an empty string', async () => {
const buffer = await readFile(join(__dirname, './images/test.jpg'))
const res = extractEtag('', buffer)
expect(res).toBe(getImageEtag(buffer))
})
})

View File

@@ -0,0 +1,188 @@
/* eslint-env jest */
import {
fetchExternalImage,
ImageError,
} from 'next/dist/server/image-optimizer'
describe('fetchExternalImage', () => {
describe('response size limit', () => {
it('should throw error when response has no body', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
body: null,
headers: {
get: jest.fn(() => null),
},
})
const error = await fetchExternalImage(
'http://example.com/no-body.jpg',
false,
50_000_000
).catch((e) => e)
expect(error).toBeInstanceOf(ImageError)
expect((error as ImageError).statusCode).toBe(400)
expect((error as ImageError).message).toBe(
'"url" parameter is valid but upstream response is invalid'
)
})
it('should throw error when exceeding maximumResponseBody config on later chunk', async () => {
const maximumResponseBody = 2_000 // 2KB custom limit
const chunkSize = 1_000 // 1KB chunks
const numChunks = 3 // 3KB total, exceeds custom 2KB limit
global.fetch = jest.fn().mockImplementation(() => {
let chunksRead = 0
const mockReadableStream = new ReadableStream({
async pull(controller) {
if (chunksRead < numChunks) {
controller.enqueue(new Uint8Array(chunkSize))
chunksRead++
} else {
controller.close()
}
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const error = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
).catch((e) => e)
expect(error).toBeInstanceOf(ImageError)
expect((error as ImageError).statusCode).toBe(413)
expect((error as ImageError).message).toBe(
'"url" parameter is valid but upstream response is invalid'
)
})
it('should throw error when exceeding maximumResponseBody config on first chunk', async () => {
const maximumResponseBody = 2_000 // 2KB custom limit
global.fetch = jest.fn().mockImplementation(() => {
const mockReadableStream = new ReadableStream({
async pull(controller) {
controller.enqueue(new Uint8Array(maximumResponseBody + 1))
controller.close()
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const error = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
).catch((e) => e)
expect(error).toBeInstanceOf(ImageError)
expect((error as ImageError).statusCode).toBe(413)
expect((error as ImageError).message).toBe(
'"url" parameter is valid but upstream response is invalid'
)
})
it('should succeed when exactly matching maximumResponseBody config on first chunk', async () => {
const maximumResponseBody = 3_000 // 3KB custom limit
global.fetch = jest.fn().mockImplementation(() => {
const mockReadableStream = new ReadableStream({
async pull(controller) {
controller.enqueue(new Uint8Array(maximumResponseBody))
controller.close()
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const result = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
)
expect(result.buffer).toBeInstanceOf(Buffer)
expect(result.buffer.length).toBe(maximumResponseBody)
})
it('should succeed when exactly matching maximumResponseBody config on later chunk', async () => {
const maximumResponseBody = 3_000 // 3KB custom limit
const chunkSize = 1_000 // 1KB chunks
const numChunks = 3 // 3KB total
global.fetch = jest.fn().mockImplementation(() => {
let chunksRead = 0
const mockReadableStream = new ReadableStream({
async pull(controller) {
if (chunksRead < numChunks) {
controller.enqueue(new Uint8Array(chunkSize))
chunksRead++
} else {
controller.close()
}
},
})
return Promise.resolve({
ok: true,
status: 200,
body: mockReadableStream,
headers: {
get: jest.fn((header: string) => {
if (header === 'Content-Type') return 'image/jpeg'
return null
}),
},
})
})
const result = await fetchExternalImage(
'http://example.com/custom-limit.jpg',
false,
maximumResponseBody
)
expect(result.buffer).toBeInstanceOf(Buffer)
expect(result.buffer.length).toBe(maximumResponseBody)
})
})
})

View File

@@ -0,0 +1,89 @@
/* eslint-env jest */
import { findClosestQuality } from 'next/dist/shared/lib/find-closest-quality'
describe('findClosestQuality', () => {
it.each<{ input: Parameters<typeof findClosestQuality>; output: number }>([
{
input: [undefined, undefined],
output: 75,
},
{
input: [50, undefined],
output: 50,
},
{
input: [50, { qualities: undefined }],
output: 50,
},
{
input: [35, { qualities: [10, 30, 50] }],
output: 30,
},
{
input: [30, { qualities: [10, 30, 50] }],
output: 30,
},
{
input: [31, { qualities: [10, 30, 50] }],
output: 30,
},
{
input: [29, { qualities: [10, 30, 50] }],
output: 30,
},
{
input: [39, { qualities: [10, 30, 50] }],
output: 30,
},
{
input: [40, { qualities: [10, 30, 50] }],
output: 30, // favor the lower number when halfway
},
{
input: [41, { qualities: [10, 30, 50] }],
output: 50,
},
{
input: [75, { qualities: [50, 75, 100] }],
output: 75,
},
{
input: [undefined, { qualities: [50, 75, 100] }],
output: 75,
},
{
input: [undefined, { qualities: [25, 60, 100] }],
output: 60, // use closest to 75 when 75 is not in the config
},
{
input: [undefined, { qualities: [25, 50, 75] }],
output: 75, // use 75 when 75 is in the config
},
{
input: [undefined, { qualities: [100, 10, 75, 15] }],
output: 75, // use 75 when 75 is in the config, even out of order
},
{
input: [10, { qualities: [100, 10, 75, 15] }],
output: 10, // use input even when config is out of order
},
{
input: [14, { qualities: [100, 10, 75, 15] }],
output: 15, // use closet input even when config is out of order
},
{
input: [1, { qualities: [75] }],
output: 75, // low quality should not return 0
},
{
input: [4, { qualities: [5, 25] }],
output: 5, // ensure low quality still rounds up
},
{
input: [6, { qualities: [5, 25] }],
output: 5, // ensure low quality still rounds down
},
])('for quality $input expected $output', ({ input, output }) => {
expect(findClosestQuality(...input)).toEqual(output)
})
})

View File

@@ -0,0 +1,41 @@
/* eslint-env jest */
import { getMaxAge } from 'next/dist/server/image-optimizer'
describe('getMaxAge', () => {
it('should return 0 when no cache-control provided', () => {
expect(getMaxAge(undefined)).toBe(0)
})
it('should return 0 when cache-control is null', () => {
expect(getMaxAge(null)).toBe(0)
})
it('should return 0 when cache-control is empty string', () => {
expect(getMaxAge('')).toBe(0)
})
it('should return 0 when cache-control max-age is not a number', () => {
expect(getMaxAge('max-age=foo')).toBe(0)
})
it('should return 0 when cache-control is no-cache', () => {
expect(getMaxAge('no-cache')).toBe(0)
})
it('should return cache-control max-age lowercase', () => {
expect(getMaxAge('max-age=9999')).toBe(9999)
})
it('should return cache-control MAX-AGE uppercase', () => {
expect(getMaxAge('MAX-AGE=9999')).toBe(9999)
})
it('should return cache-control s-maxage lowercase', () => {
expect(getMaxAge('s-maxage=9999')).toBe(9999)
})
it('should return cache-control S-MAXAGE', () => {
expect(getMaxAge('S-MAXAGE=9999')).toBe(9999)
})
it('should return cache-control s-maxage with spaces', () => {
expect(getMaxAge('public, max-age=5555, s-maxage=9999')).toBe(9999)
})
it('should return cache-control s-maxage without spaces', () => {
expect(getMaxAge('public,s-maxage=9999,max-age=5555')).toBe(9999)
})
it('should return cache-control for a quoted value', () => {
expect(getMaxAge('public, s-maxage="9999", max-age="5555"')).toBe(9999)
})
})

View File

@@ -0,0 +1,122 @@
/* eslint-env jest */
import {
getPreviouslyCachedImageOrNull,
getImageEtag,
} from 'next/dist/server/image-optimizer'
import {
CachedRouteKind,
IncrementalCacheEntry,
} from 'next/dist/server/response-cache/types'
import { readFile } from 'fs-extra'
import { join } from 'path'
const getImageUpstream = async (filepath, contentType = 'image/jpeg') => {
const buffer = await readFile(join(__dirname, filepath))
const result: Parameters<typeof getPreviouslyCachedImageOrNull>[0] = {
buffer,
contentType,
cacheControl: 'max-age=31536000',
etag: getImageEtag(buffer),
}
return result
}
const baseCacheEntry = {
revalidateAfter: Date.now() + 1000,
curRevalidate: Date.now() + 500,
revalidate: Date.now() + 1000,
isStale: false,
isMiss: false,
isFallback: false,
} as const
const getPreviousCacheEntry = async (
filepath,
extension = 'jpeg',
optimizedEtag = true
) => {
const buffer = await readFile(join(__dirname, filepath))
const upstreamEtag = getImageEtag(buffer)
const result: IncrementalCacheEntry = {
...baseCacheEntry,
value: {
kind: CachedRouteKind.IMAGE,
upstreamEtag,
etag: optimizedEtag ? 'optimized-etag' : upstreamEtag,
buffer,
extension,
},
}
return result
}
describe('shouldUsePreviouslyCachedEntry', () => {
it('should return the cached image if the upstream image matches previous cache entry upstream etag and not the optimized etag', async () => {
const previousEntry = await getPreviousCacheEntry('./images/test.jpg')
expect(
getPreviouslyCachedImageOrNull(
await getImageUpstream('./images/test.jpg'),
previousEntry
)
).toEqual(previousEntry.value)
})
it('should return null if previous cache entry value is not of kind IMAGE', async () => {
const nonImageCacheEntry: IncrementalCacheEntry = {
...baseCacheEntry,
value: { kind: CachedRouteKind.REDIRECT, props: {} },
}
expect(
getPreviouslyCachedImageOrNull(
await getImageUpstream('./images/test.jpg'),
nonImageCacheEntry
)
).toBe(null)
})
it('should return null if upstream image does not match previous cache entry upstream etag', async () => {
expect(
getPreviouslyCachedImageOrNull(
await getImageUpstream('./images/test.png', 'image/png'),
await getPreviousCacheEntry('./images/test.jpg')
)
).toBe(null)
})
it('should return null if upstream image matches optimized etag', async () => {
expect(
getPreviouslyCachedImageOrNull(
await getImageUpstream('./images/test.jpg'),
await getPreviousCacheEntry('./images/test.jpg', 'jpeg', false)
)
).toBe(null)
})
it('should return null if previous cache entry is undefined', async () => {
expect(
getPreviouslyCachedImageOrNull(
await getImageUpstream('./images/test.jpg'),
undefined
)
).toBe(null)
})
it('should return null if previous cache entry is null', async () => {
expect(
getPreviouslyCachedImageOrNull(
await getImageUpstream('./images/test.jpg'),
null
)
).toBe(null)
})
it('should return null if previous cache entry value is null', async () => {
const nullValueCacheEntry = { ...baseCacheEntry, value: null }
expect(
getPreviouslyCachedImageOrNull(
await getImageUpstream('./images/test.jpg'),
nullValueCacheEntry
)
).toBe(null)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,3 @@
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="400" height="400">
<path d="M0 200v200h400V0H0v200zm240.1-11.8 39.6 69.3-39.8.3c-22 .1-57.8.1-79.8 0l-39.8-.3 39.6-69.3c21.7-38 39.8-69.2 40.1-69.2.3 0 18.4 31.2 40.1 69.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Some files were not shown because too many files have changed in this diff Show More