Skip to content

Commit 244b0e7

Browse files
authored
Add AMP validation on export (vercel#6794)
* Add err.sh link and pool validation results to wait to show error until export is finished * Fix wording in amp-export-validation err.sh * Update validation error message Co-Authored-By: ijjk <[email protected]> * Update ways to fix text Co-Authored-By: ijjk <[email protected]> * Update why the error occurred wording Co-Authored-By: ijjk <[email protected]> * Update wording some more Co-Authored-By: ijjk <[email protected]>
1 parent 96c6284 commit 244b0e7

File tree

9 files changed

+171
-1
lines changed

9 files changed

+171
-1
lines changed

errors/amp-export-validation.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# AMP Export Validation
2+
3+
#### Why This Error Occurred
4+
5+
During export we validate AMP pages using [amphtml-validator](https://www.npmjs.com/package/amphtml-validator). Errors received from the validator will fail the export.
6+
7+
Validation errors will cause pages to not be [indexed by AMP Caches](https://www.ampproject.org/docs/fundamentals/how_cached).
8+
9+
#### Possible Ways to Fix It
10+
11+
Look at the error messages and follow their appropriate links to learn more about the error and how to correct the problem.
12+
13+
### Useful Links
14+
15+
- [AMP HTML Specification](https://www.ampproject.org/docs/fundamentals/spec)

packages/next/build/output/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function getWebpackStatusPhase(status: WebpackStatus): WebpackStatusPhase {
5858
return WebpackStatusPhase.COMPILED
5959
}
6060

61-
function formatAmpMessages(amp: AmpPageStatus) {
61+
export function formatAmpMessages(amp: AmpPageStatus) {
6262
let output = chalk.bold('Amp Validation') + '\n\n'
6363
let messages: string[][] = []
6464

packages/next/export/index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { PHASE_EXPORT, SERVER_DIRECTORY, PAGES_MANIFEST, CONFIG_FILE, BUILD_ID_F
1111
import createProgress from 'tty-aware-progress'
1212
import { promisify } from 'util'
1313
import { recursiveDelete } from '../lib/recursive-delete'
14+
import { formatAmpMessages } from '../build/output/index'
1415

1516
const mkdirp = promisify(mkdirpModule)
1617

@@ -143,6 +144,9 @@ export default async function (dir, options, configuration) {
143144
return result
144145
}, [])
145146

147+
const ampValidations = {}
148+
let hadValidationError = false
149+
146150
await Promise.all(
147151
chunks.map(
148152
chunk =>
@@ -167,12 +171,22 @@ export default async function (dir, options, configuration) {
167171
reject(payload)
168172
} else if (type === 'done') {
169173
resolve()
174+
} else if (type === 'amp-validation') {
175+
ampValidations[payload.page] = payload.result
176+
hadValidationError = hadValidationError || payload.result.errors.length
170177
}
171178
})
172179
})
173180
)
174181
)
175182

183+
if (Object.keys(ampValidations).length) {
184+
console.log(formatAmpMessages(ampValidations))
185+
}
186+
if (hadValidationError) {
187+
throw new Error(`AMP Validation caused the export to fail. https://err.sh/zeit/next.js/amp-export-validation`)
188+
}
189+
176190
// Add an empty line to the console for the better readability.
177191
log('')
178192
}

packages/next/export/worker.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { cleanAmpPath } from 'next-server/dist/server/utils'
55
import { renderToHTML } from 'next-server/dist/server/render'
66
import { writeFile } from 'fs'
77
import Sema from 'async-sema'
8+
import AmpHtmlValidator from 'amphtml-validator'
89
import { loadComponents } from 'next-server/dist/server/load-components'
910

1011
const envConfig = require('next-server/config')
@@ -67,6 +68,27 @@ process.on(
6768
await mkdirp(baseDir)
6869
const components = await loadComponents(distDir, buildId, page)
6970
const html = await renderToHTML(req, res, page, query, { ...components, ...renderOpts, ...ampOpts })
71+
72+
if (ampOpts.amphtml) {
73+
const validator = await AmpHtmlValidator.getInstance()
74+
const result = validator.validateString(html)
75+
const errors = result.errors.filter(e => e.severity === 'ERROR')
76+
const warnings = result.errors.filter(e => e.severity !== 'ERROR')
77+
78+
if (warnings.length || errors.length) {
79+
process.send({
80+
type: 'amp-validation',
81+
payload: {
82+
page,
83+
result: {
84+
errors,
85+
warnings
86+
}
87+
}
88+
})
89+
}
90+
}
91+
7092
await new Promise((resolve, reject) =>
7193
writeFile(
7294
htmlFilepath,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
// exportPathMap
3+
experimental: {
4+
amp: true
5+
}
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default () => (
2+
<div>
3+
{/* I show a warning since the amp-video script isn't added */}
4+
<amp-video src='/cats.mp4' height={400} width={800} />
5+
</div>
6+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default () => (
2+
<div>
3+
{/* I throw an error since <amp-img/> should be used instead */}
4+
<img src='/dog.gif' height={400} width={800} />
5+
{/* I show a warning since the amp-video script isn't added */}
6+
<amp-video src='/cats.mp4' height={400} width={800} />
7+
</div>
8+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default () => (
2+
<div>
3+
{/* I throw an error since <amp-img/> should be used instead */}
4+
<img src='/dog.gif' height={400} width={800} />
5+
</div>
6+
)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/* eslint-env jest */
2+
/* global jasmine */
3+
import fs from 'fs'
4+
import { join } from 'path'
5+
import { promisify } from 'util'
6+
import {
7+
File,
8+
nextBuild,
9+
runNextCommand
10+
} from 'next-test-utils'
11+
12+
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 5
13+
const access = promisify(fs.access)
14+
const appDir = join(__dirname, '../')
15+
const outDir = join(appDir, 'out')
16+
const nextConfig = new File(join(appDir, 'next.config.js'))
17+
18+
describe('AMP Validation on Export', () => {
19+
beforeAll(() => nextBuild(appDir))
20+
21+
it('shows AMP warning without throwing error', async () => {
22+
nextConfig.replace('// exportPathMap',
23+
`exportPathMap: function(defaultMap) {
24+
return {
25+
'/cat': defaultMap['/cat.amp'],
26+
}
27+
},`)
28+
29+
let stdout = ''
30+
try {
31+
await runNextCommand(['export', appDir], {
32+
instance: child => {
33+
child.stdout.on('data', chunk => {
34+
stdout += chunk.toString()
35+
})
36+
}
37+
})
38+
expect(stdout).toMatch(/warn.*The tag 'amp-video extension \.js script' is missing/)
39+
await expect(access(join(outDir, 'cat/index.html'))).resolves.toBe(undefined)
40+
} finally {
41+
nextConfig.restore()
42+
}
43+
})
44+
45+
it('throws error on AMP error', async () => {
46+
nextConfig.replace('// exportPathMap',
47+
`exportPathMap: function(defaultMap) {
48+
return {
49+
'/dog': defaultMap['/dog.amp'],
50+
}
51+
},`)
52+
53+
let stdout = ''
54+
try {
55+
await runNextCommand(['export', appDir], {
56+
instance: child => {
57+
child.stdout.on('data', chunk => {
58+
stdout += chunk.toString()
59+
})
60+
}
61+
})
62+
expect(stdout).toMatch(/error.*The tag 'img' may only appear as a descendant of tag 'noscript'. Did you mean 'amp-img'\?/)
63+
await expect(access(join(outDir, 'dog/index.html'))).resolves.toBe(undefined)
64+
} finally {
65+
nextConfig.restore()
66+
}
67+
})
68+
69+
it('shows warning and error when throwing error', async () => {
70+
nextConfig.replace('// exportPathMap',
71+
`exportPathMap: function(defaultMap) {
72+
return {
73+
'/dog-cat': defaultMap['/dog-cat.amp'],
74+
}
75+
},`)
76+
77+
let stdout = ''
78+
try {
79+
await runNextCommand(['export', appDir], {
80+
instance: child => {
81+
child.stdout.on('data', chunk => {
82+
stdout += chunk.toString()
83+
})
84+
}
85+
})
86+
expect(stdout).toMatch(/warn.*The tag 'amp-video extension \.js script' is missing/)
87+
expect(stdout).toMatch(/error.*The tag 'img' may only appear as a descendant of tag 'noscript'. Did you mean 'amp-img'\?/)
88+
await expect(access(join(outDir, 'dog-cat/index.html'))).resolves.toBe(undefined)
89+
} finally {
90+
nextConfig.restore()
91+
}
92+
})
93+
})

0 commit comments

Comments
 (0)