@schalkneethling/miyagi-core 4.4.4 → 4.5.1
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 +53 -0
- package/lib/cli/index.js +2 -0
- package/lib/cli/run.js +7 -0
- package/lib/cli/validate-html.js +110 -0
- package/lib/default-config.js +12 -0
- package/lib/i18n/en.js +23 -0
- package/lib/init/args.js +22 -0
- package/lib/validator/html-report.js +85 -0
- package/lib/validator/html.js +389 -0
- package/package.json +2 -1
package/api/index.js
CHANGED
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
toSchemaValidationResult,
|
|
13
13
|
validateSchemas,
|
|
14
14
|
} from "../lib/validator/schemas.js";
|
|
15
|
+
import {
|
|
16
|
+
validateAllHtml as validateAllHtmlImpl,
|
|
17
|
+
validateComponentHtml as validateComponentHtmlImpl,
|
|
18
|
+
} from "../lib/validator/html.js";
|
|
19
|
+
import { generateMarkdownReport } from "../lib/validator/html-report.js";
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
22
|
* @param {object} obj
|
|
@@ -307,6 +312,54 @@ function getComponentsObject(component) {
|
|
|
307
312
|
* @param {Map<string, Array<object>>} params.errorMap
|
|
308
313
|
* @returns {Array<object>}
|
|
309
314
|
*/
|
|
315
|
+
/**
|
|
316
|
+
* @param {object} [options]
|
|
317
|
+
* @param {object} [options.htmlValidateConfig]
|
|
318
|
+
* @returns {Promise<object>}
|
|
319
|
+
*/
|
|
320
|
+
export const validateHtml = async (options = {}) => {
|
|
321
|
+
global.app = await init("api");
|
|
322
|
+
const results = await validateAllHtmlImpl(options);
|
|
323
|
+
const report = generateMarkdownReport(results);
|
|
324
|
+
return {
|
|
325
|
+
success: results.summary.failed === 0,
|
|
326
|
+
data: { results, report },
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {object} obj
|
|
332
|
+
* @param {string|null} obj.component
|
|
333
|
+
* @param {object} [obj.htmlValidateConfig]
|
|
334
|
+
* @returns {Promise<object>}
|
|
335
|
+
*/
|
|
336
|
+
export const validateHtmlComponent = async (
|
|
337
|
+
{ component, ...options } = { component: null },
|
|
338
|
+
) => {
|
|
339
|
+
if (!component)
|
|
340
|
+
return {
|
|
341
|
+
success: false,
|
|
342
|
+
message:
|
|
343
|
+
'Please pass a component to `validateHtmlComponent` ({ component: "name" }).',
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
global.app = await init("api");
|
|
347
|
+
|
|
348
|
+
const componentObject = getComponentsObject(component);
|
|
349
|
+
|
|
350
|
+
if (!componentObject)
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
message: `Component "${component}" does not exist.`,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const result = await validateComponentHtmlImpl(componentObject, options);
|
|
357
|
+
return {
|
|
358
|
+
success: result.variations.every((v) => v.valid),
|
|
359
|
+
data: result,
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
310
363
|
function getLintComponentErrorsInRouteOrder({ components, errorMap }) {
|
|
311
364
|
return components
|
|
312
365
|
.map((route) => {
|
package/lib/cli/index.js
CHANGED
|
@@ -2,8 +2,10 @@ import lintImport from "./lint.js";
|
|
|
2
2
|
import componentImport from "./component.js";
|
|
3
3
|
import drupalAssetsImport from "./drupal-assets.js";
|
|
4
4
|
import doctorImport from "./doctor.js";
|
|
5
|
+
import validateHtmlImport from "./validate-html.js";
|
|
5
6
|
|
|
6
7
|
export const lint = lintImport;
|
|
7
8
|
export const component = componentImport;
|
|
8
9
|
export const drupalAssets = drupalAssetsImport;
|
|
9
10
|
export const doctor = doctorImport;
|
|
11
|
+
export const validateHtml = validateHtmlImport;
|
package/lib/cli/run.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
component as createComponentViaCli,
|
|
10
10
|
drupalAssets,
|
|
11
11
|
doctor,
|
|
12
|
+
validateHtml as validateHtmlCli,
|
|
12
13
|
} from "./index.js";
|
|
13
14
|
import { EXIT_CODES, MiyagiError } from "../errors.js";
|
|
14
15
|
|
|
@@ -220,6 +221,11 @@ async function runDrupalAssetsCommand(args) {
|
|
|
220
221
|
* @param {object} args
|
|
221
222
|
* @returns {Promise<object>}
|
|
222
223
|
*/
|
|
224
|
+
async function runValidateHtmlCommand(args) {
|
|
225
|
+
applyCliEnv(args);
|
|
226
|
+
return await validateHtmlCli(args);
|
|
227
|
+
}
|
|
228
|
+
|
|
223
229
|
async function runDoctorCommand(args) {
|
|
224
230
|
applyCliEnv(args);
|
|
225
231
|
return await doctor(args);
|
|
@@ -268,6 +274,7 @@ export async function runCli(argv = process.argv) {
|
|
|
268
274
|
new: runComponentCommand,
|
|
269
275
|
mocks: runMocksCommand,
|
|
270
276
|
lint: runLintCommand,
|
|
277
|
+
validateHtml: runValidateHtmlCommand,
|
|
271
278
|
drupalAssets: runDrupalAssetsCommand,
|
|
272
279
|
doctor: runDoctorCommand,
|
|
273
280
|
},
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import init from "./app.js";
|
|
4
|
+
import getConfig from "../config.js";
|
|
5
|
+
import log from "../logger.js";
|
|
6
|
+
import { t } from "../i18n/index.js";
|
|
7
|
+
import { EXIT_CODES } from "../errors.js";
|
|
8
|
+
import {
|
|
9
|
+
validateAllHtml,
|
|
10
|
+
validateComponentHtml,
|
|
11
|
+
validateHtmlFiles,
|
|
12
|
+
} from "../validator/html.js";
|
|
13
|
+
import { generateMarkdownReport } from "../validator/html-report.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} args
|
|
17
|
+
* @returns {Promise<object>}
|
|
18
|
+
*/
|
|
19
|
+
export default async function validateHtml(args) {
|
|
20
|
+
process.env.NODE_ENV = "development";
|
|
21
|
+
|
|
22
|
+
const filesGlob = args.files;
|
|
23
|
+
|
|
24
|
+
if (filesGlob) {
|
|
25
|
+
// Files mode — validate pre-existing HTML files
|
|
26
|
+
const config = await getConfig(args);
|
|
27
|
+
global.config = config;
|
|
28
|
+
const results = await validateHtmlFiles(filesGlob);
|
|
29
|
+
return await writeReport(results, args, config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Render mode — render components and validate
|
|
33
|
+
const componentArg = args.component;
|
|
34
|
+
const config = await getConfig(args);
|
|
35
|
+
global.app = await init(config);
|
|
36
|
+
|
|
37
|
+
let results;
|
|
38
|
+
|
|
39
|
+
if (componentArg) {
|
|
40
|
+
const component = global.state.routes.find(
|
|
41
|
+
({ alias }) =>
|
|
42
|
+
alias === path.relative(config.components.folder, componentArg),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (!component) {
|
|
46
|
+
const message = t("htmlValidation.componentNotFound").replace("{{component}}", componentArg);
|
|
47
|
+
log("error", message);
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
code: EXIT_CODES.CLI_USAGE_ERROR,
|
|
51
|
+
shouldExit: true,
|
|
52
|
+
message,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = await validateComponentHtml(component);
|
|
57
|
+
results = {
|
|
58
|
+
components: [result],
|
|
59
|
+
summary: {
|
|
60
|
+
total: 1,
|
|
61
|
+
passed: result.variations.every((v) => v.valid) ? 1 : 0,
|
|
62
|
+
failed: result.variations.some((v) => !v.valid) ? 1 : 0,
|
|
63
|
+
errors: result.variations.reduce(
|
|
64
|
+
(sum, v) =>
|
|
65
|
+
sum + v.messages.filter((m) => m.severity === 2).length,
|
|
66
|
+
0,
|
|
67
|
+
),
|
|
68
|
+
warnings: result.variations.reduce(
|
|
69
|
+
(sum, v) =>
|
|
70
|
+
sum + v.messages.filter((m) => m.severity !== 2).length,
|
|
71
|
+
0,
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
} else {
|
|
76
|
+
results = await validateAllHtml();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return await writeReport(results, args, config);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {object} results
|
|
84
|
+
* @param {object} args
|
|
85
|
+
* @param {object} config
|
|
86
|
+
* @returns {Promise<object>}
|
|
87
|
+
*/
|
|
88
|
+
async function writeReport(results, args, config) {
|
|
89
|
+
const report = generateMarkdownReport(results);
|
|
90
|
+
const outputPath = args.output ?? config.htmlValidation?.output ?? "html-validation-report.md";
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(outputPath, report, "utf-8");
|
|
94
|
+
log(
|
|
95
|
+
"info",
|
|
96
|
+
t("htmlValidation.reportWritten").replace("{{path}}", outputPath),
|
|
97
|
+
);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
log("error", t("htmlValidation.reportWriteFailed").replace("{{error}}", error.message));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
success: results.summary.failed === 0,
|
|
104
|
+
code:
|
|
105
|
+
results.summary.failed === 0
|
|
106
|
+
? EXIT_CODES.SUCCESS
|
|
107
|
+
: EXIT_CODES.VALIDATION_ERROR,
|
|
108
|
+
shouldExit: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
package/lib/default-config.js
CHANGED
|
@@ -49,6 +49,18 @@ export default {
|
|
|
49
49
|
lint: {
|
|
50
50
|
logLevel: "error",
|
|
51
51
|
},
|
|
52
|
+
htmlValidation: {
|
|
53
|
+
output: "html-validation-report.md",
|
|
54
|
+
htmlValidateConfig: {
|
|
55
|
+
extends: ["html-validate:recommended"],
|
|
56
|
+
rules: {
|
|
57
|
+
"doctype-style": "off",
|
|
58
|
+
"input-missing-label": "error",
|
|
59
|
+
"missing-doctype": "off",
|
|
60
|
+
"no-missing-references": "off",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
52
64
|
extensions: [],
|
|
53
65
|
files: {
|
|
54
66
|
css: {
|
package/lib/i18n/en.js
CHANGED
|
@@ -31,6 +31,29 @@ export default {
|
|
|
31
31
|
done: "Finished creating component {{component}}.",
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
|
+
htmlValidation: {
|
|
35
|
+
all: {
|
|
36
|
+
start: "Validating rendered HTML for all components\u2026",
|
|
37
|
+
valid: "All rendered HTML is valid!",
|
|
38
|
+
invalid: {
|
|
39
|
+
one: "1 component has HTML validation errors!",
|
|
40
|
+
other: "{{amount}} components have HTML validation errors!",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
component: {
|
|
44
|
+
start: "Validating rendered HTML for {{component}}\u2026",
|
|
45
|
+
valid: "Rendered HTML is valid!",
|
|
46
|
+
renderFailed:
|
|
47
|
+
"Could not render {{component}} variation {{variation}}.",
|
|
48
|
+
},
|
|
49
|
+
files: {
|
|
50
|
+
start: "Validating HTML files matching {{pattern}}\u2026",
|
|
51
|
+
noFilesFound: "No files found matching {{pattern}}.",
|
|
52
|
+
},
|
|
53
|
+
componentNotFound: "The component {{component}} does not seem to exist.",
|
|
54
|
+
reportWriteFailed: "Failed to write report: {{error}}.",
|
|
55
|
+
reportWritten: "HTML validation report written to {{path}}.",
|
|
56
|
+
},
|
|
34
57
|
linter: {
|
|
35
58
|
all: {
|
|
36
59
|
start: "Validating schema files and mock data for all components…",
|
package/lib/init/args.js
CHANGED
|
@@ -101,6 +101,28 @@ export default function createCli(handlers, argv = process.argv) {
|
|
|
101
101
|
}),
|
|
102
102
|
commandHandler(handlers.lint),
|
|
103
103
|
)
|
|
104
|
+
.command(
|
|
105
|
+
"validate-html [component]",
|
|
106
|
+
"Validates rendered HTML of components and generates a Markdown report",
|
|
107
|
+
(builder) =>
|
|
108
|
+
builder
|
|
109
|
+
.positional("component", {
|
|
110
|
+
description: "Optional component path to validate (render mode)",
|
|
111
|
+
type: "string",
|
|
112
|
+
})
|
|
113
|
+
.option("files", {
|
|
114
|
+
alias: "f",
|
|
115
|
+
description:
|
|
116
|
+
"Glob pattern pointing to HTML files to validate (e.g. build/miyagi/component-*.html)",
|
|
117
|
+
type: "string",
|
|
118
|
+
})
|
|
119
|
+
.option("output", {
|
|
120
|
+
alias: "o",
|
|
121
|
+
description: "Path for the Markdown report file",
|
|
122
|
+
type: "string",
|
|
123
|
+
}),
|
|
124
|
+
commandHandler(handlers.validateHtml),
|
|
125
|
+
)
|
|
104
126
|
.command(
|
|
105
127
|
"drupal-assets",
|
|
106
128
|
"Resolves Drupal *.libraries.yml dependencies and updates component $assets in mock files",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a Markdown report from HTML validation results.
|
|
3
|
+
* @param {object} results - output from validateAllHtml, validateComponentHtml, or validateHtmlFiles
|
|
4
|
+
* @param {Array<object>} results.components
|
|
5
|
+
* @param {object} results.summary
|
|
6
|
+
* @returns {string} Markdown-formatted report
|
|
7
|
+
*/
|
|
8
|
+
export function generateMarkdownReport(results) {
|
|
9
|
+
const { components, summary } = results;
|
|
10
|
+
const lines = [];
|
|
11
|
+
|
|
12
|
+
lines.push("# HTML Validation Report");
|
|
13
|
+
lines.push("");
|
|
14
|
+
lines.push(`**Date:** ${new Date().toISOString().split("T")[0]}`);
|
|
15
|
+
lines.push(
|
|
16
|
+
`**Total components:** ${summary.total} | **Passed:** ${summary.passed} | **Failed:** ${summary.failed}`,
|
|
17
|
+
);
|
|
18
|
+
lines.push(
|
|
19
|
+
`**Errors:** ${summary.errors} | **Warnings:** ${summary.warnings}`,
|
|
20
|
+
);
|
|
21
|
+
lines.push("");
|
|
22
|
+
|
|
23
|
+
// Summary table
|
|
24
|
+
lines.push("## Summary");
|
|
25
|
+
lines.push("");
|
|
26
|
+
lines.push("| Component | Status | Errors | Warnings |");
|
|
27
|
+
lines.push("|-----------|--------|--------|----------|");
|
|
28
|
+
|
|
29
|
+
for (const comp of components) {
|
|
30
|
+
const hasErrors = comp.variations.some((v) => !v.valid);
|
|
31
|
+
const status = hasErrors ? "FAIL" : "PASS";
|
|
32
|
+
let errors = 0;
|
|
33
|
+
let warnings = 0;
|
|
34
|
+
|
|
35
|
+
for (const variation of comp.variations) {
|
|
36
|
+
for (const msg of variation.messages) {
|
|
37
|
+
if (msg.severity === 2) {
|
|
38
|
+
errors++;
|
|
39
|
+
} else {
|
|
40
|
+
warnings++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lines.push(`| ${comp.component} | ${status} | ${errors} | ${warnings} |`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Failed component details
|
|
49
|
+
const failedComponents = components.filter((comp) =>
|
|
50
|
+
comp.variations.some((v) => !v.valid),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (failedComponents.length > 0) {
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push("## Failed Components");
|
|
56
|
+
|
|
57
|
+
for (const comp of failedComponents) {
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push(`### ${comp.component}`);
|
|
60
|
+
|
|
61
|
+
const failedVariations = comp.variations.filter((v) => !v.valid);
|
|
62
|
+
|
|
63
|
+
for (const variation of failedVariations) {
|
|
64
|
+
lines.push("");
|
|
65
|
+
lines.push(`#### ${variation.name}`);
|
|
66
|
+
lines.push("");
|
|
67
|
+
lines.push("| Line | Col | Severity | Rule | Message |");
|
|
68
|
+
lines.push("|------|-----|----------|------|---------|");
|
|
69
|
+
|
|
70
|
+
for (const msg of variation.messages) {
|
|
71
|
+
const severity = msg.severity === 2 ? "error" : "warning";
|
|
72
|
+
const escapedMessage = msg.message
|
|
73
|
+
.replace(/\\/g, "\\\\")
|
|
74
|
+
.replace(/\|/g, "\\|");
|
|
75
|
+
lines.push(
|
|
76
|
+
`| ${msg.line} | ${msg.column} | ${severity} | ${msg.ruleId} | ${escapedMessage} |`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lines.push("");
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { globSync } from "node:fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { HtmlValidate } from "html-validate";
|
|
5
|
+
import { getComponentData } from "../mocks/index.js";
|
|
6
|
+
import { t } from "../i18n/index.js";
|
|
7
|
+
import log from "../logger.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} templatePath
|
|
11
|
+
* @param {object} data
|
|
12
|
+
* @returns {Promise<string>}
|
|
13
|
+
*/
|
|
14
|
+
function renderTemplate(templatePath, data) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
global.app.render(templatePath, data, (error, result) => {
|
|
17
|
+
if (error) {
|
|
18
|
+
reject(error);
|
|
19
|
+
} else {
|
|
20
|
+
resolve(result);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} htmlValidateConfig
|
|
28
|
+
* @returns {HtmlValidate}
|
|
29
|
+
*/
|
|
30
|
+
function createValidator(htmlValidateConfig) {
|
|
31
|
+
return new HtmlValidate(htmlValidateConfig);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Array<object>} componentResults
|
|
36
|
+
* @returns {object}
|
|
37
|
+
*/
|
|
38
|
+
function buildSummary(componentResults) {
|
|
39
|
+
let errors = 0;
|
|
40
|
+
let warnings = 0;
|
|
41
|
+
let passed = 0;
|
|
42
|
+
let failed = 0;
|
|
43
|
+
|
|
44
|
+
for (const comp of componentResults) {
|
|
45
|
+
const hasErrors = comp.variations.some((v) => !v.valid);
|
|
46
|
+
if (hasErrors) {
|
|
47
|
+
failed++;
|
|
48
|
+
} else {
|
|
49
|
+
passed++;
|
|
50
|
+
}
|
|
51
|
+
for (const variation of comp.variations) {
|
|
52
|
+
for (const msg of variation.messages) {
|
|
53
|
+
if (msg.severity === 2) {
|
|
54
|
+
errors++;
|
|
55
|
+
} else {
|
|
56
|
+
warnings++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
total: componentResults.length,
|
|
64
|
+
passed,
|
|
65
|
+
failed,
|
|
66
|
+
errors,
|
|
67
|
+
warnings,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate rendered HTML for all components.
|
|
73
|
+
* @param {object} [options]
|
|
74
|
+
* @param {object} [options.htmlValidateConfig]
|
|
75
|
+
* @returns {Promise<{components: Array<object>, summary: object}>}
|
|
76
|
+
*/
|
|
77
|
+
export async function validateAllHtml(options = {}) {
|
|
78
|
+
const config =
|
|
79
|
+
options.htmlValidateConfig ?? global.config.htmlValidation?.htmlValidateConfig;
|
|
80
|
+
const validator = createValidator(config);
|
|
81
|
+
const components = global.state.routes.filter(
|
|
82
|
+
(route) => route.type === "components" && route.paths.tpl,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
log(
|
|
86
|
+
"info",
|
|
87
|
+
t("htmlValidation.all.start"),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const componentResults = await Promise.all(
|
|
91
|
+
components.map((component) =>
|
|
92
|
+
validateSingleComponent(component, validator),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const summary = buildSummary(componentResults);
|
|
97
|
+
|
|
98
|
+
if (summary.failed === 0) {
|
|
99
|
+
log("success", t("htmlValidation.all.valid"));
|
|
100
|
+
} else {
|
|
101
|
+
const msg =
|
|
102
|
+
summary.failed === 1
|
|
103
|
+
? t("htmlValidation.all.invalid.one")
|
|
104
|
+
: t("htmlValidation.all.invalid.other").replace(
|
|
105
|
+
"{{amount}}",
|
|
106
|
+
summary.failed,
|
|
107
|
+
);
|
|
108
|
+
log("error", msg);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { components: componentResults, summary };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate rendered HTML for a single component (all variations).
|
|
116
|
+
* @param {object} component - route object from global.state.routes
|
|
117
|
+
* @param {object} [options]
|
|
118
|
+
* @param {object} [options.htmlValidateConfig]
|
|
119
|
+
* @returns {Promise<{component: string, variations: Array<object>}>}
|
|
120
|
+
*/
|
|
121
|
+
export async function validateComponentHtml(component, options = {}) {
|
|
122
|
+
const config =
|
|
123
|
+
options.htmlValidateConfig ?? global.config.htmlValidation?.htmlValidateConfig;
|
|
124
|
+
const validator = createValidator(config);
|
|
125
|
+
|
|
126
|
+
log(
|
|
127
|
+
"info",
|
|
128
|
+
t("htmlValidation.component.start").replace(
|
|
129
|
+
"{{component}}",
|
|
130
|
+
component.paths.dir.short,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const result = await validateSingleComponent(component, validator);
|
|
135
|
+
|
|
136
|
+
const allValid = result.variations.every((v) => v.valid);
|
|
137
|
+
if (allValid) {
|
|
138
|
+
log("success", t("htmlValidation.component.valid"));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {object} component
|
|
146
|
+
* @param {HtmlValidate} validator
|
|
147
|
+
* @returns {Promise<{component: string, variations: Array<object>}>}
|
|
148
|
+
*/
|
|
149
|
+
async function validateSingleComponent(component, validator) {
|
|
150
|
+
const data = await getComponentData(component);
|
|
151
|
+
const variations = [];
|
|
152
|
+
|
|
153
|
+
if (data && data.length > 0) {
|
|
154
|
+
for (const entry of data) {
|
|
155
|
+
try {
|
|
156
|
+
const html = await renderTemplate(
|
|
157
|
+
component.paths.tpl.full,
|
|
158
|
+
entry.resolved ?? {},
|
|
159
|
+
);
|
|
160
|
+
const report = await validator.validateString(html);
|
|
161
|
+
const messages = report.results.flatMap((r) =>
|
|
162
|
+
r.messages.map((msg) => ({
|
|
163
|
+
severity: msg.severity,
|
|
164
|
+
message: msg.message,
|
|
165
|
+
ruleId: msg.ruleId,
|
|
166
|
+
line: msg.line,
|
|
167
|
+
column: msg.column,
|
|
168
|
+
})),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
variations.push({
|
|
172
|
+
name: entry.name,
|
|
173
|
+
valid: report.valid,
|
|
174
|
+
messages,
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
log(
|
|
178
|
+
"warn",
|
|
179
|
+
t("htmlValidation.component.renderFailed")
|
|
180
|
+
.replace("{{component}}", component.paths.dir.short)
|
|
181
|
+
.replace("{{variation}}", entry.name),
|
|
182
|
+
);
|
|
183
|
+
variations.push({
|
|
184
|
+
name: entry.name,
|
|
185
|
+
valid: false,
|
|
186
|
+
messages: [
|
|
187
|
+
{
|
|
188
|
+
severity: 2,
|
|
189
|
+
message: `Render error: ${error.message || error}`,
|
|
190
|
+
ruleId: "render-error",
|
|
191
|
+
line: 0,
|
|
192
|
+
column: 0,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// No mock data — render with empty object for default variation
|
|
200
|
+
try {
|
|
201
|
+
const html = await renderTemplate(component.paths.tpl.full, {});
|
|
202
|
+
const report = await validator.validateString(html);
|
|
203
|
+
const messages = report.results.flatMap((r) =>
|
|
204
|
+
r.messages.map((msg) => ({
|
|
205
|
+
severity: msg.severity,
|
|
206
|
+
message: msg.message,
|
|
207
|
+
ruleId: msg.ruleId,
|
|
208
|
+
line: msg.line,
|
|
209
|
+
column: msg.column,
|
|
210
|
+
})),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
variations.push({
|
|
214
|
+
name: "default",
|
|
215
|
+
valid: report.valid,
|
|
216
|
+
messages,
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
variations.push({
|
|
220
|
+
name: "default",
|
|
221
|
+
valid: false,
|
|
222
|
+
messages: [
|
|
223
|
+
{
|
|
224
|
+
severity: 2,
|
|
225
|
+
message: `Render error: ${error.message || error}`,
|
|
226
|
+
ruleId: "render-error",
|
|
227
|
+
line: 0,
|
|
228
|
+
column: 0,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
component: component.paths.dir.short,
|
|
237
|
+
variations,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Parse component and variation name from a build output filename.
|
|
243
|
+
* Expected pattern: component-<path>-variation-<name>.html
|
|
244
|
+
* @param {string} filePath
|
|
245
|
+
* @returns {{ component: string, variation: string }}
|
|
246
|
+
*/
|
|
247
|
+
function parseFileName(filePath) {
|
|
248
|
+
const basename = path.basename(filePath, ".html");
|
|
249
|
+
|
|
250
|
+
const variationMatch = basename.match(/^component-(.+)-variation-(.+)$/);
|
|
251
|
+
if (variationMatch) {
|
|
252
|
+
const componentPart = variationMatch[1].replace(/-/g, "/");
|
|
253
|
+
return {
|
|
254
|
+
component: componentPart,
|
|
255
|
+
variation: variationMatch[2],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Fallback: use full basename as component name
|
|
260
|
+
return {
|
|
261
|
+
component: basename,
|
|
262
|
+
variation: "default",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Validate pre-existing HTML files matching a glob pattern.
|
|
268
|
+
* Files are validated as full HTML documents.
|
|
269
|
+
* @param {string} globPattern
|
|
270
|
+
* @param {object} [options]
|
|
271
|
+
* @param {object} [options.htmlValidateConfig]
|
|
272
|
+
* @returns {Promise<{components: Array<object>, summary: object}>}
|
|
273
|
+
*/
|
|
274
|
+
export async function validateHtmlFiles(globPattern, options = {}) {
|
|
275
|
+
const baseConfig =
|
|
276
|
+
options.htmlValidateConfig ?? global.config?.htmlValidation?.htmlValidateConfig;
|
|
277
|
+
|
|
278
|
+
// For file mode, use full-document validation (don't disable doctype rules)
|
|
279
|
+
const fileConfig = {
|
|
280
|
+
...baseConfig,
|
|
281
|
+
rules: {
|
|
282
|
+
...(baseConfig?.rules ?? {}),
|
|
283
|
+
"doctype-style": undefined,
|
|
284
|
+
"missing-doctype": undefined,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Remove undefined keys so they fall back to the preset defaults
|
|
289
|
+
for (const [key, value] of Object.entries(fileConfig.rules)) {
|
|
290
|
+
if (value === undefined) {
|
|
291
|
+
delete fileConfig.rules[key];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const validator = createValidator(fileConfig);
|
|
296
|
+
|
|
297
|
+
log(
|
|
298
|
+
"info",
|
|
299
|
+
t("htmlValidation.files.start").replace("{{pattern}}", globPattern),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const files = globSync(globPattern);
|
|
303
|
+
|
|
304
|
+
if (files.length === 0) {
|
|
305
|
+
log(
|
|
306
|
+
"warn",
|
|
307
|
+
t("htmlValidation.files.noFilesFound").replace(
|
|
308
|
+
"{{pattern}}",
|
|
309
|
+
globPattern,
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
return { components: [], summary: buildSummary([]) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Group files by component
|
|
316
|
+
const componentMap = new Map();
|
|
317
|
+
|
|
318
|
+
for (const filePath of files) {
|
|
319
|
+
const { component, variation } = parseFileName(filePath);
|
|
320
|
+
if (!componentMap.has(component)) {
|
|
321
|
+
componentMap.set(component, []);
|
|
322
|
+
}
|
|
323
|
+
componentMap.get(component).push({ filePath, variation });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const componentResults = [];
|
|
327
|
+
|
|
328
|
+
for (const [componentName, fileEntries] of componentMap) {
|
|
329
|
+
const variations = [];
|
|
330
|
+
|
|
331
|
+
for (const { filePath, variation } of fileEntries) {
|
|
332
|
+
try {
|
|
333
|
+
const html = await readFile(filePath, "utf-8");
|
|
334
|
+
const report = await validator.validateString(html);
|
|
335
|
+
const messages = report.results.flatMap((r) =>
|
|
336
|
+
r.messages.map((msg) => ({
|
|
337
|
+
severity: msg.severity,
|
|
338
|
+
message: msg.message,
|
|
339
|
+
ruleId: msg.ruleId,
|
|
340
|
+
line: msg.line,
|
|
341
|
+
column: msg.column,
|
|
342
|
+
})),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
variations.push({
|
|
346
|
+
name: variation,
|
|
347
|
+
valid: report.valid,
|
|
348
|
+
messages,
|
|
349
|
+
});
|
|
350
|
+
} catch (error) {
|
|
351
|
+
variations.push({
|
|
352
|
+
name: variation,
|
|
353
|
+
valid: false,
|
|
354
|
+
messages: [
|
|
355
|
+
{
|
|
356
|
+
severity: 2,
|
|
357
|
+
message: `File read error: ${error.message || error}`,
|
|
358
|
+
ruleId: "file-error",
|
|
359
|
+
line: 0,
|
|
360
|
+
column: 0,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
componentResults.push({
|
|
368
|
+
component: componentName,
|
|
369
|
+
variations,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const summary = buildSummary(componentResults);
|
|
374
|
+
|
|
375
|
+
if (summary.failed === 0) {
|
|
376
|
+
log("success", t("htmlValidation.all.valid"));
|
|
377
|
+
} else {
|
|
378
|
+
const msg =
|
|
379
|
+
summary.failed === 1
|
|
380
|
+
? t("htmlValidation.all.invalid.one")
|
|
381
|
+
: t("htmlValidation.all.invalid.other").replace(
|
|
382
|
+
"{{amount}}",
|
|
383
|
+
summary.failed,
|
|
384
|
+
);
|
|
385
|
+
log("error", msg);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { components: componentResults, summary };
|
|
389
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schalkneethling/miyagi-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
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)",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"deepmerge": "^4.3.1",
|
|
44
44
|
"directory-tree": "^3.5.2",
|
|
45
45
|
"express": "^5.1.0",
|
|
46
|
+
"html-validate": "^10.11.2",
|
|
46
47
|
"js-yaml": "^4.1.0",
|
|
47
48
|
"marked": "^17.0.2",
|
|
48
49
|
"twing": "7.3.1",
|