Skip to content

Commit

Permalink
feat(lint): Implement front matter validator and linter
Browse files Browse the repository at this point in the history
  • Loading branch information
OnkarRuikar committed Feb 23, 2023
1 parent ac1a07b commit f512b75
Show file tree
Hide file tree
Showing 7 changed files with 442 additions and 2 deletions.
42 changes: 42 additions & 0 deletions fmlint/cli.ts
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();
127 changes: 127 additions & 0 deletions fmlint/front-matter-config.json
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"]
}
}
226 changes: 226 additions & 0 deletions fmlint/linter.ts
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);
}
}
Loading

0 comments on commit f512b75

Please sign in to comment.