Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lint): Implement front matter validator and linter #8295

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions fmlint/front-matter-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
{
"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": 120
},
"short-title": {
"title": "Short Title",
"description": "To be used in sidebars",
"type": "string",
"maxLength": 60
},
"slug": {
"title": "slug",
"description": "URL path of the page",
"type": "string"
},
"page-type": {
"title": "Page Type",
"description": "Type of the page",
"type": "string"
},
"status": {
"title": "Status",
"description": "Browser compatibility status",
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"enum": ["deprecated", "experimental", "non-standard"]
}
},
"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"
],
"Web/HTTP/Headers/Content-Security-Policy/": ["http-csp-directive"],
"Web/HTTP/Headers/Permissions-Policy/": [
"http-permissions-policy-directive"
],
"Web/HTTP/Headers/": ["guide", "http-header"],
"Web/HTTP/Methods/": ["http-method"],
"Web/HTTP/Status/": ["http-status-code"],
"Web/MathML/Element/": ["mathml-element"],
"Web/MathML/Global_attributes/": ["mathml-attribute"],
"Web/Accessibility/ARIA/Attributes/": ["aria-attribute"],
"Web/Accessibility/ARIA/Roles/": ["aria-role"],
"Web/HTTP/CORS/Errors/": ["http-cors-error"],
"global": ["guide", "landing-page"]
}
}
235 changes: 235 additions & 0 deletions fmlint/linter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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",
"short-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,
"short-title": shortTitle,
slug,
"page-type": pageType,
status,
"spec-urls": specs,
"browser-compat": bcd,
} = fmObject;

fmObject = { title };

if (shortTitle) {
fmObject["short-title"] = shortTitle;
}

fmObject["slug"] = 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, "");
yml = yml.replaceAll("$", "$$$");
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 lintFrontMatter(
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);
}
}
Loading