Skip to content

Commit

Permalink
refactor: items of layout schema (#29)
Browse files Browse the repository at this point in the history
* refactor: items of layout schema

Utilizing the if-then-else for items provides more meaningful error messages when validating JSON. Previously, if one property was missing, you would see that error for all layout item types.

* fix: remove underscore from UUIDs

* fix: restrict allowed characters of layout item keys

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Feb 7, 2024
1 parent 7ef4e8b commit 243d65e
Show file tree
Hide file tree
Showing 7 changed files with 997 additions and 761 deletions.
1,289 changes: 692 additions & 597 deletions schemas/layout.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions schemas/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,8 @@
"UUID": {
"type": "string",
"description": "Unique identifier of the action, represented in reverse-DNS format. This value is supplied by Stream Deck when events are emitted that relate to the action enabling you to identify the source of the event.\n\n**Allowed characters:**\n- Lowercase alphanumeric characters (a-z, 0-9)\n- Hyphens (-)\n- Underscores (_)\n- Periods (.)\n\nNB: `UUID` must be unique, and should be prefixed with the plugin's UUID.\n\n\n**Examples:**\n- com.elgato.wavelink.toggle-mute\n- com.elgato.discord.join-voice\n- tv.twitch.go-live",
"pattern": "^([a-z0-9\\-_]+)(\\.[a-z0-9\\-_]+)+$",
"errorMessage": "String must use reverse DNS format, and must only contain lowercase alphanumeric characters (a-z, 0-9), hyphens (-), underscores (_), and periods (.)",
"pattern": "^([a-z0-9\\-]+)(\\.[a-z0-9\\-]+)+$",
"errorMessage": "String must only contain alphanumeric characters (A-z, 0-9), hyphens (-), and periods (.), and be in reverse DNS format",
"markdownDescription": "Unique identifier of the action, represented in reverse-DNS format. This value is supplied by Stream Deck when events are emitted that relate to the action enabling you to identify the source of the event.\n\n**Allowed characters:**\n- Lowercase alphanumeric characters (a-z, 0-9)\n- Hyphens (-)\n- Underscores (_)\n- Periods (.)\n\nNB: `UUID` must be unique, and should be prefixed with the plugin's UUID.\n\n\n**Examples:**\n- com.elgato.wavelink.toggle-mute\n- com.elgato.discord.join-voice\n- tv.twitch.go-live"
},
"UserTitleEnabled": {
Expand Down Expand Up @@ -612,8 +612,8 @@
"UUID": {
"type": "string",
"description": "Unique identifier of the plugin, represented in reverse-DNS format.\n\n**Allowed characters:**\n- Lowercase alphanumeric characters (a-z, 0-9)\n- Hyphens (-)\n- Underscores (_)\n- Periods (.)\n\n**Examples:**\n- com.elgato.wavelink\n- com.elgato.discord\n- tv.twitch",
"pattern": "^([a-z0-9\\-_]+)(\\.[a-z0-9\\-_]+)+$",
"errorMessage": "String must use reverse DNS format, and must only contain lowercase alphanumeric characters (a-z, 0-9), hyphens (-), underscores (_), and periods (.)",
"pattern": "^([a-z0-9\\-]+)(\\.[a-z0-9\\-]+)+$",
"errorMessage": "String must only contain alphanumeric characters (A-z, 0-9), hyphens (-), and periods (.), and be in reverse DNS format",
"markdownDescription": "Unique identifier of the plugin, represented in reverse-DNS format.\n\n**Allowed characters:**\n- Lowercase alphanumeric characters (a-z, 0-9)\n- Hyphens (-)\n- Underscores (_)\n- Periods (.)\n\n**Examples:**\n- com.elgato.wavelink\n- com.elgato.discord\n- tv.twitch"
},
"Version": {
Expand Down
173 changes: 15 additions & 158 deletions scripts/generate-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* eslint-disable no-useless-escape */
import type { JSONSchema7 } from "json-schema";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Schema, createGenerator } from "ts-json-schema-generator";
import { createGenerator } from "ts-json-schema-generator";
import { customKeywordTransformer } from "./schema-transformers/custom-keywords";
import { layoutTransformer } from "./schema-transformers/layout";

// Create a generator so we're able to produce multiple schemas.
const generator = createGenerator({
Expand All @@ -18,173 +21,27 @@ if (!existsSync(outputDir)) {
}

generateAndWriteSchema("Manifest");
generateAndWriteSchema("Layout");
generateAndWriteSchema("Layout", layoutTransformer);

/**
* Generates the JSON schema for the specified TypeScript `type`, and writes it locally to `{type}.json`.
* @param type TypeScript type whose schema should be generated.
* @param transform Optional function used to transform the schema.
*/
function generateAndWriteSchema(type: string): void {
function generateAndWriteSchema(type: string, transform?: (schema: JSONSchema7) => void): void {
const schema = generator.createSchema(type);
applyCustomKeywords(schema);
if (transform) {
transform(schema);
}

// Apply the custom keyword transformer to all schemas
customKeywordTransformer(schema);

// Determine the output path, and serialize the schema.
const outputPath = join(outputDir, `${type.toLowerCase()}.json`);
const contents = JSON.stringify(schema, null, "\t");

// Finally write the schema.
writeFileSync(outputPath, contents);
console.log(`Successfully generated schema for ${type}.`);
}

/**
* Applies the custom keywords, aggregating the schema to form a valid structure.
* @param schema Schema to apply the custom keywords to.
*/
function applyCustomKeywords(schema: ExtendedSchema): void {
visitNode(schema, (node, keyword, value) => {
switch (keyword) {
case "description":
node.markdownDescription = value?.toString();
break;

case "filePath":
validateFilePathOptions(value);
node.pattern = generatePathPattern(value);
node.errorMessage = generatePathErrorMessage(value);

break;
}
});
}

/**
* Validates the specified {@link options} are an instance of {@link FilePathOptions}.
* @param options Options to validate.
*/
function validateFilePathOptions(options: unknown): asserts options is FilePathOptions {
if (options === null) {
throw new TypeError(`"filePath" options must not be null`);
}

if (typeof options === "boolean") {
if (options === false) {
throw new TypeError(`"false" is not a valid value for "filePath", expected: "true"`);
}

return;
}

if (typeof options !== "object" || !("extensions" in options) || !("includeExtension" in options)) {
throw new TypeError(`${JSON.stringify(options)} is not a complete set of "filePath" options, expected: { "extensions": string[], "includeExtension": boolean }`);
}
}

/**
* Generates the regular expression pattern of a property based file path's {@link options}.
* - {@link https://regexr.com/7qpi6 File path, with unknown extension}
* - {@link https://regexr.com/7qpj7 File path, with extension}
* - {@link https://regexr.com/7qp5k File path, without extension}
* @param options Options used to determine how the pattern should be generated.
* @returns Regular expression pattern.
*/
function generatePathPattern(options: FilePathOptions): string {
let pattern = "^(?![~\\.]*[\\\\\\/]+)"; // ensure the value doesn't start with a slash, or period followed by a slash.

// When the file path's extension is unknown, we simply ensure the start of the string.
if (typeof options === "boolean") {
return (pattern += ".*$");
}

// Otherwise, construct the pattern based on the valid extensions.
const exts = options.extensions
.map((extension) => {
const chars = Array.from(extension)
.slice(1)
.map((c) => `[${c.toUpperCase()}${c.toLowerCase()}]`)
.join("");

return `(${chars})`;
})
.join("|");

if (options.includeExtension) {
// Ensure the value ends with a valid extension
pattern += `.*\\.(${exts})$`;
} else {
// Use a negative look-ahead to ensure the extension isn't specified.
pattern += `(?!.*\\.(${exts})$).*$`;
}

return pattern;
}

/**
* Generates the custom error message associated with a file path.
* @param options Options that define the valid file path.
* @returns Custom error message.
*/
function generatePathErrorMessage(options: FilePathOptions): string {
if (typeof options === "boolean") {
return "String must reference file in the plugin directory.";
}

const exts = options.extensions.reduce((prev, current, index) => {
return index === 0 ? current : index === options.extensions.length - 1 ? prev + `, or ${current}` : prev + `, ${current}`;
}, "");

const errorMessage = `String must reference ${exts} file in the plugin directory`;
return options.includeExtension ? `${errorMessage}.` : `${errorMessage}, with the file extension omitted.`;
}

/**
* Traverses the specified {@link schema} and applies the visitor to each property.
* @param schema Schema to traverse
* @param visitor Visitor to each of the schema's properties.
*/
function visitNode(schema: ExtendedSchema, visitor: (schema: ExtendedSchema, keyword: keyof ExtendedSchema, value: unknown) => void): void {
if (typeof schema === "object") {
for (const [keyword, value] of Object.entries(schema)) {
if (typeof value === "object") {
visitNode(value, visitor);
}

visitor(schema, keyword as keyof ExtendedSchema, value);
}
}
}

/**
* Provides an extended JSON schema that includes the `markdownDescription` property.
*/
type ExtendedSchema = Schema & {
/**
* Custom error message shown when the value does not confirm to the defined schemas.
*/
errorMessage?: string;

/**
* Determines whether the value must represent a file path.
*/
filePath?: FilePathOptions;

/**
* Markdown representation of the description.
*/
markdownDescription?: string;
};

/**
* Options used to determine a valid file path, used to generate the regular expression pattern.
*/
type FilePathOptions =
| true
| {
/**
* Collection of valid file extensions.
*/
extensions: string[];

/**
* Determines whether the extension must be present, or omitted, from the file path.
*/
includeExtension: boolean;
};
156 changes: 156 additions & 0 deletions scripts/schema-transformers/custom-keywords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { JSONSchema7 } from "json-schema";
import { Schema } from "ts-json-schema-generator";

/**
* Applies the custom keywords, aggregating the schema to form a valid structure.
* @param schema Schema to apply the custom keywords to.
*/
export function customKeywordTransformer(schema: JSONSchema7): void {
visitNode(schema, (node, keyword, value) => {
switch (keyword) {
case "description":
node.markdownDescription = value?.toString();
break;

case "filePath":
validateFilePathOptions(value);
node.pattern = generatePathPattern(value);
node.errorMessage = generatePathErrorMessage(value);

break;
}
});
}

/**
* Validates the specified {@link options} are an instance of {@link FilePathOptions}.
* @param options Options to validate.
*/
function validateFilePathOptions(options: unknown): asserts options is FilePathOptions {
if (options === null) {
throw new TypeError(`"filePath" options must not be null`);
}

if (typeof options === "boolean") {
if (options === false) {
throw new TypeError(`"false" is not a valid value for "filePath", expected: "true"`);
}

return;
}

if (typeof options !== "object" || !("extensions" in options) || !("includeExtension" in options)) {
throw new TypeError(`${JSON.stringify(options)} is not a complete set of "filePath" options, expected: { "extensions": string[], "includeExtension": boolean }`);
}
}

/**
* Generates the regular expression pattern of a property based file path's {@link options}.
* - {@link https://regexr.com/7qpi6 File path, with unknown extension}
* - {@link https://regexr.com/7qpj7 File path, with extension}
* - {@link https://regexr.com/7qp5k File path, without extension}
* @param options Options used to determine how the pattern should be generated.
* @returns Regular expression pattern.
*/
function generatePathPattern(options: FilePathOptions): string {
let pattern = "^(?![~\\.]*[\\\\\\/]+)"; // ensure the value doesn't start with a slash, or period followed by a slash.

// When the file path's extension is unknown, we simply ensure the start of the string.
if (typeof options === "boolean") {
return (pattern += ".*$");
}

// Otherwise, construct the pattern based on the valid extensions.
const exts = options.extensions
.map((extension) => {
const chars = Array.from(extension)
.slice(1)
.map((c) => `[${c.toUpperCase()}${c.toLowerCase()}]`)
.join("");

return `(${chars})`;
})
.join("|");

if (options.includeExtension) {
// Ensure the value ends with a valid extension
pattern += `.*\\.(${exts})$`;
} else {
// Use a negative look-ahead to ensure the extension isn't specified.
pattern += `(?!.*\\.(${exts})$).*$`;
}

return pattern;
}

/**
* Generates the custom error message associated with a file path.
* @param options Options that define the valid file path.
* @returns Custom error message.
*/
function generatePathErrorMessage(options: FilePathOptions): string {
if (typeof options === "boolean") {
return "String must reference file in the plugin directory.";
}

const exts = options.extensions.reduce((prev, current, index) => {
return index === 0 ? current : index === options.extensions.length - 1 ? prev + `, or ${current}` : prev + `, ${current}`;
}, "");

const errorMessage = `String must reference ${exts} file in the plugin directory`;
return options.includeExtension ? `${errorMessage}.` : `${errorMessage}, with the file extension omitted.`;
}

/**
* Traverses the specified {@link schema} and applies the visitor to each property.
* @param schema Schema to traverse
* @param visitor Visitor to each of the schema's properties.
*/
function visitNode(schema: ExtendedSchema, visitor: (schema: ExtendedSchema, keyword: keyof ExtendedSchema, value: unknown) => void): void {
if (typeof schema === "object") {
for (const [keyword, value] of Object.entries(schema)) {
if (typeof value === "object") {
visitNode(value, visitor);
}

visitor(schema, keyword as keyof ExtendedSchema, value);
}
}
}

/**
* Provides an extended JSON schema that includes the `markdownDescription` property.
*/
type ExtendedSchema = Schema & {
/**
* Custom error message shown when the value does not confirm to the defined schemas.
*/
errorMessage?: string;

/**
* Determines whether the value must represent a file path.
*/
filePath?: FilePathOptions;

/**
* Markdown representation of the description.
*/
markdownDescription?: string;
};

/**
* Options used to determine a valid file path, used to generate the regular expression pattern.
*/
type FilePathOptions =
| true
| {
/**
* Collection of valid file extensions.
*/
extensions: string[];

/**
* Determines whether the extension must be present, or omitted, from the file path.
*/
includeExtension: boolean;
};
Loading

0 comments on commit 243d65e

Please sign in to comment.