@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.
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
+ }
@@ -380,6 +380,19 @@ export default () => {
380
380
  });
381
381
  }
382
382
 
383
+ const sharedCss = (assetsConfig.shared?.css || []).map((file) =>
384
+ path.join(
385
+ assetsConfig.root,
386
+ typeof file === "string" ? file : file.src,
387
+ ),
388
+ );
389
+ const sharedJs = (assetsConfig.shared?.js || []).map((file) =>
390
+ path.join(
391
+ assetsConfig.root,
392
+ typeof file === "string" ? file : file.src,
393
+ ),
394
+ );
395
+
383
396
  const cssJsFiles = [
384
397
  ...new Set([
385
398
  ...assetsConfig.css.map((file) =>
@@ -394,6 +407,8 @@ export default () => {
394
407
  typeof file === "string" ? file : file.src,
395
408
  ),
396
409
  ),
410
+ ...sharedCss,
411
+ ...sharedJs,
397
412
  ...assetsConfig.customProperties.files.map((file) =>
398
413
  path.join(assetsConfig.root, file),
399
414
  ),
@@ -655,6 +670,7 @@ export default () => {
655
670
  res: global.app,
656
671
  component,
657
672
  componentData: data.resolved,
673
+ componentDeclaredAssets: data.$assets || null,
658
674
  cb: async (err, response) => {
659
675
  if (err) {
660
676
  if (typeof err === "string") {
@@ -0,0 +1,159 @@
1
+ // @ts-check
2
+
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import yaml from "js-yaml";
6
+ import log from "../logger.js";
7
+ import { loadAssetsConfig } from "../drupal/load-assets-config.js";
8
+ import {
9
+ parseLibrariesYaml,
10
+ resolveComponentAssets,
11
+ mapLibraryToComponent,
12
+ } from "../drupal/resolve-library-assets.js";
13
+
14
+ /**
15
+ * @typedef {{src: string, type?: string}} JsEntry
16
+ * @typedef {{css: string[], js: JsEntry[]}} ComponentAssets
17
+ */
18
+
19
+ /**
20
+ * @param {object} args - CLI arguments from yargs
21
+ */
22
+ export default async function drupalAssets(args) {
23
+ let config;
24
+ try {
25
+ config = await loadAssetsConfig(args);
26
+ } catch (err) {
27
+ log("error", /** @type {Error} */ (err).message);
28
+ process.exit(1);
29
+ }
30
+
31
+ if (!config.libraries) {
32
+ log("error", "No libraries file specified. Use --libraries or configure it in .miyagi-assets.js.");
33
+ process.exit(1);
34
+ }
35
+
36
+ let yamlContent;
37
+ try {
38
+ yamlContent = await readFile(config.libraries, "utf8");
39
+ } catch {
40
+ log("error", `Could not read libraries file: ${config.libraries}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ const librariesMap = parseLibrariesYaml(yamlContent);
45
+ const targetLibraries = config.components || Object.keys(librariesMap);
46
+ const componentsFolder = global.config?.components?.folder || "src";
47
+
48
+ let updatedCount = 0;
49
+
50
+ for (const libraryName of targetLibraries) {
51
+ if (!librariesMap[libraryName]) {
52
+ log("warn", `Library "${libraryName}" not found in ${config.libraries} — skipping.`);
53
+ continue;
54
+ }
55
+
56
+ const componentPath = mapLibraryToComponent(
57
+ libraryName,
58
+ config.mapping,
59
+ componentsFolder,
60
+ config.autoDiscoveryPrefixes ?? undefined,
61
+ );
62
+
63
+ if (!componentPath) {
64
+ log("warn", `Could not map library "${libraryName}" to a component folder — skipping.`);
65
+ continue;
66
+ }
67
+
68
+ const assets = resolveComponentAssets(
69
+ libraryName,
70
+ librariesMap,
71
+ config.ignorePrefixes,
72
+ );
73
+
74
+ if (config.dryRun) {
75
+ log("info", `[dry-run] ${libraryName} → ${componentPath}`);
76
+ console.log(JSON.stringify({ $assets: assets }, null, "\t"));
77
+ continue;
78
+ }
79
+
80
+ const updated = await updateMockFile(
81
+ path.join(componentsFolder, componentPath),
82
+ assets,
83
+ );
84
+
85
+ if (updated) {
86
+ updatedCount++;
87
+ log("info", `Updated $assets in ${componentPath}`);
88
+ }
89
+ }
90
+
91
+ if (!config.dryRun) {
92
+ log("success", `Done. Updated ${updatedCount} component(s).`);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Reads a component's mock file, injects/replaces $assets, writes back.
98
+ * @param {string} componentDir - absolute or relative path to component folder
99
+ * @param {ComponentAssets} assets
100
+ * @returns {Promise<boolean>} true if file was updated
101
+ */
102
+ async function updateMockFile(componentDir, assets) {
103
+ const mocksConfig = global.config?.files?.mocks || {
104
+ name: "mocks",
105
+ extension: ["yaml", "yml", "json", "js"],
106
+ };
107
+ const extensions = Array.isArray(mocksConfig.extension)
108
+ ? mocksConfig.extension
109
+ : [mocksConfig.extension];
110
+
111
+ for (const ext of extensions) {
112
+ const filePath = path.join(componentDir, `${mocksConfig.name}.${ext}`);
113
+
114
+ let content;
115
+ try {
116
+ content = await readFile(filePath, "utf8");
117
+ } catch {
118
+ continue;
119
+ }
120
+
121
+ if (["yaml", "yml"].includes(ext)) {
122
+ /** @type {Record<string, unknown>} */
123
+ const data = /** @type {Record<string, unknown>} */ (
124
+ yaml.load(content) || {}
125
+ );
126
+ data.$assets = cleanAssets(assets);
127
+ await writeFile(filePath, yaml.dump(data, { indent: 2 }));
128
+ return true;
129
+ }
130
+
131
+ if (ext === "json") {
132
+ /** @type {Record<string, unknown>} */
133
+ const data = JSON.parse(content);
134
+ data.$assets = cleanAssets(assets);
135
+ await writeFile(filePath, JSON.stringify(data, null, 2) + "\n");
136
+ return true;
137
+ }
138
+ }
139
+
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Strips empty arrays from assets to keep mock files clean.
145
+ * @param {ComponentAssets} assets
146
+ * @returns {Partial<ComponentAssets>}
147
+ */
148
+ function cleanAssets(assets) {
149
+ /** @type {Partial<ComponentAssets>} */
150
+ const result = {};
151
+ if (assets.css?.length > 0) {
152
+ result.css = assets.css;
153
+ }
154
+
155
+ if (assets.js?.length > 0) {
156
+ result.js = assets.js;
157
+ }
158
+ return result;
159
+ }
package/lib/cli/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import lintImport from "./lint.js";
2
2
  import componentImport from "./component.js";
3
+ import drupalAssetsImport from "./drupal-assets.js";
3
4
 
4
5
  export const lint = lintImport;
5
6
  export const component = componentImport;
7
+ export const drupalAssets = drupalAssetsImport;
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
+ });
@@ -5,6 +5,11 @@ export default {
5
5
  assets: {
6
6
  root: "",
7
7
  css: [],
8
+ shared: {
9
+ css: [],
10
+ js: [],
11
+ },
12
+ isolateComponents: false,
8
13
  customProperties: {
9
14
  files: [],
10
15
  prefixes: {
@@ -41,6 +46,9 @@ export default {
41
46
  render: null,
42
47
  options: {},
43
48
  },
49
+ lint: {
50
+ logLevel: "error",
51
+ },
44
52
  extensions: [],
45
53
  files: {
46
54
  css: {
@@ -91,7 +99,9 @@ export default {
91
99
  },
92
100
  schema: {
93
101
  ajv: AJV,
102
+ verbose: false,
94
103
  },
104
+ schemaValidationMode: "collect-all",
95
105
  },
96
106
  projectName: "miyagi",
97
107
  defaultPort: 5000,