From 4051ffcb0183f40a4f1b5c591a602bae4659ef54 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Thu, 14 Feb 2019 16:22:57 +0100 Subject: [PATCH] [experimental] Rendering to AMP (#6218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- package.json | 5 +- packages/next-server/amp.js | 1 + packages/next-server/lib/amp.ts | 6 + packages/next-server/lib/amphtml-context.ts | 3 + packages/next-server/package.json | 3 +- packages/next-server/server/config.js | 9 + packages/next-server/server/next-server.ts | 61 ++++- packages/next-server/server/render.tsx | 241 ++++++++++++------ packages/next-server/server/router.ts | 2 +- packages/next/amp.js | 1 + packages/next/package.json | 3 +- packages/next/pages/_document.js | 39 ++- packages/next/server/next-dev-server.js | 4 +- test/integration/amphtml/next.config.js | 9 + test/integration/amphtml/pages/index.js | 1 + .../integration/amphtml/pages/use-amp-hook.js | 6 + test/integration/amphtml/test/index.test.js | 117 +++++++++ yarn.lock | 58 +++-- 18 files changed, 447 insertions(+), 122 deletions(-) create mode 100644 packages/next-server/amp.js create mode 100644 packages/next-server/lib/amp.ts create mode 100644 packages/next-server/lib/amphtml-context.ts create mode 100644 packages/next/amp.js create mode 100644 test/integration/amphtml/next.config.js create mode 100644 test/integration/amphtml/pages/index.js create mode 100644 test/integration/amphtml/pages/use-amp-hook.js create mode 100644 test/integration/amphtml/test/index.test.js diff --git a/package.json b/package.json index a5911f21..f0cf4b88 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/next-server/amp.js b/packages/next-server/amp.js new file mode 100644 index 00000000..195eca9c --- /dev/null +++ b/packages/next-server/amp.js @@ -0,0 +1 @@ +module.exports = require('./dist/lib/amp') diff --git a/packages/next-server/lib/amp.ts b/packages/next-server/lib/amp.ts new file mode 100644 index 00000000..0cfd35a9 --- /dev/null +++ b/packages/next-server/lib/amp.ts @@ -0,0 +1,6 @@ +import React from 'react' +import {IsAmpContext} from './amphtml-context' + +export function useAmp() { + return React.useContext(IsAmpContext) +} diff --git a/packages/next-server/lib/amphtml-context.ts b/packages/next-server/lib/amphtml-context.ts new file mode 100644 index 00000000..9e5d30ef --- /dev/null +++ b/packages/next-server/lib/amphtml-context.ts @@ -0,0 +1,3 @@ +import * as React from 'react' + +export const IsAmpContext: React.Context = React.createContext(false) diff --git a/packages/next-server/package.json b/packages/next-server/package.json index 2aad2e31..8d71164b 100644 --- a/packages/next-server/package.json +++ b/packages/next-server/package.json @@ -12,7 +12,8 @@ "head.js", "link.js", "router.js", - "next-config.js" + "next-config.js", + "amp.js" ], "scripts": { "build": "taskr", diff --git a/packages/next-server/server/config.js b/packages/next-server/server/config.js index a587173e..181c04cf 100644 --- a/packages/next-server/server/config.js +++ b/packages/next-server/server/config.js @@ -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, diff --git a/packages/next-server/server/next-server.ts b/packages/next-server/server/next-server.ts index 54b15b49..223179b6 100644 --- a/packages/next-server/server/next-server.ts +++ b/packages/next-server/server/next-server.ts @@ -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 { + 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 { + public async renderToAMPHTML(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}): Promise { + 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 { 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') { diff --git a/packages/next-server/server/render.tsx b/packages/next-server/server/render.tsx index ae40f4c5..09846367 100644 --- a/packages/next-server/server/render.tsx +++ b/packages/next-server/server/render.tsx @@ -1,4 +1,4 @@ -import {IncomingMessage, ServerResponse} from 'http' +import { IncomingMessage, ServerResponse } from 'http' import { ParsedUrlQuery } from 'querystring' import React from 'react' import { renderToString, renderToStaticMarkup } from 'react-dom/server' @@ -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 {getPageFiles, BuildManifest} from './get-page-files' +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) => string, element: React.ReactElement): {html: string, head: any} { +function render( + renderElementToString: (element: React.ReactElement) => string, + element: React.ReactElement, +): { html: string; head: any } { let html let head @@ -45,75 +61,101 @@ function render(renderElementToString: (element: React.ReactElement) => 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, - ErrorDebug?: React.ComponentType<{error: Error}>, + 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, { - props, - docProps, - pathname, - query, - buildId, - assetPrefix, - runtimeConfig, - nextExport, - dynamicImportsIds, - err, - dev, - staticMarkup, - devFiles, - files, - dynamicImports, -}: RenderOpts & { - props: any, - docProps: any, - pathname: string, - query: ParsedUrlQuery, - dynamicImportsIds: string[], - dynamicImports: ManifestItem[], - files: string[] - devFiles: string[], -}): string { - return '' + renderToStaticMarkup( - , +function renderDocument( + Document: React.ComponentType, + { + ampEnabled = false, + props, + docProps, + pathname, + asPath, + query, + buildId, + assetPrefix, + runtimeConfig, + nextExport, + dynamicImportsIds, + err, + dev, + amphtml, + staticMarkup, + devFiles, + files, + dynamicImports, + }: RenderOpts & { + props: any + docProps: any + pathname: string + asPath: string | undefined + query: ParsedUrlQuery + amphtml: boolean + dynamicImportsIds: string[] + dynamicImports: ManifestItem[] + files: string[] + devFiles: string[], + }, +): string { + return ( + '' + + renderToStaticMarkup( + + + , + ) ) } -export async function renderToHTML(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery, renderOpts: RenderOpts): Promise { +export async function renderToHTML( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + query: ParsedUrlQuery, + renderOpts: RenderOpts, +): Promise { const { err, dev = false, staticMarkup = false, + amphtml = false, App, Document, Component, @@ -127,22 +169,28 @@ 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"`, + ) } } const asPath = req.url const ctx = { err, req, res, 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 if (isResSent(res)) return null @@ -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, ) } - const {App: EnhancedApp, Component: EnhancedComponent} = enhanceComponents(options, App, Component) + const { + App: EnhancedApp, + Component: EnhancedComponent, + } = enhanceComponents(options, App, Component) - return render(renderElementToString, - reactLoadableModules.push(moduleName)}> - - , + return render( + renderElementToString, + + reactLoadableModules.push(moduleName)} + > + + + , ) } @@ -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, + } } diff --git a/packages/next-server/server/router.ts b/packages/next-server/server/router.ts index 4ee46932..5401fff1 100644 --- a/packages/next-server/server/router.ts +++ b/packages/next-server/server/router.ts @@ -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, diff --git a/packages/next/amp.js b/packages/next/amp.js new file mode 100644 index 00000000..042c4aca --- /dev/null +++ b/packages/next/amp.js @@ -0,0 +1 @@ +module.exports = require('next-server/amp') diff --git a/packages/next/package.json b/packages/next/package.json index 424ef66c..8250892d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -19,7 +19,8 @@ "error.js", "head.js", "link.js", - "router.js" + "router.js", + "amp.js" ], "bin": { "next": "./dist/bin/next" diff --git a/packages/next/pages/_document.js b/packages/next/pages/_document.js index a3b076f4..f70855d5 100644 --- a/packages/next/pages/_document.js +++ b/packages/next/pages/_document.js @@ -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
{children}
-} - export default class Document extends Component { static childContextTypes = { _documentProps: PropTypes.any, @@ -31,7 +27,7 @@ export default class Document extends Component { } render () { - return + return
@@ -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 (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> + </> } } diff --git a/packages/next/server/next-dev-server.js b/packages/next/server/next-dev-server.js index bc86d4ef..280da82a 100644 --- a/packages/next/server/next-dev-server.js +++ b/packages/next/server/next-dev-server.js @@ -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) { diff --git a/test/integration/amphtml/next.config.js b/test/integration/amphtml/next.config.js new file mode 100644 index 00000000..295ceb57 --- /dev/null +++ b/test/integration/amphtml/next.config.js @@ -0,0 +1,9 @@ +module.exports = { + onDemandEntries: { + // Make sure entries are not getting disposed. + maxInactiveAge: 1000 * 60 * 60 + }, + experimental: { + amp: true + } +} diff --git a/test/integration/amphtml/pages/index.js b/test/integration/amphtml/pages/index.js new file mode 100644 index 00000000..77346cb4 --- /dev/null +++ b/test/integration/amphtml/pages/index.js @@ -0,0 +1 @@ +export default () => 'Hello World' diff --git a/test/integration/amphtml/pages/use-amp-hook.js b/test/integration/amphtml/pages/use-amp-hook.js new file mode 100644 index 00000000..bc3fdc45 --- /dev/null +++ b/test/integration/amphtml/pages/use-amp-hook.js @@ -0,0 +1,6 @@ +import {useAmp} from 'next/amp' + +export default () => { + const isAmp = useAmp() + return `Hello ${isAmp ? 'AMP' : 'others'}` +} diff --git a/test/integration/amphtml/test/index.test.js b/test/integration/amphtml/test/index.test.js new file mode 100644 index 00000000..1399c4bc --- /dev/null +++ b/test/integration/amphtml/test/index.test.js @@ -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') + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index dfc2da84..c1f81b24 100644 --- a/yarn.lock +++ b/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"