1
0
Fork 0
mirror of https://github.com/terribleplan/next.js.git synced 2024-01-19 02:48:18 +00:00

[experimental] Rendering to AMP (#6218)

* Add initial AMP implementation

* Implement experimental feature flag

* Implement feedback from sbenz

* Add next/amp and `useAmp` hook

* Use /:path*/amp instead

* Add canonical

* Add amphtml tag

* Add ampEnabled for rel=“amphtml”

* Remove extra type
This commit is contained in:
Tim Neutkens 2019-02-14 16:22:57 +01:00 committed by Joe Haddad
parent 36946f9709
commit 4051ffcb01
18 changed files with 447 additions and 122 deletions

View file

@ -60,6 +60,7 @@
"@zeit/next-css": "1.0.2-canary.2", "@zeit/next-css": "1.0.2-canary.2",
"@zeit/next-sass": "1.0.2-canary.2", "@zeit/next-sass": "1.0.2-canary.2",
"@zeit/next-typescript": "1.1.2-canary.0", "@zeit/next-typescript": "1.1.2-canary.0",
"amphtml-validator": "1.0.23",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"babel-eslint": "9.0.0", "babel-eslint": "9.0.0",
"babel-jest": "23.6.0", "babel-jest": "23.6.0",
@ -83,8 +84,8 @@
"node-sass": "4.9.2", "node-sass": "4.9.2",
"pre-commit": "1.2.2", "pre-commit": "1.2.2",
"prettier": "1.15.3", "prettier": "1.15.3",
"react": "16.6.3", "react": "16.8.0",
"react-dom": "16.6.3", "react-dom": "16.8.0",
"release": "5.0.3", "release": "5.0.3",
"request-promise-core": "1.1.1", "request-promise-core": "1.1.1",
"rimraf": "2.6.2", "rimraf": "2.6.2",

View file

@ -0,0 +1 @@
module.exports = require('./dist/lib/amp')

View file

@ -0,0 +1,6 @@
import React from 'react'
import {IsAmpContext} from './amphtml-context'
export function useAmp() {
return React.useContext(IsAmpContext)
}

View file

@ -0,0 +1,3 @@
import * as React from 'react'
export const IsAmpContext: React.Context<any> = React.createContext(false)

View file

@ -12,7 +12,8 @@
"head.js", "head.js",
"link.js", "link.js",
"router.js", "router.js",
"next-config.js" "next-config.js",
"amp.js"
], ],
"scripts": { "scripts": {
"build": "taskr", "build": "taskr",

View file

@ -21,6 +21,9 @@ const defaultConfig = {
websocketPort: 0, websocketPort: 0,
websocketProxyPath: '/', websocketProxyPath: '/',
websocketProxyPort: null websocketProxyPort: null
},
experimental: {
amp: false
} }
} }
@ -47,6 +50,12 @@ export default function loadConfig (phase, dir, customConfig) {
if (userConfig.target && !targets.includes(userConfig.target)) { if (userConfig.target && !targets.includes(userConfig.target)) {
throw new Error(`Specified target is invalid. Provided: "${userConfig.target}" should be one of ${targets.join(', ')}`) throw new Error(`Specified target is invalid. Provided: "${userConfig.target}" should be one of ${targets.join(', ')}`)
} }
if (userConfig.experimental) {
userConfig.experimental = {
...defaultConfig.experimental,
...userConfig.experimental
}
}
if (userConfig.onDemandEntries) { if (userConfig.onDemandEntries) {
userConfig.onDemandEntries = { userConfig.onDemandEntries = {
...defaultConfig.onDemandEntries, ...defaultConfig.onDemandEntries,

View file

@ -30,6 +30,7 @@ export default class Server {
distDir: string distDir: string
buildId: string buildId: string
renderOpts: { renderOpts: {
ampEnabled: boolean,
staticMarkup: boolean, staticMarkup: boolean,
buildId: string, buildId: string,
generateEtags: boolean, generateEtags: boolean,
@ -53,6 +54,7 @@ export default class Server {
this.buildId = this.readBuildId() this.buildId = this.readBuildId()
this.renderOpts = { this.renderOpts = {
ampEnabled: this.nextConfig.experimental.amp,
staticMarkup, staticMarkup,
buildId: this.buildId, buildId: this.buildId,
generateEtags, generateEtags,
@ -160,6 +162,29 @@ export default class Server {
] ]
if (this.nextConfig.useFileSystemPublicRoutes) { if (this.nextConfig.useFileSystemPublicRoutes) {
if (this.nextConfig.experimental.amp) {
// It's very important to keep this route's param optional.
// (but it should support as many params as needed, separated by '/')
// Otherwise this will lead to a pretty simple DOS attack.
// See more: https://github.com/zeit/next.js/issues/2617
routes.push({
match: route('/:path*/amp'),
fn: async (req, res, params, parsedUrl) => {
let pathname
if (!params.path) {
pathname = '/'
} else {
pathname = '/' + params.path.join('/')
}
const { query } = parsedUrl
if (!pathname) {
throw new Error('pathname is undefined')
}
await this.renderToAMP(req, res, pathname, query, parsedUrl)
},
})
}
// It's very important to keep this route's param optional. // It's very important to keep this route's param optional.
// (but it should support as many params as needed, separated by '/') // (but it should support as many params as needed, separated by '/')
// Otherwise this will lead to a pretty simple DOS attack. // Otherwise this will lead to a pretty simple DOS attack.
@ -229,15 +254,47 @@ export default class Server {
return this.sendHTML(req, res, html) return this.sendHTML(req, res, html)
} }
public async renderToAMP(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}, parsedUrl?: UrlWithParsedQuery): Promise<void> {
if (!this.nextConfig.experimental.amp) {
throw new Error('"experimental.amp" is not enabled in "next.config.js"')
}
const url: any = req.url
if (isInternalUrl(url)) {
return this.handleRequest(req, res, parsedUrl)
}
if (isBlockedPage(pathname)) {
return this.render404(req, res, parsedUrl)
}
const html = await this.renderToAMPHTML(req, res, pathname, query)
// Request was ended by the user
if (html === null) {
return
}
if (this.nextConfig.poweredByHeader) {
res.setHeader('X-Powered-By', 'Next.js ' + process.env.NEXT_VERSION)
}
return this.sendHTML(req, res, html)
}
private async renderToHTMLWithComponents(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}, opts: any) { private async renderToHTMLWithComponents(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}, opts: any) {
const result = await loadComponents(this.distDir, this.buildId, pathname) const result = await loadComponents(this.distDir, this.buildId, pathname)
return renderToHTML(req, res, pathname, query, {...result, ...opts}) return renderToHTML(req, res, pathname, query, {...result, ...opts})
} }
public async renderToHTML(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}): Promise<string|null> { public async renderToAMPHTML(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}): Promise<string|null> {
if (!this.nextConfig.experimental.amp) {
throw new Error('"experimental.amp" is not enabled in "next.config.js"')
}
return this.renderToHTML(req, res, pathname, query, {amphtml: true})
}
public async renderToHTML(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}, {amphtml}: {amphtml?: boolean} = {}): Promise<string|null> {
try { try {
// To make sure the try/catch is executed // To make sure the try/catch is executed
const html = await this.renderToHTMLWithComponents(req, res, pathname, query, this.renderOpts) const html = await this.renderToHTMLWithComponents(req, res, pathname, query, {...this.renderOpts, amphtml})
return html return html
} catch (err) { } catch (err) {
if (err.code === 'ENOENT') { if (err.code === 'ENOENT') {

View file

@ -1,4 +1,4 @@
import {IncomingMessage, ServerResponse} from 'http' import { IncomingMessage, ServerResponse } from 'http'
import { ParsedUrlQuery } from 'querystring' import { ParsedUrlQuery } from 'querystring'
import React from 'react' import React from 'react'
import { renderToString, renderToStaticMarkup } from 'react-dom/server' import { renderToString, renderToStaticMarkup } from 'react-dom/server'
@ -7,15 +7,26 @@ import { loadGetInitialProps, isResSent } from '../lib/utils'
import Head, { defaultHead } from '../lib/head' import Head, { defaultHead } from '../lib/head'
import Loadable from '../lib/loadable' import Loadable from '../lib/loadable'
import LoadableCapture from '../lib/loadable-capture' import LoadableCapture from '../lib/loadable-capture'
import {getDynamicImportBundles, Manifest as ReactLoadableManifest, ManifestItem} from './get-dynamic-import-bundles' import {
import {getPageFiles, BuildManifest} from './get-page-files' getDynamicImportBundles,
Manifest as ReactLoadableManifest,
ManifestItem,
} from './get-dynamic-import-bundles'
import { getPageFiles, BuildManifest } from './get-page-files'
import { IsAmpContext } from '../lib/amphtml-context'
type Enhancer = (Component: React.ComponentType) => React.ComponentType type Enhancer = (Component: React.ComponentType) => React.ComponentType
type ComponentsEnhancer = {enhanceApp?: Enhancer, enhanceComponent?: Enhancer}|Enhancer type ComponentsEnhancer =
| { enhanceApp?: Enhancer; enhanceComponent?: Enhancer }
| Enhancer
function enhanceComponents(options: ComponentsEnhancer, App: React.ComponentType, Component: React.ComponentType): { function enhanceComponents(
options: ComponentsEnhancer,
App: React.ComponentType, App: React.ComponentType,
Component: React.ComponentType, Component: React.ComponentType,
): {
App: React.ComponentType
Component: React.ComponentType,
} { } {
// For backwards compatibility // For backwards compatibility
if (typeof options === 'function') { if (typeof options === 'function') {
@ -27,11 +38,16 @@ function enhanceComponents(options: ComponentsEnhancer, App: React.ComponentType
return { return {
App: options.enhanceApp ? options.enhanceApp(App) : App, App: options.enhanceApp ? options.enhanceApp(App) : App,
Component: options.enhanceComponent ? options.enhanceComponent(Component) : Component, Component: options.enhanceComponent
? options.enhanceComponent(Component)
: Component,
} }
} }
function render(renderElementToString: (element: React.ReactElement<any>) => string, element: React.ReactElement<any>): {html: string, head: any} { function render(
renderElementToString: (element: React.ReactElement<any>) => string,
element: React.ReactElement<any>,
): { html: string; head: any } {
let html let html
let head let head
@ -45,75 +61,101 @@ function render(renderElementToString: (element: React.ReactElement<any>) => str
} }
type RenderOpts = { type RenderOpts = {
staticMarkup: boolean, ampEnabled: boolean
buildId: string, staticMarkup: boolean
runtimeConfig?: {[key: string]: any}, buildId: string
assetPrefix?: string, runtimeConfig?: { [key: string]: any }
err?: Error|null, assetPrefix?: string
nextExport?: boolean, err?: Error | null
dev?: boolean, nextExport?: boolean
buildManifest: BuildManifest, dev?: boolean
reactLoadableManifest: ReactLoadableManifest, amphtml?: boolean
Component: React.ComponentType, buildManifest: BuildManifest
Document: React.ComponentType, reactLoadableManifest: ReactLoadableManifest
App: React.ComponentType, Component: React.ComponentType
ErrorDebug?: React.ComponentType<{error: Error}>, Document: React.ComponentType
App: React.ComponentType
ErrorDebug?: React.ComponentType<{ error: Error }>,
} }
function renderDocument(Document: React.ComponentType, { function renderDocument(
props, Document: React.ComponentType,
docProps, {
pathname, ampEnabled = false,
query, props,
buildId, docProps,
assetPrefix, pathname,
runtimeConfig, asPath,
nextExport, query,
dynamicImportsIds, buildId,
err, assetPrefix,
dev, runtimeConfig,
staticMarkup, nextExport,
devFiles, dynamicImportsIds,
files, err,
dynamicImports, dev,
}: RenderOpts & { amphtml,
props: any, staticMarkup,
docProps: any, devFiles,
pathname: string, files,
query: ParsedUrlQuery, dynamicImports,
dynamicImportsIds: string[], }: RenderOpts & {
dynamicImports: ManifestItem[], props: any
files: string[] docProps: any
devFiles: string[], pathname: string
}): string { asPath: string | undefined
return '<!DOCTYPE html>' + renderToStaticMarkup( query: ParsedUrlQuery
<Document amphtml: boolean
__NEXT_DATA__={{ dynamicImportsIds: string[]
props, // The result of getInitialProps dynamicImports: ManifestItem[]
page: pathname, // The rendered page files: string[]
query, // querystring parsed / passed by the user devFiles: string[],
buildId, // buildId is used to facilitate caching of page bundles, we send it to the client so that pageloader knows where to load bundles },
assetPrefix: assetPrefix === '' ? undefined : assetPrefix, // send assetPrefix to the client side when configured, otherwise don't sent in the resulting HTML ): string {
runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML return (
nextExport, // If this is a page exported by `next export` '<!DOCTYPE html>' +
dynamicIds: dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds, renderToStaticMarkup(
err: (err) ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML <IsAmpContext.Provider value={amphtml}>
}} <Document
staticMarkup={staticMarkup} __NEXT_DATA__={{
devFiles={devFiles} props, // The result of getInitialProps
files={files} page: pathname, // The rendered page
dynamicImports={dynamicImports} query, // querystring parsed / passed by the user
assetPrefix={assetPrefix} buildId, // buildId is used to facilitate caching of page bundles, we send it to the client so that pageloader knows where to load bundles
{...docProps} assetPrefix: assetPrefix === '' ? undefined : assetPrefix, // send assetPrefix to the client side when configured, otherwise don't sent in the resulting HTML
/>, runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML
nextExport, // If this is a page exported by `next export`
dynamicIds:
dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
err: err ? serializeError(dev, err) : undefined, // Error if one happened, otherwise don't sent in the resulting HTML
}}
ampEnabled={ampEnabled}
asPath={encodeURI(asPath || '')}
amphtml={amphtml}
staticMarkup={staticMarkup}
devFiles={devFiles}
files={files}
dynamicImports={dynamicImports}
assetPrefix={assetPrefix}
{...docProps}
/>
</IsAmpContext.Provider>,
)
) )
} }
export async function renderToHTML(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery, renderOpts: RenderOpts): Promise<string|null> { export async function renderToHTML(
req: IncomingMessage,
res: ServerResponse,
pathname: string,
query: ParsedUrlQuery,
renderOpts: RenderOpts,
): Promise<string | null> {
const { const {
err, err,
dev = false, dev = false,
staticMarkup = false, staticMarkup = false,
amphtml = false,
App, App,
Document, Document,
Component, Component,
@ -127,22 +169,28 @@ export async function renderToHTML(req: IncomingMessage, res: ServerResponse, pa
if (dev) { if (dev) {
const { isValidElementType } = require('react-is') const { isValidElementType } = require('react-is')
if (!isValidElementType(Component)) { if (!isValidElementType(Component)) {
throw new Error(`The default export is not a React Component in page: "${pathname}"`) throw new Error(
`The default export is not a React Component in page: "${pathname}"`,
)
} }
if (!isValidElementType(App)) { if (!isValidElementType(App)) {
throw new Error(`The default export is not a React Component in page: "/_app"`) throw new Error(
`The default export is not a React Component in page: "/_app"`,
)
} }
if (!isValidElementType(Document)) { if (!isValidElementType(Document)) {
throw new Error(`The default export is not a React Component in page: "/_document"`) throw new Error(
`The default export is not a React Component in page: "/_document"`,
)
} }
} }
const asPath = req.url const asPath = req.url
const ctx = { err, req, res, pathname, query, asPath } const ctx = { err, req, res, pathname, query, asPath }
const router = new Router(pathname, query, asPath) const router = new Router(pathname, query, asPath)
const props = await loadGetInitialProps(App, {Component, router, ctx}) const props = await loadGetInitialProps(App, { Component, router, ctx })
// the response might be finished on the getInitialProps call // the response might be finished on the getInitialProps call
if (isResSent(res)) return null if (isResSent(res)) return null
@ -156,23 +204,35 @@ export async function renderToHTML(req: IncomingMessage, res: ServerResponse, pa
] ]
const reactLoadableModules: string[] = [] const reactLoadableModules: string[] = []
const renderPage = (options: ComponentsEnhancer = {}): {html: string, head: any} => { const renderPage = (
const renderElementToString = staticMarkup ? renderToStaticMarkup : renderToString options: ComponentsEnhancer = {},
): { html: string; head: any } => {
const renderElementToString = staticMarkup
? renderToStaticMarkup
: renderToString
if (err && ErrorDebug) { if (err && ErrorDebug) {
return render(renderElementToString, <ErrorDebug error={err} />) return render(renderElementToString, <ErrorDebug error={err} />)
} }
const {App: EnhancedApp, Component: EnhancedComponent} = enhanceComponents(options, App, Component) const {
App: EnhancedApp,
Component: EnhancedComponent,
} = enhanceComponents(options, App, Component)
return render(renderElementToString, return render(
<LoadableCapture report={(moduleName) => reactLoadableModules.push(moduleName)}> renderElementToString,
<EnhancedApp <IsAmpContext.Provider value={amphtml}>
Component={EnhancedComponent} <LoadableCapture
router={router} report={(moduleName) => reactLoadableModules.push(moduleName)}
{...props} >
/> <EnhancedApp
</LoadableCapture>, Component={EnhancedComponent}
router={router}
{...props}
/>
</LoadableCapture>
</IsAmpContext.Provider>,
) )
} }
@ -180,14 +240,18 @@ export async function renderToHTML(req: IncomingMessage, res: ServerResponse, pa
// the response might be finished on the getInitialProps call // the response might be finished on the getInitialProps call
if (isResSent(res)) return null if (isResSent(res)) return null
const dynamicImports = [...getDynamicImportBundles(reactLoadableManifest, reactLoadableModules)] const dynamicImports = [
...getDynamicImportBundles(reactLoadableManifest, reactLoadableModules),
]
const dynamicImportsIds: any = dynamicImports.map((bundle) => bundle.id) const dynamicImportsIds: any = dynamicImports.map((bundle) => bundle.id)
return renderDocument(Document, { return renderDocument(Document, {
...renderOpts, ...renderOpts,
props, props,
docProps, docProps,
asPath,
pathname, pathname,
amphtml,
query, query,
dynamicImportsIds, dynamicImportsIds,
dynamicImports, dynamicImports,
@ -201,10 +265,17 @@ function errorToJSON(err: Error): Error {
return { name, message, stack } return { name, message, stack }
} }
function serializeError(dev: boolean|undefined, err: Error): Error & {statusCode?: number} { function serializeError(
dev: boolean | undefined,
err: Error,
): Error & { statusCode?: number } {
if (dev) { if (dev) {
return errorToJSON(err) return errorToJSON(err)
} }
return { name: 'Internal Server Error.', message: '500 - Internal Server Error.', statusCode: 500 } return {
name: 'Internal Server Error.',
message: '500 - Internal Server Error.',
statusCode: 500,
}
} }

View file

@ -4,7 +4,7 @@ import pathMatch from './lib/path-match'
export const route = pathMatch() export const route = pathMatch()
type Params = {[param: string]: string} type Params = {[param: string]: any}
export type Route = { export type Route = {
match: (pathname: string|undefined) => false|Params, match: (pathname: string|undefined) => false|Params,

1
packages/next/amp.js Normal file
View file

@ -0,0 +1 @@
module.exports = require('next-server/amp')

View file

@ -19,7 +19,8 @@
"error.js", "error.js",
"head.js", "head.js",
"link.js", "link.js",
"router.js" "router.js",
"amp.js"
], ],
"bin": { "bin": {
"next": "./dist/bin/next" "next": "./dist/bin/next"

View file

@ -4,10 +4,6 @@ import PropTypes from 'prop-types'
import {htmlEscapeJsonString} from '../server/htmlescape' import {htmlEscapeJsonString} from '../server/htmlescape'
import flush from 'styled-jsx/server' import flush from 'styled-jsx/server'
const Fragment = React.Fragment || function Fragment ({ children }) {
return <div>{children}</div>
}
export default class Document extends Component { export default class Document extends Component {
static childContextTypes = { static childContextTypes = {
_documentProps: PropTypes.any, _documentProps: PropTypes.any,
@ -31,7 +27,7 @@ export default class Document extends Component {
} }
render () { render () {
return <html> return <html amp={this.props.amphtml ? '' : null}>
<Head /> <Head />
<body> <body>
<Main /> <Main />
@ -115,10 +111,9 @@ export class Head extends Component {
} }
render () { render () {
const { head, styles, assetPrefix, __NEXT_DATA__ } = this.context._documentProps const { asPath, ampEnabled, head, styles, amphtml, assetPrefix, __NEXT_DATA__ } = this.context._documentProps
const { _devOnlyInvalidateCacheQueryString } = this.context const { _devOnlyInvalidateCacheQueryString } = this.context
const { page, buildId } = __NEXT_DATA__ const { page, buildId } = __NEXT_DATA__
const pagePathname = getPagePathname(page)
let children = this.props.children let children = this.props.children
// show a warning if Head contains <title> (only in development) // show a warning if Head contains <title> (only in development)
@ -134,12 +129,26 @@ export class Head extends Component {
return <head {...this.props}> return <head {...this.props}>
{children} {children}
{head} {head}
{page !== '/_error' && <link rel='preload' href={`${assetPrefix}/_next/static/${buildId}/pages${pagePathname}${_devOnlyInvalidateCacheQueryString}`} as='script' nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />} {amphtml && <>
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"/>
<link rel="canonical" href={asPath === '/amp' ? '/' : asPath.replace(/\/amp$/, '')} />
{/* 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: styles.map((style) => style.props.dangerouslySetInnerHTML.__html)}} />}
<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}}`}}></style>
<noscript><style amp-boilerplate="" dangerouslySetInnerHTML={{__html: `body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}`}}></style></noscript>
<script async src="https://cdn.ampproject.org/v0.js"></script>
</>}
{!amphtml && <>
{ampEnabled && <link rel="amphtml" href={asPath === '/' ? '/amp' : (asPath.replace(/\/$/, '') + '/amp')} />}
{page !== '/_error' && <link rel='preload' href={`${assetPrefix}/_next/static/${buildId}/pages${getPagePathname(page)}${_devOnlyInvalidateCacheQueryString}`} as='script' nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />}
<link rel='preload' href={`${assetPrefix}/_next/static/${buildId}/pages/_app.js${_devOnlyInvalidateCacheQueryString}`} as='script' nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} /> <link rel='preload' href={`${assetPrefix}/_next/static/${buildId}/pages/_app.js${_devOnlyInvalidateCacheQueryString}`} as='script' nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />
{this.getPreloadDynamicChunks()} {this.getPreloadDynamicChunks()}
{this.getPreloadMainLinks()} {this.getPreloadMainLinks()}
{this.getCssLinks()} {this.getCssLinks()}
{styles || null} {styles || null}
</>}
</head> </head>
} }
} }
@ -221,25 +230,29 @@ export class NextScript extends Component {
} }
render () { render () {
const { staticMarkup, assetPrefix, devFiles, __NEXT_DATA__ } = this.context._documentProps const { staticMarkup, assetPrefix, amphtml, devFiles, __NEXT_DATA__ } = this.context._documentProps
const { _devOnlyInvalidateCacheQueryString } = this.context const { _devOnlyInvalidateCacheQueryString } = this.context
if(amphtml) {
return null
}
const { page, buildId } = __NEXT_DATA__ const { page, buildId } = __NEXT_DATA__
const pagePathname = getPagePathname(page)
if (process.env.NODE_ENV !== 'production') { 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') if (this.props.crossOrigin) console.warn('Warning: `NextScript` attribute `crossOrigin` is deprecated. https://err.sh/next.js/doc-crossorigin-deprecated')
} }
return <Fragment> return <>
{devFiles ? devFiles.map((file) => <script key={file} src={`${assetPrefix}/_next/${file}${_devOnlyInvalidateCacheQueryString}`} nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />) : null} {devFiles ? devFiles.map((file) => <script key={file} src={`${assetPrefix}/_next/${file}${_devOnlyInvalidateCacheQueryString}`} nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />) : null}
{staticMarkup ? null : <script id="__NEXT_DATA__" type="application/json" nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} dangerouslySetInnerHTML={{ {staticMarkup ? null : <script id="__NEXT_DATA__" type="application/json" nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} dangerouslySetInnerHTML={{
__html: NextScript.getInlineScriptSource(this.context._documentProps) __html: NextScript.getInlineScriptSource(this.context._documentProps)
}} />} }} />}
{page !== '/_error' && <script async id={`__NEXT_PAGE__${page}`} src={`${assetPrefix}/_next/static/${buildId}/pages${pagePathname}${_devOnlyInvalidateCacheQueryString}`} nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />} {page !== '/_error' && <script async id={`__NEXT_PAGE__${page}`} src={`${assetPrefix}/_next/static/${buildId}/pages${getPagePathname(page)}${_devOnlyInvalidateCacheQueryString}`} nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />}
<script async id={`__NEXT_PAGE__/_app`} src={`${assetPrefix}/_next/static/${buildId}/pages/_app.js${_devOnlyInvalidateCacheQueryString}`} nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} /> <script async id={`__NEXT_PAGE__/_app`} src={`${assetPrefix}/_next/static/${buildId}/pages/_app.js${_devOnlyInvalidateCacheQueryString}`} nonce={this.props.nonce} crossOrigin={this.props.crossOrigin || process.crossOrigin} />
{staticMarkup ? null : this.getDynamicChunks()} {staticMarkup ? null : this.getDynamicChunks()}
{staticMarkup ? null : this.getScripts()} {staticMarkup ? null : this.getScripts()}
</Fragment> </>
} }
} }

View file

@ -91,7 +91,7 @@ export default class DevServer extends Server {
return routes return routes
} }
async renderToHTML (req, res, pathname, query) { async renderToHTML (req, res, pathname, query, options) {
const compilationErr = await this.getCompilationError(pathname) const compilationErr = await this.getCompilationError(pathname)
if (compilationErr) { if (compilationErr) {
res.statusCode = 500 res.statusCode = 500
@ -109,7 +109,7 @@ export default class DevServer extends Server {
if (!this.quiet) console.error(err) if (!this.quiet) console.error(err)
} }
return super.renderToHTML(req, res, pathname, query) return super.renderToHTML(req, res, pathname, query, options)
} }
async renderErrorToHTML (err, req, res, pathname, query) { async renderErrorToHTML (err, req, res, pathname, query) {

View file

@ -0,0 +1,9 @@
module.exports = {
onDemandEntries: {
// Make sure entries are not getting disposed.
maxInactiveAge: 1000 * 60 * 60
},
experimental: {
amp: true
}
}

View file

@ -0,0 +1 @@
export default () => 'Hello World'

View file

@ -0,0 +1,6 @@
import {useAmp} from 'next/amp'
export default () => {
const isAmp = useAmp()
return `Hello ${isAmp ? 'AMP' : 'others'}`
}

View file

@ -0,0 +1,117 @@
/* eslint-env jest */
/* global jasmine */
import { join } from 'path'
import {
nextServer,
nextBuild,
startApp,
stopApp,
renderViaHTTP
} from 'next-test-utils'
import cheerio from 'cheerio'
import amphtmlValidator from 'amphtml-validator'
const appDir = join(__dirname, '../')
let appPort
let server
let app
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 5
const context = {}
async function validateAMP (html) {
const validator = await amphtmlValidator.getInstance()
const result = validator.validateString(html)
if (result.status !== 'PASS') {
for (let ii = 0; ii < result.errors.length; ii++) {
const error = result.errors[ii]
let msg = 'line ' + error.line + ', col ' + error.col + ': ' + error.message
if (error.specUrl !== null) {
msg += ' (see ' + error.specUrl + ')'
}
((error.severity === 'ERROR') ? console.error : console.warn)(msg)
}
}
expect(result.status).toBe('PASS')
}
describe('AMP Usage', () => {
beforeAll(async () => {
await nextBuild(appDir)
app = nextServer({
dir: join(__dirname, '../'),
dev: false,
quiet: true
})
server = await startApp(app)
context.appPort = appPort = server.address().port
})
afterAll(() => stopApp(server))
describe('With basic usage', () => {
it('should render the page', async () => {
const html = await renderViaHTTP(appPort, '/')
expect(html).toMatch(/Hello World/)
})
})
describe('With basic AMP usage', () => {
it('should render the page as valid AMP', async () => {
const html = await renderViaHTTP(appPort, '/amp')
await validateAMP(html)
expect(html).toMatch(/Hello World/)
})
it('should add link preload for amp script', async () => {
const html = await renderViaHTTP(appPort, '/amp')
await validateAMP(html)
const $ = cheerio.load(html)
expect($($('link[rel=preload]').toArray().find(i => $(i).attr('href') === 'https://cdn.ampproject.org/v0.js')).attr('href')).toBe('https://cdn.ampproject.org/v0.js')
})
it('should add custom styles before amp boilerplate styles', async () => {
const html = await renderViaHTTP(appPort, '/amp')
await validateAMP(html)
const $ = cheerio.load(html)
const order = []
$('style').toArray().forEach((i) => {
if ($(i).attr('amp-custom') === '') {
order.push('amp-custom')
}
if ($(i).attr('amp-boilerplate') === '') {
order.push('amp-boilerplate')
}
})
expect(order).toEqual(['amp-custom', 'amp-boilerplate', 'amp-boilerplate'])
})
})
describe('With AMP context', () => {
it('should render the normal page that uses the AMP hook', async () => {
const html = await renderViaHTTP(appPort, '/use-amp-hook')
expect(html).toMatch(/Hello others/)
})
it('should render the AMP page that uses the AMP hook', async () => {
const html = await renderViaHTTP(appPort, '/use-amp-hook/amp')
await validateAMP(html)
expect(html).toMatch(/Hello AMP/)
})
})
describe('canonical amphtml', () => {
it('should render link rel amphtml', async () => {
const html = await renderViaHTTP(appPort, '/use-amp-hook')
const $ = cheerio.load(html)
expect($('link[rel=amphtml]').first().attr('href')).toBe('/use-amp-hook/amp')
})
it('should render the AMP page that uses the AMP hook', async () => {
const html = await renderViaHTTP(appPort, '/use-amp-hook/amp')
const $ = cheerio.load(html)
await validateAMP(html)
expect($('link[rel=canonical]').first().attr('href')).toBe('/use-amp-hook')
})
})
})

View file

@ -1879,6 +1879,15 @@ amdefine@>=0.0.4:
resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=
amphtml-validator@1.0.23:
version "1.0.23"
resolved "https://registry.yarnpkg.com/amphtml-validator/-/amphtml-validator-1.0.23.tgz#dba0c3854289563c0adaac292cd4d6096ee4d7c8"
integrity sha1-26DDhUKJVjwK2qwpLNTWCW7k18g=
dependencies:
colors "1.1.2"
commander "2.9.0"
promise "7.1.1"
ansi-colors@^3.0.0: ansi-colors@^3.0.0:
version "3.2.1" version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.1.tgz#9638047e4213f3428a11944a7d4b31cba0a3ff95" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.1.tgz#9638047e4213f3428a11944a7d4b31cba0a3ff95"
@ -3279,7 +3288,7 @@ color@^3.0.0:
color-convert "^1.9.1" color-convert "^1.9.1"
color-string "^1.5.2" color-string "^1.5.2"
colors@~1.1.2: colors@1.1.2, colors@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
integrity sha1-FopHAXVran9RoSzgyXv6KMCE7WM= integrity sha1-FopHAXVran9RoSzgyXv6KMCE7WM=
@ -3299,6 +3308,13 @@ combined-stream@^1.0.6, combined-stream@~1.0.5, combined-stream@~1.0.6:
dependencies: dependencies:
delayed-stream "~1.0.0" delayed-stream "~1.0.0"
commander@2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4"
integrity sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=
dependencies:
graceful-readlink ">= 1.0.0"
commander@^2.12.1, commander@^2.18.0, commander@^2.9.0: commander@^2.12.1, commander@^2.18.0, commander@^2.9.0:
version "2.19.0" version "2.19.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
@ -5776,6 +5792,11 @@ graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4,
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==
"graceful-readlink@>= 1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=
"growl@~> 1.10.0": "growl@~> 1.10.0":
version "1.10.5" version "1.10.5"
resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e"
@ -9637,6 +9658,13 @@ promise-retry@^1.1.1:
err-code "^1.0.0" err-code "^1.0.0"
retry "^0.10.0" retry "^0.10.0"
promise@7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf"
integrity sha1-SJZUxpJha4qlWwck+oCbt9tJxb8=
dependencies:
asap "~2.0.3"
promise@^7.0.1: promise@^7.0.1:
version "7.3.1" version "7.3.1"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
@ -9870,15 +9898,15 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
react-dom@16.6.3: react-dom@16.8.0:
version "16.6.3" version "16.8.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.6.3.tgz#8fa7ba6883c85211b8da2d0efeffc9d3825cccc0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.0.tgz#18f28d4be3571ed206672a267c66dd083145a9c4"
integrity sha512-8ugJWRCWLGXy+7PmNh8WJz3g1TaTUt1XyoIcFN+x0Zbkoz+KKdUyx1AQLYJdbFXjuF41Nmjn5+j//rxvhFjgSQ== integrity sha512-dBzoAGYZpW9Yggp+CzBPC7q1HmWSeRc93DWrwbskmG1eHJWznZB/p0l/Sm+69leIGUS91AXPB/qB3WcPnKx8Sw==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
prop-types "^15.6.2" prop-types "^15.6.2"
scheduler "^0.11.2" scheduler "^0.13.0"
react-error-overlay@4.0.0: react-error-overlay@4.0.0:
version "4.0.0" version "4.0.0"
@ -9890,15 +9918,15 @@ react-is@16.6.3, react-is@^16.3.2:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.6.3.tgz#d2d7462fcfcbe6ec0da56ad69047e47e56e7eac0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.6.3.tgz#d2d7462fcfcbe6ec0da56ad69047e47e56e7eac0"
integrity sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA== integrity sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA==
react@16.6.3: react@16.8.0:
version "16.6.3" version "16.8.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.6.3.tgz#25d77c91911d6bbdd23db41e70fb094cc1e0871c" resolved "https://registry.yarnpkg.com/react/-/react-16.8.0.tgz#8533f0e4af818f448a276eae71681d09e8dd970a"
integrity sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw== integrity sha512-g+nikW2D48kqgWSPwNo0NH9tIGG3DsQFlrtrQ1kj6W77z5ahyIHG0w8kPpz4Sdj6gyLnz0lEd/xsjOoGge2MYQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"
prop-types "^15.6.2" prop-types "^15.6.2"
scheduler "^0.11.2" scheduler "^0.13.0"
read-cmd-shim@^1.0.1: read-cmd-shim@^1.0.1:
version "1.0.1" version "1.0.1"
@ -10623,10 +10651,10 @@ sax@^1.2.4, sax@~1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.11.2: scheduler@^0.13.0:
version "0.11.3" version "0.13.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.11.3.tgz#b5769b90cf8b1464f3f3cfcafe8e3cd7555a2d6b" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.0.tgz#e701f62e1b3e78d2bbb264046d4e7260f12184dd"
integrity sha512-i9X9VRRVZDd3xZw10NY5Z2cVMbdYg6gqFecfj79USv1CFN+YrJ3gIPRKf1qlY+Sxly4djoKdfx1T+m9dnRB8kQ== integrity sha512-w7aJnV30jc7OsiZQNPVmBc+HooZuvQZIZIShKutC3tnMFMkcwVN9CZRRSSNw03OnSCKmEkK8usmwcw6dqBaLzw==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" object-assign "^4.1.1"