@schalkneethling/miyagi-core 4.0.2 → 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.
@@ -6,6 +6,7 @@ import * as helpers from "../../../helpers.js";
6
6
  import validateMocks from "../../../validator/mocks.js";
7
7
  import { getComponentData } from "../../../mocks/index.js";
8
8
  import { getUserUiConfig, getThemeMode } from "../../helpers.js";
9
+ import resolveAssets from "../../helpers/resolve-assets.js";
9
10
  import log from "../../../logger.js";
10
11
 
11
12
  /**
@@ -216,18 +217,19 @@ async function renderVariations({
216
217
  ({ shortPath }) => shortPath === component.paths.dir.short,
217
218
  );
218
219
 
220
+ const componentDeclaredAssets =
221
+ context.length > 0 ? context[0].$assets || null : null;
222
+ const { cssFiles, jsFilesHead, jsFilesBody } =
223
+ resolveAssets(componentDeclaredAssets);
224
+
219
225
  await res.render(
220
226
  "iframe_component.twig.miyagi",
221
227
  {
222
228
  lang: global.config.ui.lang,
223
229
  variations,
224
- cssFiles: global.config.assets.css,
225
- jsFilesHead: global.config.assets.js.filter(
226
- (entry) => entry.position === "head" || !entry.position,
227
- ),
228
- jsFilesBody: global.config.assets.js.filter(
229
- (entry) => entry.position === "body",
230
- ),
230
+ cssFiles,
231
+ jsFilesHead,
232
+ jsFilesBody,
231
233
  assets: {
232
234
  css: componentsEntry
233
235
  ? componentsEntry.assets.css
@@ -1,12 +1,14 @@
1
1
  import path from "path";
2
2
  import config from "../../../default-config.js";
3
3
  import { getUserUiConfig } from "../../helpers.js";
4
+ import resolveAssets from "../../helpers/resolve-assets.js";
4
5
 
5
6
  /**
6
7
  * @param {object} object - parameter object
7
8
  * @param {object} [object.res] - the express response object
8
9
  * @param {object} object.component
9
10
  * @param {object} object.componentData
11
+ * @param {object|null} [object.componentDeclaredAssets] - $assets from mocks
10
12
  * @param {Function} [object.cb] - callback function
11
13
  * @param {object} [object.cookies]
12
14
  * @returns {Promise} gets resolved when the variation has been rendered
@@ -15,6 +17,7 @@ export default async function renderIframeVariationStandalone({
15
17
  res,
16
18
  component,
17
19
  componentData,
20
+ componentDeclaredAssets = null,
18
21
  cb,
19
22
  cookies,
20
23
  }) {
@@ -38,17 +41,16 @@ export default async function renderIframeVariationStandalone({
38
41
  ({ shortPath }) => shortPath === directoryPath,
39
42
  );
40
43
 
44
+ const { cssFiles, jsFilesHead, jsFilesBody } =
45
+ resolveAssets(componentDeclaredAssets);
46
+
41
47
  await res.render(
42
48
  "component_variation.twig.miyagi",
43
49
  {
44
50
  html: result,
45
- cssFiles: global.config.assets.css,
46
- jsFilesHead: global.config.assets.js.filter(
47
- (entry) => entry.position === "head" || !entry.position,
48
- ),
49
- jsFilesBody: global.config.assets.js.filter(
50
- (entry) => entry.position === "body",
51
- ),
51
+ cssFiles,
52
+ jsFilesHead,
53
+ jsFilesBody,
52
54
  assets: {
53
55
  css: componentsEntry
54
56
  ? componentsEntry.assets.css
@@ -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.0.2",
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)",
@@ -9,7 +9,7 @@
9
9
  "bugs": "https://github.com/miyagi-dev/miyagi/issues",
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "git@github.com:miyagi-dev/miyagi.git"
12
+ "url": "https://github.com/schalkneethling/miyagi.git"
13
13
  },
14
14
  "type": "module",
15
15
  "keywords": [
@@ -20,7 +20,7 @@
20
20
  "frontend"
21
21
  ],
22
22
  "engines": {
23
- "node": ">=20.11.0"
23
+ "node": ">=24"
24
24
  },
25
25
  "files": [
26
26
  "api",
@@ -43,9 +43,10 @@
43
43
  "directory-tree": "^3.5.2",
44
44
  "express": "^5.1.0",
45
45
  "js-yaml": "^4.1.0",
46
- "marked": "^16.4.1",
46
+ "marked": "^17.0.2",
47
47
  "node-watch": "^0.7.4",
48
- "twing": "7.2.2",
48
+ "twing": "7.3.1",
49
+ "valibot": "^1.2.0",
49
50
  "ws": "^8.18.3",
50
51
  "yargs": "^18.0.0"
51
52
  },
@@ -53,11 +54,13 @@
53
54
  "@eslint/js": "^9.39.2",
54
55
  "@rollup/plugin-node-resolve": "^16.0.3",
55
56
  "@rollup/plugin-terser": "^0.4.4",
57
+ "@types/js-yaml": "^4.0.9",
56
58
  "@types/node": "^24.10.0",
59
+ "@types/yargs": "^17.0.35",
57
60
  "@vitest/coverage-v8": "^4.0.6",
58
61
  "cssnano": "^7.1.2",
59
62
  "eslint": "^9.39.0",
60
- "eslint-plugin-jsdoc": "^61.1.11",
63
+ "eslint-plugin-jsdoc": "^62.5.4",
61
64
  "globals": "^15.15.0",
62
65
  "gulp": "^5.0.1",
63
66
  "gulp-postcss": "^10.0.0",
@@ -75,8 +78,7 @@
75
78
  "build": "gulp build",
76
79
  "test": "vitest run --coverage --coverage.include=api --coverage.include=lib",
77
80
  "lint": "stylelint frontend/assets/css/ && eslint lib/ && eslint frontend/assets/js/",
78
- "fix": "eslint lib/ --fix && eslint frontend/assets/js/ --fix",
79
- "release": "standard-version"
81
+ "fix": "eslint lib/ --fix && eslint frontend/assets/js/ --fix"
80
82
  },
81
83
  "browserslist": [
82
84
  "last 2 versions",