@getquick/content 0.2.0 → 0.3.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/README.md +114 -2
- package/dist/consent-astro.js +1 -1
- package/dist/consent.js +1 -1
- package/dist/dist-D4-CMkrf.js +7047 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +2 -2
- package/dist/metadata-content.d.ts +36 -0
- package/dist/metadata-content.js +51 -0
- package/dist/metadata-testing.d.ts +34 -0
- package/dist/metadata-testing.js +142 -0
- package/dist/metadata.d.ts +94 -0
- package/dist/metadata.js +218 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +843 -55
- package/dist/site-language-BLA2mmOw.js +565 -0
- package/dist/{version-DyyNhsuU.js → version-ChwS3fUu.js} +1 -1
- package/dist/{wordpress-CUFb6YZZ.d.ts → wordpress-D_HG2rEt.d.ts} +273 -51
- package/package.json +13 -1
- package/dist/formatting-wOk2jz6J.js +0 -276
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
const columnCount = (value, fallback) => typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 6 ? value : fallback;
|
|
2
|
+
/** Restore the classes omitted by the pattern serializer. Saved content shells
|
|
3
|
+
* already have these classes and remain authoritative, including block supports. */
|
|
4
|
+
function gridLayoutHtml(html, attributes) {
|
|
5
|
+
if (html && !/^\s*<div\b[^>]*\sclass="[^"]*\bwp-block-getquick-design-grid\b[^"]*"/i.test(html)) return html;
|
|
6
|
+
const columns = [
|
|
7
|
+
["gqd-grid--", columnCount(attributes.columns, 3)],
|
|
8
|
+
["gqd-grid--tablet-", columnCount(attributes.tabletColumns, 2)],
|
|
9
|
+
["gqd-grid--mobile-", columnCount(attributes.mobileColumns, 1)]
|
|
10
|
+
];
|
|
11
|
+
return (html || "<div class=\"wp-block-getquick-design-grid\"></div>").replace(/^(\s*<div\b)([^>]*)(>)/i, (_, tag, props, end) => {
|
|
12
|
+
const existing = props.match(/\sclass="([^"]*)"/)?.[1] ?? "";
|
|
13
|
+
const classes = columns.filter(([prefix]) => !new RegExp(`(?:^|\\s)${prefix}[1-6](?:\\s|$)`).test(existing)).map(([prefix, count]) => `${prefix}${count}`);
|
|
14
|
+
if (!classes.length) return `${tag}${props}${end}`;
|
|
15
|
+
props = /\sclass="/.test(props) ? props.replace(/(\sclass=")([^"]*)"/, `$1$2 ${classes.join(" ")}"`) : `${props} class="wp-block-getquick-design-grid ${classes.join(" ")}"`;
|
|
16
|
+
return `${tag}${props}${end}`;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Install once in the document's stylesheet (chrome and content). No CMS values
|
|
20
|
+
* enter this CSS. Breakpoints and track sizing match GQ Design's Grid stylesheet. */
|
|
21
|
+
function designGridStyle() {
|
|
22
|
+
const grid = ".wp-block-getquick-design-grid";
|
|
23
|
+
const tracks = (prefix) => Array.from({ length: 6 }, (_, index) => {
|
|
24
|
+
const count = index + 1;
|
|
25
|
+
return `${grid}.${prefix}${count}{grid-template-columns:repeat(${count},minmax(0,1fr))}`;
|
|
26
|
+
}).join("\n");
|
|
27
|
+
return [
|
|
28
|
+
`${grid}{display:grid;grid-template-columns:minmax(0,1fr);gap:1rem}`,
|
|
29
|
+
`${grid}>*{min-width:0;margin-block:0}`,
|
|
30
|
+
`@media (width < 48rem){${tracks("gqd-grid--mobile-")}}`,
|
|
31
|
+
`@media (48rem <= width < 64rem){${tracks("gqd-grid--tablet-")}}`,
|
|
32
|
+
`@media (width >= 64rem){${tracks("gqd-grid--")}}`
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region frontend/src/wp-container-layout.ts
|
|
37
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
38
|
+
/** WPGraphQL renders nested blocks independently. Restore the parent's resolved
|
|
39
|
+
* variables on direct container children without replacing their own layout. */
|
|
40
|
+
function containerChildLayoutHtml(html, parentHtml) {
|
|
41
|
+
const parentTag = parentHtml.match(/^\s*<div\b[^>]*>/i)?.[0] ?? "";
|
|
42
|
+
if (!/\bgqd-container-layout\b/.test(parentTag)) return html;
|
|
43
|
+
const declarations = [...(parentTag.match(/\sstyle="([^"]*)"/)?.[1] ?? "").matchAll(/--gqd-layout-([a-z-]+):([^;"']+)/g)].filter(([, , value]) => value === "auto" || layoutValue(value)).map(([, name, value]) => `--gqd-parent-layout-${name}:${value}`).join(";");
|
|
44
|
+
if (!declarations) return html;
|
|
45
|
+
return html.replace(/^(\s*<div\b)([^>]*)(>)/i, (_, tag, props, end) => {
|
|
46
|
+
const style = /\sstyle="([^"]*)"/;
|
|
47
|
+
props = style.test(props) ? props.replace(style, (_match, old) => ` style="${old};${declarations}"`) : `${props} style="${declarations}"`;
|
|
48
|
+
return `${tag}${props}${end}`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function layoutValue(value) {
|
|
52
|
+
if (typeof value !== "string" && typeof value !== "number") return "";
|
|
53
|
+
const text = String(value);
|
|
54
|
+
const preset = text.match(/^var:preset\|spacing\|([a-z0-9-]+)$/);
|
|
55
|
+
if (preset) return `var(--wp--preset--spacing--${preset[1]})`;
|
|
56
|
+
return /^(?:none|0|[0-9.]+(?:px|rem|em|%|vw|vh|ch)|(?:var|calc|min|max|clamp)\([a-zA-Z0-9_.,%()+*/ -]+\))$/.test(text) ? text : "";
|
|
57
|
+
}
|
|
58
|
+
/** Rendered CMS shells already carry resolved theme defaults. Saved shells need
|
|
59
|
+
* the same opt-in adapter; absent defaults deliberately fall back to no width.
|
|
60
|
+
*/
|
|
61
|
+
function containerLayoutHtml(html, attributes) {
|
|
62
|
+
if (/\bgqd-container-layout\b/.test(html.match(/^\s*<[^>]+>/)?.[0] ?? "")) return html;
|
|
63
|
+
const layout = record(attributes.layout);
|
|
64
|
+
const spacing = record(record(attributes.style).spacing);
|
|
65
|
+
const gap = spacing.blockGap;
|
|
66
|
+
if (!Object.keys(layout).length && (gap === void 0 || gap === null || gap === "")) return html;
|
|
67
|
+
const type = layout.inherit || layout.contentSize ? "constrained" : layout.type ?? "default";
|
|
68
|
+
if (type !== "default" && type !== "constrained") return html;
|
|
69
|
+
const content = layoutValue(layout.contentSize);
|
|
70
|
+
const wide = layoutValue(layout.wideSize);
|
|
71
|
+
const variables = [
|
|
72
|
+
`--gqd-layout-content:${content || wide || "var(--wp--style--global--content-size, none)"}`,
|
|
73
|
+
`--gqd-layout-wide:${wide || content || "var(--wp--style--global--wide-size, none)"}`,
|
|
74
|
+
`--gqd-layout-gap:${layoutValue(typeof gap === "object" ? record(gap).top : gap) || "var(--wp--style--block-gap, 0)"}`
|
|
75
|
+
];
|
|
76
|
+
let classes = "gqd-container-layout";
|
|
77
|
+
if (type === "constrained") {
|
|
78
|
+
classes += " gqd-container-layout--constrained";
|
|
79
|
+
variables.push(`--gqd-layout-left:${layout.justifyContent === "left" ? "0" : "auto"}`, `--gqd-layout-right:${layout.justifyContent === "right" ? "0" : "auto"}`);
|
|
80
|
+
for (const side of ["left", "right"]) variables.push(`--gqd-layout-padding-${side}:${layoutValue(typeof spacing.padding === "object" ? record(spacing.padding)[side] : spacing.padding) || "0px"}`);
|
|
81
|
+
}
|
|
82
|
+
return (html || "<div class=\"wp-block-getquick-design-container\"></div>").replace(/^(\s*<div\b)([^>]*)(>)/i, (_, tag, props, end) => {
|
|
83
|
+
const add = (name, value, separator) => {
|
|
84
|
+
const pattern = new RegExp(`(\\s)${name}="([^"]*)"`);
|
|
85
|
+
props = pattern.test(props) ? props.replace(pattern, (_match, space, old) => `${space}${name}="${old}${separator}${value}"`) : `${props} ${name}="${value}"`;
|
|
86
|
+
};
|
|
87
|
+
add("class", classes, " ");
|
|
88
|
+
add("style", variables.join(";"), ";");
|
|
89
|
+
return `${tag}${props}${end}`;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region frontend/src/wp-block-renderer.ts
|
|
94
|
+
const VIDEO_HERO_BLOCK = "getquick-design/video-hero";
|
|
95
|
+
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
|
96
|
+
const BLOCKS_WITH_RENDERED_CHILDREN = /* @__PURE__ */ new Set([
|
|
97
|
+
"core/cover",
|
|
98
|
+
"core/media-text",
|
|
99
|
+
"core/navigation",
|
|
100
|
+
"core/navigation-submenu"
|
|
101
|
+
]);
|
|
102
|
+
function isRecord$1(value) {
|
|
103
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
function normalizeBlock(value) {
|
|
106
|
+
if (!isRecord$1(value) || typeof value.name !== "string") return null;
|
|
107
|
+
const attributes = isRecord$1(value.attributes) ? value.attributes : void 0;
|
|
108
|
+
const innerBlocks = Array.isArray(value.innerBlocks) ? value.innerBlocks.map(normalizeBlock).filter((block) => block !== null) : [];
|
|
109
|
+
return {
|
|
110
|
+
name: value.name,
|
|
111
|
+
attributes,
|
|
112
|
+
htmlContent: typeof value.htmlContent === "string" ? value.htmlContent : "",
|
|
113
|
+
dynamicContent: typeof value.dynamicContent === "string" ? value.dynamicContent : void 0,
|
|
114
|
+
innerBlocks
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function parseWordPressBlocks(value) {
|
|
118
|
+
let parsed = value;
|
|
119
|
+
for (let attempt = 0; attempt < 2 && typeof parsed === "string"; attempt += 1) try {
|
|
120
|
+
parsed = JSON.parse(parsed);
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(parsed)) return [];
|
|
125
|
+
return parsed.map(normalizeBlock).filter((block) => block !== null);
|
|
126
|
+
}
|
|
127
|
+
function hasVideoHero(blocks) {
|
|
128
|
+
return blocks.some((block) => block.name === "getquick-design/video-hero" || hasVideoHero(block.innerBlocks ?? []));
|
|
129
|
+
}
|
|
130
|
+
function escapeAttribute(value) {
|
|
131
|
+
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
|
|
132
|
+
}
|
|
133
|
+
function safeMediaUrl(value) {
|
|
134
|
+
if (typeof value !== "string") return "";
|
|
135
|
+
const url = value.trim();
|
|
136
|
+
if (!url || url.startsWith("//")) return "";
|
|
137
|
+
if (url.startsWith("/")) return url;
|
|
138
|
+
try {
|
|
139
|
+
const parsed = new URL(url);
|
|
140
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:" ? url : "";
|
|
141
|
+
} catch {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function youtubeVideoId(value) {
|
|
146
|
+
try {
|
|
147
|
+
const url = new URL(value);
|
|
148
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return "";
|
|
149
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
150
|
+
let id = "";
|
|
151
|
+
if (host === "youtu.be") id = url.pathname.split("/").filter(Boolean)[0] ?? "";
|
|
152
|
+
else if ([
|
|
153
|
+
"youtube.com",
|
|
154
|
+
"m.youtube.com",
|
|
155
|
+
"youtube-nocookie.com"
|
|
156
|
+
].includes(host)) {
|
|
157
|
+
id = url.searchParams.get("v") ?? "";
|
|
158
|
+
if (!id) id = url.pathname.match(/^\/(?:embed|shorts|live)\/([^/]+)/)?.[1] ?? "";
|
|
159
|
+
}
|
|
160
|
+
return YOUTUBE_ID_PATTERN.test(id) ? id : "";
|
|
161
|
+
} catch {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function videoMarkup(attributes) {
|
|
166
|
+
const posterUrl = safeMediaUrl(attributes.posterUrl);
|
|
167
|
+
const poster = posterUrl ? `<img class="wp-block-getquick-design-video-hero__poster" src="${escapeAttribute(posterUrl)}" alt="" decoding="async" />` : "";
|
|
168
|
+
if (attributes.videoType === "youtube") {
|
|
169
|
+
const videoId = typeof attributes.videoUrl === "string" ? youtubeVideoId(attributes.videoUrl) : "";
|
|
170
|
+
if (!videoId) return poster;
|
|
171
|
+
return `${poster}<iframe class="wp-block-getquick-design-video-hero__video wp-block-getquick-design-video-hero__video--youtube" src="https://www.youtube-nocookie.com/embed/${videoId}?${new URLSearchParams({
|
|
172
|
+
autoplay: "1",
|
|
173
|
+
mute: "1",
|
|
174
|
+
controls: "0",
|
|
175
|
+
loop: "1",
|
|
176
|
+
playlist: videoId,
|
|
177
|
+
playsinline: "1",
|
|
178
|
+
rel: "0"
|
|
179
|
+
}).toString()}" title="Decorative background video" allow="autoplay; encrypted-media" referrerpolicy="strict-origin-when-cross-origin" tabindex="-1" aria-hidden="true"></iframe>`;
|
|
180
|
+
}
|
|
181
|
+
const videoUrl = safeMediaUrl(attributes.videoUrl);
|
|
182
|
+
if (!videoUrl) return poster;
|
|
183
|
+
const posterAttribute = posterUrl ? ` poster="${escapeAttribute(posterUrl)}"` : "";
|
|
184
|
+
return `<video class="wp-block-getquick-design-video-hero__video" src="${escapeAttribute(videoUrl)}"${posterAttribute} autoplay muted loop playsinline preload="metadata" aria-hidden="true"></video>`;
|
|
185
|
+
}
|
|
186
|
+
function rootSectionShell(html) {
|
|
187
|
+
return html.match(/^\s*(<section\b[^>]*>)/i)?.[1] ?? "<section class=\"wp-block-getquick-design-video-hero\">";
|
|
188
|
+
}
|
|
189
|
+
function renderVideoHero(block, children) {
|
|
190
|
+
return `${rootSectionShell(block.htmlContent ?? "")}<div class="wp-block-getquick-design-video-hero__media" aria-hidden="true">${videoMarkup(block.attributes ?? {})}</div><div class="wp-block-getquick-design-video-hero__content">${children}</div></section>`;
|
|
191
|
+
}
|
|
192
|
+
function appendChildren(html, children) {
|
|
193
|
+
if (!children) return html;
|
|
194
|
+
const closingTag = html.lastIndexOf("</");
|
|
195
|
+
if (closingTag < 0) return `${html}${children}`;
|
|
196
|
+
return `${html.slice(0, closingTag)}${children}${html.slice(closingTag)}`;
|
|
197
|
+
}
|
|
198
|
+
function renderBlock(block, parentContainerHtml = "") {
|
|
199
|
+
const isContainer = block.name === "getquick-design/container";
|
|
200
|
+
let html = isContainer ? containerLayoutHtml(block.htmlContent ?? "", block.attributes ?? {}) : block.htmlContent ?? "";
|
|
201
|
+
if (block.name === "getquick-design/grid") html = gridLayoutHtml(html, block.attributes ?? {});
|
|
202
|
+
if (isContainer && parentContainerHtml) html = containerChildLayoutHtml(html, parentContainerHtml);
|
|
203
|
+
const children = (block.innerBlocks ?? []).map((child) => renderBlock(child, isContainer ? html : "")).join("");
|
|
204
|
+
if (block.name === "getquick-design/video-hero") return renderVideoHero(block, children);
|
|
205
|
+
if (!children || BLOCKS_WITH_RENDERED_CHILDREN.has(block.name)) return html;
|
|
206
|
+
return appendChildren(html, children);
|
|
207
|
+
}
|
|
208
|
+
function renderWordPressBlocks(blocks) {
|
|
209
|
+
return blocks.map((block) => renderBlock(block)).join("");
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region frontend/src/formatting.ts
|
|
213
|
+
function responseStatus(delivery) {
|
|
214
|
+
if (delivery.kind === "found") return 200;
|
|
215
|
+
return delivery.kind === "missing" ? 404 : 503;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* WPGraphQL's GMT time ("2026-10-02T09:00:00", without a zone) in ms, or null
|
|
219
|
+
* when it is absent or not a time.
|
|
220
|
+
*/
|
|
221
|
+
function cmsTime(value) {
|
|
222
|
+
if (!value) return null;
|
|
223
|
+
const time = Date.parse(/(Z|[+-]\d\d:?\d\d)$/.test(value) ? value : `${value}Z`);
|
|
224
|
+
return Number.isNaN(time) ? null : time;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* An entry's URI as a path. A CMS whose permalinks point at the Frontend gives
|
|
228
|
+
* absolute URIs (`https://acme.example/team/`); only the path is the route.
|
|
229
|
+
* Percent-encoding and the trailing slash are kept as WordPress gives them.
|
|
230
|
+
*/
|
|
231
|
+
function uriPath(uri) {
|
|
232
|
+
try {
|
|
233
|
+
return new URL(uri, "https://frontend.invalid").pathname;
|
|
234
|
+
} catch {
|
|
235
|
+
return uri;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function menuItemHref(url, internalOrigins) {
|
|
239
|
+
try {
|
|
240
|
+
const parsed = new URL(url, internalOrigins[0]);
|
|
241
|
+
if (!internalOrigins.includes(parsed.origin)) return url;
|
|
242
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
243
|
+
} catch {
|
|
244
|
+
return url;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function stripHtml(value) {
|
|
248
|
+
return value.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
|
249
|
+
}
|
|
250
|
+
/** The named character references WordPress's text commonly has (wptexturize writes most as numbers). */
|
|
251
|
+
const NAMED_ENTITIES = {
|
|
252
|
+
amp: "&",
|
|
253
|
+
lt: "<",
|
|
254
|
+
gt: ">",
|
|
255
|
+
quot: "\"",
|
|
256
|
+
apos: "'",
|
|
257
|
+
nbsp: "\xA0",
|
|
258
|
+
ndash: "–",
|
|
259
|
+
mdash: "—",
|
|
260
|
+
lsquo: "‘",
|
|
261
|
+
rsquo: "’",
|
|
262
|
+
ldquo: "“",
|
|
263
|
+
rdquo: "”",
|
|
264
|
+
hellip: "…"
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* Text with its HTML character references decoded: numeric ones, and the
|
|
268
|
+
* named ones in NAMED_ENTITIES (any other is left as written).
|
|
269
|
+
*/
|
|
270
|
+
function decodeEntities(value) {
|
|
271
|
+
return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (reference, name) => {
|
|
272
|
+
if (name[0] !== "#") return NAMED_ENTITIES[name.toLowerCase()] ?? reference;
|
|
273
|
+
const code = name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : Number(name.slice(1));
|
|
274
|
+
return code > 0 && code <= 1114111 ? String.fromCodePoint(code) : reference;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
function formatDate(value, lang = "en") {
|
|
278
|
+
return new Intl.DateTimeFormat(lang, {
|
|
279
|
+
day: "numeric",
|
|
280
|
+
month: "long",
|
|
281
|
+
year: "numeric"
|
|
282
|
+
}).format(new Date(value));
|
|
283
|
+
}
|
|
284
|
+
function routeOf(path) {
|
|
285
|
+
let route = path;
|
|
286
|
+
try {
|
|
287
|
+
route = decodeURI(path);
|
|
288
|
+
} catch {}
|
|
289
|
+
if (!route.startsWith("/")) route = `/${route}`;
|
|
290
|
+
return route.endsWith("/") ? route : `${route}/`;
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region frontend/src/design-patterns.ts
|
|
294
|
+
function isRecord(value) {
|
|
295
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* A complete pattern tree from JSON text or a parsed list. Malformed nodes
|
|
299
|
+
* fail the whole read: dropping one could promote a partial header or footer.
|
|
300
|
+
* PHP's empty attributes (`[]`) normalize to an object.
|
|
301
|
+
*/
|
|
302
|
+
function parseDesignBlocks(value) {
|
|
303
|
+
let parsed = value;
|
|
304
|
+
if (typeof parsed === "string") try {
|
|
305
|
+
parsed = JSON.parse(parsed);
|
|
306
|
+
} catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
let count = 0;
|
|
310
|
+
const blocks = (value, depth) => {
|
|
311
|
+
if (!Array.isArray(value) || value.length > 0 && depth > 32) return void 0;
|
|
312
|
+
const result = [];
|
|
313
|
+
for (const item of value) {
|
|
314
|
+
if (++count > 1e3 || !isRecord(item) || typeof item.name !== "string" || !item.name) return void 0;
|
|
315
|
+
const attributes = isRecord(item.attributes) ? item.attributes : Array.isArray(item.attributes) && item.attributes.length === 0 ? {} : void 0;
|
|
316
|
+
if (!attributes || item.content !== null && typeof item.content !== "string") return void 0;
|
|
317
|
+
const children = blocks(item.children, depth + 1);
|
|
318
|
+
if (!children) return void 0;
|
|
319
|
+
result.push({
|
|
320
|
+
name: item.name,
|
|
321
|
+
attributes,
|
|
322
|
+
content: item.content,
|
|
323
|
+
children
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return result;
|
|
327
|
+
};
|
|
328
|
+
return blocks(parsed, 0);
|
|
329
|
+
}
|
|
330
|
+
function escapeHtml(value) {
|
|
331
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
332
|
+
}
|
|
333
|
+
/** Inserts children before the HTML's last closing tag, where WordPress's wrappers keep them. */
|
|
334
|
+
function insertChildren(html, children) {
|
|
335
|
+
if (!children) return html;
|
|
336
|
+
const closingTag = html.lastIndexOf("</");
|
|
337
|
+
if (closingTag < 0) return `${html}${children}`;
|
|
338
|
+
return `${html.slice(0, closingTag)}${children}${html.slice(closingTag)}`;
|
|
339
|
+
}
|
|
340
|
+
function linkHref(url, internalOrigins) {
|
|
341
|
+
if (typeof url !== "string" || !url.trim()) return "#";
|
|
342
|
+
const href = internalOrigins.length > 0 ? menuItemHref(url.trim(), internalOrigins) : url.trim();
|
|
343
|
+
if (/^\/(?!\/)/.test(href) || href.startsWith("#") || href.startsWith("?")) return href;
|
|
344
|
+
try {
|
|
345
|
+
const protocol = new URL(href).protocol;
|
|
346
|
+
return protocol === "https:" || protocol === "http:" || protocol === "mailto:" || protocol === "tel:" ? href : "#";
|
|
347
|
+
} catch {
|
|
348
|
+
return "#";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** A media URL as an image source: http(s) only, never another scheme. */
|
|
352
|
+
function mediaUrl(value) {
|
|
353
|
+
if (typeof value !== "string") return null;
|
|
354
|
+
try {
|
|
355
|
+
const protocol = new URL(value).protocol;
|
|
356
|
+
return protocol === "https:" || protocol === "http:" ? value : null;
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function designLogo(value) {
|
|
362
|
+
const url = isRecord(value) ? mediaUrl(value.url) : null;
|
|
363
|
+
if (!isRecord(value) || !url) return null;
|
|
364
|
+
const size = (item) => typeof item === "number" && item > 0 ? item : null;
|
|
365
|
+
return {
|
|
366
|
+
url,
|
|
367
|
+
alt: typeof value.alt === "string" ? value.alt : "",
|
|
368
|
+
width: size(value.width),
|
|
369
|
+
height: size(value.height)
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* GQ Design's Site Logo node carries the Branding logo itself (`logo`, null
|
|
374
|
+
* when unset) and its authored `width`, `isLink` and `linkTarget`, so it
|
|
375
|
+
* renders without another read. Without a logo, nothing: the Site's own
|
|
376
|
+
* renderer can show its title instead.
|
|
377
|
+
*/
|
|
378
|
+
const renderSiteLogo = (block, context) => {
|
|
379
|
+
const logo = designLogo(block.attributes.logo);
|
|
380
|
+
if (!logo) return "";
|
|
381
|
+
const width = typeof block.attributes.width === "number" ? block.attributes.width : logo.width;
|
|
382
|
+
const size = [width ? ` width="${width}"` : "", width && logo.width && logo.height ? ` height="${Math.round(width * logo.height / logo.width)}"` : !width && logo.height ? ` height="${logo.height}"` : ""].join("");
|
|
383
|
+
const image = `<img class="custom-logo" src="${escapeHtml(logo.url)}" alt="${escapeHtml(logo.alt)}"${size} decoding="async" />`;
|
|
384
|
+
const className = typeof block.attributes.className === "string" ? ` ${block.attributes.className}` : "";
|
|
385
|
+
const linked = block.attributes.isLink === false ? image : `<a href="${escapeHtml(context.href(context.home))}" class="custom-logo-link" ${block.attributes.linkTarget === "_blank" ? "target=\"_blank\" rel=\"home noopener\"" : "rel=\"home\""}>${image}</a>`;
|
|
386
|
+
return `<div class="wp-block-site-logo${escapeHtml(className)}">${linked}</div>`;
|
|
387
|
+
};
|
|
388
|
+
const BUILT_IN = { "core/site-logo": renderSiteLogo };
|
|
389
|
+
/** The elements a container's `tagName` may choose, as WordPress's Group block offers them. */
|
|
390
|
+
const CONTAINER_TAGS = /* @__PURE__ */ new Set([
|
|
391
|
+
"div",
|
|
392
|
+
"header",
|
|
393
|
+
"footer",
|
|
394
|
+
"main",
|
|
395
|
+
"section",
|
|
396
|
+
"article",
|
|
397
|
+
"aside",
|
|
398
|
+
"nav"
|
|
399
|
+
]);
|
|
400
|
+
const SLUG = /^[a-z0-9-]+$/;
|
|
401
|
+
/** GQ Design's layout container, whose width, gap and padding content also restores. */
|
|
402
|
+
const DESIGN_CONTAINER = "getquick-design/container";
|
|
403
|
+
/**
|
|
404
|
+
* A container block (one with children) as WordPress wraps it. GQ Design
|
|
405
|
+
* sends saved HTML only for blocks without children, so a container's wrapper
|
|
406
|
+
* is rebuilt from its name and attributes: its element (`tagName`), its default
|
|
407
|
+
* class (`wp-block-group` for `core/group`, `wp-block-acme-hero` for
|
|
408
|
+
* `acme/hero`), and the classes WordPress derives from `className`, `align`,
|
|
409
|
+
* its preset colours and its layout type. Other styles (custom colours,
|
|
410
|
+
* spacing) need a Site renderer. GQ Design's own container
|
|
411
|
+
* (`getquick-design/container`) also gets its layout, as in content.
|
|
412
|
+
*/
|
|
413
|
+
function containerHtml(block, children) {
|
|
414
|
+
const { tagName, className, align, backgroundColor, textColor, layout } = block.attributes;
|
|
415
|
+
const tag = typeof tagName === "string" && CONTAINER_TAGS.has(tagName) ? tagName : "div";
|
|
416
|
+
const classes = [`wp-block-${block.name.replace(/^core\//, "").replaceAll("/", "-")}`];
|
|
417
|
+
if (typeof className === "string" && className.trim()) classes.push(className.trim());
|
|
418
|
+
if (typeof align === "string" && SLUG.test(align)) classes.push(`align${align}`);
|
|
419
|
+
if (typeof backgroundColor === "string" && SLUG.test(backgroundColor)) classes.push(`has-${backgroundColor}-background-color`, "has-background");
|
|
420
|
+
if (typeof textColor === "string" && SLUG.test(textColor)) classes.push(`has-${textColor}-color`, "has-text-color");
|
|
421
|
+
const layoutType = isRecord(layout) ? layout.type : void 0;
|
|
422
|
+
if (typeof layoutType === "string" && SLUG.test(layoutType)) classes.push(`is-layout-${layoutType}`);
|
|
423
|
+
return `<${tag} class="${escapeHtml(classes.join(" "))}">${children}</${tag}>`;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* A pattern (or blocks of one) as HTML. Each block is rendered by the Site's
|
|
427
|
+
* renderer for its name, else a built-in one, else as the HTML WordPress saved
|
|
428
|
+
* for it, else, with children, as a WordPress container wrapping them. A block
|
|
429
|
+
* with none of those (a dynamic block, such as a Site's own) renders nothing
|
|
430
|
+
* and is reported to `onUnrendered`. Null renders nothing, so the Site keeps
|
|
431
|
+
* its own header or footer.
|
|
432
|
+
*/
|
|
433
|
+
function renderDesignPattern(pattern, options = {}) {
|
|
434
|
+
if (!pattern) return "";
|
|
435
|
+
const renderers = {
|
|
436
|
+
...BUILT_IN,
|
|
437
|
+
...options.blocks
|
|
438
|
+
};
|
|
439
|
+
const internalOrigins = options.internalOrigins ?? [];
|
|
440
|
+
const href = (url) => linkHref(url, internalOrigins);
|
|
441
|
+
const renderAll = (blocks, ancestors, parentContainer = "") => blocks.map((block) => renderOne(block, ancestors, parentContainer)).join("");
|
|
442
|
+
function renderOne(block, ancestors, parentContainer) {
|
|
443
|
+
const inside = [...ancestors, block];
|
|
444
|
+
const renderer = Object.hasOwn(renderers, block.name) ? renderers[block.name] : void 0;
|
|
445
|
+
if (renderer) {
|
|
446
|
+
let children;
|
|
447
|
+
return renderer(block, {
|
|
448
|
+
get children() {
|
|
449
|
+
return children ??= renderAll(block.children, inside);
|
|
450
|
+
},
|
|
451
|
+
ancestors,
|
|
452
|
+
render: (blocks) => renderAll(blocks, inside),
|
|
453
|
+
href,
|
|
454
|
+
home: options.home ?? "/"
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (block.name === DESIGN_CONTAINER && (block.children.length > 0 || block.content !== null)) {
|
|
458
|
+
let wrapper = containerLayoutHtml(block.content ?? containerHtml(block, ""), block.attributes);
|
|
459
|
+
if (parentContainer) wrapper = containerChildLayoutHtml(wrapper, parentContainer);
|
|
460
|
+
return insertChildren(wrapper, renderAll(block.children, inside, wrapper));
|
|
461
|
+
}
|
|
462
|
+
if (block.name === "getquick-design/grid" && (block.children.length > 0 || block.content !== null)) return insertChildren(gridLayoutHtml(block.content ?? containerHtml(block, ""), block.attributes), renderAll(block.children, inside));
|
|
463
|
+
const children = renderAll(block.children, inside);
|
|
464
|
+
if (block.content !== null) return insertChildren(block.content, children);
|
|
465
|
+
if (block.children.length > 0) return containerHtml(block, children);
|
|
466
|
+
options.onUnrendered?.(block);
|
|
467
|
+
return "";
|
|
468
|
+
}
|
|
469
|
+
return renderAll(Array.isArray(pattern) ? pattern : pattern.blocks, []);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* A menu a Site block carries in its attributes (a flat list of `{ id,
|
|
473
|
+
* parentId, label, url, target }`, as GQ Design's serializer filter documents
|
|
474
|
+
* for `getquick/header-navigation`) as a tree, with each link as the
|
|
475
|
+
* Frontend serves it. Items that aren't menu items are left out; one whose
|
|
476
|
+
* parent isn't listed, or that is in a cycle of parents, is a top-level item.
|
|
477
|
+
*/
|
|
478
|
+
function designMenuItems(value, href = (url) => linkHref(url, [])) {
|
|
479
|
+
if (!Array.isArray(value)) return [];
|
|
480
|
+
const id = (item) => typeof item === "number" || typeof item === "string" ? String(item) : "";
|
|
481
|
+
const seen = /* @__PURE__ */ new Set();
|
|
482
|
+
const items = value.filter(isRecord).filter((item) => {
|
|
483
|
+
const key = id(item.id);
|
|
484
|
+
if (!key || seen.has(key)) return false;
|
|
485
|
+
seen.add(key);
|
|
486
|
+
return true;
|
|
487
|
+
});
|
|
488
|
+
const byId = /* @__PURE__ */ new Map();
|
|
489
|
+
const parentOf = /* @__PURE__ */ new Map();
|
|
490
|
+
for (const item of items) {
|
|
491
|
+
const parentId = id(item.parentId);
|
|
492
|
+
if (parentId && parentId !== "0") parentOf.set(id(item.id), parentId);
|
|
493
|
+
byId.set(id(item.id), {
|
|
494
|
+
id: id(item.id),
|
|
495
|
+
label: typeof item.label === "string" ? item.label : "",
|
|
496
|
+
href: href(item.url),
|
|
497
|
+
target: item.target === "_blank" ? "_blank" : null,
|
|
498
|
+
children: []
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const inCycle = (start) => {
|
|
502
|
+
const visited = /* @__PURE__ */ new Set();
|
|
503
|
+
for (let at = parentOf.get(start); at !== void 0; at = parentOf.get(at)) {
|
|
504
|
+
if (at === start) return true;
|
|
505
|
+
if (visited.has(at)) return false;
|
|
506
|
+
visited.add(at);
|
|
507
|
+
}
|
|
508
|
+
return false;
|
|
509
|
+
};
|
|
510
|
+
const roots = [];
|
|
511
|
+
for (const [key, node] of byId) {
|
|
512
|
+
const parentId = parentOf.get(key);
|
|
513
|
+
const parent = parentId !== void 0 && !inCycle(key) ? byId.get(parentId) : void 0;
|
|
514
|
+
(parent ? parent.children : roots).push(node);
|
|
515
|
+
}
|
|
516
|
+
return roots;
|
|
517
|
+
}
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region frontend/src/site-language.ts
|
|
520
|
+
/**
|
|
521
|
+
* The `<html lang>` for a WordPress locale: its language and region as a
|
|
522
|
+
* BCP 47 tag (pt_PT_ao90 → pt-PT). WordPress's variants (ao90, formal) aren't
|
|
523
|
+
* BCP 47 subtags, so they are left out; no locale means English.
|
|
524
|
+
*/
|
|
525
|
+
function htmlLang(locale) {
|
|
526
|
+
if (!locale) return "en";
|
|
527
|
+
return locale.split("_").slice(0, 2).join("-");
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* configuration's languages: wordpress.locale, the default, served at /, then
|
|
531
|
+
* each of wordpress.languages under its slug. A monolingual Site has one.
|
|
532
|
+
*/
|
|
533
|
+
function siteLanguages(wordpress) {
|
|
534
|
+
const locale = wordpress?.locale ?? "";
|
|
535
|
+
const language = (locale, slug, isDefault) => ({
|
|
536
|
+
locale,
|
|
537
|
+
slug,
|
|
538
|
+
code: slug.toUpperCase().replaceAll("-", "_"),
|
|
539
|
+
lang: htmlLang(locale),
|
|
540
|
+
isDefault,
|
|
541
|
+
home: isDefault ? "/" : `/${slug}/`
|
|
542
|
+
});
|
|
543
|
+
return [language(locale, locale.split("_")[0] || "en", true), ...(wordpress?.languages ?? []).map(({ locale, slug }) => language(locale, slug, false))];
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* A route's language: the one whose slug is its first segment, else the
|
|
547
|
+
* default language, which has no directory.
|
|
548
|
+
*/
|
|
549
|
+
function routeLanguage(route, among) {
|
|
550
|
+
const segment = route.split("/")[1];
|
|
551
|
+
return among.find((language) => !language.isDefault && language.slug === segment) ?? among[0];
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* How logs and reports name something a language has: "the front page" for
|
|
555
|
+
* the default language's, "the en front page" for another's.
|
|
556
|
+
*/
|
|
557
|
+
function languageSubject(language, what) {
|
|
558
|
+
return language.isDefault ? `the ${what}` : `the ${language.slug} ${what}`;
|
|
559
|
+
}
|
|
560
|
+
/** Whether a route is a language's home (/, /en/): served as a home, never as an entry. */
|
|
561
|
+
function isLanguageHome(route, among) {
|
|
562
|
+
return among.some((language) => language.home === route);
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
export { containerChildLayoutHtml as C, youtubeVideoId as S, designGridStyle as T, uriPath as _, siteLanguages as a, parseWordPressBlocks as b, parseDesignBlocks as c, decodeEntities as d, formatDate as f, stripHtml as g, routeOf as h, routeLanguage as i, renderDesignPattern as l, responseStatus as m, isLanguageHome as n, designMenuItems as o, menuItemHref as p, languageSubject as r, escapeHtml as s, htmlLang as t, cmsTime as u, VIDEO_HERO_BLOCK as v, containerLayoutHtml as w, renderWordPressBlocks as x, hasVideoHero as y };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//#endregion
|
|
2
2
|
//#region frontend/src/consent/version.ts
|
|
3
3
|
/** The UI/adapter version recorded with each decision (CN-R7). */
|
|
4
|
-
const CONSENT_UI_VERSION = `gq-content@0.
|
|
4
|
+
const CONSENT_UI_VERSION = `gq-content@0.3.1`;
|
|
5
5
|
//#endregion
|
|
6
6
|
export { CONSENT_UI_VERSION as t };
|