@schalkneethling/miyagi-core 4.1.1 → 4.2.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/api/index.js CHANGED
@@ -7,6 +7,11 @@ import build from "../lib/build/index.js";
7
7
  import generateMockData from "../lib/generator/mocks.js";
8
8
  import generateComponent from "../lib/generator/component.js";
9
9
  import validateMockData from "../lib/validator/mocks.js";
10
+ import {
11
+ getSchemaValidationMode,
12
+ toSchemaValidationResult,
13
+ validateSchemas,
14
+ } from "../lib/validator/schemas.js";
10
15
 
11
16
  /**
12
17
  * @param {object} obj
@@ -172,14 +177,44 @@ export const createComponent = async ({ component, only = [], skip = [] }) => {
172
177
 
173
178
  export const lintComponents = async () => {
174
179
  global.app = await init("api");
175
- const promises = [];
180
+ const mode = getSchemaValidationMode();
181
+ const components = global.state.routes.filter((route) => route.paths.tpl);
182
+ const schemaValidation = validateSchemas({
183
+ components,
184
+ });
185
+ const schemaErrorsByComponent = new Map();
186
+
187
+ schemaValidation.errors.forEach((entry) => {
188
+ if (!schemaErrorsByComponent.has(entry.component)) {
189
+ schemaErrorsByComponent.set(entry.component, []);
190
+ }
191
+ schemaErrorsByComponent
192
+ .get(entry.component)
193
+ .push(toSchemaValidationResult(entry));
194
+ });
176
195
 
177
- global.state.routes.forEach((route) => {
178
- if (route.paths.tpl) {
179
- promises.push(
196
+ if (mode === "fail-fast" && schemaValidation.errors.length > 0) {
197
+ return {
198
+ success: false,
199
+ data: getLintComponentErrorsInRouteOrder({
200
+ components,
201
+ errorMap: schemaErrorsByComponent,
202
+ }),
203
+ };
204
+ }
205
+
206
+ const promises = components
207
+ .filter((route) => !schemaErrorsByComponent.has(route.paths.dir.short))
208
+ .map(
209
+ (route) =>
180
210
  new Promise((resolve) => {
181
211
  getComponentData(route).then((data) => {
182
- const validation = validateMockData(route, data || [], true);
212
+ const validation = validateMockData(
213
+ route,
214
+ data || [],
215
+ true,
216
+ schemaValidation.validSchemas,
217
+ );
183
218
 
184
219
  resolve({
185
220
  component: route.alias,
@@ -187,13 +222,25 @@ export const lintComponents = async () => {
187
222
  });
188
223
  });
189
224
  }),
190
- );
191
- }
192
- });
225
+ );
193
226
 
194
227
  return await Promise.all(promises)
195
228
  .then((res) => {
196
- const errors = res.filter((result) => result?.errors?.length > 0);
229
+ res.forEach((result) => {
230
+ if (!result?.errors?.length) {
231
+ return;
232
+ }
233
+ const componentErrors = schemaErrorsByComponent.get(result.component) || [];
234
+ schemaErrorsByComponent.set(result.component, [
235
+ ...componentErrors,
236
+ ...result.errors,
237
+ ]);
238
+ });
239
+
240
+ const errors = getLintComponentErrorsInRouteOrder({
241
+ components,
242
+ errorMap: schemaErrorsByComponent,
243
+ });
197
244
 
198
245
  return {
199
246
  success: errors.length === 0,
@@ -216,8 +263,26 @@ export const lintComponent = async ({ component }) => {
216
263
  message: `The component ${component} does not seem to exist.`,
217
264
  };
218
265
 
266
+ const allSchemaValidation = validateSchemas({
267
+ components: [componentObject],
268
+ });
269
+
270
+ if (allSchemaValidation.errors.length > 0) {
271
+ return {
272
+ success: false,
273
+ data: allSchemaValidation.errors.map((entry) =>
274
+ toSchemaValidationResult(entry),
275
+ ),
276
+ };
277
+ }
278
+
219
279
  const data = await getComponentData(componentObject);
220
- const errors = validateMockData(componentObject, data, true);
280
+ const errors = validateMockData(
281
+ componentObject,
282
+ data || [],
283
+ true,
284
+ allSchemaValidation.validSchemas,
285
+ );
221
286
 
222
287
  return {
223
288
  success: errors === null || errors?.length === 0,
@@ -234,3 +299,26 @@ function getComponentsObject(component) {
234
299
  (route) => route.paths.dir.short === component,
235
300
  );
236
301
  }
302
+
303
+ /**
304
+ * @param {object} params
305
+ * @param {Array<object>} params.components
306
+ * @param {Map<string, Array<object>>} params.errorMap
307
+ * @returns {Array<object>}
308
+ */
309
+ function getLintComponentErrorsInRouteOrder({ components, errorMap }) {
310
+ return components
311
+ .map((route) => {
312
+ const componentErrors = errorMap.get(route.alias) || [];
313
+
314
+ if (componentErrors.length === 0) {
315
+ return null;
316
+ }
317
+
318
+ return {
319
+ component: route.alias,
320
+ errors: componentErrors,
321
+ };
322
+ })
323
+ .filter(Boolean);
324
+ }
package/lib/cli/lint.js CHANGED
@@ -4,6 +4,11 @@ import getConfig from "../config.js";
4
4
  import log from "../logger.js";
5
5
  import { getComponentData } from "../mocks/index.js";
6
6
  import validateMockData from "../validator/mocks.js";
7
+ import {
8
+ getSchemaValidationMode,
9
+ toSchemaValidationResult,
10
+ validateSchemas,
11
+ } from "../validator/schemas.js";
7
12
  import { t } from "../i18n/index.js";
8
13
 
9
14
  /**
@@ -14,6 +19,8 @@ export default async function lint(args) {
14
19
 
15
20
  const componentArg = args._.slice(1)[0];
16
21
  const config = await getConfig(args);
22
+ process.env.MIYAGI_LOG_CONTEXT = "lint";
23
+ process.env.MIYAGI_LOG_LEVEL = config.lint?.logLevel || "error";
17
24
  global.app = await init(config);
18
25
 
19
26
  if (componentArg) {
@@ -23,8 +30,20 @@ export default async function lint(args) {
23
30
  );
24
31
 
25
32
  if (component) {
33
+ const schemaValidation = validateSchemas({
34
+ components: [component],
35
+ });
36
+
37
+ if (schemaValidation.errors.length > 0) {
38
+ reportSchemaErrors(schemaValidation.errors);
39
+ process.exit(1);
40
+ }
41
+
42
+ log("success", "All schemas valid.");
43
+
26
44
  await validateComponentMockData({
27
45
  component,
46
+ validSchemas: schemaValidation.validSchemas,
28
47
  });
29
48
  } else {
30
49
  log("error", `The component ${componentArg} does not seem to exist.`);
@@ -40,81 +59,90 @@ export default async function lint(args) {
40
59
  */
41
60
  async function validateAllMockData(exitProcess = true) {
42
61
  log("info", t("linter.all.start"));
62
+ const mode = getSchemaValidationMode();
63
+ const components = global.state.routes.filter(
64
+ (route) => route.type === "components" && route.paths.tpl,
65
+ );
66
+ const schemaValidation = validateSchemas({
67
+ components,
68
+ });
69
+ const invalidSchemaComponents = new Set(
70
+ schemaValidation.errors.map((entry) => entry.component),
71
+ );
43
72
 
44
- const promises = [];
45
-
46
- global.state.routes.forEach((route) => {
47
- if (route.type === "components") {
48
- promises.push(
49
- new Promise((resolve, reject) => {
50
- validateComponentMockData({
51
- component: route,
52
- silent: true,
53
- exitProcess: false,
54
- })
55
- .then((result) => resolve(result))
56
- .catch((err) => {
57
- console.error(err);
58
- reject();
59
- });
60
- }),
61
- );
73
+ if (schemaValidation.errors.length === 0) {
74
+ log("success", "All schemas valid.");
75
+ }
76
+
77
+ if (schemaValidation.errors.length > 0 && mode === "fail-fast") {
78
+ reportSchemaErrors(schemaValidation.errors);
79
+ log(
80
+ "error",
81
+ schemaValidation.errors.length === 1
82
+ ? t("linter.all.schema.invalid.one")
83
+ : t("linter.all.schema.invalid.other").replace(
84
+ "{{amount}}",
85
+ schemaValidation.errors.length,
86
+ ),
87
+ );
88
+ if (exitProcess) {
89
+ process.exit(1);
62
90
  }
63
- });
91
+ return;
92
+ }
64
93
 
65
- Promise.all(promises)
66
- .then((results) => {
67
- const mockInvalidResults = results.filter(
68
- (result) => result?.valid === false && result.type === "mocks",
69
- );
70
- const schemaInvalidResults = results.filter(
71
- (result) => result?.valid === false && result.type === "schema",
72
- );
94
+ const results = await Promise.all(
95
+ components
96
+ .filter((route) => !invalidSchemaComponents.has(route.paths.dir.short))
97
+ .map((component) =>
98
+ validateComponentMockData({
99
+ component,
100
+ silent: true,
101
+ exitProcess: false,
102
+ validSchemas: schemaValidation.validSchemas,
103
+ }),
104
+ ),
105
+ );
106
+ const mockInvalidResults = results.filter(
107
+ (result) => result?.valid === false && result.type === "mocks",
108
+ );
73
109
 
74
- if (
75
- mockInvalidResults.length === 0 &&
76
- schemaInvalidResults.length === 0
77
- ) {
78
- log("success", t("linter.all.valid"));
79
- if (exitProcess) {
80
- process.exit(0);
81
- }
82
- }
110
+ if (mode === "collect-all" && schemaValidation.errors.length > 0) {
111
+ reportSchemaErrors(schemaValidation.errors);
112
+ log(
113
+ "error",
114
+ schemaValidation.errors.length === 1
115
+ ? t("linter.all.schema.invalid.one")
116
+ : t("linter.all.schema.invalid.other").replace(
117
+ "{{amount}}",
118
+ schemaValidation.errors.length,
119
+ ),
120
+ );
121
+ }
83
122
 
84
- if (schemaInvalidResults.length > 0) {
85
- log(
86
- "error",
87
- schemaInvalidResults.length === 1
88
- ? t("linter.all.schema.invalid.one")
89
- : t("linter.all.schema.invalid.other").replace(
90
- "{{amount}}",
91
- schemaInvalidResults.length,
92
- ),
93
- );
94
- }
123
+ if (mockInvalidResults.length > 0) {
124
+ log(
125
+ "error",
126
+ mockInvalidResults.length === 1
127
+ ? t("linter.all.mocks.invalid.one")
128
+ : t("linter.all.mocks.invalid.other").replace(
129
+ "{{amount}}",
130
+ mockInvalidResults.length,
131
+ ),
132
+ );
133
+ }
95
134
 
96
- if (mockInvalidResults.length > 0) {
97
- log(
98
- "error",
99
- mockInvalidResults.length === 1
100
- ? t("linter.all.mocks.invalid.one")
101
- : t("linter.all.mocks.invalid.other").replace(
102
- "{{amount}}",
103
- mockInvalidResults.length,
104
- ),
105
- );
106
- }
135
+ if (mockInvalidResults.length === 0 && schemaValidation.errors.length === 0) {
136
+ log("success", t("linter.all.valid"));
137
+ if (exitProcess) {
138
+ process.exit(0);
139
+ }
140
+ return;
141
+ }
107
142
 
108
- if (exitProcess) {
109
- process.exit(1);
110
- }
111
- })
112
- .catch((err) => {
113
- console.error(err);
114
- if (exitProcess) {
115
- process.exit(1);
116
- }
117
- });
143
+ if (exitProcess) {
144
+ process.exit(1);
145
+ }
118
146
  }
119
147
 
120
148
  /**
@@ -122,12 +150,14 @@ async function validateAllMockData(exitProcess = true) {
122
150
  * @param {object} obj.component
123
151
  * @param {boolean} [obj.silent]
124
152
  * @param {boolean} [obj.exitProcess]
153
+ * @param {Array<object>} [obj.validSchemas]
125
154
  * @returns {Promise<object|null>}
126
155
  */
127
156
  async function validateComponentMockData({
128
157
  component,
129
158
  silent,
130
159
  exitProcess = true,
160
+ validSchemas = [],
131
161
  }) {
132
162
  if (!silent) {
133
163
  log(
@@ -139,42 +169,60 @@ async function validateComponentMockData({
139
169
  );
140
170
  }
141
171
 
142
- const data = await getComponentData(component);
172
+ const data = (await getComponentData(component)) || [];
143
173
 
144
- if (data) {
145
- for (const { messages } of data) {
174
+ if (data.length > 0) {
175
+ for (const { messages = [] } of data) {
146
176
  for (const { type, text, verbose } of messages) {
147
177
  log(type, text, verbose);
148
178
  }
149
179
  }
180
+ }
150
181
 
151
- const results = validateMockData(component, data);
182
+ const results = validateMockData(component, data, false, validSchemas);
152
183
 
153
- if (!results) return null;
184
+ if (!results) return null;
154
185
 
155
- if (results.length === 0) {
156
- if (!silent) {
157
- log("success", t("linter.component.valid"));
158
- }
186
+ if (results.length === 0) {
187
+ if (!silent) {
188
+ log("success", t("linter.component.valid"));
189
+ }
159
190
 
160
- if (exitProcess) {
161
- process.exit(0);
162
- } else {
163
- return {
164
- valid: true,
165
- };
166
- }
167
- } else {
168
- if (exitProcess) {
169
- process.exit(0);
170
- } else {
171
- return {
172
- valid: false,
173
- type: results[0].type,
174
- };
175
- }
191
+ if (exitProcess) {
192
+ process.exit(0);
176
193
  }
194
+
195
+ return {
196
+ valid: true,
197
+ };
177
198
  }
178
199
 
179
- return null;
200
+ if (exitProcess) {
201
+ process.exit(1);
202
+ }
203
+
204
+ return {
205
+ valid: false,
206
+ type: results[0].type,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * @param {Array<object>} schemaErrors
212
+ */
213
+ function reportSchemaErrors(schemaErrors) {
214
+ schemaErrors.forEach((entry) => {
215
+ const result = toSchemaValidationResult(entry);
216
+ log("error", `${entry.component}:\n${result.data[0].message}`);
217
+ log("error", `schema: ${entry.schemaFile}`);
218
+ if (entry.schemaPath || entry.instancePath) {
219
+ log(
220
+ "error",
221
+ `schemaPath: ${entry.schemaPath || "-"} | instancePath: ${entry.instancePath || "-"}`,
222
+ );
223
+ }
224
+ if (entry.hint) {
225
+ log("warn", entry.hint);
226
+ }
227
+ });
180
228
  }
@@ -0,0 +1,11 @@
1
+ export const LINT_LOG_LEVELS = Object.freeze({
2
+ ERROR: "error",
3
+ WARN: "warn",
4
+ INFO: "info",
5
+ });
6
+
7
+ export const LINT_LOG_LEVEL_ORDER = Object.freeze({
8
+ [LINT_LOG_LEVELS.ERROR]: 0,
9
+ [LINT_LOG_LEVELS.WARN]: 1,
10
+ [LINT_LOG_LEVELS.INFO]: 2,
11
+ });
@@ -46,6 +46,9 @@ export default {
46
46
  render: null,
47
47
  options: {},
48
48
  },
49
+ lint: {
50
+ logLevel: "error",
51
+ },
49
52
  extensions: [],
50
53
  files: {
51
54
  css: {
@@ -96,7 +99,9 @@ export default {
96
99
  },
97
100
  schema: {
98
101
  ajv: AJV,
102
+ verbose: false,
99
103
  },
104
+ schemaValidationMode: "collect-all",
100
105
  },
101
106
  projectName: "miyagi",
102
107
  defaultPort: 5000,
package/lib/i18n/en.js CHANGED
@@ -70,6 +70,10 @@ export default {
70
70
  invalid: "Mock data does not match schema file.",
71
71
  noSchemaFound:
72
72
  "No schema file found or the schema file could not be parsed as valid JSON.",
73
+ schemaMissing:
74
+ "Component {{component}} has no schema file (expected: {{schemaFile}}). Consider adding it to components.ignores if this is expected.",
75
+ schemaParseFailed:
76
+ "Schema file {{schemaFile}} could not be parsed as {{format}}.",
73
77
  },
74
78
  },
75
79
  serverStarted: "Running miyagi server at http://localhost:{{port}}",
@@ -7,6 +7,7 @@ import deepMerge from "deepmerge";
7
7
  import log from "../logger.js";
8
8
  import appConfig from "../default-config.js";
9
9
  import { t, available as langAvailable } from "../i18n/index.js";
10
+ import { LINT_LOG_LEVELS } from "../constants/lint-log-levels.js";
10
11
  import fs from "fs";
11
12
  import path from "path";
12
13
 
@@ -326,6 +327,14 @@ export default (userConfig = {}) => {
326
327
  merged.ui.lang = "en";
327
328
  }
328
329
 
330
+ if (!Object.values(LINT_LOG_LEVELS).includes(merged.lint.logLevel)) {
331
+ log(
332
+ "warn",
333
+ `Invalid config.lint.logLevel "${merged.lint.logLevel}". Falling back to "${defaultUserConfig.lint.logLevel}".`,
334
+ );
335
+ merged.lint.logLevel = defaultUserConfig.lint.logLevel;
336
+ }
337
+
329
338
  return merged;
330
339
  };
331
340
 
package/lib/logger.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ LINT_LOG_LEVEL_ORDER,
3
+ LINT_LOG_LEVELS,
4
+ } from "./constants/lint-log-levels.js";
5
+
1
6
  const COLORS = {
2
7
  grey: "\x1b[90m",
3
8
  red: "\x1b[31m",
@@ -21,8 +26,17 @@ const TYPES = {
21
26
  * @param {string|Error} [verboseMessage]
22
27
  */
23
28
  export default function log(type, message, verboseMessage) {
24
- if (process.env.MIYAGI_JS_API) return;
25
- if (!(type in TYPES)) return;
29
+ if (process.env.MIYAGI_JS_API) {
30
+ return;
31
+ }
32
+
33
+ if (!(type in TYPES)) {
34
+ return;
35
+ }
36
+
37
+ if (!shouldLogType(type)) {
38
+ return;
39
+ }
26
40
 
27
41
  const date = new Date();
28
42
  const year = date.getFullYear();
@@ -59,6 +73,27 @@ export default function log(type, message, verboseMessage) {
59
73
  }
60
74
  }
61
75
 
76
+ /**
77
+ * @param {string} type
78
+ * @returns {boolean}
79
+ */
80
+ function shouldLogType(type) {
81
+ if (process.env.MIYAGI_LOG_CONTEXT !== "lint") {
82
+ return true;
83
+ }
84
+
85
+ const configuredLevel = process.env.MIYAGI_LOG_LEVEL || "error";
86
+ const normalizedType = type === "success" ? "info" : type;
87
+ const configuredLevelValue =
88
+ LINT_LOG_LEVEL_ORDER[configuredLevel] ??
89
+ LINT_LOG_LEVEL_ORDER[LINT_LOG_LEVELS.ERROR];
90
+ const typeLevelValue =
91
+ LINT_LOG_LEVEL_ORDER[normalizedType] ??
92
+ LINT_LOG_LEVEL_ORDER[LINT_LOG_LEVELS.INFO];
93
+
94
+ return typeLevelValue <= configuredLevelValue;
95
+ }
96
+
62
97
  /**
63
98
  * @param {string} color
64
99
  * @param {string} str
@@ -11,6 +11,31 @@ import { marked as Markdown } from "marked";
11
11
  import * as helpers from "../helpers.js";
12
12
  import log from "../logger.js";
13
13
 
14
+ /**
15
+ * @param {string} fileName
16
+ * @returns {boolean}
17
+ */
18
+ function isSchemaFile(fileName) {
19
+ const schemaFileName = `${global.config.files.schema.name}.${global.config.files.schema.extension}`;
20
+ return path.basename(fileName) === schemaFileName;
21
+ }
22
+
23
+ /**
24
+ * @param {string} fileName
25
+ * @param {Error & { code?: string }} err
26
+ */
27
+ function markFileReadError(fileName, err) {
28
+ if (!global.state?.fileReadErrors || !isSchemaFile(fileName)) {
29
+ return;
30
+ }
31
+
32
+ global.state.fileReadErrors[fileName] = {
33
+ type: "schema-parse",
34
+ code: err?.code || null,
35
+ message: err?.message || String(err),
36
+ };
37
+ }
38
+
14
39
  /**
15
40
  * Checks if a given array of file paths includes a given file path
16
41
  * @param {string} file - file path string
@@ -134,8 +159,13 @@ export const readFile = async function (fileName) {
134
159
  case [".yaml", ".yml"].includes(path.extname(fileName)):
135
160
  {
136
161
  try {
137
- return await getYamlFileContent(fileName);
162
+ const content = await getYamlFileContent(fileName);
163
+ if (global.state?.fileReadErrors) {
164
+ delete global.state.fileReadErrors[fileName];
165
+ }
166
+ return content;
138
167
  } catch (err) {
168
+ markFileReadError(fileName, err);
139
169
  log("error", `Error when reading file ${fileName}`, err);
140
170
  }
141
171
  }
@@ -153,8 +183,13 @@ export const readFile = async function (fileName) {
153
183
  [".js", ".mjs"].includes(path.extname(fileName)):
154
184
  {
155
185
  try {
156
- return await getJsFileContent(fileName);
186
+ const content = await getJsFileContent(fileName);
187
+ if (global.state?.fileReadErrors) {
188
+ delete global.state.fileReadErrors[fileName];
189
+ }
190
+ return content;
157
191
  } catch (err) {
192
+ markFileReadError(fileName, err);
158
193
  log("error", `Error when reading file ${fileName}`, err);
159
194
  }
160
195
  }
@@ -162,8 +197,13 @@ export const readFile = async function (fileName) {
162
197
  case fileName.endsWith(".json"):
163
198
  {
164
199
  try {
165
- return await getParsedJsonFileContent(fileName);
200
+ const content = await getParsedJsonFileContent(fileName);
201
+ if (global.state?.fileReadErrors) {
202
+ delete global.state.fileReadErrors[fileName];
203
+ }
204
+ return content;
166
205
  } catch (err) {
206
+ markFileReadError(fileName, err);
167
207
  log("error", `Error when reading file ${fileName}`, err);
168
208
  }
169
209
  }
@@ -187,6 +227,9 @@ export const readFile = async function (fileName) {
187
227
  */
188
228
  export const getFileContents = async function (sourceTree) {
189
229
  const fileContents = {};
230
+ if (global.state) {
231
+ global.state.fileReadErrors = {};
232
+ }
190
233
  const paths = await getFilePaths(sourceTree);
191
234
 
192
235
  if (paths) {
@@ -18,6 +18,7 @@ export default async function setState(methods) {
18
18
  if (!global.state) {
19
19
  global.state = {
20
20
  routes: [],
21
+ fileReadErrors: {},
21
22
  };
22
23
  }
23
24
 
@@ -1,4 +1,5 @@
1
1
  import jsYaml from "js-yaml";
2
+ import { existsSync } from "node:fs";
2
3
  import deepMerge from "deepmerge";
3
4
  import log from "../logger.js";
4
5
  import { t } from "../i18n/index.js";
@@ -9,31 +10,19 @@ import { t } from "../i18n/index.js";
9
10
  * @param {object} component
10
11
  * @param {Array} dataArray - an array with mock data
11
12
  * @param {boolean} [noCli]
13
+ * @param {Array<object>} [validSchemas]
12
14
  * @returns {null|object[]} null if there is no schema or an array with booleans defining the validity of the entries in the data array
13
15
  */
14
- export default function validateMockData(component, dataArray, noCli) {
16
+ export default function validateMockData(
17
+ component,
18
+ dataArray,
19
+ noCli,
20
+ validSchemas = [],
21
+ ) {
15
22
  const componentSchema =
16
23
  global.state.fileContents[component.paths.schema.full];
17
24
 
18
25
  if (componentSchema) {
19
- const schemas = [];
20
-
21
- Object.entries(global.state.fileContents).forEach(([key, value]) => {
22
- if (
23
- key.endsWith(
24
- `${global.config.files.schema.name}.${global.config.files.schema.extension}`,
25
- )
26
- ) {
27
- const arr = Array.isArray(value) ? value : [value];
28
-
29
- arr.forEach((schema) => {
30
- if (schema && componentSchema.$id !== schema.$id) {
31
- schemas.push(schema);
32
- }
33
- });
34
- }
35
- });
36
-
37
26
  const validity = [];
38
27
  let validate;
39
28
  let jsonSchemaValidator;
@@ -43,16 +32,19 @@ export default function validateMockData(component, dataArray, noCli) {
43
32
  deepMerge(
44
33
  {
45
34
  allErrors: true,
46
- schemas: schemas.map((schema, i) => {
47
- if (!schema.$id) {
48
- schema.$id = i.toString();
49
- }
50
- return schema;
51
- }),
52
35
  },
53
36
  global.config.schema.options || {},
54
37
  ),
55
38
  );
39
+
40
+ validSchemas.forEach((entry) => {
41
+ // Preload only other validated schemas for cross-component $ref resolution.
42
+ // The current component schema is compiled below and must not be added twice.
43
+ if (entry?.schemaFile !== component.paths.schema.full && entry?.schema) {
44
+ jsonSchemaValidator.addSchema(entry.schema);
45
+ }
46
+ });
47
+
56
48
  validate = jsonSchemaValidator.compile(componentSchema);
57
49
  } catch (e) {
58
50
  const message = e.toString();
@@ -95,9 +87,22 @@ export default function validateMockData(component, dataArray, noCli) {
95
87
  }
96
88
 
97
89
  if (!global.config.isBuild && !noCli) {
90
+ const parseError = global.state.fileReadErrors?.[component.paths.schema.full];
91
+ const schemaExistsOnDisk = existsSync(component.paths.schema.full);
92
+ const warningMessage = parseError
93
+ ? t("validator.mocks.schemaParseFailed")
94
+ .replace("{{schemaFile}}", component.paths.schema.short)
95
+ .replace("{{format}}", "JSON or YAML")
96
+ : schemaExistsOnDisk
97
+ ? t("validator.mocks.schemaParseFailed")
98
+ .replace("{{schemaFile}}", component.paths.schema.short)
99
+ .replace("{{format}}", "JSON or YAML")
100
+ : t("validator.mocks.schemaMissing")
101
+ .replace("{{component}}", component.paths.dir.short)
102
+ .replace("{{schemaFile}}", component.paths.schema.short);
98
103
  log(
99
104
  "warn",
100
- `${component.paths.dir.short}: ${t("validator.mocks.noSchemaFound")}`,
105
+ warningMessage,
101
106
  );
102
107
  }
103
108
 
@@ -0,0 +1,234 @@
1
+ // @ts-check
2
+
3
+ import deepMerge from "deepmerge";
4
+
5
+ const DEFAULT_SCHEMA_VALIDATION_MODE = "collect-all";
6
+ const ALLOWED_SCHEMA_VALIDATION_MODES = new Set(["collect-all", "fail-fast"]);
7
+
8
+ /**
9
+ * @returns {"collect-all"|"fail-fast"}
10
+ */
11
+ export function getSchemaValidationMode() {
12
+ const mode = global.config.schemaValidationMode ?? DEFAULT_SCHEMA_VALIDATION_MODE;
13
+
14
+ if (!ALLOWED_SCHEMA_VALIDATION_MODES.has(mode)) {
15
+ return DEFAULT_SCHEMA_VALIDATION_MODE;
16
+ }
17
+
18
+ return mode;
19
+ }
20
+
21
+ /**
22
+ * @param {object} options
23
+ * @param {Array<object>} [options.components]
24
+ * @returns {{ valid: boolean, errors: Array<object>, validSchemas: Array<object> }}
25
+ */
26
+ export function validateSchemas({ components } = {}) {
27
+ const validSchemas = [];
28
+ const errors = [];
29
+ const componentRoutes =
30
+ components ??
31
+ global.state.routes.filter((route) => route.type === "components" && route.paths.tpl);
32
+ const validator = new global.config.schema.ajv(
33
+ deepMerge(
34
+ {
35
+ allErrors: true,
36
+ },
37
+ global.config.schema.options || {},
38
+ ),
39
+ );
40
+
41
+ let pendingSchemas = componentRoutes
42
+ .map((component, index) => {
43
+ // Absolute schema file path.
44
+ const schemaFile = component.paths.schema.full;
45
+ // Parsed schema from in-memory state cache.
46
+ const schemaFromState = global.state.fileContents[schemaFile];
47
+
48
+ if (!schemaFromState) {
49
+ return null;
50
+ }
51
+
52
+ const schema = structuredClone(schemaFromState);
53
+ if (!schema.$id) {
54
+ schema.$id = component.paths.schema.short || schemaFile || index.toString();
55
+ }
56
+
57
+ return {
58
+ component: component.paths.dir.short,
59
+ schemaFile,
60
+ rawSchema: schemaFromState,
61
+ schema,
62
+ };
63
+ })
64
+ .filter(Boolean);
65
+
66
+ while (pendingSchemas.length > 0) {
67
+ let progress = false;
68
+ const retrySchemas = [];
69
+
70
+ pendingSchemas.forEach((entry) => {
71
+ try {
72
+ validator.compile(entry.schema);
73
+ if (!validator.getSchema(entry.schema.$id)) {
74
+ validator.addSchema(entry.schema);
75
+ }
76
+ validSchemas.push({
77
+ component: entry.component,
78
+ schemaFile: entry.schemaFile,
79
+ schema: entry.schema,
80
+ });
81
+ progress = true;
82
+ } catch (error) {
83
+ if (isUnresolvedRefError(error)) {
84
+ retrySchemas.push(entry);
85
+ return;
86
+ }
87
+
88
+ errors.push(
89
+ buildSchemaValidationError({
90
+ error,
91
+ component: entry.component,
92
+ schemaFile: entry.schemaFile,
93
+ rawSchema: entry.rawSchema,
94
+ }),
95
+ );
96
+ }
97
+ });
98
+
99
+ if (!progress) {
100
+ retrySchemas.forEach((entry) => {
101
+ const error = new Error(
102
+ `can't resolve reference while validating schema ${entry.schemaFile}`,
103
+ );
104
+ errors.push(
105
+ buildSchemaValidationError({
106
+ error,
107
+ component: entry.component,
108
+ schemaFile: entry.schemaFile,
109
+ rawSchema: entry.rawSchema,
110
+ }),
111
+ );
112
+ });
113
+ break;
114
+ }
115
+
116
+ pendingSchemas = retrySchemas;
117
+ }
118
+
119
+ return {
120
+ valid: errors.length === 0,
121
+ errors,
122
+ validSchemas,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * @param {object} obj
128
+ * @param {Error & { errors?: Array<object> }} obj.error
129
+ * @param {string} obj.component
130
+ * @param {string} obj.schemaFile
131
+ * @param {object} obj.rawSchema
132
+ * @returns {object}
133
+ */
134
+ function buildSchemaValidationError({ error, component, schemaFile, rawSchema }) {
135
+ const ajvErrors = Array.isArray(error?.errors) ? error.errors : [];
136
+ const [firstAjvError] = ajvErrors;
137
+ const hint = getSchemaHint(rawSchema, ajvErrors);
138
+ const type = isUnresolvedRefError(error) ? "schema-ref" : "schema";
139
+
140
+ return {
141
+ type,
142
+ component,
143
+ schemaFile,
144
+ message: error?.toString?.() || "Unknown schema validation error",
145
+ schemaPath: firstAjvError?.schemaPath || "",
146
+ instancePath: firstAjvError?.instancePath || "",
147
+ hint,
148
+ details: ajvErrors.map((entry) => ({
149
+ keyword: entry.keyword,
150
+ message: entry.message,
151
+ schemaPath: entry.schemaPath,
152
+ instancePath: entry.instancePath,
153
+ params: entry.params,
154
+ })),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * @param {object} schema
160
+ * @param {Array<object>} ajvErrors
161
+ * @returns {string|undefined}
162
+ */
163
+ function getSchemaHint(schema, ajvErrors) {
164
+ if (schema?.properties === null) {
165
+ return "Hint: `properties` resolves to null. In YAML this often means `properties:` has no nested keys.";
166
+ }
167
+
168
+ if (
169
+ ajvErrors.some(
170
+ (error) =>
171
+ error?.schemaPath?.endsWith("/properties/type") &&
172
+ error?.instancePath?.includes("/properties/"),
173
+ )
174
+ ) {
175
+ return "Hint: check each field `type` value; it must be a valid JSON Schema type.";
176
+ }
177
+
178
+ return undefined;
179
+ }
180
+
181
+ /**
182
+ * @param {Error & { message?: string, missingRef?: string, missingSchema?: string, code?: string }} error
183
+ * @returns {boolean}
184
+ */
185
+ function isUnresolvedRefError(error) {
186
+ if (typeof error?.missingRef === "string" && error.missingRef.length > 0) {
187
+ return true;
188
+ }
189
+
190
+ if (
191
+ typeof error?.missingSchema === "string" &&
192
+ error.missingSchema.length > 0
193
+ ) {
194
+ return true;
195
+ }
196
+
197
+ if (error?.code === "ERR_MISSING_REF") {
198
+ return true;
199
+ }
200
+
201
+ return /can't resolve reference|missing ref|missing schema/i.test(
202
+ error?.message || "",
203
+ );
204
+ }
205
+
206
+ /**
207
+ * @param {object} schemaError
208
+ * @param {object} [options]
209
+ * @param {boolean} [options.verbose]
210
+ * @returns {{ type: "schema"|"schema-ref", data: Array<object> }}
211
+ */
212
+ export function toSchemaValidationResult(schemaError, options = {}) {
213
+ const useVerbose =
214
+ options.verbose ?? global.config?.schema?.verbose === true;
215
+ const formattedError = {
216
+ message: schemaError.message,
217
+ component: schemaError.component,
218
+ schemaFile: schemaError.schemaFile,
219
+ hint: schemaError.hint,
220
+ };
221
+
222
+ if (useVerbose) {
223
+ formattedError.schemaPath = schemaError.schemaPath;
224
+ formattedError.instancePath = schemaError.instancePath;
225
+ formattedError.details = schemaError.details;
226
+ }
227
+
228
+ return {
229
+ type: schemaError.type || "schema",
230
+ data: [
231
+ formattedError,
232
+ ],
233
+ };
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schalkneethling/miyagi-core",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "miyagi is a component development tool for JavaScript template engines.",
5
5
  "main": "index.js",
6
6
  "author": "Schalk Neethling <schalkneethling@duck.com>, Michael Großklaus <mail@mgrossklaus.de> (https://www.mgrossklaus.de)",