diff --git a/package-lock.json b/package-lock.json index 360a192..41d8145 100644 --- a/package-lock.json +++ b/package-lock.json @@ -906,6 +906,15 @@ "@sinonjs/commons": "^1.7.0" } }, + "@types/ajv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/ajv/-/ajv-1.0.0.tgz", + "integrity": "sha1-T7JEB0Ly9sMOf7B5e4OfxvaWaCo=", + "dev": true, + "requires": { + "ajv": "*" + } + }, "@types/babel__core": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.7.tgz", @@ -1154,7 +1163,6 @@ "version": "6.12.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.2.tgz", "integrity": "sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2355,8 +2363,7 @@ "fast-deep-equal": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", - "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==", - "dev": true + "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==" }, "fast-glob": { "version": "3.2.2", @@ -2375,8 +2382,7 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-levenshtein": { "version": "2.0.6", @@ -4274,8 +4280,7 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stringify-safe": { "version": "5.0.1", @@ -5216,8 +5221,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "qs": { "version": "6.5.2", @@ -6364,7 +6368,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", - "dev": true, "requires": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index 055a00a..4d93191 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "author": "Argemiro Neto", "license": "ISC", "dependencies": { + "ajv": "^6.12.2", "arg": "^4.1.3", "esm": "^3.2.25", "inquirer": "^7.1.0", @@ -31,6 +32,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.10.1", + "@types/ajv": "^1.0.0", "@types/inquirer": "^6.5.0", "@types/jest": "^25.2.3", "@types/js-yaml": "^3.12.4", diff --git a/src/create.ts b/src/create.ts index 413675b..27a082a 100644 --- a/src/create.ts +++ b/src/create.ts @@ -1,4 +1,4 @@ -import * as ncp from "ncp"; +import ncp from "ncp"; import * as path from "path"; import { promisify } from "util"; diff --git a/src/utils/schema/index.ts b/src/utils/schema/index.ts new file mode 100644 index 0000000..d1f159b --- /dev/null +++ b/src/utils/schema/index.ts @@ -0,0 +1,212 @@ +import meta from "./meta"; + +export default { + ...meta, + type: "object", + properties: { + version: { + $ref: "#/definitions/semantic_version", + description: "The tutorial version. Must be unique for the tutorial.", + examples: ["0.1.0", "1.0.0"], + }, + + // summary + summary: { + type: "object", + properties: { + title: { + $ref: "#/definitions/title", + description: "The title of tutorial", + }, + description: { + type: "string", + description: "A summary of the the tutorial", + minLength: 10, + maxLength: 400, + }, + }, + additionalProperties: false, + required: ["title", "description"], + }, + + // config + config: { + type: "object", + properties: { + testRunner: { + type: "object", + description: "The test runner configuration", + properties: { + command: { + type: "string", + description: "Command line to start the test runner", + examples: ["./node_modules/.bin/mocha"], + }, + args: { + type: "object", + description: + "A configuration of command line args for your test runner", + properties: { + filter: { + type: "string", + description: + "the command line arg for filtering tests with a regex pattern", + examples: ["--grep"], + }, + tap: { + type: "string", + description: + "The command line arg for configuring a TAP reporter. See https://github.com/sindresorhus/awesome-tap for examples.", + examples: ["--reporter=mocha-tap-reporter"], + }, + }, + additionalProperties: false, + required: ["tap"], + }, + directory: { + type: "string", + description: "An optional folder for the test runner", + examples: ["coderoad"], + }, + setup: { + type: "object", + $ref: "#/definitions/setup_action", + description: + "Setup commits or commands used for setting up the test runner on tutorial launch", + }, + }, + required: ["command", "args"], + }, + repo: { + type: "object", + description: "The repo holding the git commits for the tutorial", + properties: { + uri: { + type: "string", + description: "The uri source of the tutorial", + format: "uri", + examples: ["https://github.com/name/tutorial-name.git"], + }, + branch: { + description: + "The branch of the repo where the tutorial config file exists", + type: "string", + examples: ["master"], + }, + }, + additionalProperties: false, + required: ["uri", "branch"], + }, + }, + dependencies: { + type: "array", + description: "A list of tutorial dependencies", + items: { + type: "object", + properties: { + name: { + type: "string", + description: + "The command line process name of the dependency. It will be checked by running `name --version`", + examples: ["node", "python"], + }, + version: { + type: "string", + description: + "The version requirement. See https://github.com/npm/node-semver for options", + examples: [">=10"], + }, + }, + required: ["name", "version"], + }, + }, + appVersions: { + type: "object", + description: + "A list of compatable coderoad versions. Currently only a VSCode extension.", + properties: { + vscode: { + type: "string", + description: + "The version range for coderoad-vscode that this tutorial is compatable with", + examples: [">=0.7.0"], + }, + }, + }, + additionalProperties: false, + required: ["testRunner", "repo"], + }, + + // levels + levels: { + type: "array", + description: + 'Levels are the stages a user goes through in the tutorial. A level may contain a group of tasks called "steps" that must be completed to proceed', + items: { + type: "object", + properties: { + title: { + $ref: "#/definitions/title", + description: "A title for the level", + }, + summary: { + type: "string", + description: "A high-level summary of the level", + maxLength: 250, + }, + content: { + type: "string", + description: "Content for a tutorial written as Markdown", + }, + setup: { + $ref: "#/definitions/setup_action", + description: + "An optional point for loading commits, running commands or opening files", + }, + steps: { + type: "array", + items: { + type: "object", + properties: { + content: { + type: "string", + description: + "The text displayed explaining information about the current task, written as markdown", + }, + setup: { + allOf: [ + { + $ref: "#/definitions/setup_action", + description: + "A point for loading commits. It can also run commands and/or open files", + }, + { + required: ["commits"], + }, + ], + }, + solution: { + allOf: [ + { + $ref: "#/definitions/setup_action", + description: + "The solution commits that can be loaded if the user gets stuck. It can also run commands and/or open files", + }, + { + required: ["commits"], + }, + ], + }, + }, + required: ["content", "setup", "solution"], + }, + }, + }, + required: ["title", "description", "content"], + }, + minItems: 1, + }, + }, + additionalProperties: false, + required: ["version", "summary", "config", "levels"], +}; diff --git a/src/utils/schema/meta.ts b/src/utils/schema/meta.ts new file mode 100644 index 0000000..91e0e11 --- /dev/null +++ b/src/utils/schema/meta.ts @@ -0,0 +1,103 @@ +export default { + $schema: "http://json-schema.org/draft-07/schema#", + $id: "http://coderoad.io/tutorial_version.schema.json", + title: "Tutorial Version", + description: + "A CodeRoad tutorial version. This JSON data is converted into a tutorial with the CodeRoad editor extension", + definitions: { + semantic_version: { + type: "string", + description: + 'A semantic version, such as "1.0.0". Learn more at https://semver.org/', + pattern: "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$", + minLength: 5, + maxLength: 14, + examples: ["0.1.0", "1.0.0"], + }, + sha1_hash: { + type: "string", + description: "A SHA1 hash created by Git", + pattern: "^[0-9a-f]{5,40}$", + minLength: 5, + maxLength: 40, + }, + title: { + type: "string", + minLength: 1, + maxLength: 40, + }, + file_path: { + type: "string", + description: "A path to a file", + pattern: "(\\\\?([^\\/]*[\\/])*)([^\\/]+)$", + minLength: 4, + examples: ["src/file.js"], + }, + file_array: { + type: "array", + description: + "An array of files which will be opened by the editor when entering the level or step", + items: { + $ref: "#/definitions/file_path", + uniqueItems: true, + }, + }, + command_array: { + type: "array", + description: + "An array of command line commands that will be called when the user enters the level or step. Currently commands are limited for security purposes", + items: { + type: "string", + enum: ["npm install"], + }, + }, + commit_array: { + type: "array", + description: + "An array of git commits which will be loaded when the level/step or solution is loaded", + items: { + $ref: "#/definitions/sha1_hash", + uniqueItems: true, + }, + minItems: 1, + }, + setup_action: { + type: "object", + description: + "A collection of files/commits/commands that run when a level/step or solution is loaded", + properties: { + files: { + $ref: "#/definitions/file_array", + }, + commits: { + $ref: "#/definitions/commit_array", + }, + commands: { + $ref: "#/definitions/command_array", + }, + watchers: { + type: "array", + description: + "An array file paths that, when updated, will trigger the test runner to run", + items: { + $ref: "#/definitions/file_path", + uniqueItems: true, + }, + }, + filter: { + type: "string", + description: + "A regex pattern that will be passed to the test runner to limit the number of tests running", + examples: ["^TestSuiteName"], + }, + subtasks: { + type: "boolean", + description: + 'A feature that shows subtasks: all active test names and the status of the tests (pass/fail). Use together with "filter"', + examples: [true], + }, + }, + additionalProperties: false, + }, + }, +}; diff --git a/src/utils/validate.ts b/src/utils/validate.ts new file mode 100644 index 0000000..bd1dd35 --- /dev/null +++ b/src/utils/validate.ts @@ -0,0 +1,24 @@ +import * as T from "../../typings/tutorial"; +import schema from "./schema"; + +// https://www.npmjs.com/package/ajv +// @ts-ignore ajv typings not working +import JsonSchema from "ajv"; + +export function validateSchema(json: any) { + // validate using https://json-schema.org/ + const jsonSchema = new JsonSchema({ allErrors: true, verbose: true }); + // support draft-07 of json schema + jsonSchema.addMetaSchema(require("ajv/lib/refs/json-schema-draft-07.json")); + + const validator = jsonSchema.compile(schema); + const valid = validator(json); + + if (!valid) { + // log errors + console.log(jsonSchema.errorsText()); + throw new Error("Invalid schema. See log for details"); + } + + return true; +}