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-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",

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",
"link.js",
"router.js",
"next-config.js"
"next-config.js",
"amp.js"
],
"scripts": {
"build": "taskr",

View file

@ -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,

View file

@ -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') {

View file

@ -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<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,75 +61,101 @@ 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,
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 '<!DOCTYPE html>' + renderToStaticMarkup(
<Document
__NEXT_DATA__={{
props, // The result of getInitialProps
page: pathname, // The rendered page
query, // querystring parsed / passed by the user
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
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
}}
staticMarkup={staticMarkup}
devFiles={devFiles}
files={files}
dynamicImports={dynamicImports}
assetPrefix={assetPrefix}
{...docProps}
/>,
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 (
'<!DOCTYPE html>' +
renderToStaticMarkup(
<IsAmpContext.Provider value={amphtml}>
<Document
__NEXT_DATA__={{
props, // The result of getInitialProps
page: pathname, // The rendered page
query, // querystring parsed / passed by the user
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
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 {
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, <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)}>
<EnhancedApp
Component={EnhancedComponent}
router={router}
{...props}
/>
</LoadableCapture>,
return render(
renderElementToString,
<IsAmpContext.Provider value={amphtml}>
<LoadableCapture
report={(moduleName) => reactLoadableModules.push(moduleName)}
>
<EnhancedApp
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
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,
}
}

View file

@ -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
View file

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

View file

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

View file

@ -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>
</>
}
}

View file

@ -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) {

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"
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"