From a7036acc1ce824a0ee5e91b61b287058b3ccd215 Mon Sep 17 00:00:00 2001 From: Richard Herman <1429781+GeekyEggo@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:55:32 +0100 Subject: [PATCH] feat: enable validation via import (#37) * feat: enable validation functionality via importing * refactor: rename export * build: add static schema build option * refactor: import JSON schemas as objects * chore: remove unused script, and add 0.3.1 changelog * deps: fix security alerts --------- Co-authored-by: Richard Herman Co-authored-by: Andrew Story --- .gitignore | 1 + CHANGELOG.md | 6 + package-lock.json | 75 +++++----- package.json | 12 +- rollup.config.ts | 140 +++++++++++++----- src/{index.ts => cli.ts} | 0 src/common/ordered-array.ts | 14 +- src/main.ts | 13 ++ src/validation/entry.ts | 24 +-- .../{entry-collection.ts => file-result.ts} | 34 ++--- src/validation/index.ts | 5 + src/validation/plugin/index.ts | 5 +- src/validation/plugin/plugin.ts | 17 ++- src/validation/result.ts | 19 +-- 14 files changed, 234 insertions(+), 131 deletions(-) rename src/{index.ts => cli.ts} (100%) create mode 100644 src/main.ts rename src/validation/{entry-collection.ts => file-result.ts} (58%) create mode 100644 src/validation/index.ts diff --git a/.gitignore b/.gitignore index 3c1a7f6..609d105 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build and temporary files bin/ +dist/ /.tmp/ # CLI diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f98e1..bb9d753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ # Change Log +## 0.3.1 + +### ✨ New + +- Enable validation of Stream Deck plugins programmatically. + ## 0.3.0 ### ✨ New diff --git a/package-lock.json b/package-lock.json index e684d05..121945e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.3.0", "license": "MIT", "dependencies": { - "@elgato/schemas": "^0.3.0" + "@elgato/schemas": "^0.3.5" }, "bin": { "sd": "bin/streamdeck.mjs", @@ -34,7 +34,7 @@ "ajv": "^8.12.0", "chalk": "^5.3.0", "commander": "^11.0.0", - "ejs": "^3.1.9", + "ejs": "^3.1.10", "eslint": "^8.51.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-jsdoc": "^46.8.2", @@ -47,6 +47,7 @@ "log-symbols": "^5.1.0", "rage-edit": "^1.2.0", "rollup": "^4.0.2", + "rollup-plugin-dts": "^6.1.0", "semver": "^7.6.0", "tar": "^7.0.1", "tslib": "^2.6.2", @@ -95,7 +96,6 @@ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", "dev": true, - "peer": true, "dependencies": { "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" @@ -109,7 +109,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -122,7 +121,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -137,7 +135,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, - "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -146,15 +143,13 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -284,7 +279,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, - "peer": true, "engines": { "node": ">=6.9.0" } @@ -294,7 +288,6 @@ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dev": true, - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -309,7 +302,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, - "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -322,7 +314,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, - "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -337,7 +328,6 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, - "peer": true, "dependencies": { "color-name": "1.1.3" } @@ -346,15 +336,13 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@babel/highlight/node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, - "peer": true, "engines": { "node": ">=0.8.0" } @@ -481,9 +469,9 @@ } }, "node_modules/@elgato/schemas": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@elgato/schemas/-/schemas-0.3.0.tgz", - "integrity": "sha512-5Ckn9M7LYEmNgw48WKAQYygt6o1rr38wqY0pCFlu+RWMd9ntCl6u1MpzJoxvA0YlT0l/dqTsp3cbQ4iBrg5RzA==" + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@elgato/schemas/-/schemas-0.3.5.tgz", + "integrity": "sha512-6o2eiuKI6TVah50t3CvJVRoh0wPhldvNXo/S5zu5cwowKktC+5sQ5PSuVPpEAbJ6uyKEiLF5pQ1dM3PrNm/F+A==" }, "node_modules/@es-joy/jsdoccomment": { "version": "0.41.0", @@ -1726,12 +1714,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2046,9 +2034,9 @@ "dev": true }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -2560,9 +2548,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -2819,7 +2807,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, - "peer": true, "engines": { "node": ">=4" } @@ -3206,8 +3193,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/js-yaml": { "version": "4.1.0", @@ -4028,6 +4014,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-dts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.1.0.tgz", + "integrity": "sha512-ijSCPICkRMDKDLBK9torss07+8dl9UpY9z1N/zTeA1cIqdzMlpkV3MOOC7zukyvQfDyxa1s3Dl2+DeiP/G6DOw==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.4" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^7.22.13" + }, + "peerDependencies": { + "rollup": "^3.29.4 || ^4", + "typescript": "^4.5 || ^5.0" + } + }, "node_modules/run-async": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", @@ -4337,7 +4345,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, - "peer": true, "dependencies": { "has-flag": "^3.0.0" }, diff --git a/package.json b/package.json index b39a27a..ea6260a 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ "streamdeck": "bin/streamdeck.mjs", "sd": "bin/streamdeck.mjs" }, + "main": "./dist/index.js", "files": [ - "bin/streamdeck.mjs", - "template" + "./bin/streamdeck.mjs", + "./dist/*.js", + "./dist/*.d.ts", + "./template" ], "type": "module", "engines": { @@ -62,7 +65,7 @@ "ajv": "^8.12.0", "chalk": "^5.3.0", "commander": "^11.0.0", - "ejs": "^3.1.9", + "ejs": "^3.1.10", "eslint": "^8.51.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-jsdoc": "^46.8.2", @@ -75,12 +78,13 @@ "log-symbols": "^5.1.0", "rage-edit": "^1.2.0", "rollup": "^4.0.2", + "rollup-plugin-dts": "^6.1.0", "semver": "^7.6.0", "tar": "^7.0.1", "tslib": "^2.6.2", "typescript": "^5.2.2" }, "dependencies": { - "@elgato/schemas": "^0.3.0" + "@elgato/schemas": "^0.3.5" } } diff --git a/rollup.config.ts b/rollup.config.ts index 31cb967..74eb5a8 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -3,45 +3,115 @@ import json from "@rollup/plugin-json"; import nodeResolve from "@rollup/plugin-node-resolve"; import terser from "@rollup/plugin-terser"; import typescript from "@rollup/plugin-typescript"; -import path from "node:path"; +import path, { extname } from "node:path"; import url from "node:url"; import { RollupOptions } from "rollup"; +import dts from "rollup-plugin-dts"; const isWatching = !!process.env.ROLLUP_WATCH; -const config: RollupOptions = { - input: "src/index.ts", - output: { - banner: "#!/usr/bin/env node", - file: "bin/streamdeck.mjs", - sourcemap: isWatching, - sourcemapPathTransform: (relativeSourcePath: string, sourcemapPath: string): string => { - return url.pathToFileURL(path.resolve(path.dirname(sourcemapPath), relativeSourcePath)).href; - } - }, - external: [ - /* Ignore @elgato/schema to enable auto-update. */ - "@elgato/schemas", - "@elgato/schemas/streamdeck/plugins/", - "@elgato/schemas/streamdeck/plugins/layout.json", - "@elgato/schemas/streamdeck/plugins/manifest.json" - ], - plugins: [ - typescript(), - json(), - commonjs(), - nodeResolve({ - browser: false, - exportConditions: ["node"], - preferBuiltins: true - }), - !isWatching && - terser({ - format: { - comments: false - } - }) - ] +/** + * Ignore @elgato/schema to enable auto-update. + */ +const external = [ + "@elgato/schemas", + "@elgato/schemas/streamdeck/plugins/", + "@elgato/schemas/streamdeck/plugins/json", +]; + +/** + * Gets the {@link RollupOptions} for the specified options. + * @param opts Options to convert to {@link RollupOptions}. + * @returns The {@link RollupOptions}. + */ +function getOptions(opts: Options): RollupOptions[] { + const { input, output: file, banner, declarations } = opts; + + /** + * TypeScript compiler options. + */ + const rollupOpts: RollupOptions[] = [ + { + input, + output: { + banner, + file, + sourcemap: isWatching, + sourcemapPathTransform: (relativeSourcePath: string, sourcemapPath: string): string => { + return url.pathToFileURL(path.resolve(path.dirname(sourcemapPath), relativeSourcePath)).href; + }, + }, + external, + plugins: [ + typescript(), + json(), + commonjs(), + nodeResolve({ + browser: false, + exportConditions: ["node"], + preferBuiltins: true, + }), + !isWatching && + terser({ + format: { + comments: false, + }, + }), + ], + }, + ]; + + /** + * TypeScript declaration options. + */ + if (declarations) { + rollupOpts.push({ + input, + output: { + file: `${file.slice(0, extname(file).length * -1)}.d.ts`, + }, + external, + plugins: [dts()], + }); + } + + return rollupOpts; +} + +/** + * Minimal required fields that represent {@link RollupOptions}. + */ +type Options = { + /** + * Input file path. + */ + input: string; + + /** + * Output file path. + */ + output: string; + + /** + * Optional banner to prefix to the output contents. + */ + banner?: string; + + /** + * Determines whether to output declarations. + */ + declarations?: boolean; }; -export default config; +export default [ + ...getOptions({ + input: "src/cli.ts", + output: "bin/streamdeck.mjs", + banner: "#!/usr/bin/env node", + }), + ...getOptions({ + input: "src/main.ts", + output: "dist/index.js", + declarations: true, + }), +]; diff --git a/src/index.ts b/src/cli.ts similarity index 100% rename from src/index.ts rename to src/cli.ts diff --git a/src/common/ordered-array.ts b/src/common/ordered-array.ts index 0c26d42..5884a45 100644 --- a/src/common/ordered-array.ts +++ b/src/common/ordered-array.ts @@ -5,7 +5,7 @@ export class OrderedArray extends Array { /** * Delegates responsible for determining the sort order. */ - private readonly compareOn: ((value: T) => number | string)[]; + readonly #compareOn: ((value: T) => number | string)[]; /** * Initializes a new instance of the {@link OrderedArray} class. @@ -13,7 +13,7 @@ export class OrderedArray extends Array { */ constructor(...compareOn: ((value: T) => number | string)[]) { super(); - this.compareOn = compareOn; + this.#compareOn = compareOn; } /** @@ -22,7 +22,7 @@ export class OrderedArray extends Array { * @returns New length of the array. */ public push(value: T): number { - super.splice(this.sortedIndex(value), 0, value); + super.splice(this.#sortedIndex(value), 0, value); return this.length; } @@ -32,8 +32,8 @@ export class OrderedArray extends Array { * @param b Item B. * @returns `-1` when {@link a} is less than {@link b}, `1` when {@link a} is greater than {@link b}, otherwise `0` */ - private compare(a: T, b: T): number { - for (const compareOn of this.compareOn) { + #compare(a: T, b: T): number { + for (const compareOn of this.#compareOn) { const x = compareOn(a); const y = compareOn(b); @@ -53,13 +53,13 @@ export class OrderedArray extends Array { * @param value The value. * @returns Index. */ - private sortedIndex(value: T): number { + #sortedIndex(value: T): number { let low = 0; let high = this.length; while (low < high) { const mid = (low + high) >>> 1; - const comparison = this.compare(value, this[mid]); + const comparison = this.#compare(value, this[mid]); if (comparison === 0) { return mid; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c645ff0 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,13 @@ +import chalk from "chalk"; + +export { + ValidationLevel, + type FileValidationResult, + type ValidationEntry, + type ValidationEntryDetails, + type ValidationResult, +} from "./validation"; + +export { validatePlugin as validateStreamDeckPlugin } from "./validation/plugin"; + +chalk.level = 0; diff --git a/src/validation/entry.ts b/src/validation/entry.ts index 9af0446..e517bbf 100644 --- a/src/validation/entry.ts +++ b/src/validation/entry.ts @@ -27,32 +27,32 @@ export class ValidationEntry { this.message = message.slice(0, -1); } + // Determine the location string based on how much location information is provided. if (this.details?.location?.column || this.details?.location?.line) { this.location = `${this.details.location.line}`; if (this.details.location.column) { this.location += `:${this.details.location.column}`; } } + + // Prepend the location's key to the message if there is one; this is typically the JSON property name, for example "CodePath", "Author", etc. + if (this.details?.location?.key) { + this.message = `${chalk.cyan(this.details.location.key)} ${message}`; + } } /** - * Converts the entry to a string. - * @param padding Padding required to align the position of each entry. + * Converts the entry to a summary string. + * @param padding Optional padding required to align the position of each entry. * @returns String that represents the entry. */ - public toString(padding: number): string { + public toSummary(padding?: number): string { // Apply additional padding to the position so entries without position aren't misaligned. - const position = padding === 0 ? "" : `${this.location.padEnd(padding + 2)}`; + const position = padding === undefined || padding === 0 ? "" : `${this.location.padEnd(padding + 2)}`; const level = ValidationLevel[this.level].padEnd(7); - // Construct the base message - let message = this.message; - if (this.details?.location?.key) { - message = `${chalk.cyan(this.details.location.key)} ${message}`; - } - - // Prepend the position and level. - message = ` ${chalk.dim(position)}${this.level === ValidationLevel.error ? chalk.red(level) : chalk.yellow(level)} ${message}`; + // Prepend the position and level to the message. + let message = ` ${chalk.dim(position)}${this.level === ValidationLevel.error ? chalk.red(level) : chalk.yellow(level)} ${this.message}`; // Attach the suggestion; we prefix a hidden position so that errors are clickable within supported terminals (for example, VSCode). if (this.details?.suggestion) { diff --git a/src/validation/entry-collection.ts b/src/validation/file-result.ts similarity index 58% rename from src/validation/entry-collection.ts rename to src/validation/file-result.ts index 1453442..1289069 100644 --- a/src/validation/entry-collection.ts +++ b/src/validation/file-result.ts @@ -5,37 +5,35 @@ import { StdOut } from "../common/stdout"; import type { ValidationEntry } from "./entry"; /** - * Collection of {@link ValidationEntry}. + * Provides validation results for a specific file path. */ -export class ValidationEntryCollection { - /** - * Entries within this collection. - */ - private entries = new OrderedArray( - (x) => x.level, - (x) => x.details?.location?.line ?? Infinity, - (x) => x.details?.location?.column ?? Infinity, - (x) => x.message, - ); - +export class FileValidationResult extends OrderedArray { /** * Tracks the padding required for the location of a validation entry, i.e. the text before the entry level. */ private padding = 0; /** - * Initializes a new instance of the {@link ValidationEntryCollection} class. + * Initializes a new instance of the {@link FileValidationResult} class. * @param path Path that groups the entries together. */ - constructor(public readonly path: string) {} + constructor(public readonly path: string) { + super( + (x) => x.level, + (x) => x.details?.location?.line ?? Infinity, + (x) => x.details?.location?.column ?? Infinity, + (x) => x.message, + ); + } /** * Adds the specified {@link entry} to the collection. * @param entry Entry to add. + * @returns New length of the validation results. */ - public add(entry: ValidationEntry): void { + public push(entry: ValidationEntry): number { this.padding = Math.max(this.padding, entry.location.length); - this.entries.push(entry); + return super.push(entry); } /** @@ -43,7 +41,7 @@ export class ValidationEntryCollection { * @param output Output to write to. */ public writeTo(output: StdOut): void { - if (this.entries.length === 0) { + if (this.length === 0) { return; } @@ -54,7 +52,7 @@ export class ValidationEntryCollection { output.log(); } - this.entries.forEach((entry) => output.log(entry.toString(this.padding))); + this.forEach((entry) => output.log(entry.toSummary(this.padding))); output.log(); } } diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 0000000..e5add25 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,5 @@ +export * from "./entry"; +export * from "./file-result"; +export * from "./result"; +export * from "./rule"; +export * from "./validator"; diff --git a/src/validation/plugin/index.ts b/src/validation/plugin/index.ts index 87f59a0..ba1c0ea 100644 --- a/src/validation/plugin/index.ts +++ b/src/validation/plugin/index.ts @@ -16,8 +16,9 @@ import { pathIsDirectoryAndUuid } from "./rules/path-input"; * @param path Path to the plugin. * @returns The validation result. */ -export function validatePlugin(path: string): Promise { - return validate(path, createContext(path), [ +export async function validatePlugin(path: string): Promise { + const ctx = await createContext(path); + return validate(path, ctx, [ pathIsDirectoryAndUuid, manifestExistsAndSchemaIsValid, manifestFilesExist, diff --git a/src/validation/plugin/plugin.ts b/src/validation/plugin/plugin.ts index 7b88b2b..2cfbef9 100644 --- a/src/validation/plugin/plugin.ts +++ b/src/validation/plugin/plugin.ts @@ -1,13 +1,11 @@ import type { Layout, Manifest } from "@elgato/schemas/streamdeck/plugins"; -import { createRequire } from "node:module"; +import type { AnySchema } from "ajv"; import { basename, dirname, join, resolve } from "node:path"; import { JsonLocation, LocationRef } from "../../common/location"; import { JsonFileContext, JsonSchema } from "../../json"; import { isPredefinedLayoutLike, isValidPluginId } from "../../stream-deck"; -const nodeRequire = createRequire(import.meta.url); - /** * Suffixed associated with a plugin directory. */ @@ -18,12 +16,13 @@ export const directorySuffix = ".sdPlugin"; * @param path Plugin directory. * @returns Plugin context. */ -export function createContext(path: string): PluginContext { +export async function createContext(path: string): Promise { const id = basename(path).replace(/\.sdPlugin$/, ""); + const { manifest, layout } = await import("@elgato/schemas/streamdeck/plugins/json"); return { hasValidId: isValidPluginId(id), - manifest: new ManifestJsonFileContext(join(path, "manifest.json")), + manifest: new ManifestJsonFileContext(join(path, "manifest.json"), manifest, layout), id, }; } @@ -40,11 +39,13 @@ class ManifestJsonFileContext extends JsonFileContext { /** * Initializes a new instance of the {@link ManifestJsonFileContext} class. * @param path Path to the manifest file. + * @param manifestSchema JSON schema that defines the manifest. + * @param layoutSchema JSON schema that defines a layout. */ - constructor(path: string) { - super(path, new JsonSchema(nodeRequire("@elgato/schemas/streamdeck/plugins/manifest.json"))); + constructor(path: string, manifestSchema: AnySchema, layoutSchema: AnySchema) { + super(path, new JsonSchema(manifestSchema)); - const compiledLayoutSchema = new JsonSchema(nodeRequire("@elgato/schemas/streamdeck/plugins/layout.json")); + const compiledLayoutSchema = new JsonSchema(layoutSchema); this.value.Actions?.forEach((action) => { if (action.Encoder?.layout !== undefined && !isPredefinedLayoutLike(action.Encoder?.layout.value)) { const filePath = resolve(dirname(path), action.Encoder.layout.value); diff --git a/src/validation/result.ts b/src/validation/result.ts index 094a790..a6e4894 100644 --- a/src/validation/result.ts +++ b/src/validation/result.ts @@ -1,14 +1,11 @@ import { StdOut } from "../common/stdout"; import { type ValidationEntry, ValidationLevel } from "./entry"; -import { ValidationEntryCollection } from "./entry-collection"; +import { FileValidationResult } from "./file-result"; /** - * Validation result containing a collection of {@link ValidationEntryCollection} grouped by the directory or file path they're associated with. + * Validation result containing a collection of {@link FileValidationResult} grouped by the directory or file path they're associated with. */ -export class ValidationResult - extends Array - implements ReadonlyArray -{ +export class ValidationResult extends Array implements ReadonlyArray { /** * Private backing field for {@link Result.errorCount}. */ @@ -31,13 +28,13 @@ export class ValidationResult this.warningCount++; } - let collection = this.find((c) => c.path === path); - if (collection === undefined) { - collection = new ValidationEntryCollection(path); - this.push(collection); + let fileResult = this.find((c) => c.path === path); + if (fileResult === undefined) { + fileResult = new FileValidationResult(path); + this.push(fileResult); } - collection.add(entry); + fileResult.push(entry); } /**