v8r 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 📦 [4.0.0](https://www.npmjs.com/package/v8r/v/4.0.0) - 2024-08-19
4
+
5
+ * **Breaking:** Change to the JSON output format. The `results` key is now an array instead of an object.
6
+ In v8r <4, `results` was an object mapping filename to result object. For example:
7
+ ```json
8
+ {
9
+ "results": {
10
+ "./package.json": {
11
+ "fileLocation": "./package.json",
12
+ "schemaLocation": "https://json.schemastore.org/package.json",
13
+ "valid": true,
14
+ "errors": [],
15
+ "code": 0
16
+ }
17
+ }
18
+ }
19
+ ```
20
+
21
+ In v8r >=4 `results` is now an array of result objects. For example:
22
+ ```json
23
+ {
24
+ "results": [
25
+ {
26
+ "fileLocation": "./package.json",
27
+ "schemaLocation": "https://json.schemastore.org/package.json",
28
+ "valid": true,
29
+ "errors": [],
30
+ "code": 0
31
+ }
32
+ ]
33
+ }
34
+ ```
35
+ * Plugin system: It is now possible to extend the functionality of v8r by using or writing plugins. See https://chris48s.github.io/v8r/category/plugins/ for further information
36
+ * Documentation improvements
37
+
38
+ ## 📦 [3.1.1](https://www.npmjs.com/package/v8r/v/3.1.1) - 2024-08-03
39
+
40
+ * Allow 'toml' as an allowed value for parser in custom catalog
41
+
3
42
  ## 📦 [3.1.0](https://www.npmjs.com/package/v8r/v/3.1.0) - 2024-06-03
4
43
 
5
44
  * Add ability to configure a proxy using global-agent
6
45
 
7
46
  ## 📦 [3.0.0](https://www.npmjs.com/package/v8r/v/3.0.0) - 2024-01-25
8
47
 
9
- * Drop compatibility with node 16
48
+ * **Breaking:** Drop compatibility with node 16
10
49
  * Add ability to validate Toml documents
11
50
 
12
51
  ## 📦 [2.1.0](https://www.npmjs.com/package/v8r/v/2.1.0) - 2023-10-23
@@ -15,7 +54,7 @@
15
54
 
16
55
  ## 📦 [2.0.0](https://www.npmjs.com/package/v8r/v/2.0.0) - 2023-05-02
17
56
 
18
- * Drop compatibility with node 14
57
+ * **Breaking:** Drop compatibility with node 14
19
58
  * Upgrade glob and minimatch to latest versions
20
59
  * Tested on node 20
21
60
 
package/README.md CHANGED
@@ -1,233 +1,13 @@
1
1
  # v8r
2
2
 
3
- ![build](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/build-status.svg)
4
- ![coverage](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/coverage.svg)
5
- ![version](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-version.svg)
6
- ![license](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-license.svg)
7
- ![node](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-node-version.svg)
3
+ [![build](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/build-status.svg)](https://github.com/chris48s/v8r/actions/workflows/build.yml?query=branch%3Amain)
4
+ [![coverage](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/coverage.svg)](https://app.codecov.io/gh/chris48s/v8r)
5
+ [![version](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-version.svg)](https://www.npmjs.com/package/v8r)
6
+ [![license](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-license.svg)](https://www.npmjs.com/package/v8r)
7
+ [![node](https://raw.githubusercontent.com/chris48s/v8r/badges/.badges/main/package-node-version.svg)](https://www.npmjs.com/package/v8r)
8
8
 
9
- v8r is a command-line JSON and YAML validator that uses [Schema Store](https://www.schemastore.org/) to detect a suitable schema for your input files based on the filename.
9
+ v8r is a command-line validator that uses [Schema Store](https://www.schemastore.org/) to detect a suitable schema for your input files based on the filename.
10
10
 
11
- ## Getting Started
11
+ 📦 Install the package from [NPM](https://www.npmjs.com/package/v8r)
12
12
 
13
- One-off:
14
- ```bash
15
- npx v8r@latest <filename>
16
- ```
17
-
18
- Local install:
19
- ```bash
20
- npm install -g v8r
21
- v8r <filename>
22
- ```
23
-
24
- ## Usage Examples
25
-
26
- ### Validating files
27
-
28
- v8r can validate JSON or YAML files. You can pass filenames or glob patterns:
29
-
30
- ```bash
31
- # single filename
32
- $ v8r package.json
33
-
34
- # multiple files
35
- $ v8r file1.json file2.json
36
-
37
- # glob patterns
38
- $ v8r 'dir/*.yml' 'dir/*.yaml'
39
- ```
40
-
41
- [DigitalOcean's Glob Tool](https://www.digitalocean.com/community/tools/glob) can be used to help construct glob patterns
42
-
43
- ### Manually specifying a schema
44
-
45
- By default, v8r queries [Schema Store](https://www.schemastore.org/) to detect a suitable schema based on the filename.
46
-
47
- ```bash
48
- # if v8r can't auto-detect a schema for your file..
49
- $ v8r feature.geojson
50
- ✖ Could not find a schema to validate feature.geojson
51
-
52
- # ..you can specify one using the --schema flag
53
- $ v8r feature.geojson --schema https://json.schemastore.org/geojson
54
- ℹ Validating feature.geojson against schema from https://json.schemastore.org/geojson ...
55
- ✔ feature.geojson is valid
56
- ```
57
-
58
- ### Using a custom catlog
59
-
60
- Using the `--schema` flag will validate all files matched by the glob pattern against that schema. You can also define a custom [schema catalog](https://json.schemastore.org/schema-catalog.json). v8r will search any custom catalogs before falling back to [Schema Store](https://www.schemastore.org/).
61
-
62
- ```bash
63
- $ cat > my-catalog.json <<EOF
64
- { "\$schema": "https://json.schemastore.org/schema-catalog.json",
65
- "version": 1,
66
- "schemas": [ { "name": "geojson",
67
- "description": "geojson",
68
- "url": "https://json.schemastore.org/geojson.json",
69
- "fileMatch": ["*.geojson"] } ] }
70
- EOF
71
-
72
- $ v8r feature.geojson -c my-catalog.json
73
- ℹ Found schema in my-catalog.json ...
74
- ℹ Validating feature.geojson against schema from https://json.schemastore.org/geojson ...
75
- ✔ feature.geojson is valid
76
- ```
77
-
78
- This can be used to specify different custom schemas for multiple file patterns.
79
-
80
- ## Configuration
81
-
82
- v8r uses CosmiConfig to search for a configuration. This means you can specify your configuration in any of the following places:
83
-
84
- - `package.json`
85
- - `.v8rrc`
86
- - `.v8rrc.json`
87
- - `.v8rrc.yaml`
88
- - `.v8rrc.yml`
89
- - `.v8rrc.js`
90
- - `.v8rrc.cjs`
91
- - `v8r.config.js`
92
- - `v8r.config.cjs`
93
-
94
- v8r only searches for a config file in the current working directory.
95
-
96
- Example yaml config file:
97
-
98
- ```yaml
99
- # - One or more filenames or glob patterns describing local file or files to validate
100
- # - overridden by passing one or more positional arguments
101
- patterns: ['*json']
102
-
103
- # - Level of verbose logging. 0 is standard, higher numbers are more verbose
104
- # - overridden by passing --verbose / -v
105
- # - default = 0
106
- verbose: 2
107
-
108
- # - Exit with code 0 even if an error was encountered. True means a non-zero exit
109
- # code is only issued if validation could be completed successfully and one or
110
- # more files were invalid
111
- # - overridden by passing --ignore-errors
112
- # - default = false
113
- ignoreErrors: true
114
-
115
- # - Remove cached HTTP responses older than cacheTtl seconds old.
116
- # Specifying 0 clears and disables cache completely
117
- # - overridden by passing --cache-ttl
118
- # - default = 600
119
- cacheTtl: 86400
120
-
121
- # - Output format for validation results
122
- # - overridden by passing --format
123
- # - default = text
124
- format: "json"
125
-
126
- # - A custom schema catalog.
127
- # This catalog will be searched ahead of any custom catalogs passed using
128
- # --catalogs or SchemaStore.org
129
- # The format of this is subtly different to the format of a catalog
130
- # passed via --catalogs (which matches the SchemaStore.org format)
131
- customCatalog:
132
- schemas:
133
- - name: Custom Schema # The name of the schema (required)
134
- description: Custom Schema # A description of the schema (optional)
135
-
136
- # A Minimatch glob expression for matching up file names with a schema (required)
137
- fileMatch: ["*.geojson"]
138
-
139
- # A URL or local file path for the schema location (required)
140
- # Unlike the SchemaStore.org format, which has a `url` key,
141
- # custom catalogs defined in v8r config files have a `location` key
142
- # which can refer to either a URL or local file.
143
- # Relative paths are interpreted as relative to the config file location.
144
- location: foo/bar/geojson-schema.json
145
-
146
- # A custom parser to use for files matching fileMatch
147
- # instead of trying to infer the correct parser from the filename (optional)
148
- # This property is specific to custom catalogs defined in v8r config files
149
- parser: json5
150
- ```
151
-
152
- The config file format is specified more formally in a JSON Schema:
153
-
154
- - [machine-readable JSON](config-schema.json)
155
- - [human-readable HTML](https://json-schema-viewer.vercel.app/view?url=https%3A%2F%2Fraw.githubusercontent.com%2Fchris48s%2Fv8r%2Fmain%2Fconfig-schema.json&show_breadcrumbs=on&template_name=flat)
156
-
157
- ## Configuring a Proxy
158
-
159
- It is possible to configure a proxy via [global-agent](https://www.npmjs.com/package/global-agent) using the `GLOBAL_AGENT_HTTP_PROXY` environment variable:
160
-
161
- ```bash
162
- export GLOBAL_AGENT_HTTP_PROXY=http://myproxy:8888
163
- ```
164
-
165
- ## Exit codes
166
-
167
- * v8r always exits with code `0` when:
168
- * The input glob pattern(s) matched one or more files, all input files were validated against a schema, and all input files were **valid**
169
- * `v8r` was called with `--help` or `--version` flags
170
-
171
- * By default v8r exits with code `1` when an error was encountered trying to validate one or more input files. For example:
172
- * No suitable schema could be found
173
- * An error was encountered during an HTTP request
174
- * An input file was not JSON or yaml
175
- * etc
176
-
177
- This behaviour can be modified using the `--ignore-errors` flag. When invoked with `--ignore-errors` v8r will exit with code `0` even if one of these errors was encountered while attempting validation. A non-zero exit code will only be issued if validation could be completed successfully and the file was invalid.
178
-
179
- * v8r always exits with code `97` when:
180
- * There was an error loading a config file
181
- * A config file was loaded but failed validation
182
-
183
- * v8r always exits with code `98` when:
184
- * An input glob pattern was invalid
185
- * An input glob pattern was valid but did not match any files
186
-
187
- * v8r always exits with code `99` when:
188
- * The input glob pattern matched one or more files, one or more input files were validated against a schema and the input file was **invalid**
189
-
190
- ## Versioning
191
-
192
- v8r follows [semantic versioning](https://semver.org/). For this project, the "API" is defined as:
193
-
194
- - CLI flags and options
195
- - CLI exit codes
196
- - The configuration file format
197
- - The native JSON output format
198
-
199
- A "breaking change" also includes:
200
-
201
- - Dropping compatibility with a major Node JS version
202
- - Dropping compatibility with a JSON Schema draft
203
-
204
- ## FAQ
205
-
206
- ### ❓ How does `v8r` decide what schema to validate against if I don't supply one?
207
-
208
- 💡 `v8r` queries the [Schema Store catalog](https://www.schemastore.org/) to try and find a suitable schema based on the name of the input file.
209
-
210
- ### ❓ My file is valid, but it doesn't validate against one of the suggested schemas.
211
-
212
- 💡 `v8r` is a fairly thin layer of glue between [Schema Store](https://www.schemastore.org/) (where the schemas come from) and [ajv](https://www.npmjs.com/package/ajv) (the validation engine). It is likely that this kind of problem is either an issue with the schema or validation engine.
213
-
214
- * Schema store issue tracker: https://github.com/SchemaStore/schemastore/issues
215
- * Ajv issue tracker: https://github.com/ajv-validator/ajv/issues
216
-
217
- ### ❓ What JSON schema versions are compatible?
218
-
219
- 💡 `v8r` works with JSON schema drafts:
220
-
221
- * draft-04
222
- * draft-06
223
- * draft-07
224
- * draft 2019-09
225
- * draft 2020-12
226
-
227
- ### ❓ Will 100% of the schemas on schemastore.org work with this tool?
228
-
229
- 💡 No. There are some with [known issues](https://github.com/chris48s/v8r/issues/18)
230
-
231
- ### ❓ Can `v8r` validate against a local schema?
232
-
233
- 💡 Yes. The `--schema` flag can be either a path to a local file or a URL. You can also use a [config file](#configuration) to include local schemas in a custom catalog.
13
+ 📚 Jump into the [Documentation](https://chris48s.github.io/v8r) to get started
@@ -49,13 +49,8 @@
49
49
  "type": "string"
50
50
  },
51
51
  "parser": {
52
- "description": "A custom parser to use for files matching fileMatch instead of trying to infer the correct parser from the filename",
53
- "type": "string",
54
- "enum": [
55
- "json",
56
- "yaml",
57
- "json5"
58
- ]
52
+ "description": "A custom parser to use for files matching fileMatch instead of trying to infer the correct parser from the filename. 'json', 'json5', 'toml' and 'yaml' are always valid. Plugins may define additional values which are valid here.",
53
+ "type": "string"
59
54
  }
60
55
  }
61
56
  }
@@ -63,12 +58,8 @@
63
58
  }
64
59
  },
65
60
  "format": {
66
- "description": "Output format for validation results",
67
- "type": "string",
68
- "enum": [
69
- "text",
70
- "json"
71
- ]
61
+ "description": "Output format for validation results. 'text' and 'json' are always valid. Plugins may define additional values which are valid here.",
62
+ "type": "string"
72
63
  },
73
64
  "ignoreErrors": {
74
65
  "description": "Exit with code 0 even if an error was encountered. True means a non-zero exit code is only issued if validation could be completed successfully and one or more files were invalid",
@@ -87,6 +78,15 @@
87
78
  "description": "Level of verbose logging. 0 is standard, higher numbers are more verbose",
88
79
  "type": "integer",
89
80
  "minimum": 0
81
+ },
82
+ "plugins": {
83
+ "type": "array",
84
+ "description": "An array of strings describing v8r plugins to load",
85
+ "uniqueItems": true,
86
+ "items": {
87
+ "type": "string",
88
+ "pattern": "^(package:|file:)"
89
+ }
90
90
  }
91
91
  }
92
92
  }
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "v8r",
3
- "version": "3.1.0",
4
- "description": "A command-line JSON and YAML validator that's on your wavelength",
3
+ "version": "4.0.0",
4
+ "description": "A command-line JSON, YAML and TOML validator that's on your wavelength",
5
5
  "scripts": {
6
6
  "test": "V8R_CACHE_NAME=v8r-test c8 --reporter=text mocha \"src/**/*.spec.js\"",
7
- "lint": "eslint \"**/*.{js,cjs}\"",
7
+ "lint": "eslint \"**/*.{js,cjs,mjs}\"",
8
8
  "coverage": "c8 report --reporter=cobertura",
9
- "prettier": "prettier --write \"**/*.{js,cjs}\"",
10
- "prettier:check": "prettier --check \"**/*.{js,cjs}\"",
9
+ "prettier": "prettier --write \"**/*.{js,cjs,mjs}\"",
10
+ "prettier:check": "prettier --check \"**/*.{js,cjs,mjs}\"",
11
11
  "v8r": "src/index.js"
12
12
  },
13
13
  "bin": {
14
14
  "v8r": "src/index.js"
15
15
  },
16
- "main": "src/index.js",
17
- "exports": "./src/index.js",
16
+ "exports": "./src/public.js",
18
17
  "files": [
19
18
  "src/**/!(*.spec).js",
20
19
  "config-schema.json",
@@ -46,15 +45,17 @@
46
45
  "yargs": "^17.0.1"
47
46
  },
48
47
  "devDependencies": {
49
- "c8": "^9.1.0",
50
- "eslint": "^9.2.0",
48
+ "c8": "^10.1.2",
49
+ "eslint": "^9.9.0",
51
50
  "eslint-config-prettier": "^9.0.0",
51
+ "eslint-plugin-jsdoc": "^50.2.2",
52
52
  "eslint-plugin-mocha": "^10.0.3",
53
53
  "eslint-plugin-prettier": "^5.0.0",
54
- "mocha": "^10.0.0",
54
+ "mocha": "^10.7.3",
55
55
  "mock-cwd": "^1.0.0",
56
56
  "nock": "^13.0.4",
57
- "prettier": "^3.0.0"
57
+ "prettier": "^3.0.0",
58
+ "prettier-plugin-jsdoc": "^1.3.0"
58
59
  },
59
60
  "engines": {
60
61
  "node": ">=18"
@@ -3,30 +3,19 @@ import { createRequire } from "module";
3
3
  // https://nodejs.org/api/esm.html#esm_experimental_json_modules
4
4
  const require = createRequire(import.meta.url);
5
5
 
6
- import Ajv2019 from "ajv/dist/2019.js";
7
6
  import { cosmiconfig } from "cosmiconfig";
8
7
  import decamelize from "decamelize";
9
8
  import isUrl from "is-url";
10
9
  import path from "path";
11
10
  import yargs from "yargs";
12
11
  import { hideBin } from "yargs/helpers";
12
+ import {
13
+ validateConfigAgainstSchema,
14
+ validateConfigDocumentParsers,
15
+ validateConfigOutputFormats,
16
+ } from "./config-validators.js";
13
17
  import logger from "./logger.js";
14
- import { logErrors } from "./output-formatters.js";
15
-
16
- function validateConfig(configFile) {
17
- const ajv = new Ajv2019({ allErrors: true, strict: false });
18
- const schema = require("../config-schema.json");
19
- const validateFn = ajv.compile(schema);
20
- const valid = validateFn(configFile.config);
21
- if (!valid) {
22
- logErrors(
23
- configFile.filepath ? configFile.filepath : "",
24
- validateFn.errors,
25
- );
26
- throw new Error("Malformed config file");
27
- }
28
- return valid;
29
- }
18
+ import { loadAllPlugins, resolveUserPlugins } from "./plugins.js";
30
19
 
31
20
  function preProcessConfig(configFile) {
32
21
  if (!configFile?.config?.customCatalog?.schemas) {
@@ -52,8 +41,6 @@ async function getCosmiConfig(cosmiconfigOptions) {
52
41
  } else {
53
42
  logger.info(`No config file found`);
54
43
  }
55
- validateConfig(configFile);
56
- preProcessConfig(configFile);
57
44
  return configFile;
58
45
  }
59
46
 
@@ -72,7 +59,7 @@ function getRelativeFilePath(config) {
72
59
  return path.relative(process.cwd(), config.filepath);
73
60
  }
74
61
 
75
- function parseArgs(argv, config) {
62
+ function parseArgs(argv, config, documentFormats, outputFormats) {
76
63
  const parser = yargs(hideBin(argv));
77
64
 
78
65
  let command = "$0 <patterns..>";
@@ -91,7 +78,7 @@ function parseArgs(argv, config) {
91
78
  parser
92
79
  .command(
93
80
  command,
94
- "Validate local json/yaml files against schema(s)",
81
+ `Validate local ${documentFormats.join("/")} files against schema(s)`,
95
82
  (yargs) => {
96
83
  yargs.positional("patterns", patternsOpts);
97
84
  },
@@ -142,7 +129,7 @@ function parseArgs(argv, config) {
142
129
  })
143
130
  .option("format", {
144
131
  type: "string",
145
- choices: ["text", "json"],
132
+ choices: outputFormats,
146
133
  default: "text",
147
134
  describe: "Output format for validation results",
148
135
  })
@@ -168,10 +155,68 @@ function parseArgs(argv, config) {
168
155
  return parser.argv;
169
156
  }
170
157
 
171
- async function getConfig(argv, cosmiconfigOptions = {}) {
172
- const config = await getCosmiConfig(cosmiconfigOptions);
173
- const args = parseArgs(argv, config);
174
- return mergeConfigs(args, config);
158
+ function getDocumentFormats(loadedPlugins) {
159
+ let documentFormats = [];
160
+ for (const plugin of loadedPlugins) {
161
+ documentFormats = documentFormats.concat(plugin.registerInputFileParsers());
162
+ }
163
+ return documentFormats;
164
+ }
165
+
166
+ function getOutputFormats(loadedPlugins) {
167
+ let outputFormats = [];
168
+ for (const plugin of loadedPlugins) {
169
+ outputFormats = outputFormats.concat(plugin.registerOutputFormats());
170
+ }
171
+ return outputFormats;
172
+ }
173
+
174
+ async function bootstrap(argv, config, cosmiconfigOptions = {}) {
175
+ if (config) {
176
+ // special case for unit testing purposes
177
+ // this allows us to inject an incomplete config and bypass the validation
178
+ const { allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } =
179
+ await loadAllPlugins(config.plugins || []);
180
+ return {
181
+ config,
182
+ allLoadedPlugins,
183
+ loadedCorePlugins,
184
+ loadedUserPlugins,
185
+ };
186
+ }
187
+
188
+ // load the config file and validate it against the schema
189
+ const configFile = await getCosmiConfig(cosmiconfigOptions);
190
+ validateConfigAgainstSchema(configFile);
191
+
192
+ // load both core and user plugins
193
+ let plugins = resolveUserPlugins(configFile.config.plugins || []);
194
+ const { allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } =
195
+ await loadAllPlugins(plugins);
196
+ const documentFormats = getDocumentFormats(allLoadedPlugins);
197
+ const outputFormats = getOutputFormats(allLoadedPlugins);
198
+
199
+ // now we have documentFormats and outputFormats
200
+ // we can finish validating and processing the config
201
+ validateConfigDocumentParsers(configFile, documentFormats);
202
+ validateConfigOutputFormats(configFile, outputFormats);
203
+ preProcessConfig(configFile);
204
+
205
+ // parse command line arguments
206
+ const args = parseArgs(argv, configFile, documentFormats, outputFormats);
207
+
208
+ return {
209
+ config: mergeConfigs(args, configFile),
210
+ allLoadedPlugins,
211
+ loadedCorePlugins,
212
+ loadedUserPlugins,
213
+ };
175
214
  }
176
215
 
177
- export { getConfig, parseArgs, preProcessConfig, validateConfig };
216
+ export {
217
+ bootstrap,
218
+ getDocumentFormats,
219
+ getOutputFormats,
220
+ parseArgs,
221
+ preProcessConfig,
222
+ };
package/src/cli.js CHANGED
@@ -4,19 +4,18 @@ import os from "os";
4
4
  import path from "path";
5
5
  import isUrl from "is-url";
6
6
  import { validate } from "./ajv.js";
7
+ import { bootstrap } from "./bootstrap.js";
7
8
  import { Cache } from "./cache.js";
8
9
  import { getCatalogs, getMatchForFilename } from "./catalogs.js";
9
- import { getConfig } from "./config.js";
10
10
  import { getFiles } from "./glob.js";
11
11
  import { getFromUrlOrFile } from "./io.js";
12
12
  import logger from "./logger.js";
13
- import { logErrors, resultsToJson } from "./output-formatters.js";
14
- import { parseDocument } from "./parser.js";
13
+ import { parseFile } from "./parser.js";
15
14
 
16
15
  const EXIT = {
17
16
  VALID: 0,
18
17
  ERROR: 1,
19
- INVALID_CONFIG: 97,
18
+ INVALID_CONFIG_OR_PLUGIN: 97,
20
19
  NOT_FOUND: 98,
21
20
  INVALID: 99,
22
21
  };
@@ -34,7 +33,7 @@ function getFlatCache() {
34
33
  return flatCache.load("v8r", CACHE_DIR);
35
34
  }
36
35
 
37
- async function validateFile(filename, config, cache) {
36
+ async function validateFile(filename, config, plugins, cache) {
38
37
  logger.info(`Processing ${filename}`);
39
38
  let result = {
40
39
  fileLocation: filename,
@@ -55,9 +54,11 @@ async function validateFile(filename, config, cache) {
55
54
  `Validating ${filename} against schema from ${schemaLocation} ...`,
56
55
  );
57
56
 
58
- const data = parseDocument(
57
+ const data = parseFile(
58
+ plugins,
59
59
  await fs.promises.readFile(filename, "utf8"),
60
- catalogMatch.parser ? `.${catalogMatch.parser}` : path.extname(filename),
60
+ filename,
61
+ catalogMatch.parser,
61
62
  );
62
63
 
63
64
  const strictMode = config.verbose >= 2 ? "log" : false;
@@ -101,7 +102,7 @@ function resultsToStatusCode(results, ignoreErrors) {
101
102
  }
102
103
 
103
104
  function Validator() {
104
- return async function (config) {
105
+ return async function (config, plugins) {
105
106
  let filenames = [];
106
107
  for (const pattern of config.patterns) {
107
108
  const matches = await getFiles(pattern);
@@ -115,20 +116,32 @@ function Validator() {
115
116
  const ttl = secondsToMilliseconds(config.cacheTtl || 0);
116
117
  const cache = new Cache(getFlatCache(), ttl);
117
118
 
118
- const results = Object.fromEntries(filenames.map((key) => [key, null]));
119
- for (const [filename] of Object.entries(results)) {
120
- results[filename] = await validateFile(filename, config, cache);
121
-
122
- if (results[filename].valid === false && config.format === "text") {
123
- logErrors(filename, results[filename].errors);
119
+ let results = [];
120
+ for (const filename of filenames) {
121
+ const result = await validateFile(filename, config, plugins, cache);
122
+ results.push(result);
123
+
124
+ for (const plugin of plugins) {
125
+ const message = plugin.getSingleResultLogMessage(
126
+ result,
127
+ filename,
128
+ config.format,
129
+ );
130
+ if (message != null) {
131
+ logger.log(message);
132
+ break;
133
+ }
124
134
  }
125
- // else: silence is golden
126
135
 
127
136
  cache.resetCounters();
128
137
  }
129
138
 
130
- if (config.format === "json") {
131
- resultsToJson(results);
139
+ for (const plugin of plugins) {
140
+ const message = plugin.getAllResultsLogMessage(results, config.format);
141
+ if (message != null) {
142
+ logger.log(message);
143
+ break;
144
+ }
132
145
  }
133
146
 
134
147
  return resultsToStatusCode(results, config.ignoreErrors);
@@ -136,21 +149,41 @@ function Validator() {
136
149
  }
137
150
 
138
151
  async function cli(config) {
139
- if (!config) {
140
- try {
141
- config = await getConfig(process.argv);
142
- } catch (e) {
143
- logger.error(e.message);
144
- return EXIT.INVALID_CONFIG;
145
- }
152
+ let allLoadedPlugins, loadedCorePlugins, loadedUserPlugins;
153
+ try {
154
+ ({ config, allLoadedPlugins, loadedCorePlugins, loadedUserPlugins } =
155
+ await bootstrap(process.argv, config));
156
+ } catch (e) {
157
+ logger.error(e.message);
158
+ return EXIT.INVALID_CONFIG_OR_PLUGIN;
146
159
  }
147
160
 
148
161
  logger.setVerbosity(config.verbose);
149
162
  logger.debug(`Merged args/config: ${JSON.stringify(config, null, 2)}`);
150
163
 
164
+ /*
165
+ Note there is a bit of a chicken and egg problem here.
166
+ We have to load the plugins before we can load the config
167
+ but this logger.debug() needs to happen AFTER we call logger.setVerbosity().
168
+ */
169
+ logger.debug(
170
+ `Loaded user plugins: ${JSON.stringify(
171
+ loadedUserPlugins.map((plugin) => plugin.constructor.name),
172
+ null,
173
+ 2,
174
+ )}`,
175
+ );
176
+ logger.debug(
177
+ `Loaded core plugins: ${JSON.stringify(
178
+ loadedCorePlugins.map((plugin) => plugin.constructor.name),
179
+ null,
180
+ 2,
181
+ )}`,
182
+ );
183
+
151
184
  try {
152
185
  const validate = new Validator();
153
- return await validate(config);
186
+ return await validate(config, allLoadedPlugins);
154
187
  } catch (e) {
155
188
  logger.error(e.message);
156
189
  return EXIT.ERROR;
@@ -0,0 +1,54 @@
1
+ import { createRequire } from "module";
2
+ // TODO: once JSON modules is stable these requires could become imports
3
+ // https://nodejs.org/api/esm.html#esm_experimental_json_modules
4
+ const require = createRequire(import.meta.url);
5
+
6
+ import Ajv2019 from "ajv/dist/2019.js";
7
+ import logger from "./logger.js";
8
+ import { formatErrors } from "./output-formatters.js";
9
+
10
+ function validateConfigAgainstSchema(configFile) {
11
+ const ajv = new Ajv2019({ allErrors: true, strict: false });
12
+ const schema = require("../config-schema.json");
13
+ const validateFn = ajv.compile(schema);
14
+ const valid = validateFn(configFile.config);
15
+ if (!valid) {
16
+ logger.log(
17
+ formatErrors(
18
+ configFile.filepath ? configFile.filepath : "",
19
+ validateFn.errors,
20
+ ),
21
+ );
22
+ throw new Error("Malformed config file");
23
+ }
24
+ return true;
25
+ }
26
+
27
+ function validateConfigDocumentParsers(configFile, documentFormats) {
28
+ for (const schema of configFile.config?.customCatalog?.schemas || []) {
29
+ if (schema?.parser != null && !documentFormats.includes(schema?.parser)) {
30
+ throw new Error(
31
+ `Malformed config file: "${schema.parser}" not in ${JSON.stringify(documentFormats)}`,
32
+ );
33
+ }
34
+ }
35
+ return true;
36
+ }
37
+
38
+ function validateConfigOutputFormats(configFile, outputFormats) {
39
+ if (
40
+ configFile.config?.format != null &&
41
+ !outputFormats.includes(configFile.config?.format)
42
+ ) {
43
+ throw new Error(
44
+ `Malformed config file: "${configFile.config.format}" not in ${JSON.stringify(outputFormats)}`,
45
+ );
46
+ }
47
+ return true;
48
+ }
49
+
50
+ export {
51
+ validateConfigAgainstSchema,
52
+ validateConfigDocumentParsers,
53
+ validateConfigOutputFormats,
54
+ };
package/src/glob.js CHANGED
@@ -3,7 +3,9 @@ import logger from "./logger.js";
3
3
 
4
4
  async function getFiles(pattern) {
5
5
  try {
6
- return await glob(pattern, { dot: true, dotRelative: true });
6
+ let matches = await glob(pattern, { dot: true, dotRelative: true });
7
+ matches.sort((a, b) => a.localeCompare(b));
8
+ return matches;
7
9
  } catch (e) {
8
10
  logger.error(e.message);
9
11
  return [];
@@ -1,19 +1,13 @@
1
1
  import Ajv from "ajv";
2
- import logger from "./logger.js";
3
2
 
4
- function logErrors(filename, errors) {
3
+ function formatErrors(filename, errors) {
5
4
  const ajv = new Ajv();
6
- logger.log(
5
+ return (
7
6
  ajv.errorsText(errors, {
8
7
  separator: "\n",
9
8
  dataVar: filename + "#",
10
- }),
9
+ }) + "\n"
11
10
  );
12
- logger.log("");
13
11
  }
14
12
 
15
- function resultsToJson(results) {
16
- logger.log(JSON.stringify({ results }, null, 2));
17
- }
18
-
19
- export { logErrors, resultsToJson };
13
+ export { formatErrors };
package/src/parser.js CHANGED
@@ -1,24 +1,24 @@
1
- import JSON5 from "json5";
1
+ import path from "path";
2
2
  import yaml from "js-yaml";
3
- import { parse } from "smol-toml";
3
+ import { Document } from "./plugins.js";
4
4
 
5
- function parseDocument(contents, format) {
6
- switch (format) {
7
- case ".json":
8
- case ".geojson":
9
- case ".jsonld":
10
- return JSON.parse(contents);
11
- case ".jsonc":
12
- case ".json5":
13
- return JSON5.parse(contents);
14
- case ".yml":
15
- case ".yaml":
16
- return yaml.load(contents);
17
- case ".toml":
18
- return parse(contents);
19
- default:
20
- throw new Error(`Unsupported format ${format}`);
5
+ function parseFile(plugins, contents, filename, parser) {
6
+ for (const plugin of plugins) {
7
+ const result = plugin.parseInputFile(contents, filename, parser);
8
+ if (result != null) {
9
+ if (!(result instanceof Document)) {
10
+ throw new Error(
11
+ `Plugin ${plugin.constructor.name} returned an unexpected type from parseInputFile hook. Expected Document, got ${typeof result}`,
12
+ );
13
+ }
14
+ return result.document;
15
+ }
21
16
  }
17
+
18
+ const errorMessage = parser
19
+ ? `Unsupported format ${parser}`
20
+ : `Unsupported format ${path.extname(filename).slice(1)}`;
21
+ throw new Error(errorMessage);
22
22
  }
23
23
 
24
24
  function parseSchema(contents, location) {
@@ -33,4 +33,4 @@ function parseSchema(contents, location) {
33
33
  return JSON.parse(contents);
34
34
  }
35
35
 
36
- export { parseDocument, parseSchema };
36
+ export { parseFile, parseSchema };
@@ -0,0 +1,17 @@
1
+ import { BasePlugin } from "../plugins.js";
2
+
3
+ class JsonOutput extends BasePlugin {
4
+ static name = "v8r-plugin-json-output";
5
+
6
+ registerOutputFormats() {
7
+ return ["json"];
8
+ }
9
+
10
+ getAllResultsLogMessage(results, format) {
11
+ if (format === "json") {
12
+ return JSON.stringify({ results }, null, 2);
13
+ }
14
+ }
15
+ }
16
+
17
+ export default JsonOutput;
@@ -0,0 +1,18 @@
1
+ import { BasePlugin } from "../plugins.js";
2
+ import { formatErrors } from "../output-formatters.js";
3
+
4
+ class TextOutput extends BasePlugin {
5
+ static name = "v8r-plugin-text-output";
6
+
7
+ registerOutputFormats() {
8
+ return ["text"];
9
+ }
10
+
11
+ getSingleResultLogMessage(result, fileLocation, format) {
12
+ if (result.valid === false && format === "text") {
13
+ return formatErrors(fileLocation, result.errors);
14
+ }
15
+ }
16
+ }
17
+
18
+ export default TextOutput;
@@ -0,0 +1,25 @@
1
+ import { BasePlugin, Document } from "../plugins.js";
2
+
3
+ class JsonParser extends BasePlugin {
4
+ static name = "v8r-plugin-json-parser";
5
+
6
+ registerInputFileParsers() {
7
+ return ["json"];
8
+ }
9
+
10
+ parseInputFile(contents, fileLocation, parser) {
11
+ if (parser === "json") {
12
+ return new Document(JSON.parse(contents));
13
+ } else if (parser == null) {
14
+ if (
15
+ fileLocation.endsWith(".json") ||
16
+ fileLocation.endsWith(".geojson") ||
17
+ fileLocation.endsWith(".jsonld")
18
+ ) {
19
+ return new Document(JSON.parse(contents));
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ export default JsonParser;
@@ -0,0 +1,22 @@
1
+ import JSON5 from "json5";
2
+ import { BasePlugin, Document } from "../plugins.js";
3
+
4
+ class Json5Parser extends BasePlugin {
5
+ static name = "v8r-plugin-json5-parser";
6
+
7
+ registerInputFileParsers() {
8
+ return ["json5"];
9
+ }
10
+
11
+ parseInputFile(contents, fileLocation, parser) {
12
+ if (parser === "json5") {
13
+ return new Document(JSON5.parse(contents));
14
+ } else if (parser == null) {
15
+ if (fileLocation.endsWith(".json5") || fileLocation.endsWith(".jsonc")) {
16
+ return new Document(JSON5.parse(contents));
17
+ }
18
+ }
19
+ }
20
+ }
21
+
22
+ export default Json5Parser;
@@ -0,0 +1,22 @@
1
+ import { parse } from "smol-toml";
2
+ import { BasePlugin, Document } from "../plugins.js";
3
+
4
+ class TomlParser extends BasePlugin {
5
+ static name = "v8r-plugin-toml-parser";
6
+
7
+ registerInputFileParsers() {
8
+ return ["toml"];
9
+ }
10
+
11
+ parseInputFile(contents, fileLocation, parser) {
12
+ if (parser === "toml") {
13
+ return new Document(parse(contents));
14
+ } else if (parser == null) {
15
+ if (fileLocation.endsWith(".toml")) {
16
+ return new Document(parse(contents));
17
+ }
18
+ }
19
+ }
20
+ }
21
+
22
+ export default TomlParser;
@@ -0,0 +1,22 @@
1
+ import yaml from "js-yaml";
2
+ import { BasePlugin, Document } from "../plugins.js";
3
+
4
+ class YamlParser extends BasePlugin {
5
+ static name = "v8r-plugin-yaml-parser";
6
+
7
+ registerInputFileParsers() {
8
+ return ["yaml"];
9
+ }
10
+
11
+ parseInputFile(contents, fileLocation, parser) {
12
+ if (parser === "yaml") {
13
+ return new Document(yaml.load(contents));
14
+ } else if (parser == null) {
15
+ if (fileLocation.endsWith(".yaml") || fileLocation.endsWith(".yml")) {
16
+ return new Document(yaml.load(contents));
17
+ }
18
+ }
19
+ }
20
+ }
21
+
22
+ export default YamlParser;
package/src/plugins.js ADDED
@@ -0,0 +1,223 @@
1
+ import path from "path";
2
+
3
+ /**
4
+ * Base class for all v8r plugins.
5
+ *
6
+ * @abstract
7
+ */
8
+ class BasePlugin {
9
+ /**
10
+ * Name of the plugin. All plugins must declare a name starting with
11
+ * `v8r-plugin-`.
12
+ *
13
+ * @type {string}
14
+ * @static
15
+ */
16
+ static name = "untitled plugin";
17
+
18
+ /**
19
+ * Use the `registerInputFileParsers` hook to tell v8r about additional file
20
+ * formats that can be parsed. Any parsers registered with this hook become
21
+ * valid values for the `parser` property in custom schemas.
22
+ *
23
+ * @returns {string[]} File parsers to register
24
+ */
25
+ registerInputFileParsers() {
26
+ return [];
27
+ }
28
+
29
+ /**
30
+ * Use the `parseInputFile` hook to tell v8r how to parse files.
31
+ *
32
+ * If `parseInputFile` returns anything other than undefined, that return
33
+ * value will be used and no further plugins will be invoked. If
34
+ * `parseInputFile` returns undefined, v8r will move on to the next plugin in
35
+ * the stack.
36
+ *
37
+ * @param {string} contents - The unparsed file content.
38
+ * @param {string} fileLocation - The file path. Filenames are resolved and
39
+ * normalised by [glob](https://www.npmjs.com/package/glob) using the
40
+ * `dotRelative` option. This means relative paths in the current directory
41
+ * will be prefixed with `./` (or `.\` on Windows) even if this was not
42
+ * present in the input filename or pattern.
43
+ * @param {string | undefined} parser - If the user has specified a parser to
44
+ * use for this file in a custom schema, this will be passed to
45
+ * `parseInputFile` in the `parser` param.
46
+ * @returns {Document | undefined} Parsed file contents
47
+ */
48
+ // eslint-disable-next-line no-unused-vars
49
+ parseInputFile(contents, fileLocation, parser) {
50
+ return undefined;
51
+ }
52
+
53
+ /**
54
+ * Use the `registerOutputFormats` hook to tell v8r about additional output
55
+ * formats that can be generated. Any formats registered with this hook become
56
+ * valid values for the `format` property in the config file and the
57
+ * `--format` command line argument.
58
+ *
59
+ * @returns {string[]} Output formats to register
60
+ */
61
+ registerOutputFormats() {
62
+ return [];
63
+ }
64
+
65
+ /**
66
+ * Use the `getSingleResultLogMessage` hook to provide a log message for v8r
67
+ * to output after processing a single file.
68
+ *
69
+ * If `getSingleResultLogMessage` returns anything other than undefined, that
70
+ * return value will be used and no further plugins will be invoked. If
71
+ * `getSingleResultLogMessage` returns undefined, v8r will move on to the next
72
+ * plugin in the stack.
73
+ *
74
+ * Any message returned from this function will be written to stdout.
75
+ *
76
+ * @param {ValidationResult} result - Result of attempting to validate this
77
+ * document.
78
+ * @param {string} fileLocation - The document file path. Filenames are
79
+ * resolved and normalised by [glob](https://www.npmjs.com/package/glob)
80
+ * using the `dotRelative` option. This means relative paths in the current
81
+ * directory will be prefixed with `./` (or `.\` on Windows) even if this
82
+ * was not present in the input filename or pattern.
83
+ * @param {string} format - The user's requested output format as specified in
84
+ * the config file or via the `--format` command line argument.
85
+ * @returns {string | undefined} Log message
86
+ */
87
+ // eslint-disable-next-line no-unused-vars
88
+ getSingleResultLogMessage(result, fileLocation, format) {
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * Use the `getAllResultsLogMessage` hook to provide a log message for v8r to
94
+ * output after processing all files.
95
+ *
96
+ * If `getAllResultsLogMessage` returns anything other than undefined, that
97
+ * return value will be used and no further plugins will be invoked. If
98
+ * `getAllResultsLogMessage` returns undefined, v8r will move on to the next
99
+ * plugin in the stack.
100
+ *
101
+ * Any message returned from this function will be written to stdout.
102
+ *
103
+ * @param {ValidationResult[]} results - Results of attempting to validate
104
+ * these documents.
105
+ * @param {string} format - The user's requested output format as specified in
106
+ * the config file or via the `--format` command line argument.
107
+ * @returns {string | undefined} Log message
108
+ */
109
+ // eslint-disable-next-line no-unused-vars
110
+ getAllResultsLogMessage(results, format) {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ class Document {
116
+ /**
117
+ * Document is a thin wrapper class for a document we want to validate after
118
+ * parsing a file
119
+ *
120
+ * @param {any} document - The object to be wrapped
121
+ */
122
+ constructor(document) {
123
+ this.document = document;
124
+ }
125
+ }
126
+
127
+ function validatePlugin(plugin) {
128
+ if (
129
+ typeof plugin.name !== "string" ||
130
+ !plugin.name.startsWith("v8r-plugin-")
131
+ ) {
132
+ throw new Error(`Plugin ${plugin.name} does not declare a valid name`);
133
+ }
134
+
135
+ if (!(plugin.prototype instanceof BasePlugin)) {
136
+ throw new Error(`Plugin ${plugin.name} does not extend BasePlugin`);
137
+ }
138
+
139
+ for (const prop of Object.getOwnPropertyNames(BasePlugin.prototype)) {
140
+ const method = plugin.prototype[prop];
141
+ const argCount = plugin.prototype[prop].length;
142
+ if (typeof method !== "function") {
143
+ throw new Error(
144
+ `Error loading plugin ${plugin.name}: must have a method called ${method}`,
145
+ );
146
+ }
147
+ const expectedArgs = BasePlugin.prototype[prop].length;
148
+ if (expectedArgs !== argCount) {
149
+ throw new Error(
150
+ `Error loading plugin ${plugin.name}: ${prop} must take exactly ${expectedArgs} arguments`,
151
+ );
152
+ }
153
+ }
154
+ }
155
+
156
+ function resolveUserPlugins(userPlugins) {
157
+ let plugins = [];
158
+ for (let plugin of userPlugins) {
159
+ if (plugin.startsWith("package:")) {
160
+ plugins.push(plugin.slice(8));
161
+ }
162
+ if (plugin.startsWith("file:")) {
163
+ plugins.push(path.resolve(process.cwd(), plugin.slice(5)));
164
+ }
165
+ }
166
+ return plugins;
167
+ }
168
+
169
+ async function loadPlugins(plugins) {
170
+ let loadedPlugins = [];
171
+ for (const plugin of plugins) {
172
+ loadedPlugins.push(await import(plugin));
173
+ }
174
+ loadedPlugins = loadedPlugins.map((plugin) => plugin.default);
175
+ loadedPlugins.forEach((plugin) => validatePlugin(plugin));
176
+ loadedPlugins = loadedPlugins.map((plugin) => new plugin());
177
+ return loadedPlugins;
178
+ }
179
+
180
+ async function loadAllPlugins(userPlugins) {
181
+ const loadedUserPlugins = await loadPlugins(userPlugins);
182
+
183
+ const corePlugins = [
184
+ "./plugins/parser-json.js",
185
+ "./plugins/parser-json5.js",
186
+ "./plugins/parser-toml.js",
187
+ "./plugins/parser-yaml.js",
188
+ "./plugins/output-text.js",
189
+ "./plugins/output-json.js",
190
+ ];
191
+ const loadedCorePlugins = await loadPlugins(corePlugins);
192
+
193
+ return {
194
+ allLoadedPlugins: loadedUserPlugins.concat(loadedCorePlugins),
195
+ loadedCorePlugins,
196
+ loadedUserPlugins,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * @typedef {object} ValidationResult
202
+ * @property {string} fileLocation - Path of the document that was validated.
203
+ * Filenames are resolved and normalised by
204
+ * [glob](https://www.npmjs.com/package/glob) using the `dotRelative` option.
205
+ * This means relative paths in the current directory will be prefixed with
206
+ * `./` (or `.\` on Windows) even if this was not present in the input
207
+ * filename or pattern.
208
+ * @property {string | null} schemaLocation - Location of the schema used to
209
+ * validate this file if one could be found. `null` if no schema was found.
210
+ * @property {boolean | null} valid - Result of the validation (true/false) if a
211
+ * schema was found. `null` if no schema was found and no validation could be
212
+ * performed.
213
+ * @property {ErrorObject[]} errors - An array of [AJV Error
214
+ * Objects](https://ajv.js.org/api.html#error-objects) describing any errors
215
+ * encountered when validating this document.
216
+ */
217
+
218
+ /**
219
+ * @external ErrorObject
220
+ * @see https://ajv.js.org/api.html#error-objects
221
+ */
222
+
223
+ export { BasePlugin, Document, loadAllPlugins, resolveUserPlugins };
package/src/public.js ADDED
@@ -0,0 +1,3 @@
1
+ import { BasePlugin, Document } from "./plugins.js";
2
+
3
+ export { BasePlugin, Document };