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

refactor: items of layout schema #29

Merged
merged 3 commits into from
Feb 7, 2024
Merged
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
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