rsnext/packages/next/pages/_document.tsx
JJ Kasper b2d1d87e7f
Add initial changes for i18n support (#17370)
This adds the initial changes outlined in the [i18n routing RFC](https://github.com/vercel/next.js/discussions/17078). This currently treats the locale prefix on routes similar to how the basePath is treated in that the config doesn't require any changes to your pages directory and is automatically stripped/added based on the detected locale that should be used. 

Currently redirecting occurs on the `/` route if a locale is detected regardless of if an optional catch-all route would match the `/` route or not we may want to investigate whether we want to disable this redirection automatically if an `/index.js` file isn't present at root of the pages directory. 

TODO: 

- [x] ensure locale detection/populating works in serverless mode correctly
- [x] add tests for locale handling in different modes, fallback/getStaticProps/getServerSideProps

To be continued in fall-up PRs

- [ ] add tests for revalidate, auto-export, basePath + i18n
- [ ] add mapping of domains with locales
- [ ] investigate detecting locale against non-index routes and populating the locale in a cookie

x-ref: https://github.com/vercel/next.js/issues/17110
2020-10-07 21:11:01 +00:00

779 lines
24 KiB
TypeScript

import PropTypes from 'prop-types'
import React, { Component, ReactNode, useContext } from 'react'
import flush from 'styled-jsx/server'
import {
AMP_RENDER_TARGET,
OPTIMIZED_FONT_PROVIDERS,
} from '../next-server/lib/constants'
import { DocumentContext as DocumentComponentContext } from '../next-server/lib/document-context'
import {
DocumentContext,
DocumentInitialProps,
DocumentProps,
} from '../next-server/lib/utils'
import {
BuildManifest,
getPageFiles,
} from '../next-server/server/get-page-files'
import { cleanAmpPath } from '../next-server/server/utils'
import { htmlEscapeJsonString } from '../server/htmlescape'
export { DocumentContext, DocumentInitialProps, DocumentProps }
export type OriginProps = {
nonce?: string
crossOrigin?: string
}
function dedupe<T extends { file: string }>(bundles: T[]): T[] {
const files = new Set<string>()
const kept: T[] = []
for (const bundle of bundles) {
if (files.has(bundle.file)) continue
files.add(bundle.file)
kept.push(bundle)
}
return kept
}
function getOptionalModernScriptVariant(path: string): string {
if (process.env.__NEXT_MODERN_BUILD) {
return path.replace(/\.js$/, '.module.js')
}
return path
}
type DocumentFiles = {
sharedFiles: readonly string[]
pageFiles: readonly string[]
allFiles: readonly string[]
}
function getDocumentFiles(
buildManifest: BuildManifest,
pathname: string
): DocumentFiles {
const sharedFiles: readonly string[] = getPageFiles(buildManifest, '/_app')
const pageFiles: readonly string[] =
pathname !== '/_error' ? getPageFiles(buildManifest, pathname) : []
return {
sharedFiles,
pageFiles,
allFiles: [...new Set([...sharedFiles, ...pageFiles])],
}
}
/**
* `Document` component handles the initial `document` markup and renders only on the server side.
* Commonly used for implementing server side rendering for `css-in-js` libraries.
*/
export default class Document<P = {}> extends Component<DocumentProps & P> {
static headTagsMiddleware = process.env.__NEXT_PLUGINS
? import(
// @ts-ignore loader syntax
'next-plugin-loader?middleware=document-head-tags-server!'
)
: () => []
/**
* `getInitialProps` hook returns the context object with the addition of `renderPage`.
* `renderPage` callback executes `React` rendering logic synchronously to support server-rendering wrappers
*/
static async getInitialProps(
ctx: DocumentContext
): Promise<DocumentInitialProps> {
const enhanceApp = (App: any) => {
return (props: any) => <App {...props} />
}
const { html, head } = await ctx.renderPage({ enhanceApp })
const styles = [...flush()]
return { html, head, styles }
}
static renderDocument<P>(
DocumentComponent: new () => Document<P>,
props: DocumentProps & P
): React.ReactElement {
return (
<DocumentComponentContext.Provider value={props}>
<DocumentComponent {...props} />
</DocumentComponentContext.Provider>
)
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export function Html(
props: React.DetailedHTMLProps<
React.HtmlHTMLAttributes<HTMLHtmlElement>,
HTMLHtmlElement
>
) {
const { inAmpMode, docComponentsRendered, locale } = useContext(
DocumentComponentContext
)
docComponentsRendered.Html = true
return (
<html
{...props}
lang={props.lang || locale || undefined}
amp={inAmpMode ? '' : undefined}
data-ampdevmode={
inAmpMode && process.env.NODE_ENV !== 'production' ? '' : undefined
}
/>
)
}
export class Head extends Component<
OriginProps &
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLHeadElement>,
HTMLHeadElement
>
> {
static contextType = DocumentComponentContext
static propTypes = {
nonce: PropTypes.string,
crossOrigin: PropTypes.string,
}
context!: React.ContextType<typeof DocumentComponentContext>
getCssLinks(files: DocumentFiles): JSX.Element[] | null {
const {
assetPrefix,
devOnlyCacheBusterQueryString,
dynamicImports,
} = this.context
const cssFiles = files.allFiles.filter((f) => f.endsWith('.css'))
const sharedFiles = new Set(files.sharedFiles)
let dynamicCssFiles = dedupe(
dynamicImports.filter((f) => f.file.endsWith('.css'))
).map((f) => f.file)
if (dynamicCssFiles.length) {
const existing = new Set(cssFiles)
dynamicCssFiles = dynamicCssFiles.filter(
(f) => !(existing.has(f) || sharedFiles.has(f))
)
cssFiles.push(...dynamicCssFiles)
}
const cssLinkElements: JSX.Element[] = []
cssFiles.forEach((file) => {
const isSharedFile = sharedFiles.has(file)
cssLinkElements.push(
<link
key={`${file}-preload`}
nonce={this.props.nonce}
rel="preload"
href={`${assetPrefix}/_next/${encodeURI(
file
)}${devOnlyCacheBusterQueryString}`}
as="style"
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>,
<link
key={file}
nonce={this.props.nonce}
rel="stylesheet"
href={`${assetPrefix}/_next/${encodeURI(
file
)}${devOnlyCacheBusterQueryString}`}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
data-n-g={isSharedFile ? '' : undefined}
data-n-p={isSharedFile ? undefined : ''}
/>
)
})
return cssLinkElements.length === 0 ? null : cssLinkElements
}
getPreloadDynamicChunks() {
const {
dynamicImports,
assetPrefix,
devOnlyCacheBusterQueryString,
} = this.context
return (
dedupe(dynamicImports)
.map((bundle) => {
// `dynamicImports` will contain both `.js` and `.module.js` when the
// feature is enabled. This clause will filter down to the modern
// variants only.
if (!bundle.file.endsWith(getOptionalModernScriptVariant('.js'))) {
return null
}
return (
<link
rel="preload"
key={bundle.file}
href={`${assetPrefix}/_next/${encodeURI(
bundle.file
)}${devOnlyCacheBusterQueryString}`}
as="script"
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
)
})
// Filter out nulled scripts
.filter(Boolean)
)
}
getPreloadMainLinks(files: DocumentFiles): JSX.Element[] | null {
const { assetPrefix, devOnlyCacheBusterQueryString } = this.context
const preloadFiles = files.allFiles.filter((file: string) => {
// `dynamicImports` will contain both `.js` and `.module.js` when
// the feature is enabled. This clause will filter down to the
// modern variants only.
return file.endsWith(getOptionalModernScriptVariant('.js'))
})
return !preloadFiles.length
? null
: preloadFiles.map((file: string) => (
<link
key={file}
nonce={this.props.nonce}
rel="preload"
href={`${assetPrefix}/_next/${encodeURI(
file
)}${devOnlyCacheBusterQueryString}`}
as="script"
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
))
}
makeStylesheetInert(node: ReactNode): ReactNode {
return React.Children.map(node, (c: any) => {
if (
c.type === 'link' &&
c.props['href'] &&
OPTIMIZED_FONT_PROVIDERS.some((url) => c.props['href'].startsWith(url))
) {
const newProps = { ...(c.props || {}) }
newProps['data-href'] = newProps['href']
newProps['href'] = undefined
return React.cloneElement(c, newProps)
} else if (c.props && c.props['children']) {
c.props['children'] = this.makeStylesheetInert(c.props['children'])
}
return c
})
}
render() {
const {
styles,
ampPath,
inAmpMode,
hybridAmp,
canonicalBase,
__NEXT_DATA__,
dangerousAsPath,
headTags,
unstable_runtimeJS,
} = this.context
const disableRuntimeJS = unstable_runtimeJS === false
this.context.docComponentsRendered.Head = true
let { head } = this.context
let children = this.props.children
// show a warning if Head contains <title> (only in development)
if (process.env.NODE_ENV !== 'production') {
children = React.Children.map(children, (child: any) => {
const isReactHelmet = child?.props?.['data-react-helmet']
if (!isReactHelmet) {
if (child?.type === 'title') {
console.warn(
"Warning: <title> should not be used in _document.js's <Head>. https://err.sh/next.js/no-document-title"
)
} else if (
child?.type === 'meta' &&
child?.props?.name === 'viewport'
) {
console.warn(
"Warning: viewport meta tags should not be used in _document.js's <Head>. https://err.sh/next.js/no-document-viewport-meta"
)
}
}
return child
})
if (this.props.crossOrigin)
console.warn(
'Warning: `Head` attribute `crossOrigin` is deprecated. https://err.sh/next.js/doc-crossorigin-deprecated'
)
}
if (process.env.__NEXT_OPTIMIZE_FONTS && !inAmpMode) {
children = this.makeStylesheetInert(children)
}
let hasAmphtmlRel = false
let hasCanonicalRel = false
// show warning and remove conflicting amp head tags
head = React.Children.map(head || [], (child) => {
if (!child) return child
const { type, props } = child
if (inAmpMode) {
let badProp: string = ''
if (type === 'meta' && props.name === 'viewport') {
badProp = 'name="viewport"'
} else if (type === 'link' && props.rel === 'canonical') {
hasCanonicalRel = true
} else if (type === 'script') {
// only block if
// 1. it has a src and isn't pointing to ampproject's CDN
// 2. it is using dangerouslySetInnerHTML without a type or
// a type of text/javascript
if (
(props.src && props.src.indexOf('ampproject') < -1) ||
(props.dangerouslySetInnerHTML &&
(!props.type || props.type === 'text/javascript'))
) {
badProp = '<script'
Object.keys(props).forEach((prop) => {
badProp += ` ${prop}="${props[prop]}"`
})
badProp += '/>'
}
}
if (badProp) {
console.warn(
`Found conflicting amp tag "${child.type}" with conflicting prop ${badProp} in ${__NEXT_DATA__.page}. https://err.sh/next.js/conflicting-amp-tag`
)
return null
}
} else {
// non-amp mode
if (type === 'link' && props.rel === 'amphtml') {
hasAmphtmlRel = true
}
}
return child
})
// try to parse styles from fragment for backwards compat
const curStyles: React.ReactElement[] = Array.isArray(styles)
? (styles as React.ReactElement[])
: []
if (
inAmpMode &&
styles &&
// @ts-ignore Property 'props' does not exist on type ReactElement
styles.props &&
// @ts-ignore Property 'props' does not exist on type ReactElement
Array.isArray(styles.props.children)
) {
const hasStyles = (el: React.ReactElement) =>
el?.props?.dangerouslySetInnerHTML?.__html
// @ts-ignore Property 'props' does not exist on type ReactElement
styles.props.children.forEach((child: React.ReactElement) => {
if (Array.isArray(child)) {
child.forEach((el) => hasStyles(el) && curStyles.push(el))
} else if (hasStyles(child)) {
curStyles.push(child)
}
})
}
const files: DocumentFiles = getDocumentFiles(
this.context.buildManifest,
this.context.__NEXT_DATA__.page
)
return (
<head {...this.props}>
{this.context.isDevelopment && (
<>
<style
data-next-hide-fouc
data-ampdevmode={inAmpMode ? 'true' : undefined}
dangerouslySetInnerHTML={{
__html: `body{display:none}`,
}}
/>
<noscript
data-next-hide-fouc
data-ampdevmode={inAmpMode ? 'true' : undefined}
>
<style
dangerouslySetInnerHTML={{
__html: `body{display:block}`,
}}
/>
</noscript>
</>
)}
{children}
{head}
{inAmpMode && (
<>
<meta
name="viewport"
content="width=device-width,minimum-scale=1,initial-scale=1"
/>
{!hasCanonicalRel && (
<link
rel="canonical"
href={canonicalBase + cleanAmpPath(dangerousAsPath)}
/>
)}
{/* https://www.ampproject.org/docs/fundamentals/optimize_amp#optimize-the-amp-runtime-loading */}
<link
rel="preload"
as="script"
href="https://cdn.ampproject.org/v0.js"
/>
{/* Add custom styles before AMP styles to prevent accidental overrides */}
{styles && (
<style
amp-custom=""
dangerouslySetInnerHTML={{
__html: curStyles
.map((style) => style.props.dangerouslySetInnerHTML.__html)
.join('')
.replace(/\/\*# sourceMappingURL=.*\*\//g, '')
.replace(/\/\*@ sourceURL=.*?\*\//g, ''),
}}
/>
)}
<style
amp-boilerplate=""
dangerouslySetInnerHTML={{
__html: `body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}`,
}}
/>
<noscript>
<style
amp-boilerplate=""
dangerouslySetInnerHTML={{
__html: `body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}`,
}}
/>
</noscript>
<script async src="https://cdn.ampproject.org/v0.js" />
</>
)}
{!inAmpMode && (
<>
{!hasAmphtmlRel && hybridAmp && (
<link
rel="amphtml"
href={canonicalBase + getAmpPath(ampPath, dangerousAsPath)}
/>
)}
{process.env.__NEXT_OPTIMIZE_FONTS
? this.makeStylesheetInert(this.getCssLinks(files))
: this.getCssLinks(files)}
<noscript data-n-css />
{!disableRuntimeJS && this.getPreloadDynamicChunks()}
{!disableRuntimeJS && this.getPreloadMainLinks(files)}
{this.context.isDevelopment && (
// this element is used to mount development styles so the
// ordering matches production
// (by default, style-loader injects at the bottom of <head />)
<noscript id="__next_css__DO_NOT_USE__" />
)}
{styles || null}
</>
)}
{React.createElement(React.Fragment, {}, ...(headTags || []))}
</head>
)
}
}
export function Main() {
const { inAmpMode, html, docComponentsRendered } = useContext(
DocumentComponentContext
)
docComponentsRendered.Main = true
if (inAmpMode) return <>{AMP_RENDER_TARGET}</>
return <div id="__next" dangerouslySetInnerHTML={{ __html: html }} />
}
export class NextScript extends Component<OriginProps> {
static contextType = DocumentComponentContext
static propTypes = {
nonce: PropTypes.string,
crossOrigin: PropTypes.string,
}
context!: React.ContextType<typeof DocumentComponentContext>
// Source: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
static safariNomoduleFix =
'!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();'
getDynamicChunks(files: DocumentFiles) {
const {
dynamicImports,
assetPrefix,
isDevelopment,
devOnlyCacheBusterQueryString,
} = this.context
return dedupe(dynamicImports).map((bundle) => {
let modernProps = {}
if (process.env.__NEXT_MODERN_BUILD) {
modernProps = bundle.file.endsWith('.module.js')
? { type: 'module' }
: { noModule: true }
}
if (!bundle.file.endsWith('.js') || files.allFiles.includes(bundle.file))
return null
return (
<script
async={!isDevelopment}
key={bundle.file}
src={`${assetPrefix}/_next/${encodeURI(
bundle.file
)}${devOnlyCacheBusterQueryString}`}
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
{...modernProps}
/>
)
})
}
getScripts(files: DocumentFiles) {
const {
assetPrefix,
buildManifest,
isDevelopment,
devOnlyCacheBusterQueryString,
} = this.context
const normalScripts = files.allFiles.filter((file) => file.endsWith('.js'))
const lowPriorityScripts = buildManifest.lowPriorityFiles?.filter((file) =>
file.endsWith('.js')
)
return [...normalScripts, ...lowPriorityScripts].map((file) => {
let modernProps = {}
if (process.env.__NEXT_MODERN_BUILD) {
modernProps = file.endsWith('.module.js')
? { type: 'module' }
: { noModule: true }
}
return (
<script
key={file}
src={`${assetPrefix}/_next/${encodeURI(
file
)}${devOnlyCacheBusterQueryString}`}
nonce={this.props.nonce}
async={!isDevelopment}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
{...modernProps}
/>
)
})
}
getPolyfillScripts() {
// polyfills.js has to be rendered as nomodule without async
// It also has to be the first script to load
const {
assetPrefix,
buildManifest,
devOnlyCacheBusterQueryString,
} = this.context
return buildManifest.polyfillFiles
.filter(
(polyfill) =>
polyfill.endsWith('.js') && !polyfill.endsWith('.module.js')
)
.map((polyfill) => (
<script
key={polyfill}
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
noModule={true}
src={`${assetPrefix}/_next/${polyfill}${devOnlyCacheBusterQueryString}`}
/>
))
}
static getInlineScriptSource(documentProps: DocumentProps): string {
const { __NEXT_DATA__ } = documentProps
try {
const data = JSON.stringify(__NEXT_DATA__)
return htmlEscapeJsonString(data)
} catch (err) {
if (err.message.indexOf('circular structure')) {
throw new Error(
`Circular structure in "getInitialProps" result of page "${__NEXT_DATA__.page}". https://err.sh/vercel/next.js/circular-structure`
)
}
throw err
}
}
render() {
const {
assetPrefix,
inAmpMode,
buildManifest,
unstable_runtimeJS,
docComponentsRendered,
devOnlyCacheBusterQueryString,
} = this.context
const disableRuntimeJS = unstable_runtimeJS === false
docComponentsRendered.NextScript = true
if (inAmpMode) {
if (process.env.NODE_ENV === 'production') {
return null
}
const ampDevFiles = [
...buildManifest.devFiles,
...buildManifest.ampDevFiles,
]
return (
<>
{disableRuntimeJS ? null : (
<script
id="__NEXT_DATA__"
type="application/json"
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
dangerouslySetInnerHTML={{
__html: NextScript.getInlineScriptSource(this.context),
}}
data-ampdevmode
/>
)}
{ampDevFiles.map((file) => (
<script
key={file}
src={`${assetPrefix}/_next/${file}${devOnlyCacheBusterQueryString}`}
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
data-ampdevmode
/>
))}
</>
)
}
if (process.env.NODE_ENV !== 'production') {
if (this.props.crossOrigin)
console.warn(
'Warning: `NextScript` attribute `crossOrigin` is deprecated. https://err.sh/next.js/doc-crossorigin-deprecated'
)
}
const files: DocumentFiles = getDocumentFiles(
this.context.buildManifest,
this.context.__NEXT_DATA__.page
)
return (
<>
{!disableRuntimeJS && buildManifest.devFiles
? buildManifest.devFiles.map((file: string) => (
<script
key={file}
src={`${assetPrefix}/_next/${encodeURI(
file
)}${devOnlyCacheBusterQueryString}`}
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
))
: null}
{disableRuntimeJS ? null : (
<script
id="__NEXT_DATA__"
type="application/json"
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
dangerouslySetInnerHTML={{
__html: NextScript.getInlineScriptSource(this.context),
}}
/>
)}
{process.env.__NEXT_MODERN_BUILD && !disableRuntimeJS ? (
<script
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
noModule={true}
dangerouslySetInnerHTML={{
__html: NextScript.safariNomoduleFix,
}}
/>
) : null}
{!disableRuntimeJS && this.getPolyfillScripts()}
{disableRuntimeJS ? null : this.getDynamicChunks(files)}
{disableRuntimeJS ? null : this.getScripts(files)}
</>
)
}
}
function getAmpPath(ampPath: string, asPath: string): string {
return ampPath || `${asPath}${asPath.includes('?') ? '&' : '?'}amp=1`
}