@schalkneethling/miyagi-core 4.4.4 → 4.6.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 +53 -0
- package/dist/css/iframe.css +1 -1
- package/dist/css/main.css +1 -1
- package/dist/js/iframe.js +1 -1
- package/dist/js/main.build.js +1 -1
- package/dist/js/main.js +1 -1
- package/frontend/assets/css/main/controls.css +91 -0
- package/frontend/assets/css/main.css +5 -1
- package/frontend/assets/css/tokens.css +7 -0
- package/frontend/assets/js/_controls.js +206 -0
- package/frontend/assets/js/_main.js +2 -0
- package/frontend/assets/js/_socket.js +23 -0
- package/frontend/assets/js/iframe.js +12 -0
- package/frontend/views/component_variation.twig.miyagi +3 -0
- package/frontend/views/main.twig.miyagi +9 -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/init/router.js +56 -1
- package/lib/render/helpers/apply-overrides.js +36 -0
- package/lib/render/views/iframe/variation.js +19 -1
- package/lib/render/views/iframe/variation.standalone.js +9 -0
- package/lib/validator/html-report.js +85 -0
- package/lib/validator/html.js +389 -0
- package/package.json +3 -2
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const CACHE_PREFIX = "miyagi-controls::";
|
|
2
|
+
const LS_MODE_KEY = "miyagi-controls-mode";
|
|
3
|
+
|
|
4
|
+
function getPanel() {
|
|
5
|
+
return document.getElementById("controls-panel");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function getFieldsContainer() {
|
|
9
|
+
return document.getElementById("controls-fields");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hidePanel() {
|
|
13
|
+
const panel = getPanel();
|
|
14
|
+
if (!panel) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
panel.hidden = true;
|
|
18
|
+
getFieldsContainer().innerHTML = "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function showPanel() {
|
|
22
|
+
const panel = getPanel();
|
|
23
|
+
if (panel) {
|
|
24
|
+
panel.hidden = false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function initMode() {
|
|
29
|
+
const panel = getPanel();
|
|
30
|
+
if (!panel) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mode = localStorage.getItem(LS_MODE_KEY) ?? "docked";
|
|
35
|
+
panel.setAttribute("data-mode", mode);
|
|
36
|
+
|
|
37
|
+
const toggle = document.getElementById("controls-mode-toggle");
|
|
38
|
+
if (toggle) {
|
|
39
|
+
toggle.addEventListener("click", () => {
|
|
40
|
+
const next =
|
|
41
|
+
panel.getAttribute("data-mode") === "docked" ? "floating" : "docked";
|
|
42
|
+
panel.setAttribute("data-mode", next);
|
|
43
|
+
localStorage.setItem(LS_MODE_KEY, next);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildOverrideSrc(baseSrc, property, value) {
|
|
49
|
+
const url = new URL(baseSrc, window.location.origin);
|
|
50
|
+
url.searchParams.set(`overrides[${property}]`, String(value));
|
|
51
|
+
return url.pathname + url.search;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function updateIframe(src) {
|
|
55
|
+
const iframe = document.getElementById("iframe");
|
|
56
|
+
const frameWrapper = document.querySelector(".FrameWrapper");
|
|
57
|
+
if (!iframe || !frameWrapper) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
iframe.remove();
|
|
61
|
+
iframe.src = src;
|
|
62
|
+
frameWrapper.appendChild(iframe);
|
|
63
|
+
loadControls(src);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderControls(controls, iframeSrc) {
|
|
67
|
+
const fieldsContainer = getFieldsContainer();
|
|
68
|
+
fieldsContainer.innerHTML = "";
|
|
69
|
+
|
|
70
|
+
const url = new URL(iframeSrc, window.location.origin);
|
|
71
|
+
const urlOverrides = {};
|
|
72
|
+
url.searchParams.forEach((value, key) => {
|
|
73
|
+
const match = key.match(/^overrides\[(.+)\]$/);
|
|
74
|
+
if (match) {
|
|
75
|
+
urlOverrides[match[1]] = value;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
controls.forEach(({ property, type, values, current }) => {
|
|
80
|
+
const displayValue =
|
|
81
|
+
urlOverrides[property] !== undefined ? urlOverrides[property] : current;
|
|
82
|
+
|
|
83
|
+
const fieldEl = document.createElement("div");
|
|
84
|
+
fieldEl.className = "Controls-field";
|
|
85
|
+
|
|
86
|
+
const labelEl = document.createElement("label");
|
|
87
|
+
labelEl.className = "Controls-label";
|
|
88
|
+
|
|
89
|
+
const labelText = document.createElement("span");
|
|
90
|
+
labelText.className = "Controls-labelText";
|
|
91
|
+
labelText.textContent = property;
|
|
92
|
+
labelEl.appendChild(labelText);
|
|
93
|
+
|
|
94
|
+
let inputEl;
|
|
95
|
+
|
|
96
|
+
if (type === "enum") {
|
|
97
|
+
inputEl = document.createElement("select");
|
|
98
|
+
inputEl.className = "Controls-select";
|
|
99
|
+
values.forEach((value) => {
|
|
100
|
+
const option = document.createElement("option");
|
|
101
|
+
option.value = value;
|
|
102
|
+
option.textContent = value;
|
|
103
|
+
// eslint-disable-next-line eqeqeq
|
|
104
|
+
if (value == displayValue) {
|
|
105
|
+
option.selected = true;
|
|
106
|
+
}
|
|
107
|
+
inputEl.appendChild(option);
|
|
108
|
+
});
|
|
109
|
+
inputEl.addEventListener("change", () => {
|
|
110
|
+
updateIframe(buildOverrideSrc(iframeSrc, property, inputEl.value));
|
|
111
|
+
});
|
|
112
|
+
} else if (type === "boolean") {
|
|
113
|
+
inputEl = document.createElement("input");
|
|
114
|
+
inputEl.type = "checkbox";
|
|
115
|
+
inputEl.className = "Controls-checkbox";
|
|
116
|
+
inputEl.checked =
|
|
117
|
+
typeof displayValue === "string"
|
|
118
|
+
? displayValue === "true"
|
|
119
|
+
: Boolean(displayValue);
|
|
120
|
+
inputEl.addEventListener("change", () => {
|
|
121
|
+
updateIframe(buildOverrideSrc(iframeSrc, property, inputEl.checked));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (inputEl) {
|
|
126
|
+
labelEl.appendChild(inputEl);
|
|
127
|
+
fieldEl.appendChild(labelEl);
|
|
128
|
+
fieldsContainer.appendChild(fieldEl);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
showPanel();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function loadControls(iframeSrc) {
|
|
136
|
+
const url = new URL(iframeSrc, window.location.origin);
|
|
137
|
+
const file = url.searchParams.get("file");
|
|
138
|
+
const variation = url.searchParams.get("variation");
|
|
139
|
+
|
|
140
|
+
if (!file || !variation) {
|
|
141
|
+
hidePanel();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const cacheKey = `${CACHE_PREFIX}${file}::${variation}`;
|
|
146
|
+
let data = null;
|
|
147
|
+
try {
|
|
148
|
+
data = JSON.parse(sessionStorage.getItem(cacheKey) ?? "null");
|
|
149
|
+
} catch {
|
|
150
|
+
sessionStorage.removeItem(cacheKey);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!data) {
|
|
154
|
+
try {
|
|
155
|
+
const res = await fetch(
|
|
156
|
+
`/api/component-controls?file=${encodeURIComponent(file)}&variation=${encodeURIComponent(variation)}`,
|
|
157
|
+
);
|
|
158
|
+
data = await res.json();
|
|
159
|
+
sessionStorage.setItem(cacheKey, JSON.stringify(data));
|
|
160
|
+
} catch {
|
|
161
|
+
hidePanel();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (data.controls?.length) {
|
|
167
|
+
renderControls(data.controls, iframeSrc);
|
|
168
|
+
} else {
|
|
169
|
+
hidePanel();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function invalidateControlsCache(paths) {
|
|
174
|
+
for (const key of Object.keys(sessionStorage)) {
|
|
175
|
+
if (!key.startsWith(CACHE_PREFIX)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!paths || paths.length === 0) {
|
|
180
|
+
sessionStorage.removeItem(key);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const file = key.slice(CACHE_PREFIX.length).split("::")[0];
|
|
185
|
+
if (paths.some((p) => p.includes(file))) {
|
|
186
|
+
sessionStorage.removeItem(key);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.addEventListener("message", (e) => {
|
|
192
|
+
if (e.data?.type === "miyagi:invalidate-cache") {
|
|
193
|
+
invalidateControlsCache(e.data.paths ?? []);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
198
|
+
initMode();
|
|
199
|
+
const iframe = document.getElementById("iframe");
|
|
200
|
+
if (iframe) {
|
|
201
|
+
const src = iframe.getAttribute("src");
|
|
202
|
+
if (src) {
|
|
203
|
+
loadControls(src);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import "./_goto.js";
|
|
2
2
|
import "./_search.js";
|
|
3
|
+
import { loadControls } from "./_controls.js";
|
|
3
4
|
import ThemeConfigSwitcher from "./config-switcher/theme.js";
|
|
4
5
|
import TextDirectionConfigSwitcher from "./config-switcher/text-direction.js";
|
|
5
6
|
import DevelopmentModeConfigSwitcher from "./config-switcher/development-mode.js";
|
|
@@ -77,6 +78,7 @@ class Main {
|
|
|
77
78
|
this.elements.iframe.remove();
|
|
78
79
|
this.elements.iframe.src = src;
|
|
79
80
|
this.elements.frameWrapper.appendChild(this.elements.iframe);
|
|
81
|
+
loadControls(src);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/**
|
|
@@ -49,6 +49,22 @@ function parseScope(messageData) {
|
|
|
49
49
|
return parseJsonScope(messageData);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function parseReason(messageData) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(messageData).reason ?? null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parsePaths(messageData) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(messageData).paths ?? [];
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
function scheduleReconnect() {
|
|
53
69
|
const jitter = Math.floor(Math.random() * 100);
|
|
54
70
|
const delay = Math.min(retryDelay + jitter, MAX_RETRY_DELAY_MS);
|
|
@@ -67,6 +83,13 @@ function connect() {
|
|
|
67
83
|
};
|
|
68
84
|
|
|
69
85
|
websocket.onmessage = (message) => {
|
|
86
|
+
const reason = parseReason(message.data);
|
|
87
|
+
if (reason === "schema" || reason === "data") {
|
|
88
|
+
window.parent.postMessage(
|
|
89
|
+
{ type: "miyagi:invalidate-cache", paths: parsePaths(message.data) },
|
|
90
|
+
"*",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
70
93
|
triggerReload(parseScope(message.data));
|
|
71
94
|
};
|
|
72
95
|
|
|
@@ -13,6 +13,18 @@ if (
|
|
|
13
13
|
window.location = new URL(window.location).replace("&embedded=true", "");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
17
|
+
const mockDataEl = document.getElementById("miyagi-mock-data");
|
|
18
|
+
if (mockDataEl) {
|
|
19
|
+
try {
|
|
20
|
+
const detail = JSON.parse(mockDataEl.textContent);
|
|
21
|
+
document.dispatchEvent(new CustomEvent("miyagi:mockdata", { detail }));
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.debug("[miyagi] Failed to parse mock data island:", e);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
16
28
|
document.addEventListener("DOMContentLoaded", function () {
|
|
17
29
|
const links = Array.from(document.querySelectorAll(".Component-file"));
|
|
18
30
|
const styleguide = document.querySelector(".Styleguide");
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
{% if assets.js %}
|
|
25
25
|
<script src="{{ assets.js }}"></script>
|
|
26
26
|
{% endif %}
|
|
27
|
+
{% if mockDataResolved is defined %}
|
|
28
|
+
<script type="application/json" id="miyagi-mock-data">{{ mockDataResolved|replace({'</script>': '<\\/script>'})|raw }}</script>
|
|
29
|
+
{% endif %}
|
|
27
30
|
<script>
|
|
28
31
|
{{ theme.js }}
|
|
29
32
|
|
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
<div class="FrameWrapper">
|
|
20
20
|
<iframe class="Frame" id="iframe" src="{{ iframeSrc }}" name="iframe" title="Components"></iframe>
|
|
21
21
|
</div>
|
|
22
|
+
<div class="Controls" id="controls-panel" hidden>
|
|
23
|
+
<div class="Controls-header">
|
|
24
|
+
<h2 class="Controls-heading">Controls</h2>
|
|
25
|
+
<button class="Controls-modeToggle" id="controls-mode-toggle">
|
|
26
|
+
<span class="u-hiddenVisually">Toggle floating panel</span>
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="Controls-fields" id="controls-fields"></div>
|
|
30
|
+
</div>
|
|
22
31
|
</main>
|
|
23
32
|
</body>
|
|
24
33
|
</html>
|
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",
|
package/lib/init/router.js
CHANGED
|
@@ -160,8 +160,61 @@ export default function Router() {
|
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
global.app.get("/api/component-controls", async (req, res) => {
|
|
164
|
+
const { file, variation } = req.query;
|
|
165
|
+
|
|
166
|
+
if (!file || !variation) {
|
|
167
|
+
return res.json({ controls: [] });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const routesEntry = global.state.routes.find(
|
|
171
|
+
(route) => route.paths.dir.short === file,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!routesEntry) {
|
|
175
|
+
return res.json({ controls: [] });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const schemaPath = routesEntry.paths?.schema?.full;
|
|
179
|
+
const schema = schemaPath
|
|
180
|
+
? global.state.fileContents[schemaPath]
|
|
181
|
+
: undefined;
|
|
182
|
+
|
|
183
|
+
if (!schema?.properties) {
|
|
184
|
+
return res.json({ controls: [] });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!getDataForComponent(routesEntry)) {
|
|
188
|
+
return res.json({ controls: [] });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const data = await getVariationData(routesEntry, decodeURI(variation));
|
|
192
|
+
const resolvedData = data?.resolved ?? {};
|
|
193
|
+
const controls = [];
|
|
194
|
+
|
|
195
|
+
for (const [property, propSchema] of Object.entries(schema.properties)) {
|
|
196
|
+
if (Array.isArray(propSchema.enum) && propSchema.enum.length > 0) {
|
|
197
|
+
controls.push({
|
|
198
|
+
property,
|
|
199
|
+
type: "enum",
|
|
200
|
+
values: propSchema.enum,
|
|
201
|
+
current:
|
|
202
|
+
resolvedData[property] ?? propSchema.default ?? propSchema.enum[0],
|
|
203
|
+
});
|
|
204
|
+
} else if (propSchema.type === "boolean") {
|
|
205
|
+
controls.push({
|
|
206
|
+
property,
|
|
207
|
+
type: "boolean",
|
|
208
|
+
current: resolvedData[property] ?? propSchema.default ?? false,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return res.json({ controls });
|
|
214
|
+
});
|
|
215
|
+
|
|
163
216
|
global.app.get("/component", async (req, res) => {
|
|
164
|
-
const { file, variation, embedded } = req.query;
|
|
217
|
+
const { file, variation, embedded, overrides } = req.query;
|
|
165
218
|
|
|
166
219
|
if (!file) {
|
|
167
220
|
return res.redirect(302, global.config.indexPath.default);
|
|
@@ -209,6 +262,7 @@ export default function Router() {
|
|
|
209
262
|
variation,
|
|
210
263
|
cookies: req.cookies,
|
|
211
264
|
data,
|
|
265
|
+
overrides: overrides ?? null,
|
|
212
266
|
});
|
|
213
267
|
}
|
|
214
268
|
|
|
@@ -224,6 +278,7 @@ export default function Router() {
|
|
|
224
278
|
componentData: data?.resolved ?? {},
|
|
225
279
|
componentDeclaredAssets: data?.$assets || null,
|
|
226
280
|
cookies: req.cookies,
|
|
281
|
+
overrides: overrides ?? null,
|
|
227
282
|
});
|
|
228
283
|
}
|
|
229
284
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies query-param override values to resolved component data.
|
|
3
|
+
* Values are coerced to the appropriate type based on the JSON schema.
|
|
4
|
+
* Only schema-defined properties are overridden; unknown keys are ignored.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} data - the resolved mock data object
|
|
7
|
+
* @param {object} overrides - key/value overrides from req.query.overrides
|
|
8
|
+
* @param {object} [schema] - the JSON schema for the component
|
|
9
|
+
* @returns {object} a new object with overrides applied
|
|
10
|
+
*/
|
|
11
|
+
export default function applyOverrides(data, overrides, schema) {
|
|
12
|
+
if (!overrides || typeof overrides !== "object") {
|
|
13
|
+
return data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const properties = schema?.properties ?? {};
|
|
17
|
+
const coerced = {};
|
|
18
|
+
|
|
19
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
20
|
+
const propSchema = properties[key];
|
|
21
|
+
if (!propSchema) continue;
|
|
22
|
+
|
|
23
|
+
if (propSchema.type === "boolean") {
|
|
24
|
+
coerced[key] = value === "true";
|
|
25
|
+
} else if (
|
|
26
|
+
Array.isArray(propSchema.enum) &&
|
|
27
|
+
propSchema.enum.every((v) => typeof v === "number")
|
|
28
|
+
) {
|
|
29
|
+
coerced[key] = Number(value);
|
|
30
|
+
} else {
|
|
31
|
+
coerced[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { ...data, ...coerced };
|
|
36
|
+
}
|
|
@@ -4,6 +4,7 @@ import { t } from "../../../i18n/index.js";
|
|
|
4
4
|
import * as helpers from "../../../helpers.js";
|
|
5
5
|
import validateMocks from "../../../validator/mocks.js";
|
|
6
6
|
import { getUserUiConfig, getThemeMode } from "../../helpers.js";
|
|
7
|
+
import applyOverrides from "../../helpers/apply-overrides.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* @param {object} object - parameter object
|
|
@@ -22,9 +23,16 @@ export default async function renderIframeVariation({
|
|
|
22
23
|
cb,
|
|
23
24
|
cookies,
|
|
24
25
|
data,
|
|
26
|
+
overrides,
|
|
25
27
|
}) {
|
|
26
28
|
const rawComponentData = (data && data.raw) ?? null;
|
|
27
|
-
|
|
29
|
+
let componentData = (data && data.resolved) ?? null;
|
|
30
|
+
|
|
31
|
+
if (overrides && componentData) {
|
|
32
|
+
const schema =
|
|
33
|
+
global.state.fileContents[component.paths?.schema?.full] ?? null;
|
|
34
|
+
componentData = applyOverrides(componentData, overrides, schema);
|
|
35
|
+
}
|
|
28
36
|
const themeMode = getThemeMode(cookies);
|
|
29
37
|
|
|
30
38
|
const validatedMocks = validateMocks(component, [
|
|
@@ -44,6 +52,16 @@ export default async function renderIframeVariation({
|
|
|
44
52
|
standaloneUrl = `/component?file=${path.dirname(
|
|
45
53
|
component.paths.tpl.short,
|
|
46
54
|
)}&variation=${encodeURIComponent(variation)}`;
|
|
55
|
+
|
|
56
|
+
if (overrides && Object.keys(overrides).length > 0) {
|
|
57
|
+
const overrideParams = Object.entries(overrides)
|
|
58
|
+
.map(
|
|
59
|
+
([k, v]) =>
|
|
60
|
+
`overrides[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`,
|
|
61
|
+
)
|
|
62
|
+
.join("&");
|
|
63
|
+
standaloneUrl += `&${overrideParams}`;
|
|
64
|
+
}
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
const mockValidation = validatedMocks
|