Skip to content

Commit

Permalink
ci(lint): refactor & add type decl (#335)
Browse files Browse the repository at this point in the history
  • Loading branch information
nekowinston authored Nov 18, 2023
1 parent 1a92e07 commit bb4ea33
Show file tree
Hide file tree
Showing 3 changed files with 274 additions and 64 deletions.
18 changes: 3 additions & 15 deletions scripts/lint/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { verifyMetadata } from "./metadata.ts";
import { lint } from "./stylelint.ts";

const flags = parseFlags(Deno.args, { boolean: ["fix"] });
const stylesheets = walk(join(REPO_ROOT, "styles"), {
const subDir = flags._[0]?.toString() ?? "";
const stylesheets = walk(join(REPO_ROOT, "styles", subDir), {
includeFiles: true,
includeDirs: false,
includeSymlinks: false,
Expand All @@ -29,20 +30,7 @@ for await (const entry of stylesheets) {
const content = await Deno.readTextFile(entry.path);

// verify the usercss metadata
const { globalVars, isLess } = await verifyMetadata(entry, content, repo)
.catch((e) => {
const lines = content.split("\n");
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (e.index >= line.length) {
e.index -= line.length;
startLine++;
} else break;
}
log(e.message, { file, startLine, content }, "error");
throw e;
});
const { globalVars, isLess } = verifyMetadata(entry, content, repo);
// don't attempt to compile or lint non-less files
if (!isLess) continue;

Expand Down
97 changes: 48 additions & 49 deletions scripts/lint/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// TODO: remove this once types for usercss-meta are available
// deno-lint-ignore-file no-explicit-any

import chalk from "chalk";
// @deno-types="../usercss-meta.d.ts";
import usercssMeta from "usercss-meta";
import { log } from "./logger.ts";
import { sprintf } from "std/fmt/printf.ts";
Expand All @@ -13,59 +11,60 @@ export const verifyMetadata = (
entry: WalkEntry,
content: string,
repo: string,
): Promise<{
globalVars: Record<string, string>;
isLess: boolean;
}> => {
return new Promise((resolve, reject) => {
const assert = assertions(repo);
const file = relative(REPO_ROOT, entry.path);
) => {
const assert = assertions(repo);
const file = relative(REPO_ROOT, entry.path);

let metadata: Record<string, any> = {};
try {
metadata = usercssMeta.parse(content).metadata;
} catch (err) {
log(err, { file }, "error");
reject(err);
}
const { metadata, errors: parsingErrors } = usercssMeta.parse(content, {
allowErrors: true,
});

Object.entries(assert).forEach(([k, v]) => {
const defacto = metadata[k];
if (defacto !== v) {
const line = content
.split("\n")
.findIndex((line) => line.includes(k)) + 1;
// pretty print / annotate the parsing errors
parsingErrors.map((e) => {
let startLine = 0;
for (const line of content.split("\n")) {
startLine++;
e.index -= line.length + 1;
if (e.index < 0) break;
}
log(e.message, { file, startLine, content });
});

const message = sprintf(
"Metadata %s should be %s but is %s",
chalk.bold(k),
chalk.green(v),
chalk.red(defacto),
);
Object.entries(assert).forEach(([k, v]) => {
const defacto = metadata[k];
if (defacto !== v) {
const line = content
.split("\n")
.findIndex((line) => line.includes(k)) + 1;

log(message, {
file,
startLine: line !== 0 ? line : undefined,
content,
}, "warning");
}
});
const message = sprintf(
"Metadata %s should be %s but is %s",
chalk.bold(k),
chalk.green(v),
chalk.red(defacto),
);

// parse the usercss variables to less global variables, e.g.
// `@var select lightFlavor "Light Flavor" ["latte:Latte*", "frappe:Frappé", "macchiato:Macchiato", "mocha:Mocha"]`
// gets parsed as
// `lightFlavor: "latte"`
log(message, {
file,
startLine: line !== 0 ? line : undefined,
content,
}, "warning");
}
});

const globalVars = Object.entries<{ default: string }>(metadata.vars)
.reduce((acc, [k, v]) => {
return { ...acc, [k]: v.default };
}, {});
// parse the usercss variables to less global variables, e.g.
// `@var select lightFlavor "Light Flavor" ["latte:Latte*", "frappe:Frappé", "macchiato:Macchiato", "mocha:Mocha"]`
// gets parsed as
// `lightFlavor: "latte"`
const globalVars = Object.entries(metadata.vars)
.reduce((acc, [k, v]) => {
return { ...acc, [k]: v.default };
}, {});

resolve({
globalVars,
isLess: metadata.preprocessor === assert.preprocessor,
});
});
return {
globalVars,
isLess: metadata.preprocessor === assert.preprocessor,
};
};

const assertions = (repo: string) => {
Expand Down
223 changes: 223 additions & 0 deletions scripts/usercss-meta.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
declare namespace usercssMeta {
export const ParseError: ParseError;

export interface ParseError extends Error {
code:
| "invalidCheckboxDefault"
| "invalidRange"
| "invalidRangeMultipleUnits"
| "invalidRangeTooManyValues"
| "invalidRangeValue"
| "invalidRangeDefault"
| "invalidRangeMin"
| "invalidRangeMax"
| "invalidRangeStep"
| "invalidRangeUnits"
| "invalidNumber"
| "invalidSelect"
| "invalidSelectValue"
| "invalidSelectEmptyOptions"
| "invalidSelectLabel"
| "invalidSelectMultipleDefaults"
| "invalidSelectNameDuplicated"
| "invalidString"
| "invalidURLProtocol"
| "invalidVersion"
| "invalidWord"
| "missingChar"
| "missingEOT"
| "missingMandatory"
| "missingValue"
| "unknownJSONLiteral"
| "unknownMeta"
| "unknownVarType";

message: string;

/**
* The string index where the error occurs
*/
index: number;

/**
* An array of values that is used to compose the error message.
* This allows other clients to generate i18n error message.
*/
args: unknown[];
}

// TODO: export util types
// export const util: {};

/**
* This is a shortcut of `createParser(options).parse(text);`
*/
export function parse(
content: string,
options?: ParserOptions,
): ParseResult;

/**
* Create a metadata parser.
*/
export function createParser(options?: ParserOptions): Parser;

// TODO: export stringify types
// export function stringify(
// metadata: Metadata,
// options: StringifierOptions,
// ): string;
// export function createStringifier(options: StringifierOptions): Stringifier;

type Parser = {
/**
* Parse the text (metadata header) and return the result.
*/
parse: typeof parse;

/**
* Validate the value of the variable object.
* This function uses the validators defined in `createParser`.
*/
validateVar: (varObj: VarObj) => void;
};

type ParserOptions = {
/**
* `unknownKey` decides how to parse unknown keys. Possible values are:
* - `ignore`: The directive is ignored. Default.
* - `assign`: Assign the text value (characters before `\s*\n`) to result object.
* - `throw`: Throw a `ParseError`.
* @default "ignore"
*/
unknownKey?: "ignore" | "assign" | "throw";

/**
* mandatoryKeys marks multiple keys as mandatory. If some keys are missing then throw a ParseError
* @default ["name", "namespace", "version"]
*/
mandatoryKeys?: string[];

/**
* A `key`/`parseFunction` map.
* It allows users to extend the parser.
*
* @example
* const parser = createParser({
* mandatoryKeys: [],
* parseKey: {
* myKey: util.parseNumber
* }
* });
* const {metadata} = parser.parse(`
* /* ==UserStyle==
* \@myKey 123456
* ==/UserStyle==
* `);
* assert.equal(metadata.myKey, 123456);
*/
parseKey?: Record<string, unknown>;

/**
* A `variableType`/`parseFunction` map.
* It extends the parser to parse additional variable types.
*
* @example
* const parser = createParser({
* mandatoryKeys: [],
* parseVar: {
* myvar: util.parseNumber
* }
* });
* const {metadata} = parser.parse(`/* ==UserStyle==
* \@var myvar var-name 'Customized variable' 123456
* ==/UserStyle== *\/`);
* const va = metadata.vars['var-name'];
* assert.equal(va.type, 'myvar');
* assert.equal(va.label, 'Customized variable');
* assert.equal(va.default, 123456);
*/
parseVar?: Record<string, unknown>;
/**
* A `key`/`validateFunction` map, which is used to validate the metadata value.
* The function accepts a state object.
*
* @example
* const parser = createParser({
* validateKey: {
* updateURL: state => {
* if (/example\.com/.test(state.value)) {
* throw new ParseError({
* message: 'Example.com is not a good URL',
* index: state.valueIndex
* });
* }
* }
* }
* });
*/
validateKey?: Record<string, validateFn>;

/**
* A `variableType`/`validateFunction` map, which is used to validate variables.
* The function accepts a state object.
*
* @example
* const parser = createParser({
* validateVar: {
* color: state => {
* if (state.value === 'red') {
* throw new ParseError({
* message: '`red` is not allowed',
* index: state.valueIndex
* });
* }
* }
* }
* });
*/
validateVar?: Record<string, (state: StateObject) => void>;

/**
* If allowErrors is true, the parser will collect parsing errors while
* `parser.parse()` and return them as {@link ParseResult.errors}
* Otherwise, the first parsing error will be thrown.
* @default false
*/
allowErrors?: boolean;
};

export type StateObject = {
key: string;
type: string;
value: string;
varResult: unknown;
text: string;
lastIndex: number;
valueIndex: number;
shouldIgnore: boolean;
};

type validateFn = (state: StateObject) => void;

type VarObj = {
label: string;
name: string;
value?: string;
default?: string;
options?: unknown;
};
type Metadata = {
vars: VarObj[];
[key: string]: unknown;
};

type ParseResult = {
metadata: Metadata;
errors: ParseError[];
};
}

declare module "usercss-meta" {
export = usercssMeta;
}

0 comments on commit bb4ea33

Please sign in to comment.