edgepress 2026.9.3
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 +70 -0
- package/config.yml +146 -0
- package/content/pages/home/en.md +84 -0
- package/content/pages/home/zh-cn.md +84 -0
- package/content/pages/plugin-development/en.md +108 -0
- package/content/pages/plugin-development/zh-cn.md +108 -0
- package/content/pages/privacy-policy/en.md +73 -0
- package/content/pages/privacy-policy/zh-cn.md +73 -0
- package/content/pages/project-introduction/en.md +88 -0
- package/content/pages/project-introduction/zh-cn.md +88 -0
- package/content/pages/quick-start/en.md +147 -0
- package/content/pages/quick-start/zh-cn.md +147 -0
- package/content/pages/theme-development/en.md +106 -0
- package/content/pages/theme-development/zh-cn.md +106 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +169 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +169 -0
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +30 -0
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +30 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +28 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +28 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +39 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +39 -0
- package/edgepress.config.mjs +18 -0
- package/languages/base/en.json +55 -0
- package/languages/packs/zh-CN.json +55 -0
- package/package.json +59 -0
- package/plugins/consent/index.js +40 -0
- package/project-compatibility.json +48 -0
- package/src/assets.js +181 -0
- package/src/build.js +277 -0
- package/src/cli.js +344 -0
- package/src/concurrency.js +26 -0
- package/src/config.js +396 -0
- package/src/content.js +160 -0
- package/src/generators.js +263 -0
- package/src/i18n.js +64 -0
- package/src/maintenance.js +122 -0
- package/src/markdown.js +65 -0
- package/src/page-blocks.js +350 -0
- package/src/page-check.js +416 -0
- package/src/plugin-api.js +75 -0
- package/src/preview.js +167 -0
- package/src/report-visuals.js +480 -0
- package/src/theme.js +161 -0
- package/src/worker.js +139 -0
- package/static/.gitkeep +0 -0
- package/static/edgepress/language-select.js +5 -0
- package/static/edgepress/plugins/advertising/google-adsense.js +26 -0
- package/static/edgepress/plugins/captcha/cloudflare-turnstile.js +28 -0
- package/static/edgepress/plugins/captcha/google-recaptcha.js +27 -0
- package/static/edgepress/plugins/captcha/hcaptcha.js +27 -0
- package/static/edgepress/plugins/consent/manager.js +180 -0
- package/static/edgepress/plugins/statistics/baidu-tongji.js +8 -0
- package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +9 -0
- package/static/edgepress/plugins/statistics/google-analytics.js +13 -0
- package/static/edgepress/plugins/tracking/google-tag-manager.js +10 -0
- package/static/edgepress/plugins/tracking/meta-pixel.js +18 -0
- package/static/edgepress/search.js +84 -0
- package/static/edgepress-markdown-guide.svg +21 -0
- package/templates/gitignore +6 -0
- package/templates/site-readme.md +20 -0
- package/themes/atelier/assets/style.css +120 -0
- package/themes/atelier/layouts/layout.html +17 -0
- package/themes/atelier/layouts/partials/footer.html +4 -0
- package/themes/atelier/layouts/partials/head.html +17 -0
- package/themes/atelier/layouts/partials/header.html +5 -0
- package/themes/atelier/theme.json +6 -0
- package/themes/default/assets/style.css +157 -0
- package/themes/default/layouts/layout.html +12 -0
- package/themes/default/layouts/partials/footer.html +4 -0
- package/themes/default/layouts/partials/head.html +19 -0
- package/themes/default/layouts/partials/header.html +5 -0
- package/themes/default/theme.json +6 -0
- package/themes/signal/assets/style.css +113 -0
- package/themes/signal/layouts/layout.html +12 -0
- package/themes/signal/layouts/partials/footer.html +4 -0
- package/themes/signal/layouts/partials/head.html +17 -0
- package/themes/signal/layouts/partials/header.html +5 -0
- package/themes/signal/theme.json +6 -0
- package/wrangler.jsonc +13 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { plainText } from './markdown.js';
|
|
2
|
+
import { translate } from './i18n.js';
|
|
3
|
+
|
|
4
|
+
const captchaProviders = new Set(['cloudflare-turnstile', 'google-recaptcha', 'hcaptcha']);
|
|
5
|
+
|
|
6
|
+
function escapeHtml(value) {
|
|
7
|
+
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
|
8
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
9
|
+
})[char]);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function text(value, label, limit = 4000, optional = false) {
|
|
13
|
+
if (optional && (value === undefined || value === null || value === '')) return '';
|
|
14
|
+
if (typeof value !== 'string' || value.length > limit) throw new Error(label + ' must be a string of at most ' + limit + ' characters');
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function safeUrl(value, label) {
|
|
19
|
+
const url = text(value, label, 2048).trim();
|
|
20
|
+
if (/^[\x00-\x20]|[\x00-\x20\\]/.test(url) || /^\/\//.test(url) || /^(?:javascript|data|vbscript):/i.test(url)) {
|
|
21
|
+
throw new Error(label + ' must be a safe site path or HTTP URL');
|
|
22
|
+
}
|
|
23
|
+
if (url.startsWith('/')) return url;
|
|
24
|
+
let parsed;
|
|
25
|
+
try { parsed = new URL(url); } catch { throw new Error(label + ' must be a safe site path or HTTP URL'); }
|
|
26
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new Error(label + ' must use HTTP or HTTPS');
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function list(value, label, min = 1, max = 50) {
|
|
31
|
+
if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error(label + ' must contain ' + min + ' to ' + max + ' items');
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function enabledIntegration(config, id, category) {
|
|
36
|
+
const section = config.browserPlugins[category];
|
|
37
|
+
const match = section?.enabled && section.services.find((item) => item.id === id);
|
|
38
|
+
if (!match) throw new Error('Page block requires a configured ' + category + ' service with id: ' + id);
|
|
39
|
+
return match;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function dateLabel(date, locale) {
|
|
43
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(date);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderPrivacyServices(context) {
|
|
47
|
+
const groups = [
|
|
48
|
+
['tracking', translate(context.config, context.locale, 'pluginTracking')],
|
|
49
|
+
['statistics', translate(context.config, context.locale, 'pluginStatistics')],
|
|
50
|
+
['advertising', translate(context.config, context.locale, 'pluginAdvertising')],
|
|
51
|
+
['captcha', translate(context.config, context.locale, 'pluginCaptcha')]
|
|
52
|
+
];
|
|
53
|
+
const configured = groups.flatMap(([key, label]) => {
|
|
54
|
+
const section = context.config.browserPlugins[key];
|
|
55
|
+
return (section?.enabled ? section.services : []).map((service) => ({ ...service, category: label }));
|
|
56
|
+
});
|
|
57
|
+
if (!configured.length) {
|
|
58
|
+
return '<p class="privacy-empty">' + escapeHtml(text(context.emptyText, 'privacy-services.emptyText', 2000)) + '</p>';
|
|
59
|
+
}
|
|
60
|
+
return '<ul class="privacy-service-list">' + configured.map((service) => '<li><h3>' + escapeHtml(service.category + ' · ' + service.provider) +
|
|
61
|
+
'</h3><p><strong>' + escapeHtml(translate(context.config, context.locale, 'servicePurpose')) + ':</strong> ' + escapeHtml(service.purpose) +
|
|
62
|
+
'</p><p><strong>' + escapeHtml(translate(context.config, context.locale, 'serviceRetention')) + ':</strong> ' + escapeHtml(service.retention) + '</p></li>').join('') + '</ul>';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderLatestPosts(block, context) {
|
|
66
|
+
const count = block.count ?? 3;
|
|
67
|
+
if (!Number.isInteger(count) || count < 1 || count > 12) throw new Error('latest-posts.count must be an integer from 1 to 12');
|
|
68
|
+
const posts = context.site.posts.filter((post) => post.locale === context.locale ||
|
|
69
|
+
(!context.site.posts.some((item) => item.locale === context.locale) && post.locale === context.config.i18n.defaultLocale)).slice(0, count);
|
|
70
|
+
const title = escapeHtml(text(block.title, 'latest-posts.title', 200));
|
|
71
|
+
const cards = posts.map((post) => '<article class="post-card"><h3><a href="' + escapeHtml('/' + post.path.split('/').filter(Boolean).join('/') + (post.path.endsWith('/') ? '/' : '')) + '">' +
|
|
72
|
+
escapeHtml(post.title) + '</a></h3><p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, context.locale)) +
|
|
73
|
+
'</time></p><p>' + escapeHtml(post.description || plainText(post.markdown).slice(0, 220)) + '</p></article>').join('');
|
|
74
|
+
return '<section class="latest-posts"><h2>' + title + '</h2>' +
|
|
75
|
+
(cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + '</section>';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderMediaText(block) {
|
|
79
|
+
const mediaType = text(block.mediaType, 'media-text.mediaType', 10);
|
|
80
|
+
if (!['image', 'video'].includes(mediaType)) throw new Error('media-text.mediaType must be image or video');
|
|
81
|
+
const src = escapeHtml(safeUrl(block.src, 'media-text.src'));
|
|
82
|
+
const alt = escapeHtml(text(block.alt, 'media-text.alt', 500));
|
|
83
|
+
const caption = text(block.caption, 'media-text.caption', 500, true);
|
|
84
|
+
const placement = block.placement ?? 'left';
|
|
85
|
+
if (!['left', 'right'].includes(placement)) throw new Error('media-text.placement must be left or right');
|
|
86
|
+
let media;
|
|
87
|
+
if (mediaType === 'image') {
|
|
88
|
+
media = '<img src="' + src + '" alt="' + alt + '" loading="lazy" decoding="async">';
|
|
89
|
+
} else {
|
|
90
|
+
if (!alt.trim()) throw new Error('media-text.alt must describe the video');
|
|
91
|
+
const captions = escapeHtml(safeUrl(block.captions, 'media-text.captions'));
|
|
92
|
+
const language = escapeHtml(text(block.captionLanguage, 'media-text.captionLanguage', 24));
|
|
93
|
+
if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(language)) throw new Error('media-text.captionLanguage must be a language tag');
|
|
94
|
+
const trackLabel = escapeHtml(text(block.captionLabel, 'media-text.captionLabel', 120));
|
|
95
|
+
const poster = block.poster ? ' poster="' + escapeHtml(safeUrl(block.poster, 'media-text.poster')) + '"' : '';
|
|
96
|
+
media = '<video controls preload="none" aria-label="' + alt + '"' + poster + '><source src="' + src + '"><track kind="captions" src="' + captions + '" srclang="' + language + '" label="' + trackLabel + '" default></video>';
|
|
97
|
+
}
|
|
98
|
+
const title = text(block.title, 'media-text.title', 240, true);
|
|
99
|
+
const content = text(block.text, 'media-text.text', 2000);
|
|
100
|
+
let action = '';
|
|
101
|
+
if (block.cta) {
|
|
102
|
+
action = '<p><a class="button" href="' + escapeHtml(safeUrl(block.cta.url, 'media-text.cta.url')) + '">' +
|
|
103
|
+
escapeHtml(text(block.cta.label, 'media-text.cta.label', 120)) + '</a></p>';
|
|
104
|
+
}
|
|
105
|
+
return '<article class="media-text-block media-text-block--' + placement + '"><figure>' + media +
|
|
106
|
+
(caption ? '<figcaption>' + escapeHtml(caption) + '</figcaption>' : '') + '</figure><div class="media-text-copy">' +
|
|
107
|
+
(title ? '<h2>' + escapeHtml(title) + '</h2>' : '') + '<p>' + escapeHtml(content) + '</p>' + action + '</div></article>';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function renderBlock(block, context, depth, index) {
|
|
111
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) throw new Error('Page blocks must be objects');
|
|
112
|
+
if (depth > 4) throw new Error('Page block nesting may not exceed 4 levels');
|
|
113
|
+
const type = text(block.type, 'block.type', 40);
|
|
114
|
+
switch (type) {
|
|
115
|
+
case 'hero': {
|
|
116
|
+
const heading = text(block.title, 'hero.title', 240);
|
|
117
|
+
const level = context.isHomepage && index === 0 ? 'h1' : 'h2';
|
|
118
|
+
const eyebrow = text(block.eyebrow, 'hero.eyebrow', 120, true);
|
|
119
|
+
const description = text(block.text, 'hero.text', 1000, true);
|
|
120
|
+
let action = '';
|
|
121
|
+
if (block.cta) {
|
|
122
|
+
const label = escapeHtml(text(block.cta.label, 'hero.cta.label', 120));
|
|
123
|
+
const url = escapeHtml(safeUrl(block.cta.url, 'hero.cta.url'));
|
|
124
|
+
action = '<p><a class="button" href="' + url + '">' + label + '</a></p>';
|
|
125
|
+
}
|
|
126
|
+
const highlights = block.highlights === undefined ? [] : list(block.highlights, 'hero.highlights', 1, 4);
|
|
127
|
+
const highlightPanel = highlights.length ? '<ul class="hero-highlights">' + highlights.map((item) => '<li>' +
|
|
128
|
+
escapeHtml(text(item, 'hero highlight', 180)) + '</li>').join('') + '</ul>' : '';
|
|
129
|
+
return '<section class="hero-block' + (highlightPanel ? ' hero-block--split' : '') + '"><div class="hero-main">' +
|
|
130
|
+
(eyebrow ? '<p class="eyebrow">' + escapeHtml(eyebrow) + '</p>' : '') + '<' + level + '>' + escapeHtml(heading) + '</' + level + '>' +
|
|
131
|
+
(description ? '<p class="hero-copy">' + escapeHtml(description) + '</p>' : '') + action + '</div>' + highlightPanel + '</section>';
|
|
132
|
+
}
|
|
133
|
+
case 'section': {
|
|
134
|
+
const heading = text(block.title, 'section.title', 200, true);
|
|
135
|
+
const label = text(block.label, 'section.label', 200, true);
|
|
136
|
+
if (!heading && !label) throw new Error('section needs a title or accessible label');
|
|
137
|
+
const tone = block.tone ?? 'plain';
|
|
138
|
+
if (!['plain', 'soft', 'accent', 'dark'].includes(tone)) throw new Error('section.tone must be plain, soft, accent, or dark');
|
|
139
|
+
const width = block.width ?? 'default';
|
|
140
|
+
if (!['default', 'wide', 'narrow'].includes(width)) throw new Error('section.width must be default, wide, or narrow');
|
|
141
|
+
const id = block.id === undefined ? '' : text(block.id, 'section.id', 80);
|
|
142
|
+
if (id && !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('section.id must use lowercase letters, numbers, and hyphens');
|
|
143
|
+
if (id) {
|
|
144
|
+
context.sectionIds ??= new Set();
|
|
145
|
+
if (context.sectionIds.has(id)) throw new Error('Page section IDs must be unique: ' + id);
|
|
146
|
+
context.sectionIds.add(id);
|
|
147
|
+
}
|
|
148
|
+
const intro = text(block.text, 'section.text', 1000, true);
|
|
149
|
+
const children = await renderElements(block.blocks, context, depth + 1);
|
|
150
|
+
context.sectionHeadingCounter = (context.sectionHeadingCounter || 0) + 1;
|
|
151
|
+
const headingId = 'page-section-heading-' + context.sectionHeadingCounter;
|
|
152
|
+
const labelledBy = heading ? ' aria-labelledby="' + headingId + '"' : ' aria-label="' + escapeHtml(label) + '"';
|
|
153
|
+
return '<section' + (id ? ' id="' + escapeHtml(id) + '"' : '') + ' class="page-section page-section--' + tone + '"' + labelledBy +
|
|
154
|
+
'><div class="section-inner section-inner--' + width + '">' + (heading ? '<h2 id="' + headingId + '">' + escapeHtml(heading) + '</h2>' : '') +
|
|
155
|
+
(intro ? '<p class="section-intro">' + escapeHtml(intro) + '</p>' : '') + children + '</div></section>';
|
|
156
|
+
}
|
|
157
|
+
case 'heading': {
|
|
158
|
+
const value = escapeHtml(text(block.text, 'heading.text', 240));
|
|
159
|
+
const level = block.level ?? 2;
|
|
160
|
+
if (![2, 3, 4].includes(level)) throw new Error('heading.level must be 2, 3, or 4');
|
|
161
|
+
const id = block.id === undefined ? '' : text(block.id, 'heading.id', 80);
|
|
162
|
+
if (id && !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error('heading.id must use lowercase letters, numbers, and hyphens');
|
|
163
|
+
return '<h' + level + (id ? ' id="' + escapeHtml(id) + '"' : '') + ' class="content-heading">' + value + '</h' + level + '>';
|
|
164
|
+
}
|
|
165
|
+
case 'text': {
|
|
166
|
+
let paragraphs = block.paragraphs;
|
|
167
|
+
if (paragraphs === undefined && typeof block.text === 'string') paragraphs = block.text.split(/\n\s*\n/).filter(Boolean);
|
|
168
|
+
if (!Array.isArray(paragraphs) || paragraphs.length < 1 || paragraphs.length > 30) throw new Error('text needs a text value or 1 to 30 paragraphs');
|
|
169
|
+
return '<div class="text-widget">' + paragraphs.map((paragraph) => '<p>' + escapeHtml(text(paragraph, 'text paragraph', 4000)) + '</p>').join('') + '</div>';
|
|
170
|
+
}
|
|
171
|
+
case 'media-text': return renderMediaText(block);
|
|
172
|
+
case 'display-text': {
|
|
173
|
+
const value = escapeHtml(text(block.text, 'display-text.text', 1000));
|
|
174
|
+
const style = block.style ?? 'editorial';
|
|
175
|
+
if (!['editorial', 'serif', 'outline', 'mono', 'accent'].includes(style)) throw new Error('display-text.style must be editorial, serif, outline, mono, or accent');
|
|
176
|
+
const level = block.level ?? 2;
|
|
177
|
+
if (![2, 3, 4].includes(level)) throw new Error('display-text.level must be 2, 3, or 4');
|
|
178
|
+
return '<h' + level + ' class="display-text display-text--' + style + '">' + value + '</h' + level + '>';
|
|
179
|
+
}
|
|
180
|
+
case 'link-list': {
|
|
181
|
+
const items = list(block.items, 'link-list.items', 1, 30);
|
|
182
|
+
const title = text(block.title, 'link-list.title', 200);
|
|
183
|
+
return '<nav class="link-list"' + (title ? ' aria-label="' + escapeHtml(title) + '"' : '') + '>' +
|
|
184
|
+
(title ? '<h3>' + escapeHtml(title) + '</h3>' : '') + '<ul>' + items.map((item) => {
|
|
185
|
+
const label = escapeHtml(text(item?.label, 'link-list label', 200));
|
|
186
|
+
const url = escapeHtml(safeUrl(item?.url, 'link-list.url'));
|
|
187
|
+
const description = text(item?.text, 'link-list.text', 500, true);
|
|
188
|
+
return '<li><a href="' + url + '">' + label + '</a>' + (description ? '<p>' + escapeHtml(description) + '</p>' : '') + '</li>';
|
|
189
|
+
}).join('') + '</ul></nav>';
|
|
190
|
+
}
|
|
191
|
+
case 'steps': {
|
|
192
|
+
const items = list(block.items, 'steps.items', 1, 20);
|
|
193
|
+
const title = text(block.title, 'steps.title', 200, true);
|
|
194
|
+
const headingTag = title ? 'h4' : 'h3';
|
|
195
|
+
return '<div class="steps-widget">' + (title ? '<h3>' + escapeHtml(title) + '</h3>' : '') + '<ol>' + items.map((item) => {
|
|
196
|
+
const heading = escapeHtml(text(item?.title, 'step.title', 200));
|
|
197
|
+
const description = text(item?.text, 'step.text', 1000, true);
|
|
198
|
+
const command = text(item?.command, 'step.command', 2000, true);
|
|
199
|
+
return '<li><' + headingTag + '>' + heading + '</' + headingTag + '>' + (description ? '<p>' + escapeHtml(description) + '</p>' : '') +
|
|
200
|
+
(command ? '<pre><code>' + escapeHtml(command) + '</code></pre>' : '') + '</li>';
|
|
201
|
+
}).join('') + '</ol></div>';
|
|
202
|
+
}
|
|
203
|
+
case 'code': {
|
|
204
|
+
const source = escapeHtml(text(block.code, 'code.code', 12000));
|
|
205
|
+
const language = block.language === undefined ? '' : text(block.language, 'code.language', 40);
|
|
206
|
+
if (language && !/^[a-z0-9+#.-]+$/i.test(language)) throw new Error('code.language must be a simple language name');
|
|
207
|
+
const title = text(block.title, 'code.title', 200, true);
|
|
208
|
+
return '<figure class="code-widget">' + (title ? '<figcaption>' + escapeHtml(title) + '</figcaption>' : '') +
|
|
209
|
+
'<pre><code' + (language ? ' class="language-' + escapeHtml(language) + '"' : '') + '>' + source + '</code></pre></figure>';
|
|
210
|
+
}
|
|
211
|
+
case 'data-table': {
|
|
212
|
+
const headers = list(block.headers, 'data-table.headers', 1, 12);
|
|
213
|
+
const rows = list(block.rows, 'data-table.rows', 1, 100);
|
|
214
|
+
const title = text(block.title, 'data-table.title', 200);
|
|
215
|
+
for (const row of rows) if (!Array.isArray(row) || row.length !== headers.length) throw new Error('Each data-table row must match the number of headers');
|
|
216
|
+
return '<div class="table-widget"><table><caption>' + escapeHtml(title) + '</caption><thead><tr>' + headers.map((item) =>
|
|
217
|
+
'<th scope="col">' + escapeHtml(text(item, 'data-table header', 200)) + '</th>').join('') + '</tr></thead><tbody>' +
|
|
218
|
+
rows.map((row) => '<tr>' + row.map((cell) => '<td>' + escapeHtml(text(cell, 'data-table cell', 1000)) + '</td>').join('') + '</tr>').join('') +
|
|
219
|
+
'</tbody></table></div>';
|
|
220
|
+
}
|
|
221
|
+
case 'list': {
|
|
222
|
+
const items = list(block.items, 'list.items', 1, 100);
|
|
223
|
+
const title = text(block.title, 'list.title', 200, true);
|
|
224
|
+
const ordered = block.ordered === true;
|
|
225
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
226
|
+
return '<div class="list-widget">' + (title ? '<h3>' + escapeHtml(title) + '</h3>' : '') + '<' + tag + '>' +
|
|
227
|
+
items.map((item) => '<li>' + escapeHtml(text(item, 'list item', 1000)) + '</li>').join('') + '</' + tag + '></div>';
|
|
228
|
+
}
|
|
229
|
+
case 'notice': {
|
|
230
|
+
const heading = text(block.title, 'notice.title', 200, true);
|
|
231
|
+
const message = escapeHtml(text(block.text, 'notice.text', 2000));
|
|
232
|
+
const tone = block.tone ?? 'info';
|
|
233
|
+
if (!['info', 'warning', 'success'].includes(tone)) throw new Error('notice.tone must be info, warning, or success');
|
|
234
|
+
return '<aside class="notice-widget notice-widget--' + tone + '" role="note">' + (heading ? '<h3>' + escapeHtml(heading) + '</h3>' : '') + '<p>' + message + '</p></aside>';
|
|
235
|
+
}
|
|
236
|
+
case 'privacy-services': {
|
|
237
|
+
const emptyText = text(block.emptyText, 'privacy-services.emptyText', 2000);
|
|
238
|
+
return '<div class="privacy-services-widget">' + renderPrivacyServices({ ...context, emptyText }) + '</div>';
|
|
239
|
+
}
|
|
240
|
+
case 'privacy-consent': {
|
|
241
|
+
const storageLabel = escapeHtml(text(block.storageLabel, 'privacy-consent.storageLabel', 120));
|
|
242
|
+
const expiryLabel = escapeHtml(text(block.expiryLabel, 'privacy-consent.expiryLabel', 120));
|
|
243
|
+
const versionLabel = escapeHtml(text(block.versionLabel, 'privacy-consent.versionLabel', 120));
|
|
244
|
+
const expiryText = text(block.expiryText, 'privacy-consent.expiryText', 120);
|
|
245
|
+
if (!expiryText.includes('{days}')) throw new Error('privacy-consent.expiryText must include a {days} placeholder');
|
|
246
|
+
const consent = context.config.browserPlugins.consent;
|
|
247
|
+
return '<dl class="privacy-controller-details"><div><dt>' + storageLabel + '</dt><dd>edgepress-privacy-choice</dd></div>' +
|
|
248
|
+
'<div><dt>' + expiryLabel + '</dt><dd>' + escapeHtml(expiryText.replace('{days}', String(consent.expiresDays))) + '</dd></div>' +
|
|
249
|
+
'<div><dt>' + versionLabel + '</dt><dd>' + escapeHtml(consent.version) + '</dd></div></dl>';
|
|
250
|
+
}
|
|
251
|
+
case 'privacy-controller': {
|
|
252
|
+
const controller = context.config.privacy.controller;
|
|
253
|
+
const name = controller.name.trim();
|
|
254
|
+
const contact = controller.contact.trim();
|
|
255
|
+
const missingTitle = escapeHtml(text(block.missingTitle, 'privacy-controller.missingTitle', 200));
|
|
256
|
+
const missingText = escapeHtml(text(block.missingText, 'privacy-controller.missingText', 2000));
|
|
257
|
+
const nameLabel = escapeHtml(text(block.nameLabel, 'privacy-controller.nameLabel', 120));
|
|
258
|
+
const contactLabel = escapeHtml(text(block.contactLabel, 'privacy-controller.contactLabel', 120));
|
|
259
|
+
if (!name || !contact) {
|
|
260
|
+
return '<aside class="notice-widget notice-widget--warning"><h3>' + missingTitle + '</h3><p>' + missingText + '</p></aside>';
|
|
261
|
+
}
|
|
262
|
+
return '<dl class="privacy-controller-details"><div><dt>' + nameLabel + '</dt><dd>' + escapeHtml(name) +
|
|
263
|
+
'</dd></div><div><dt>' + contactLabel + '</dt><dd>' + escapeHtml(contact) + '</dd></div></dl>';
|
|
264
|
+
}
|
|
265
|
+
case 'feature-grid': {
|
|
266
|
+
const items = list(block.items, 'feature-grid.items', 1, 12);
|
|
267
|
+
const heading = text(block.title, 'feature-grid.title', 200);
|
|
268
|
+
const cells = items.map((item) => {
|
|
269
|
+
const title = escapeHtml(text(item?.title, 'feature title', 200));
|
|
270
|
+
const description = escapeHtml(text(item?.text, 'feature text', 1000, true));
|
|
271
|
+
const href = item?.url ? safeUrl(item.url, 'feature.url') : '';
|
|
272
|
+
return '<article class="feature-card"><h3>' + (href ? '<a href="' + escapeHtml(href) + '">' + title + '</a>' : title) + '</h3>' +
|
|
273
|
+
(description ? '<p>' + description + '</p>' : '') + '</article>';
|
|
274
|
+
}).join('');
|
|
275
|
+
return '<section class="feature-section"><h2>' + escapeHtml(heading) + '</h2><div class="feature-grid">' + cells + '</div></section>';
|
|
276
|
+
}
|
|
277
|
+
case 'image': {
|
|
278
|
+
const src = safeUrl(block.src, 'image.src');
|
|
279
|
+
const alt = text(block.alt, 'image.alt', 500);
|
|
280
|
+
const caption = text(block.caption, 'image.caption', 500, true);
|
|
281
|
+
return '<figure class="image-block"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(alt) + '" loading="lazy" decoding="async">' +
|
|
282
|
+
(caption ? '<figcaption>' + escapeHtml(caption) + '</figcaption>' : '') + '</figure>';
|
|
283
|
+
}
|
|
284
|
+
case 'quote': {
|
|
285
|
+
const quote = escapeHtml(text(block.text, 'quote.text', 4000));
|
|
286
|
+
const attribution = text(block.attribution, 'quote.attribution', 240, true);
|
|
287
|
+
return '<figure class="quote-block"><blockquote><p>' + quote + '</p></blockquote>' + (attribution ? '<figcaption>' + escapeHtml(attribution) + '</figcaption>' : '') + '</figure>';
|
|
288
|
+
}
|
|
289
|
+
case 'cta': {
|
|
290
|
+
const title = escapeHtml(text(block.title, 'cta.title', 240));
|
|
291
|
+
const description = text(block.text, 'cta.text', 1000, true);
|
|
292
|
+
const label = escapeHtml(text(block.label, 'cta.label', 120));
|
|
293
|
+
const url = escapeHtml(safeUrl(block.url, 'cta.url'));
|
|
294
|
+
return '<section class="cta-block"><h2>' + title + '</h2>' + (description ? '<p>' + escapeHtml(description) + '</p>' : '') +
|
|
295
|
+
'<p><a class="button" href="' + url + '">' + label + '</a></p></section>';
|
|
296
|
+
}
|
|
297
|
+
case 'faq': {
|
|
298
|
+
const items = list(block.items, 'faq.items', 1, 30);
|
|
299
|
+
const title = escapeHtml(text(block.title, 'faq.title', 200));
|
|
300
|
+
return '<section class="faq-block"><h2>' + title + '</h2>' + items.map((item) => '<details><summary>' +
|
|
301
|
+
escapeHtml(text(item?.question, 'faq.question', 500)) + '</summary><p>' + escapeHtml(text(item?.answer, 'faq.answer', 4000)) + '</p></details>').join('') + '</section>';
|
|
302
|
+
}
|
|
303
|
+
case 'latest-posts': return renderLatestPosts(block, context);
|
|
304
|
+
case 'ad-slot': {
|
|
305
|
+
const integration = enabledIntegration(context.config, block.integration, 'advertising');
|
|
306
|
+
return '<div class="ad-slot" data-edgepress-ad="' + escapeHtml(integration.id) + '" aria-label="' + escapeHtml(text(block.label, 'ad-slot.label', 120)) + '"></div>';
|
|
307
|
+
}
|
|
308
|
+
case 'captcha': {
|
|
309
|
+
if (!captchaProviders.has(block.provider)) throw new Error('captcha.provider must be a supported consent-gated provider');
|
|
310
|
+
const integration = context.config.browserPlugins.captcha.enabled && context.config.browserPlugins.captcha.services.find((item) => item.provider === block.provider);
|
|
311
|
+
if (!integration) throw new Error('captcha block needs a configured consent-gated integration: ' + block.provider);
|
|
312
|
+
const siteKey = integration.siteKey;
|
|
313
|
+
return '<div class="captcha-block" data-edgepress-captcha="' + escapeHtml(block.provider) + '" data-sitekey="' + escapeHtml(siteKey) + '" aria-label="' + escapeHtml(text(block.label, 'captcha.label', 120)) + '"></div>';
|
|
314
|
+
}
|
|
315
|
+
default: throw new Error('Unsupported page block type: ' + type);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function renderElements(blocks, context, depth = 0) {
|
|
320
|
+
if (!Array.isArray(blocks) || blocks.length > 100) throw new Error('Page blocks must be an array with at most 100 items');
|
|
321
|
+
const rendered = [];
|
|
322
|
+
for (let index = 0; index < blocks.length; index += 1) {
|
|
323
|
+
context.blockCount = (context.blockCount || 0) + 1;
|
|
324
|
+
if (context.blockCount > 200) throw new Error('A page may contain at most 200 blocks including nested blocks');
|
|
325
|
+
rendered.push(await renderBlock(blocks[index], context, depth, index));
|
|
326
|
+
}
|
|
327
|
+
return rendered.join('\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function renderBlocks(rows, context) {
|
|
331
|
+
if (!Array.isArray(rows) || rows.length > 100) throw new Error('Page layout needs an array of at most 100 rows');
|
|
332
|
+
const rendered = [];
|
|
333
|
+
for (const row of rows) {
|
|
334
|
+
if (!row || typeof row !== 'object' || Array.isArray(row) || 'type' in row) {
|
|
335
|
+
throw new Error('Page layout rows start with a columns count; put element types inside cells');
|
|
336
|
+
}
|
|
337
|
+
for (const key of Object.keys(row)) if (!['columns', 'cells'].includes(key)) throw new Error('Unsupported page row option: ' + key);
|
|
338
|
+
const columns = row.columns;
|
|
339
|
+
if (!Number.isInteger(columns) || columns < 1 || columns > 4) throw new Error('Each page row needs a columns count from 1 to 4');
|
|
340
|
+
if (!Array.isArray(row.cells) || row.cells.length !== columns) throw new Error('Each row needs one cells entry per configured column');
|
|
341
|
+
context.blockCount = (context.blockCount || 0) + 1;
|
|
342
|
+
if (context.blockCount > 200) throw new Error('A page may contain at most 200 blocks including nested blocks');
|
|
343
|
+
const cells = await Promise.all(row.cells.map(async (blocks, index) => {
|
|
344
|
+
if (!Array.isArray(blocks) || !blocks.length) throw new Error('Every page row cell needs at least one element block');
|
|
345
|
+
return '<div class="page-builder-cell" data-cell="' + (index + 1) + '">' + await renderElements(blocks, context, 1) + '</div>';
|
|
346
|
+
}));
|
|
347
|
+
rendered.push('<div class="page-builder-row" data-columns="' + columns + '">' + cells.join('') + '</div>');
|
|
348
|
+
}
|
|
349
|
+
return rendered.join('\n');
|
|
350
|
+
}
|