Skip to content

Commit 47b53ad

Browse files
rsesesarahs
andauthored
ajv lib/frontmatter (github#35487)
Co-authored-by: Sarah Schneider <[email protected]>
1 parent 5a228aa commit 47b53ad

File tree

6 files changed

+126
-218
lines changed

6 files changed

+126
-218
lines changed

lib/frontmatter.js

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import parse from './read-frontmatter.js'
2-
import semver from 'semver'
32
import { allVersions } from './all-versions.js'
43
import { allTools } from './all-tools.js'
54
import { getDeepDataByLanguage } from './get-data.js'
@@ -16,10 +15,12 @@ const layoutNames = [
1615
const guideTypes = ['overview', 'quick_start', 'tutorial', 'how_to', 'reference']
1716

1817
export const schema = {
18+
type: 'object',
19+
required: ['title', 'versions'],
20+
additionalProperties: false,
1921
properties: {
2022
title: {
2123
type: 'string',
22-
required: true,
2324
translatable: true,
2425
},
2526
shortTitle: {
@@ -69,7 +70,7 @@ export const schema = {
6970
layout: {
7071
type: ['string', 'boolean'],
7172
enum: layoutNames,
72-
message: 'must be the filename of an existing layout file, or `false` for no layout',
73+
errorMessage: 'must be the filename of an existing layout file, or `false` for no layout',
7374
},
7475
redirect_from: {
7576
type: 'array',
@@ -120,8 +121,12 @@ export const schema = {
120121
items: {
121122
type: 'object',
122123
properties: {
123-
title: 'string',
124-
href: 'string',
124+
title: {
125+
type: 'string',
126+
},
127+
href: {
128+
type: 'string',
129+
},
125130
},
126131
},
127132
},
@@ -170,8 +175,12 @@ export const schema = {
170175
communityRedirect: {
171176
type: 'object',
172177
properties: {
173-
name: 'string',
174-
href: 'string',
178+
name: {
179+
type: 'string',
180+
},
181+
href: {
182+
type: 'string',
183+
},
175184
},
176185
},
177186
// Platform-specific content preference
@@ -196,15 +205,16 @@ export const schema = {
196205
// External products specified on the homepage
197206
externalProducts: {
198207
type: 'object',
208+
required: ['electron'],
199209
properties: {
200210
electron: {
201211
type: 'object',
202-
required: true,
212+
required: ['id', 'name', 'href', 'external'],
203213
properties: {
204-
id: { type: 'string', required: true },
205-
name: { type: 'string', required: true },
206-
href: { type: 'string', format: 'url', required: true },
207-
external: { type: 'boolean', required: true },
214+
id: { type: 'string' },
215+
name: { type: 'string' },
216+
href: { type: 'string', format: 'url' },
217+
external: { type: 'boolean' },
208218
},
209219
},
210220
},
@@ -252,20 +262,22 @@ const featureVersionsProp = {
252262
items: {
253263
type: 'string',
254264
},
255-
message:
265+
errorMessage:
256266
'must be the name (or names) of a feature that matches "filename" in data/features/_filename_.yml',
257267
},
258268
}
259269

260270
const semverRange = {
261271
type: 'string',
262-
conform: semver.validRange,
263-
message: 'Must be a valid SemVer range',
272+
format: 'semver',
273+
// This is JSON pointer syntax with ajv so we can specify the bad version
274+
// in the error message.
275+
// eslint-disable-next-line no-template-curly-in-string
276+
errorMessage: 'Must be a valid SemVer range: ${0}',
264277
}
265278

266279
schema.properties.versions = {
267280
type: ['object', 'string'], // allow a '*' string to indicate all versions
268-
required: true,
269281
additionalProperties: false, // don't allow any versions in FM that aren't defined in lib/all-versions
270282
properties: Object.values(allVersions).reduce((acc, versionObj) => {
271283
acc[versionObj.plan] = semverRange
@@ -277,8 +289,6 @@ schema.properties.versions = {
277289
export function frontmatter(markdown, opts = {}) {
278290
const defaults = {
279291
schema,
280-
validateKeyNames: true,
281-
validateKeyOrder: false, // TODO: enable this once we've sorted all the keys. See issue 9658
282292
}
283293

284294
return parse(markdown, Object.assign({}, defaults, opts))

lib/read-frontmatter.js

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import matter from 'gray-matter'
2-
import revalidator from 'revalidator'
3-
import { difference, intersection } from 'lodash-es'
2+
import Ajv from 'ajv'
3+
import addErrors from 'ajv-errors'
4+
import addFormats from 'ajv-formats'
5+
import semver from 'semver'
46

5-
function readFrontmatter(markdown, opts = { validateKeyNames: false, validateKeyOrder: false }) {
6-
const schema = opts.schema || { properties: {} }
7+
const ajv = new Ajv({ allErrors: true, allowUnionTypes: true })
8+
ajv.addKeyword({
9+
keyword: 'translatable',
10+
})
11+
ajv.addFormat('semver', {
12+
validate: (x) => semver.validRange(x),
13+
})
14+
addErrors(ajv)
15+
addFormats(ajv)
16+
17+
function readFrontmatter(markdown, opts = {}) {
18+
const schema = opts.schema || { type: 'object', properties: {} }
719
const filepath = opts.filepath || null
820

921
let content, data
@@ -33,40 +45,39 @@ function readFrontmatter(markdown, opts = { validateKeyNames: false, validateKey
3345
return { errors }
3446
}
3547

36-
const allowedKeys = Object.keys(schema.properties)
37-
const existingKeys = Object.keys(data)
38-
const expectedKeys = intersection(allowedKeys, existingKeys)
39-
40-
;({ errors } = revalidator.validate(data, schema))
48+
const ajvValidate = ajv.compile(schema)
49+
const valid = ajvValidate(data)
4150

42-
// add filepath property to each error object
43-
if (errors.length && filepath) {
44-
errors = errors.map((error) => Object.assign(error, { filepath }))
51+
if (!valid) {
52+
errors = ajvValidate.errors
4553
}
4654

47-
// validate key names
48-
if (opts.validateKeyNames) {
49-
const invalidKeys = difference(existingKeys, allowedKeys)
50-
invalidKeys.forEach((key) => {
51-
const error = {
52-
property: key,
53-
message: `not allowed. Allowed properties are: ${allowedKeys.join(', ')}`,
54-
}
55-
if (filepath) error.filepath = filepath
56-
errors.push(error)
57-
})
55+
// Combine the AJV-supplied `instancePath` and `params` into a more user-friendly frontmatter path.
56+
// For example, given:
57+
// "instancePath": "/versions",
58+
// "params": { "additionalProperty": "ftp" }
59+
// return:
60+
// property: 'versions.ftp'
61+
//
62+
// The purpose is to help users understand that the error is on the `fpt` key within the `versions` object.
63+
// Note if the error is on a top-level FM property like `title`, the `instancePath` will be empty.
64+
const cleanPropertyPath = (params, instancePath) => {
65+
const mainProps = Object.values(params)[0]
66+
if (!instancePath) return mainProps
67+
68+
const prefixProps = instancePath.replace('/', '').replace(/\//g, '.')
69+
return typeof mainProps !== 'object' ? `${prefixProps}.${mainProps}` : prefixProps
5870
}
5971

60-
// validate key order
61-
if (opts.validateKeyOrder && existingKeys.join('') !== expectedKeys.join('')) {
62-
const error = {
63-
property: 'keys',
64-
message: `keys must be in order. Current: ${existingKeys.join(
65-
','
66-
)}; Expected: ${expectedKeys.join(',')}`,
67-
}
68-
if (filepath) error.filepath = filepath
69-
errors.push(error)
72+
if (!valid && filepath) {
73+
errors = ajvValidate.errors.map((error) => {
74+
const userFriendly = {}
75+
userFriendly.property = cleanPropertyPath(error.params, error.instancePath)
76+
userFriendly.message = error.message
77+
userFriendly.reason = error.keyword
78+
userFriendly.filepath = filepath
79+
return userFriendly
80+
})
7081
}
7182

7283
return { content, data, errors }

tests/helpers/schemas/feature-versions-schema.js

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,4 @@ featureVersions.additionalProperties = false
2020
// avoid ajv strict warning
2121
featureVersions.type = 'object'
2222

23-
// *** TODO: We can drop the following once the frontmatter.js schema has been updated to work with AJV. ***
24-
const properties = {}
25-
Object.keys(featureVersions.properties.versions.properties).forEach((key) => {
26-
const value = Object.assign({}, featureVersions.properties.versions.properties[key])
27-
28-
// AJV supports errorMessage, not message.
29-
value.errorMessage = value.message
30-
delete value.message
31-
32-
// AJV doesn't support conform, so we'll add semver validation in the lint-versioning test.
33-
if (value.conform) {
34-
value.format = 'semver'
35-
delete value.conform
36-
}
37-
properties[key] = value
38-
})
39-
40-
featureVersions.properties.versions.properties = properties
41-
delete featureVersions.properties.versions.required
42-
// *** End TODO ***
43-
4423
export default featureVersions

tests/helpers/schemas/learning-tracks-schema.js

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,6 @@ import { schema } from '../../../lib/frontmatter.js'
44
// so we can import that part of the FM schema.
55
const versionsProps = Object.assign({}, schema.properties.versions)
66

7-
// Tweak the imported versions schema so it works with AJV.
8-
// *** TODO: We can drop the following once the frontmatter.js schema has been updated to work with AJV. ***
9-
const properties = {}
10-
Object.keys(versionsProps.properties).forEach((key) => {
11-
const value = Object.assign({}, versionsProps.properties[key])
12-
13-
// AJV supports errorMessage, not message.
14-
value.errorMessage = value.message
15-
delete value.message
16-
17-
// AJV doesn't support conform, so we'll add semver validation in the lint-files test.
18-
if (value.conform) {
19-
value.format = 'semver'
20-
delete value.conform
21-
}
22-
properties[key] = value
23-
})
24-
25-
versionsProps.properties = properties
26-
// *** End TODO ***
27-
287
// `versions` are not required in learning tracks the way they are in FM.
298
delete versionsProps.required
309

tests/helpers/schemas/secret-scanning-schema.js

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,6 @@ import { schema } from '../../../lib/frontmatter.js'
44
// so we can import that part of the FM schema.
55
const versionsProps = Object.assign({}, schema.properties.versions)
66

7-
// Tweak the imported versions schema so it works with AJV.
8-
// *** TODO: We can drop the following once the frontmatter.js schema has been updated to work with AJV. ***
9-
const properties = {}
10-
Object.keys(versionsProps.properties).forEach((key) => {
11-
const value = Object.assign({}, versionsProps.properties[key])
12-
13-
// AJV supports errorMessage, not message.
14-
value.errorMessage = value.message
15-
delete value.message
16-
17-
// AJV doesn't support conform, so we'll add semver validation in the lint-secret-scanning-data test.
18-
if (value.conform) {
19-
value.format = 'semver'
20-
delete value.conform
21-
}
22-
properties[key] = value
23-
})
24-
25-
versionsProps.properties = properties
26-
delete versionsProps.required
27-
// *** End TODO ***
28-
297
// The secret-scanning.json contains an array of objects that look like this:
308
// {
319
// "provider": "Azure",

0 commit comments

Comments
 (0)