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:
parent
36946f9709
commit
4051ffcb01
|
@ -60,6 +60,7 @@
|
|||
"@zeit/next-css": "1.0.2-canary.2",
|
||||
"@zeit/next-sass": "1.0.2-canary.2",
|
||||
"@zeit/next-typescript": "1.1.2-canary.0",
|
||||
"amphtml-validator": "1.0.23",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-eslint": "9.0.0",
|
||||
"babel-jest": "23.6.0",
|
||||
|
@ -83,8 +84,8 @@
|
|||
"node-sass": "4.9.2",
|
||||
"pre-commit": "1.2.2",
|
||||
"prettier": "1.15.3",
|
||||
"react": "16.6.3",
|
||||
"react-dom": "16.6.3",
|
||||
"react": "16.8.0",
|
||||
"react-dom": "16.8.0",
|
||||
"release": "5.0.3",
|
||||
"request-promise-core": "1.1.1",
|
||||
"rimraf": "2.6.2",
|
||||
|
|
1
packages/next-server/amp.js
Normal file
1
packages/next-server/amp.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = require('./dist/lib/amp')
|
6
packages/next-server/lib/amp.ts
Normal file
6
packages/next-server/lib/amp.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import React from 'react'
|
||||
import {IsAmpContext} from './amphtml-context'
|
||||
|
||||
export function useAmp() {
|
||||
return React.useContext(IsAmpContext)
|
||||
}
|
3
packages/next-server/lib/amphtml-context.ts
Normal file
3
packages/next-server/lib/amphtml-context.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import * as React from 'react'
|
||||
|
||||
export const IsAmpContext: React.Context<any> = React.createContext(false)
|
|
@ -12,7 +12,8 @@
|
|||
"head.js",
|
||||
"link.js",
|
||||
"router.js",
|
||||
"next-config.js"
|
||||
"next-config.js",
|
||||
"amp.js"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "taskr",
|
||||
|
|
|
@ -21,6 +21,9 @@ const defaultConfig = {
|
|||
websocketPort: 0,
|
||||
websocketProxyPath: '/',
|
||||
websocketProxyPort: null
|
||||
},
|
||||
experimental: {
|
||||
amp: false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,6 +50,12 @@ export default function loadConfig (phase, dir, customConfig) {
|
|||
if (userConfig.target && !targets.includes(userConfig.target)) {
|
||||
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) {
|
||||
userConfig.onDemandEntries = {
|
||||
...defaultConfig.onDemandEntries,
|
||||
|
|
|
@ -30,6 +30,7 @@ export default class Server {
|
|||
distDir: string
|
||||
buildId: string
|
||||
renderOpts: {
|
||||
ampEnabled: boolean,
|
||||
staticMarkup: boolean,
|
||||
buildId: string,
|
||||
generateEtags: boolean,
|
||||
|
@ -53,6 +54,7 @@ export default class Server {
|
|||
|
||||
this.buildId = this.readBuildId()
|
||||
this.renderOpts = {
|
||||
ampEnabled: this.nextConfig.experimental.amp,
|
||||
staticMarkup,
|
||||
buildId: this.buildId,
|
||||
generateEtags,
|
||||
|
@ -160,6 +162,29 @@ export default class Server {
|
|||
]
|
||||
|
||||
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.
|
||||
// (but it should support as many params as needed, separated by '/')
|
||||
// Otherwise this will lead to a pretty simple DOS attack.
|
||||
|
@ -229,15 +254,47 @@ export default class Server {
|
|||
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) {
|
||||
const result = await loadComponents(this.distDir, this.buildId, pathname)
|
||||
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 {
|
||||
// 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
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
|
|
|
@ -7,15 +7,26 @@ import { loadGetInitialProps, isResSent } from '../lib/utils'
|
|||
import Head, { defaultHead } from '../lib/head'
|
||||
import Loadable from '../lib/loadable'
|
||||
import LoadableCapture from '../lib/loadable-capture'
|
||||
import {getDynamicImportBundles, Manifest as ReactLoadableManifest, ManifestItem} from './get-dynamic-import-bundles'
|
||||
import {
|
||||
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 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,
|
||||
Component: React.ComponentType,
|
||||
): {
|
||||
App: React.ComponentType
|
||||
Component: React.ComponentType,
|
||||
} {
|
||||
// For backwards compatibility
|
||||
if (typeof options === 'function') {
|
||||
|
@ -27,11 +38,16 @@ function enhanceComponents(options: ComponentsEnhancer, App: React.ComponentType
|
|||
|
||||
return {
|
||||
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 head
|
||||
|
||||
|
@ -45,25 +61,31 @@ function render(renderElementToString: (element: React.ReactElement<any>) => str
|
|||
}
|
||||
|
||||
type RenderOpts = {
|
||||
staticMarkup: boolean,
|
||||
buildId: string,
|
||||
runtimeConfig?: {[key: string]: any},
|
||||
assetPrefix?: string,
|
||||
err?: Error|null,
|
||||
nextExport?: boolean,
|
||||
dev?: boolean,
|
||||
buildManifest: BuildManifest,
|
||||
reactLoadableManifest: ReactLoadableManifest,
|
||||
Component: React.ComponentType,
|
||||
Document: React.ComponentType,
|
||||
App: React.ComponentType,
|
||||
ampEnabled: boolean
|
||||
staticMarkup: boolean
|
||||
buildId: string
|
||||
runtimeConfig?: { [key: string]: any }
|
||||
assetPrefix?: string
|
||||
err?: Error | null
|
||||
nextExport?: boolean
|
||||
dev?: boolean
|
||||
amphtml?: boolean
|
||||
buildManifest: BuildManifest
|
||||
reactLoadableManifest: ReactLoadableManifest
|
||||
Component: React.ComponentType
|
||||
Document: React.ComponentType
|
||||
App: React.ComponentType
|
||||
ErrorDebug?: React.ComponentType<{ error: Error }>,
|
||||
}
|
||||
|
||||
function renderDocument(Document: React.ComponentType, {
|
||||
function renderDocument(
|
||||
Document: React.ComponentType,
|
||||
{
|
||||
ampEnabled = false,
|
||||
props,
|
||||
docProps,
|
||||
pathname,
|
||||
asPath,
|
||||
query,
|
||||
buildId,
|
||||
assetPrefix,
|
||||
|
@ -72,21 +94,28 @@ function renderDocument(Document: React.ComponentType, {
|
|||
dynamicImportsIds,
|
||||
err,
|
||||
dev,
|
||||
amphtml,
|
||||
staticMarkup,
|
||||
devFiles,
|
||||
files,
|
||||
dynamicImports,
|
||||
}: RenderOpts & {
|
||||
props: any,
|
||||
docProps: any,
|
||||
pathname: string,
|
||||
query: ParsedUrlQuery,
|
||||
dynamicImportsIds: string[],
|
||||
dynamicImports: ManifestItem[],
|
||||
props: any
|
||||
docProps: any
|
||||
pathname: string
|
||||
asPath: string | undefined
|
||||
query: ParsedUrlQuery
|
||||
amphtml: boolean
|
||||
dynamicImportsIds: string[]
|
||||
dynamicImports: ManifestItem[]
|
||||
files: string[]
|
||||
devFiles: string[],
|
||||
}): string {
|
||||
return '<!DOCTYPE html>' + renderToStaticMarkup(
|
||||
},
|
||||
): string {
|
||||
return (
|
||||
'<!DOCTYPE html>' +
|
||||
renderToStaticMarkup(
|
||||
<IsAmpContext.Provider value={amphtml}>
|
||||
<Document
|
||||
__NEXT_DATA__={{
|
||||
props, // The result of getInitialProps
|
||||
|
@ -96,24 +125,37 @@ function renderDocument(Document: React.ComponentType, {
|
|||
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
|
||||
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 {
|
||||
err,
|
||||
dev = false,
|
||||
staticMarkup = false,
|
||||
amphtml = false,
|
||||
App,
|
||||
Document,
|
||||
Component,
|
||||
|
@ -127,15 +169,21 @@ export async function renderToHTML(req: IncomingMessage, res: ServerResponse, pa
|
|||
if (dev) {
|
||||
const { isValidElementType } = require('react-is')
|
||||
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)) {
|
||||
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)) {
|
||||
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"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,23 +204,35 @@ export async function renderToHTML(req: IncomingMessage, res: ServerResponse, pa
|
|||
]
|
||||
|
||||
const reactLoadableModules: string[] = []
|
||||
const renderPage = (options: ComponentsEnhancer = {}): {html: string, head: any} => {
|
||||
const renderElementToString = staticMarkup ? renderToStaticMarkup : renderToString
|
||||
const renderPage = (
|
||||
options: ComponentsEnhancer = {},
|
||||
): { html: string; head: any } => {
|
||||
const renderElementToString = staticMarkup
|
||||
? renderToStaticMarkup
|
||||
: renderToString
|
||||
|
||||
if (err && ErrorDebug) {
|
||||
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,
|
||||
<LoadableCapture report={(moduleName) => reactLoadableModules.push(moduleName)}>
|
||||
return render(
|
||||
renderElementToString,
|
||||
<IsAmpContext.Provider value={amphtml}>
|
||||
<LoadableCapture
|
||||
report={(moduleName) => reactLoadableModules.push(moduleName)}
|
||||
>
|
||||
<EnhancedApp
|
||||
Component={EnhancedComponent}
|
||||
router={router}
|
||||
{...props}
|
||||
/>
|
||||
</LoadableCapture>,
|
||||
</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
|
||||
if (isResSent(res)) return null
|
||||
|
||||
const dynamicImports = [...getDynamicImportBundles(reactLoadableManifest, reactLoadableModules)]
|
||||
const dynamicImports = [
|
||||
...getDynamicImportBundles(reactLoadableManifest, reactLoadableModules),
|
||||
]
|
||||
const dynamicImportsIds: any = dynamicImports.map((bundle) => bundle.id)
|
||||
|
||||
return renderDocument(Document, {
|
||||
...renderOpts,
|
||||
props,
|
||||
docProps,
|
||||
asPath,
|
||||
pathname,
|
||||
amphtml,
|
||||
query,
|
||||
dynamicImportsIds,
|
||||
dynamicImports,
|
||||
|
@ -201,10 +265,17 @@ function errorToJSON(err: Error): Error {
|
|||
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) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import pathMatch from './lib/path-match'
|
|||
|
||||
export const route = pathMatch()
|
||||
|
||||
type Params = {[param: string]: string}
|
||||
type Params = {[param: string]: any}
|
||||
|
||||
export type Route = {
|
||||
match: (pathname: string|undefined) => false|Params,
|
||||
|
|
1
packages/next/amp.js
Normal file
1
packages/next/amp.js
Normal file
|
@ -0,0 +1 @@
|
|||
module.exports = require('next-server/amp')
|
|
@ -19,7 +19,8 @@
|
|||
"error.js",
|
||||
"head.js",
|
||||
"link.js",
|
||||
"router.js"
|
||||
"router.js",
|
||||
"amp.js"
|
||||
],
|
||||
"bin": {
|
||||
"next": "./dist/bin/next"
|
||||
|
|
|
@ -4,10 +4,6 @@ import PropTypes from 'prop-types'
|
|||
import {htmlEscapeJsonString} from '../server/htmlescape'
|
||||
import flush from 'styled-jsx/server'
|
||||
|
||||
const Fragment = React.Fragment || function Fragment ({ children }) {
|
||||
return <div>{children}</div>
|
||||
}
|
||||
|
||||
export default class Document extends Component {
|
||||
static childContextTypes = {
|
||||
_documentProps: PropTypes.any,
|
||||
|
@ -31,7 +27,7 @@ export default class Document extends Component {
|
|||
}
|
||||
|
||||
render () {
|
||||
return <html>
|
||||
return <html amp={this.props.amphtml ? '' : null}>
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
|
@ -115,10 +111,9 @@ export class Head extends Component {
|
|||
}
|
||||
|
||||
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 { page, buildId } = __NEXT_DATA__
|
||||
const pagePathname = getPagePathname(page)
|
||||
|
||||
let children = this.props.children
|
||||
// show a warning if Head contains <title> (only in development)
|
||||
|
@ -134,12 +129,26 @@ export class Head extends Component {
|
|||
return <head {...this.props}>
|
||||
{children}
|
||||
{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} />
|
||||
{this.getPreloadDynamicChunks()}
|
||||
{this.getPreloadMainLinks()}
|
||||
{this.getCssLinks()}
|
||||
{styles || null}
|
||||
</>}
|
||||
</head>
|
||||
}
|
||||
}
|
||||
|
@ -221,25 +230,29 @@ export class NextScript extends Component {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { staticMarkup, assetPrefix, devFiles, __NEXT_DATA__ } = this.context._documentProps
|
||||
const { staticMarkup, assetPrefix, amphtml, devFiles, __NEXT_DATA__ } = this.context._documentProps
|
||||
const { _devOnlyInvalidateCacheQueryString } = this.context
|
||||
|
||||
if(amphtml) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { page, buildId } = __NEXT_DATA__
|
||||
const pagePathname = getPagePathname(page)
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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}
|
||||
{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)
|
||||
}} />}
|
||||
{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} />
|
||||
{staticMarkup ? null : this.getDynamicChunks()}
|
||||
{staticMarkup ? null : this.getScripts()}
|
||||
</Fragment>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -91,7 +91,7 @@ export default class DevServer extends Server {
|
|||
return routes
|
||||
}
|
||||
|
||||
async renderToHTML (req, res, pathname, query) {
|
||||
async renderToHTML (req, res, pathname, query, options) {
|
||||
const compilationErr = await this.getCompilationError(pathname)
|
||||
if (compilationErr) {
|
||||
res.statusCode = 500
|
||||
|
@ -109,7 +109,7 @@ export default class DevServer extends Server {
|
|||
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) {
|
||||
|
|
9
test/integration/amphtml/next.config.js
Normal file
9
test/integration/amphtml/next.config.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
onDemandEntries: {
|
||||
// Make sure entries are not getting disposed.
|
||||
maxInactiveAge: 1000 * 60 * 60
|
||||
},
|
||||
experimental: {
|
||||
amp: true
|
||||
}
|
||||
}
|
1
test/integration/amphtml/pages/index.js
Normal file
1
test/integration/amphtml/pages/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export default () => 'Hello World'
|
6
test/integration/amphtml/pages/use-amp-hook.js
Normal file
6
test/integration/amphtml/pages/use-amp-hook.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import {useAmp} from 'next/amp'
|
||||
|
||||
export default () => {
|
||||
const isAmp = useAmp()
|
||||
return `Hello ${isAmp ? 'AMP' : 'others'}`
|
||||
}
|
117
test/integration/amphtml/test/index.test.js
Normal file
117
test/integration/amphtml/test/index.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
58
yarn.lock
58
yarn.lock
|
@ -1879,6 +1879,15 @@ amdefine@>=0.0.4:
|
|||
resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
|
||||
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:
|
||||
version "3.2.1"
|
||||
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-string "^1.5.2"
|
||||
|
||||
colors@~1.1.2:
|
||||
colors@1.1.2, colors@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
|
||||
integrity sha1-FopHAXVran9RoSzgyXv6KMCE7WM=
|
||||
|
@ -3299,6 +3308,13 @@ combined-stream@^1.0.6, combined-stream@~1.0.5, combined-stream@~1.0.6:
|
|||
dependencies:
|
||||
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:
|
||||
version "2.19.0"
|
||||
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"
|
||||
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":
|
||||
version "1.10.5"
|
||||
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"
|
||||
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:
|
||||
version "7.3.1"
|
||||
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"
|
||||
strip-json-comments "~2.0.1"
|
||||
|
||||
react-dom@16.6.3:
|
||||
version "16.6.3"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.6.3.tgz#8fa7ba6883c85211b8da2d0efeffc9d3825cccc0"
|
||||
integrity sha512-8ugJWRCWLGXy+7PmNh8WJz3g1TaTUt1XyoIcFN+x0Zbkoz+KKdUyx1AQLYJdbFXjuF41Nmjn5+j//rxvhFjgSQ==
|
||||
react-dom@16.8.0:
|
||||
version "16.8.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.0.tgz#18f28d4be3571ed206672a267c66dd083145a9c4"
|
||||
integrity sha512-dBzoAGYZpW9Yggp+CzBPC7q1HmWSeRc93DWrwbskmG1eHJWznZB/p0l/Sm+69leIGUS91AXPB/qB3WcPnKx8Sw==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.2"
|
||||
scheduler "^0.11.2"
|
||||
scheduler "^0.13.0"
|
||||
|
||||
react-error-overlay@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"
|
||||
integrity sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA==
|
||||
|
||||
react@16.6.3:
|
||||
version "16.6.3"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.6.3.tgz#25d77c91911d6bbdd23db41e70fb094cc1e0871c"
|
||||
integrity sha512-zCvmH2vbEolgKxtqXL2wmGCUxUyNheYn/C+PD1YAjfxHC54+MhdruyhO7QieQrYsYeTxrn93PM2y0jRH1zEExw==
|
||||
react@16.8.0:
|
||||
version "16.8.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.8.0.tgz#8533f0e4af818f448a276eae71681d09e8dd970a"
|
||||
integrity sha512-g+nikW2D48kqgWSPwNo0NH9tIGG3DsQFlrtrQ1kj6W77z5ahyIHG0w8kPpz4Sdj6gyLnz0lEd/xsjOoGge2MYQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.2"
|
||||
scheduler "^0.11.2"
|
||||
scheduler "^0.13.0"
|
||||
|
||||
read-cmd-shim@^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"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
|
||||
scheduler@^0.11.2:
|
||||
version "0.11.3"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.11.3.tgz#b5769b90cf8b1464f3f3cfcafe8e3cd7555a2d6b"
|
||||
integrity sha512-i9X9VRRVZDd3xZw10NY5Z2cVMbdYg6gqFecfj79USv1CFN+YrJ3gIPRKf1qlY+Sxly4djoKdfx1T+m9dnRB8kQ==
|
||||
scheduler@^0.13.0:
|
||||
version "0.13.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.0.tgz#e701f62e1b3e78d2bbb264046d4e7260f12184dd"
|
||||
integrity sha512-w7aJnV30jc7OsiZQNPVmBc+HooZuvQZIZIShKutC3tnMFMkcwVN9CZRRSSNw03OnSCKmEkK8usmwcw6dqBaLzw==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
|
Loading…
Reference in a new issue