-
Notifications
You must be signed in to change notification settings - Fork 513
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(lint): Implement front matter validator and linter
- Loading branch information
1 parent
ac1a07b
commit f512b75
Showing
7 changed files
with
442 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
#!/usr/bin/env node | ||
|
||
import path from "node:path"; | ||
import { CliArgsAndOptions } from "./types.js"; | ||
import caporal from "@caporal/core"; | ||
import { runLinter } from "./linter.js"; | ||
import { fileURLToPath } from "node:url"; | ||
import { CONTENT_ROOT, CONTENT_TRANSLATED_ROOT } from "../libs/env/index.js"; | ||
|
||
const { program } = caporal; | ||
const appDirectory = new URL("fmlint", import.meta.url); | ||
const resolveApp = (relativePath) => | ||
fileURLToPath(new URL(relativePath, appDirectory)); | ||
|
||
program | ||
.version("0.0.0") | ||
.option("--cwd <path>", "Explicit current-working-directory", { | ||
validator: program.STRING, | ||
default: process.cwd(), | ||
}) | ||
.option("--config <path>", "Front matter config file location", { | ||
validator: program.STRING, | ||
default: resolveApp("front-matter-config.json"), | ||
}) | ||
.option("--fix", "Save formatted/corrected output", { | ||
validator: program.BOOLEAN, | ||
default: false, | ||
}) | ||
.argument("[files...]", "list of files and/or directories to check", { | ||
default: [CONTENT_ROOT, CONTENT_TRANSLATED_ROOT].filter(Boolean), | ||
}) | ||
.action(({ args, options, logger }: CliArgsAndOptions) => { | ||
const cwd = options.cwd || process.cwd(); | ||
const files = (args.files || []).map((f) => path.resolve(cwd, f)); | ||
if (!files.length) { | ||
logger.info("No files to lint."); | ||
return; | ||
} | ||
return runLinter(files, options); | ||
}); | ||
|
||
program.run(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
{ | ||
"lineWidth": 80, | ||
"schema": { | ||
"title": "Front matter schema", | ||
"type": "object", | ||
"additionalProperties": false, | ||
"required": ["title", "slug"], | ||
"properties": { | ||
"title": { | ||
"title": "Title", | ||
"description": "Rendered page title and for SEO", | ||
"type": "string", | ||
"maxLength": 150 | ||
}, | ||
"slug": { | ||
"title": "slug", | ||
"description": "URL path of the page", | ||
"type": "string" | ||
}, | ||
"page-type": { | ||
"title": "Page Type", | ||
"type": "string" | ||
}, | ||
"status": { | ||
"title": "Status", | ||
"description": "Browser compatibility status", | ||
"type": "array", | ||
"uniqueItems": true, | ||
"items": { | ||
"type": "string", | ||
"enum": ["experimental", "non-standard", "deprecated"] | ||
} | ||
}, | ||
"browser-compat": { | ||
"title": "Browser Compatibility", | ||
"description": "Browser compatibility location", | ||
"type": ["array", "string"] | ||
}, | ||
"spec-urls": { | ||
"title": "Specification URLs", | ||
"description": "Specification locations", | ||
"type": ["array", "string"], | ||
"format": "uri", | ||
"items": { | ||
"type": "string", | ||
"format": "uri" | ||
} | ||
} | ||
} | ||
}, | ||
"allowedPageTypes": { | ||
"Glossary": ["glossary-definition", "glossary-disambiguation"], | ||
"MDN/": ["landing-page", "mdn-community-guide", "mdn-writing-guide"], | ||
"Web/SVG/": ["guide", "landing-page", "svg-attribute", "svg-element"], | ||
"Web/HTML/": [ | ||
"guide", | ||
"landing-page", | ||
"html-attribute", | ||
"html-attribute-value", | ||
"html-element" | ||
], | ||
"Mozilla/Add-ons/WebExtensions/": [ | ||
"guide", | ||
"landing-page", | ||
"webextension-api-function", | ||
"webextension-api", | ||
"webextension-api-event", | ||
"webextension-api-property", | ||
"webextension-api-type", | ||
"webextension-manifest-key" | ||
], | ||
"Web/API/": [ | ||
"guide", | ||
"landing-page", | ||
"web-api-overview", | ||
"web-api-global-function", | ||
"web-api-global-property", | ||
"web-api-interface", | ||
"web-api-constructor", | ||
"web-api-instance-method", | ||
"web-api-instance-property", | ||
"web-api-instance-event", | ||
"web-api-static-method", | ||
"web-api-static-property", | ||
"web-api-event", | ||
"webgl-extension", | ||
"webgl-extension-method" | ||
], | ||
"Web/CSS/": [ | ||
"guide", | ||
"landing-page", | ||
"css-at-rule", | ||
"css-at-rule-descriptor", | ||
"css-combinator", | ||
"css-function", | ||
"css-keyword", | ||
"css-media-feature", | ||
"css-module", | ||
"css-property", | ||
"css-pseudo-class", | ||
"css-pseudo-element", | ||
"css-selector", | ||
"css-shorthand-property", | ||
"css-type" | ||
], | ||
"Web/JavaScript/": [ | ||
"guide", | ||
"landing-page", | ||
"javascript-class", | ||
"javascript-constructor", | ||
"javascript-error", | ||
"javascript-function", | ||
"javascript-global-property", | ||
"javascript-instance-accessor-property", | ||
"javascript-instance-data-property", | ||
"javascript-instance-method", | ||
"javascript-language-feature", | ||
"javascript-namespace", | ||
"javascript-operator", | ||
"javascript-statement", | ||
"javascript-static-accessor-property", | ||
"javascript-static-data-property", | ||
"javascript-static-method" | ||
], | ||
"global": ["guide", "landing-page"] | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,226 @@ | ||
import { LinterOptions, FMConfig, ValidationError } from "./types.js"; | ||
import fs from "node:fs/promises"; | ||
import path from "node:path"; | ||
import os from "node:os"; | ||
|
||
import YAML from "js-yaml"; | ||
import _Ajv from "ajv"; | ||
|
||
import addFormats from "ajv-formats"; | ||
import { betterAjvErrors } from "@apideck/better-ajv-errors"; | ||
|
||
import { eachLimit } from "async"; | ||
import cliProgress from "cli-progress"; | ||
import { fdir, PathsOutput } from "fdir"; | ||
|
||
const ORDER = [ | ||
"title", | ||
"slug", | ||
"page-type", | ||
"status", | ||
"browser-compat", | ||
"spec-urls", | ||
]; | ||
const FM_RX = /(?<=^---\n)title[\s\S]+?(?=\n---$)/gm; | ||
|
||
function getRelativePath(filePath: string): string { | ||
return path.relative(process.cwd(), filePath); | ||
} | ||
|
||
function areAttributesInOrder(fm: object): boolean { | ||
let prevIndex = -1; | ||
let inOrder = true; | ||
for (const attribute of Object.keys(fm)) { | ||
const index = ORDER.indexOf(attribute); | ||
if (index === -1) { | ||
continue; | ||
} | ||
if (index <= prevIndex) { | ||
inOrder = false; | ||
break; | ||
} | ||
prevIndex = index; | ||
} | ||
return inOrder; | ||
} | ||
|
||
export async function checkFrontMatter( | ||
filePath: string, | ||
options: LinterOptions | ||
) { | ||
let content = await fs.readFile(filePath, "utf-8"); | ||
const frontMatter = content.match(FM_RX)[0]; | ||
let fmObject = YAML.load(frontMatter); | ||
|
||
// find a validator for the file path | ||
let validator = null; | ||
for (const type of Object.keys(options.validators)) { | ||
const rx = new RegExp(`^${type}`, "g"); | ||
if (rx.test(fmObject.slug)) { | ||
validator = options.validators[type]; | ||
break; | ||
} | ||
} | ||
if (validator === null) { | ||
validator = options.validators["global"]; | ||
} | ||
|
||
// validate and collect errors | ||
const valid = validator(fmObject); | ||
const validationErrors: ValidationError[] = betterAjvErrors({ | ||
schema: validator.schema, | ||
data: fmObject, | ||
errors: validator.errors, | ||
}); | ||
const errors = []; | ||
if (!valid) { | ||
for (const error of validationErrors) { | ||
let message = error.message.replace("{base}", "Front matter"); | ||
if (error.context.allowedValues) { | ||
message += `: \n\t${error.context.allowedValues.join(", ")}`; | ||
} | ||
errors.push(message); | ||
} | ||
} | ||
|
||
const inOrder = areAttributesInOrder(fmObject); | ||
let fixableError = null; | ||
if (!options.fix && !inOrder) { | ||
fixableError = `${getRelativePath( | ||
filePath | ||
)}\n\t Front matter attributes are not in required order: ${ORDER.join( | ||
"->" | ||
)}`; | ||
} | ||
|
||
// if --fix option is true, fix order and prettify | ||
if (options.fix) { | ||
const { | ||
title, | ||
slug, | ||
"page-type": pageType, | ||
status, | ||
"spec-urls": specs, | ||
"browser-compat": bcd, | ||
} = fmObject; | ||
|
||
fmObject = { title, slug }; | ||
|
||
if (pageType) { | ||
fmObject["page-type"] = pageType; | ||
} | ||
|
||
if (status && status?.length) { | ||
fmObject["status"] = status; | ||
} | ||
|
||
if (bcd && bcd?.length) { | ||
if (Array.isArray(bcd) && bcd.length === 1) { | ||
fmObject["browser-compat"] = bcd[0]; | ||
} else { | ||
fmObject["browser-compat"] = bcd; | ||
} | ||
} | ||
|
||
if (specs && specs?.length) { | ||
if (Array.isArray(specs) && specs.length === 1) { | ||
fmObject["spec-urls"] = specs[0]; | ||
} else { | ||
fmObject["spec-urls"] = specs; | ||
} | ||
} | ||
|
||
let yml = YAML.dump(fmObject, { | ||
skipInvalid: true, | ||
lineWidth: options.config.lineWidth, | ||
quotingType: '"', | ||
}); | ||
yml = yml.replace(/[\s\n]+$/g, ""); | ||
content = content.replace(frontMatter, yml); | ||
|
||
fs.writeFile(filePath, content); | ||
} | ||
|
||
return [ | ||
errors.length | ||
? `Error: ${getRelativePath(filePath)}\n${errors.join("\n")}` | ||
: null, | ||
fixableError, | ||
]; | ||
} | ||
|
||
async function resolveDirectory(file: string): Promise<string[]> { | ||
const stats = await fs.lstat(file); | ||
if (stats.isDirectory()) { | ||
const api = new fdir() | ||
.withErrors() | ||
.withFullPaths() | ||
.filter((filePath) => filePath.endsWith("index.md")) | ||
.crawl(file); | ||
return api.withPromise() as Promise<PathsOutput>; | ||
} else if (stats.isFile() && file.endsWith("index.md")) { | ||
return [file]; | ||
} else { | ||
return []; | ||
} | ||
} | ||
|
||
// create ajv validators for each document type | ||
function compileValidators(config: FMConfig) { | ||
const AJV = _Ajv as unknown as typeof _Ajv.default; | ||
const ajv = new AJV({ allowUnionTypes: true, allErrors: true }); | ||
addFormats.default(ajv); | ||
const validators = {}; | ||
|
||
for (const type of Object.keys(config.allowedPageTypes)) { | ||
const copy = JSON.parse(JSON.stringify(config.schema)); | ||
|
||
copy.properties["page-type"].enum = config.allowedPageTypes[type]; | ||
validators[type] = ajv.compile(copy); | ||
} | ||
return validators; | ||
} | ||
|
||
// lint front matter | ||
export async function runLinter( | ||
filesAndDirectories: string[], | ||
options: LinterOptions | ||
) { | ||
const files = ( | ||
await Promise.all(filesAndDirectories.map(resolveDirectory)) | ||
).flat(); | ||
|
||
options.config = JSON.parse( | ||
await fs.readFile(options.config as any, "utf-8") | ||
); | ||
options.validators = compileValidators(options.config); | ||
|
||
const progressBar = new cliProgress.SingleBar({ etaBuffer: 100 }); | ||
progressBar.start(files.length, 0); | ||
|
||
const errors = []; | ||
const fixableErrors = []; | ||
await eachLimit(files, os.cpus().length, async (file) => { | ||
try { | ||
const [error, fixableError] = await checkFrontMatter(file, options); | ||
error && errors.push(error); | ||
fixableError && fixableErrors.push(fixableError); | ||
} catch (err) { | ||
errors.push(err); | ||
} finally { | ||
progressBar.increment(); | ||
} | ||
}); | ||
progressBar.stop(); | ||
console.log(errors.length, fixableErrors.length); | ||
if (errors.length || fixableErrors.length) { | ||
let msg = errors.map((error) => `${error}`).join("\n\n"); | ||
|
||
if (fixableErrors.length) { | ||
msg += | ||
"\n\nFollowing fixable errors can be fixed using '--fix true' option\n"; | ||
msg += fixableErrors.map((error) => `${error}`).join("\n"); | ||
} | ||
throw new Error(msg); | ||
} | ||
} |
Oops, something went wrong.