Also create head when creating root layout (#42571)

When creating a root layout automatically in dev - also create a default
head file.

This PR also makes sure more cases are covered when creating root
layouts by trying to find an available dir as close to `
app/` as possible that won't affect other layouts.
## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm build && pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
This commit is contained in:
Hannes Bornö 2022-11-08 18:14:14 +01:00 committed by GitHub
parent 81890c8cfb
commit 25d5d8c2e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 247 additions and 66 deletions

View file

@ -26,7 +26,7 @@ function getRootLayout(isTs: boolean) {
}) {
return (
<html>
<head></head>
<head />
<body>{children}</body>
</html>
)
@ -37,7 +37,7 @@ function getRootLayout(isTs: boolean) {
return `export default function RootLayout({ children }) {
return (
<html>
<head></head>
<head />
<body>{children}</body>
</html>
)
@ -45,6 +45,19 @@ function getRootLayout(isTs: boolean) {
`
}
function getHead() {
return `export default function Head() {
return (
<>
<title></title>
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link rel="icon" href="/favicon.ico" />
</>
)
}
`
}
export async function verifyRootLayout({
dir,
appDir,
@ -63,15 +76,38 @@ export async function verifyRootLayout({
appDir,
`**/layout.{${pageExtensions.join(',')}}`
)
const hasLayout = layoutFiles.length !== 0
const normalizedPagePath = pagePath.replace(`${APP_DIR_ALIAS}/`, '')
const firstSegmentValue = normalizedPagePath.split('/')[0]
const pageRouteGroup = firstSegmentValue.startsWith('(')
? firstSegmentValue
: undefined
const pagePathSegments = normalizedPagePath.split('/')
if (pageRouteGroup || !hasLayout) {
// Find an available dir to place the layout file in, the layout file can't affect any other layout.
// Place the layout as close to app/ as possible.
let availableDir: string | undefined
if (layoutFiles.length === 0) {
// If there's no other layout file we can place the layout file in the app dir.
// However, if the page is within a route group directly under app (e.g. app/(routegroup)/page.js)
// prefer creating the root layout in that route group.
const firstSegmentValue = pagePathSegments[0]
availableDir = firstSegmentValue.startsWith('(') ? firstSegmentValue : ''
} else {
pagePathSegments.pop() // remove the page from segments
let currentSegments: string[] = []
for (const segment of pagePathSegments) {
currentSegments.push(segment)
// Find the dir closest to app/ where a layout can be created without affecting other layouts.
if (
!layoutFiles.some((file) =>
file.startsWith(currentSegments.join('/'))
)
) {
availableDir = currentSegments.join('/')
break
}
}
}
if (typeof availableDir === 'string') {
const resolvedTsConfigPath = path.join(dir, tsconfigPath)
const hasTsConfig = await fs.access(resolvedTsConfigPath).then(
() => true,
@ -80,19 +116,35 @@ export async function verifyRootLayout({
const rootLayoutPath = path.join(
appDir,
// If the page is within a route group directly under app (e.g. app/(routegroup)/page.js)
// prefer creating the root layout in that route group. Otherwise create the root layout in the app root.
pageRouteGroup ? pageRouteGroup : '',
availableDir,
`layout.${hasTsConfig ? 'tsx' : 'js'}`
)
await fs.writeFile(rootLayoutPath, getRootLayout(hasTsConfig))
const headPath = path.join(
appDir,
availableDir,
`head.${hasTsConfig ? 'tsx' : 'js'}`
)
const hasHead = await fs.access(headPath).then(
() => true,
() => false
)
if (!hasHead) {
await fs.writeFile(headPath, getHead())
}
console.log(
chalk.green(
`\nYour page ${chalk.bold(
`app/${normalizedPagePath}`
)} did not have a root layout, we created ${chalk.bold(
`app${rootLayoutPath.replace(appDir, '')}`
)} for you.`
)}${
!hasHead
? ` and ${chalk.bold(`app${headPath.replace(appDir, '')}`)}`
: ''
} for you.`
) + '\n'
)

View file

@ -2,6 +2,7 @@ import path from 'path'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import webdriver from 'next-webdriver'
import { check } from 'next-test-utils'
describe('app-dir create root layout', () => {
const isDev = (global as any).isNextDev
@ -23,9 +24,7 @@ describe('app-dir create root layout', () => {
beforeAll(async () => {
next = await createNext({
files: {
'app/page.js': new FileRef(
path.join(__dirname, 'create-root-layout/app/page.js')
),
app: new FileRef(path.join(__dirname, 'create-root-layout/app')),
'next.config.js': new FileRef(
path.join(__dirname, 'create-root-layout/next.config.js')
),
@ -40,27 +39,44 @@ describe('app-dir create root layout', () => {
it('create root layout', async () => {
const outputIndex = next.cliOutput.length
const browser = await webdriver(next.url, '/')
const browser = await webdriver(next.url, '/route')
expect(await browser.elementById('page-text').text()).toBe(
'Hello world!'
)
expect(next.cliOutput.slice(outputIndex)).toInclude(
'Your page app/page.js did not have a root layout, we created app/layout.js for you.'
await check(
() => next.cliOutput.slice(outputIndex),
/did not have a root layout/
)
expect(next.cliOutput.slice(outputIndex)).toMatch(
'Your page app/route/page.js did not have a root layout, we created app/layout.js and app/head.js for you.'
)
expect(await next.readFile('app/layout.js')).toMatchInlineSnapshot(`
"export default function RootLayout({ children }) {
return (
<html>
<head></head>
<head />
<body>{children}</body>
</html>
)
}
"
`)
expect(await next.readFile('app/head.js')).toMatchInlineSnapshot(`
"export default function Head() {
return (
<>
<title></title>
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
</>
)
}
"
`)
})
})
@ -85,28 +101,113 @@ describe('app-dir create root layout', () => {
it('create root layout', async () => {
const outputIndex = next.cliOutput.length
const browser = await webdriver(next.url, '/path2')
const browser = await webdriver(next.url, '/')
expect(await browser.elementById('page-text').text()).toBe(
'Hello world 2'
'Hello world'
)
await check(
() => next.cliOutput.slice(outputIndex),
/did not have a root layout/
)
expect(next.cliOutput.slice(outputIndex)).toInclude(
'Your page app/(group2)/path2/page.js did not have a root layout, we created app/(group2)/layout.js for you.'
'Your page app/(group)/page.js did not have a root layout, we created app/(group)/layout.js and app/(group)/head.js for you.'
)
expect(await next.readFile('app/(group2)/layout.js'))
expect(await next.readFile('app/(group)/layout.js'))
.toMatchInlineSnapshot(`
"export default function RootLayout({ children }) {
return (
<html>
<head></head>
<head />
<body>{children}</body>
</html>
)
}
"
`)
expect(await next.readFile('app/(group)/head.js'))
.toMatchInlineSnapshot(`
"export default function Head() {
return (
<>
<title></title>
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
</>
)
}
"
`)
})
})
describe('find available dir', () => {
beforeAll(async () => {
next = await createNext({
files: {
app: new FileRef(
path.join(
__dirname,
'create-root-layout/app-find-available-dir'
)
),
'next.config.js': new FileRef(
path.join(__dirname, 'create-root-layout/next.config.js')
),
},
dependencies: {
react: 'experimental',
'react-dom': 'experimental',
},
})
})
afterAll(() => next.destroy())
it('create root layout', async () => {
const outputIndex = next.cliOutput.length
const browser = await webdriver(next.url, '/route/second/inner')
expect(await browser.elementById('page-text').text()).toBe(
'Hello world'
)
await check(
() => next.cliOutput.slice(outputIndex),
/did not have a root layout/
)
expect(next.cliOutput.slice(outputIndex)).toInclude(
'Your page app/(group)/route/second/inner/page.js did not have a root layout, we created app/(group)/route/second/layout.js and app/(group)/route/second/head.js for you.'
)
expect(await next.readFile('app/(group)/route/second/layout.js'))
.toMatchInlineSnapshot(`
"export default function RootLayout({ children }) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}
"
`)
expect(await next.readFile('app/(group)/route/second/head.js'))
.toMatchInlineSnapshot(`
"export default function Head() {
return (
<>
<title></title>
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
</>
)
}
"
`)
})
})
})
@ -116,7 +217,7 @@ describe('app-dir create root layout', () => {
next = await createNext({
files: {
'app/page.tsx': new FileRef(
path.join(__dirname, 'create-root-layout/app/page.js')
path.join(__dirname, 'create-root-layout/app/route/page.js')
),
'next.config.js': new FileRef(
path.join(__dirname, 'create-root-layout/next.config.js')
@ -141,8 +242,12 @@ describe('app-dir create root layout', () => {
'Hello world!'
)
await check(
() => next.cliOutput.slice(outputIndex),
/did not have a root layout/
)
expect(next.cliOutput.slice(outputIndex)).toInclude(
'Your page app/page.tsx did not have a root layout, we created app/layout.tsx for you.'
'Your page app/page.tsx did not have a root layout, we created app/layout.tsx and app/head.tsx for you.'
)
expect(await next.readFile('app/layout.tsx')).toMatchInlineSnapshot(`
@ -153,13 +258,26 @@ describe('app-dir create root layout', () => {
}) {
return (
<html>
<head></head>
<head />
<body>{children}</body>
</html>
)
}
"
`)
expect(await next.readFile('app/head.tsx')).toMatchInlineSnapshot(`
"export default function Head() {
return (
<>
<title></title>
<meta content=\\"width=device-width, initial-scale=1\\" name=\\"viewport\\" />
<link rel=\\"icon\\" href=\\"/favicon.ico\\" />
</>
)
}
"
`)
})
})
} else {
@ -169,7 +287,7 @@ describe('app-dir create root layout', () => {
skipStart: true,
files: {
'app/page.js': new FileRef(
path.join(__dirname, 'create-root-layout/app/page.js')
path.join(__dirname, 'create-root-layout/app/route/page.js')
),
'next.config.js': new FileRef(
path.join(__dirname, 'create-root-layout/next.config.js')

View file

@ -0,0 +1,9 @@
export default function Head() {
return (
<>
<title></title>
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link rel="icon" href="/favicon.ico" />
</>
)
}

View file

@ -1,7 +1,6 @@
export default function RootLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="page-text">Hello world</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="page-text">Hello world</p>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p id="page-text">Hello world</p>
}

View file

@ -1,3 +0,0 @@
export default function Page() {
return <p id="page-text">Hello world 1</p>
}

View file

@ -1,3 +0,0 @@
export default function Page() {
return <p id="page-text">Hello world 2</p>
}