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.
Files changed (80) hide show
  1. package/README.md +70 -0
  2. package/config.yml +146 -0
  3. package/content/pages/home/en.md +84 -0
  4. package/content/pages/home/zh-cn.md +84 -0
  5. package/content/pages/plugin-development/en.md +108 -0
  6. package/content/pages/plugin-development/zh-cn.md +108 -0
  7. package/content/pages/privacy-policy/en.md +73 -0
  8. package/content/pages/privacy-policy/zh-cn.md +73 -0
  9. package/content/pages/project-introduction/en.md +88 -0
  10. package/content/pages/project-introduction/zh-cn.md +88 -0
  11. package/content/pages/quick-start/en.md +147 -0
  12. package/content/pages/quick-start/zh-cn.md +147 -0
  13. package/content/pages/theme-development/en.md +106 -0
  14. package/content/pages/theme-development/zh-cn.md +106 -0
  15. package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +169 -0
  16. package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +169 -0
  17. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +30 -0
  18. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +30 -0
  19. package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +28 -0
  20. package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +28 -0
  21. package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +39 -0
  22. package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +39 -0
  23. package/edgepress.config.mjs +18 -0
  24. package/languages/base/en.json +55 -0
  25. package/languages/packs/zh-CN.json +55 -0
  26. package/package.json +59 -0
  27. package/plugins/consent/index.js +40 -0
  28. package/project-compatibility.json +48 -0
  29. package/src/assets.js +181 -0
  30. package/src/build.js +277 -0
  31. package/src/cli.js +344 -0
  32. package/src/concurrency.js +26 -0
  33. package/src/config.js +396 -0
  34. package/src/content.js +160 -0
  35. package/src/generators.js +263 -0
  36. package/src/i18n.js +64 -0
  37. package/src/maintenance.js +122 -0
  38. package/src/markdown.js +65 -0
  39. package/src/page-blocks.js +350 -0
  40. package/src/page-check.js +416 -0
  41. package/src/plugin-api.js +75 -0
  42. package/src/preview.js +167 -0
  43. package/src/report-visuals.js +480 -0
  44. package/src/theme.js +161 -0
  45. package/src/worker.js +139 -0
  46. package/static/.gitkeep +0 -0
  47. package/static/edgepress/language-select.js +5 -0
  48. package/static/edgepress/plugins/advertising/google-adsense.js +26 -0
  49. package/static/edgepress/plugins/captcha/cloudflare-turnstile.js +28 -0
  50. package/static/edgepress/plugins/captcha/google-recaptcha.js +27 -0
  51. package/static/edgepress/plugins/captcha/hcaptcha.js +27 -0
  52. package/static/edgepress/plugins/consent/manager.js +180 -0
  53. package/static/edgepress/plugins/statistics/baidu-tongji.js +8 -0
  54. package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +9 -0
  55. package/static/edgepress/plugins/statistics/google-analytics.js +13 -0
  56. package/static/edgepress/plugins/tracking/google-tag-manager.js +10 -0
  57. package/static/edgepress/plugins/tracking/meta-pixel.js +18 -0
  58. package/static/edgepress/search.js +84 -0
  59. package/static/edgepress-markdown-guide.svg +21 -0
  60. package/templates/gitignore +6 -0
  61. package/templates/site-readme.md +20 -0
  62. package/themes/atelier/assets/style.css +120 -0
  63. package/themes/atelier/layouts/layout.html +17 -0
  64. package/themes/atelier/layouts/partials/footer.html +4 -0
  65. package/themes/atelier/layouts/partials/head.html +17 -0
  66. package/themes/atelier/layouts/partials/header.html +5 -0
  67. package/themes/atelier/theme.json +6 -0
  68. package/themes/default/assets/style.css +157 -0
  69. package/themes/default/layouts/layout.html +12 -0
  70. package/themes/default/layouts/partials/footer.html +4 -0
  71. package/themes/default/layouts/partials/head.html +19 -0
  72. package/themes/default/layouts/partials/header.html +5 -0
  73. package/themes/default/theme.json +6 -0
  74. package/themes/signal/assets/style.css +113 -0
  75. package/themes/signal/layouts/layout.html +12 -0
  76. package/themes/signal/layouts/partials/footer.html +4 -0
  77. package/themes/signal/layouts/partials/head.html +17 -0
  78. package/themes/signal/layouts/partials/header.html +5 -0
  79. package/themes/signal/theme.json +6 -0
  80. package/wrangler.jsonc +13 -0
package/src/worker.js ADDED
@@ -0,0 +1,139 @@
1
+ const MAX_BACKEND_BODY_BYTES = 1_000_000;
2
+
3
+ const json = (data, status = 200, headers = {}) => Response.json(data, {
4
+ status,
5
+ headers: { 'Cache-Control': 'no-store', ...headers }
6
+ });
7
+
8
+ async function readBoundedBody(body, limit) {
9
+ const reader = body.getReader();
10
+ const chunks = [];
11
+ let size = 0;
12
+ try {
13
+ while (true) {
14
+ const { done, value } = await reader.read();
15
+ if (done) break;
16
+ size += value.byteLength;
17
+ if (size > limit) {
18
+ await reader.cancel();
19
+ return null;
20
+ }
21
+ chunks.push(value);
22
+ }
23
+ } finally {
24
+ reader.releaseLock();
25
+ }
26
+
27
+ const result = new Uint8Array(size);
28
+ let offset = 0;
29
+ for (const chunk of chunks) {
30
+ result.set(chunk, offset);
31
+ offset += chunk.byteLength;
32
+ }
33
+ return result;
34
+ }
35
+
36
+ async function noStore(response) {
37
+ const headers = new Headers(response.headers);
38
+ headers.set('Cache-Control', 'no-store');
39
+ headers.set('X-Content-Type-Options', 'nosniff');
40
+ return new Response(response.body, {
41
+ status: response.status,
42
+ statusText: response.statusText,
43
+ headers
44
+ });
45
+ }
46
+
47
+ function backendEndpoint(baseValue, requestUrl) {
48
+ let endpoint;
49
+ try { endpoint = new URL(baseValue); } catch { return null; }
50
+ const localHttp = endpoint.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(endpoint.hostname);
51
+ if ((!['https:', 'http:'].includes(endpoint.protocol) || (endpoint.protocol !== 'https:' && !localHttp)) ||
52
+ endpoint.username || endpoint.password || endpoint.search || endpoint.hash) return null;
53
+ const incoming = new URL(requestUrl);
54
+ const basePath = endpoint.pathname.replace(/\/+$/, '');
55
+ endpoint.pathname = basePath + incoming.pathname;
56
+ endpoint.search = incoming.search;
57
+ endpoint.hash = '';
58
+ return endpoint;
59
+ }
60
+
61
+ async function readApiBody(request) {
62
+ if (!request.body) return new Uint8Array();
63
+ const declaredLength = request.headers.get('content-length');
64
+ if (declaredLength !== null) {
65
+ const length = Number(declaredLength);
66
+ if (!Number.isSafeInteger(length) || length < 0) return { error: 'invalid_content_length', status: 400 };
67
+ if (length > MAX_BACKEND_BODY_BYTES) return { error: 'payload_too_large', status: 413 };
68
+ }
69
+ const body = await readBoundedBody(request.body, MAX_BACKEND_BODY_BYTES);
70
+ return body === null ? { error: 'payload_too_large', status: 413 } : body;
71
+ }
72
+
73
+ async function proxyConfiguredBackend(request, env, requestUrl) {
74
+ const endpoint = backendEndpoint(env.BACKEND_URL, requestUrl);
75
+ if (!endpoint || typeof env.BACKEND_TOKEN !== 'string' || !env.BACKEND_TOKEN.trim()) {
76
+ return json({ error: 'backend_unavailable' }, 503);
77
+ }
78
+ const body = await readApiBody(request);
79
+ if (body?.error) return json({ error: body.error }, body.status);
80
+ const headers = new Headers();
81
+ for (const name of ['accept', 'accept-language', 'content-type', 'if-match', 'if-none-match']) {
82
+ const value = request.headers.get(name);
83
+ if (value !== null) headers.set(name, value);
84
+ }
85
+ headers.set('authorization', 'Bearer ' + env.BACKEND_TOKEN);
86
+ const upstream = new Request(endpoint, {
87
+ method: request.method,
88
+ headers,
89
+ body: request.method === 'GET' ? undefined : body,
90
+ redirect: 'manual'
91
+ });
92
+ return await noStore(await fetch(upstream));
93
+ }
94
+
95
+ export default {
96
+ async fetch(request, env) {
97
+ const url = new URL(request.url);
98
+ if (url.pathname === '/api/health') {
99
+ if (request.method !== 'GET') {
100
+ return json({ error: 'method_not_allowed' }, 405, { Allow: 'GET' });
101
+ }
102
+ return json({ status: 'ok', service: 'edgepress' });
103
+ }
104
+
105
+ if (url.pathname.startsWith('/api/v1/')) {
106
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) {
107
+ return json({ error: 'method_not_allowed' }, 405, { Allow: 'GET, POST, PUT, PATCH, DELETE' });
108
+ }
109
+ if (request.body && request.method === 'GET') return json({ error: 'body_not_allowed' }, 400);
110
+ if (env.BACKEND) {
111
+ const body = await readApiBody(request);
112
+ if (body?.error) return json({ error: body.error }, body.status);
113
+ const backendRequest = new Request(request.url, {
114
+ method: request.method,
115
+ headers: request.headers,
116
+ body: request.body ? body : undefined
117
+ });
118
+ return await noStore(await env.BACKEND.fetch(backendRequest));
119
+ }
120
+ if (env.BACKEND_URL || env.BACKEND_TOKEN) return await proxyConfiguredBackend(request, env, request.url);
121
+ return json({ error: 'backend_unavailable' }, 503);
122
+ }
123
+
124
+ if (url.pathname.startsWith('/api/')) return json({ error: 'not_found' }, 404);
125
+ const assetResponse = await env.ASSETS.fetch(request);
126
+ if (assetResponse.status !== 403 || /(?:^|\/)403\.html$/i.test(url.pathname)) return assetResponse;
127
+ const locale = url.pathname.match(/^\/([A-Za-z0-9-]+)\//)?.[1];
128
+ const errorPaths = [locale ? '/' + locale + '/403.html' : '', '/403.html'].filter(Boolean);
129
+ for (const path of [...new Set(errorPaths)]) {
130
+ const errorPage = await env.ASSETS.fetch(new Request(new URL(path, request.url)));
131
+ if (!errorPage.ok) continue;
132
+ const headers = new Headers(errorPage.headers);
133
+ headers.set('Cache-Control', 'no-store');
134
+ headers.delete('Content-Length');
135
+ return new Response(errorPage.body, { status: 403, headers });
136
+ }
137
+ return assetResponse;
138
+ }
139
+ };
File without changes
@@ -0,0 +1,5 @@
1
+ for (const select of document.querySelectorAll('[data-language-select]')) {
2
+ select.addEventListener('change', () => {
3
+ if (select.value) window.location.assign(select.value);
4
+ });
5
+ }
@@ -0,0 +1,26 @@
1
+ export function load(integration) {
2
+ const slots = [...document.querySelectorAll('[data-edgepress-ad="' + CSS.escape(integration.id) + '"]')];
3
+ if (!slots.length) return;
4
+ if (!window.__edgepressAdsenseLoaded) {
5
+ window.__edgepressAdsenseLoaded = true;
6
+ const script = document.createElement('script');
7
+ script.async = true;
8
+ script.crossOrigin = 'anonymous';
9
+ script.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=' + encodeURIComponent(integration.clientId);
10
+ script.dataset.edgepressProvider = 'google-adsense';
11
+ document.head.append(script);
12
+ }
13
+ for (const slot of slots) {
14
+ if (slot.dataset.loaded) continue;
15
+ slot.dataset.loaded = 'true';
16
+ const ad = document.createElement('ins');
17
+ ad.className = 'adsbygoogle';
18
+ ad.style.display = 'block';
19
+ ad.dataset.adClient = integration.clientId;
20
+ ad.dataset.adFormat = 'auto';
21
+ ad.dataset.fullWidthResponsive = 'true';
22
+ slot.append(ad);
23
+ window.adsbygoogle = window.adsbygoogle || [];
24
+ window.adsbygoogle.push({});
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ let loading;
2
+
3
+ export async function load(integration) {
4
+ if (!document.querySelector('[data-edgepress-captcha="cloudflare-turnstile"]')) return;
5
+ await (loading ||= loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit', 'cloudflare-turnstile'));
6
+ for (const element of document.querySelectorAll('[data-edgepress-captcha="cloudflare-turnstile"]')) {
7
+ if (element.dataset.rendered || !window.turnstile) continue;
8
+ element.dataset.rendered = 'true';
9
+ window.turnstile.render(element, {
10
+ sitekey: element.dataset.sitekey || integration.siteKey,
11
+ callback: (token) => element.dispatchEvent(new CustomEvent('edgepress:captcha-success', { bubbles: true, detail: { token } })),
12
+ 'error-callback': () => { element.dataset.rendered = ''; }
13
+ });
14
+ }
15
+ }
16
+
17
+ function loadScript(src, name) {
18
+ return new Promise((resolve, reject) => {
19
+ if (window.turnstile) return resolve();
20
+ const script = document.createElement('script');
21
+ script.async = true;
22
+ script.src = src;
23
+ script.dataset.edgepressProvider = name;
24
+ script.onload = resolve;
25
+ script.onerror = () => reject(new Error('Could not load CAPTCHA provider'));
26
+ document.head.append(script);
27
+ });
28
+ }
@@ -0,0 +1,27 @@
1
+ let loading;
2
+
3
+ export async function load(integration) {
4
+ if (!document.querySelector('[data-edgepress-captcha="google-recaptcha"]')) return;
5
+ await (loading ||= loadScript('https://www.google.com/recaptcha/api.js?render=explicit', 'google-recaptcha'));
6
+ for (const element of document.querySelectorAll('[data-edgepress-captcha="google-recaptcha"]')) {
7
+ if (element.dataset.rendered || !window.grecaptcha?.render) continue;
8
+ element.dataset.rendered = 'true';
9
+ window.grecaptcha.render(element, {
10
+ sitekey: element.dataset.sitekey || integration.siteKey,
11
+ callback: (token) => element.dispatchEvent(new CustomEvent('edgepress:captcha-success', { bubbles: true, detail: { token } }))
12
+ });
13
+ }
14
+ }
15
+
16
+ function loadScript(src, name) {
17
+ return new Promise((resolve, reject) => {
18
+ if (window.grecaptcha?.render) return resolve();
19
+ const script = document.createElement('script');
20
+ script.async = true;
21
+ script.src = src;
22
+ script.dataset.edgepressProvider = name;
23
+ script.onload = resolve;
24
+ script.onerror = () => reject(new Error('Could not load CAPTCHA provider'));
25
+ document.head.append(script);
26
+ });
27
+ }
@@ -0,0 +1,27 @@
1
+ let loading;
2
+
3
+ export async function load(integration) {
4
+ if (!document.querySelector('[data-edgepress-captcha="hcaptcha"]')) return;
5
+ await (loading ||= loadScript('https://js.hcaptcha.com/1/api.js?render=explicit', 'hcaptcha'));
6
+ for (const element of document.querySelectorAll('[data-edgepress-captcha="hcaptcha"]')) {
7
+ if (element.dataset.rendered || !window.hcaptcha?.render) continue;
8
+ element.dataset.rendered = 'true';
9
+ window.hcaptcha.render(element, {
10
+ sitekey: element.dataset.sitekey || integration.siteKey,
11
+ callback: (token) => element.dispatchEvent(new CustomEvent('edgepress:captcha-success', { bubbles: true, detail: { token } }))
12
+ });
13
+ }
14
+ }
15
+
16
+ function loadScript(src, name) {
17
+ return new Promise((resolve, reject) => {
18
+ if (window.hcaptcha?.render) return resolve();
19
+ const script = document.createElement('script');
20
+ script.async = true;
21
+ script.src = src;
22
+ script.dataset.edgepressProvider = name;
23
+ script.onload = resolve;
24
+ script.onerror = () => reject(new Error('Could not load CAPTCHA provider'));
25
+ document.head.append(script);
26
+ });
27
+ }
@@ -0,0 +1,180 @@
1
+ const modules = {
2
+ 'google-tag-manager': () => import('../tracking/google-tag-manager.js'),
3
+ 'meta-pixel': () => import('../tracking/meta-pixel.js'),
4
+ 'cloudflare-web-analytics': () => import('../statistics/cloudflare-web-analytics.js'),
5
+ 'google-analytics': () => import('../statistics/google-analytics.js'),
6
+ 'baidu-tongji': () => import('../statistics/baidu-tongji.js'),
7
+ 'google-adsense': () => import('../advertising/google-adsense.js'),
8
+ 'cloudflare-turnstile': () => import('../captcha/cloudflare-turnstile.js'),
9
+ 'google-recaptcha': () => import('../captcha/google-recaptcha.js'),
10
+ 'hcaptcha': () => import('../captcha/hcaptcha.js')
11
+ };
12
+
13
+ const storageKey = 'edgepress-privacy-choice';
14
+ const configElement = document.getElementById('edgepress-privacy-config');
15
+ if (configElement) {
16
+ try { initialize(JSON.parse(configElement.textContent)); }
17
+ catch { /* An invalid config does not enable any optional integration. */ }
18
+ }
19
+
20
+ function initialize(config) {
21
+ const privacy = config.privacy || {};
22
+ const integrations = Array.isArray(privacy.integrations) ? privacy.integrations : [];
23
+ const ui = config.ui || {};
24
+ const root = document.createElement('div');
25
+ root.className = 'privacy-manager';
26
+
27
+ const settingsButton = document.createElement('button');
28
+ settingsButton.type = 'button';
29
+ settingsButton.className = 'privacy-settings-button';
30
+ settingsButton.textContent = ui.privacySettings || 'Privacy settings';
31
+
32
+ const panel = document.createElement('section');
33
+ panel.className = 'privacy-panel';
34
+ panel.setAttribute('role', 'dialog');
35
+ panel.setAttribute('aria-modal', 'false');
36
+ panel.setAttribute('aria-labelledby', 'privacy-panel-title');
37
+ panel.lang = config.siteLanguage || document.documentElement.lang;
38
+ panel.hidden = true;
39
+
40
+ const heading = document.createElement('h2');
41
+ heading.id = 'privacy-panel-title';
42
+ heading.tabIndex = -1;
43
+ heading.textContent = ui.privacyNotice || 'Optional services';
44
+ const intro = document.createElement('p');
45
+ intro.textContent = ui.privacyIntro || 'Choose which optional services may load.';
46
+ const details = document.createElement('div');
47
+ details.className = 'privacy-details';
48
+ details.setAttribute('aria-label', ui.optionalServices || 'Optional services');
49
+ const actions = document.createElement('div');
50
+ actions.className = 'privacy-actions';
51
+ const close = makeButton(ui.closePrivacy || 'Close settings', 'privacy-close');
52
+
53
+ const accept = makeButton(ui.acceptOptional || 'Accept all', 'privacy-accept');
54
+ const reject = makeButton(ui.rejectOptional || 'Reject optional', 'privacy-reject');
55
+ const save = makeButton(ui.savePreferences || 'Save choices', 'privacy-save');
56
+ actions.append(accept, reject, save);
57
+ panel.append(close, heading, intro, details);
58
+ if (privacy.controller?.name || privacy.controller?.contact) {
59
+ const controller = document.createElement('p');
60
+ controller.className = 'privacy-controller';
61
+ controller.textContent = (ui.privacyController || 'Site operator') + ': ' + [privacy.controller.name, privacy.controller.contact].filter(Boolean).join(' · ');
62
+ panel.append(controller);
63
+ }
64
+ if (privacy.policyUrl) {
65
+ const policy = document.createElement('a');
66
+ policy.href = privacy.policyUrl;
67
+ policy.textContent = ui.privacyPolicy || 'Privacy policy';
68
+ policy.className = 'privacy-policy-link';
69
+ panel.append(policy);
70
+ }
71
+ panel.append(actions);
72
+ root.append(settingsButton, panel);
73
+ document.body.append(root);
74
+
75
+ const saved = readChoice(privacy);
76
+ const checkboxes = new Map();
77
+ for (const integration of integrations) {
78
+ const label = document.createElement('label');
79
+ label.className = 'privacy-service';
80
+ const checkbox = document.createElement('input');
81
+ checkbox.type = 'checkbox';
82
+ checkbox.value = integration.id;
83
+ checkbox.checked = saved?.allowed.includes(integration.id) || false;
84
+ const info = document.createElement('span');
85
+ const title = document.createElement('strong');
86
+ title.textContent = integration.provider;
87
+ const purpose = document.createElement('span');
88
+ purpose.textContent = (ui.servicePurpose || 'Purpose') + ': ' + integration.purpose;
89
+ info.append(title, document.createElement('br'), purpose);
90
+ if (integration.retention) {
91
+ const retention = document.createElement('span');
92
+ retention.textContent = ' · ' + (ui.serviceRetention || 'Retention') + ': ' + integration.retention;
93
+ info.append(retention);
94
+ }
95
+ label.append(checkbox, info);
96
+ details.append(label);
97
+ checkboxes.set(integration.id, checkbox);
98
+ }
99
+ if (!integrations.length) {
100
+ const empty = document.createElement('p');
101
+ empty.textContent = ui.noIntegrations || 'No optional services are configured.';
102
+ details.append(empty);
103
+ accept.disabled = true;
104
+ reject.disabled = true;
105
+ save.disabled = true;
106
+ }
107
+
108
+ settingsButton.addEventListener('click', () => {
109
+ panel.hidden = !panel.hidden;
110
+ if (!panel.hidden) heading.focus?.();
111
+ });
112
+ close.addEventListener('click', () => { panel.hidden = true; settingsButton.focus(); });
113
+ accept.addEventListener('click', () => saveChoice(integrations.map((item) => item.id)));
114
+ reject.addEventListener('click', () => saveChoice([]));
115
+ save.addEventListener('click', () => saveChoice([...checkboxes].filter(([, checkbox]) => checkbox.checked).map(([id]) => id)));
116
+
117
+ function saveChoice(allowed) {
118
+ const previous = readChoice(privacy);
119
+ const next = makeChoice(privacy, allowed);
120
+ persistChoice(next);
121
+ panel.hidden = true;
122
+ if (previous && previous.allowed.some((id) => !next.allowed.includes(id))) {
123
+ intro.textContent = ui.consentReload || 'Your choice was saved. Reloading to apply the updated settings.';
124
+ window.location.reload();
125
+ return;
126
+ }
127
+ activate(next.allowed);
128
+ }
129
+
130
+ if (saved) activate(saved.allowed);
131
+ else if (integrations.length) panel.hidden = false;
132
+
133
+ function makeButton(label, className) {
134
+ const button = document.createElement('button');
135
+ button.type = 'button';
136
+ button.className = className;
137
+ button.textContent = label;
138
+ return button;
139
+ }
140
+ }
141
+
142
+ function makeChoice(privacy, allowed) {
143
+ const expiresAt = Date.now() + Math.max(1, Math.min(730, privacy.consent?.expiresDays || 180)) * 86400000;
144
+ const fingerprint = JSON.stringify({ controller: privacy.controller, policyUrl: privacy.policyUrl, integrations: privacy.integrations });
145
+ return { version: privacy.consent?.version || '1', fingerprint, expiresAt, allowed: [...new Set(allowed)] };
146
+ }
147
+
148
+ function readChoice(privacy) {
149
+ try {
150
+ const saved = JSON.parse(localStorage.getItem(storageKey));
151
+ const fingerprint = JSON.stringify({ controller: privacy.controller, policyUrl: privacy.policyUrl, integrations: privacy.integrations });
152
+ if (!saved || saved.version !== (privacy.consent?.version || '1') || saved.fingerprint !== fingerprint || saved.expiresAt <= Date.now() || !Array.isArray(saved.allowed)) {
153
+ localStorage.removeItem(storageKey);
154
+ return null;
155
+ }
156
+ const validIds = new Set((privacy.integrations || []).map((item) => item.id));
157
+ saved.allowed = saved.allowed.filter((id) => validIds.has(id));
158
+ return saved;
159
+ } catch { return null; }
160
+ }
161
+
162
+ function persistChoice(choice) {
163
+ try { localStorage.setItem(storageKey, JSON.stringify(choice)); } catch { /* Session choice still applies in memory. */ }
164
+ }
165
+
166
+ async function activate(allowed) {
167
+ const config = JSON.parse(document.getElementById('edgepress-privacy-config').textContent);
168
+ const permitted = new Set(allowed);
169
+ for (const integration of config.privacy.integrations || []) {
170
+ if (!permitted.has(integration.id)) continue;
171
+ const loader = modules[integration.provider];
172
+ if (!loader) continue;
173
+ try {
174
+ const provider = await loader();
175
+ await provider.load(integration);
176
+ } catch (error) {
177
+ console.error('EdgePress optional integration failed to load:', integration.provider, error);
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,8 @@
1
+ export function load(integration) {
2
+ if (document.querySelector('script[data-edgepress-provider="baidu-tongji"]')) return;
3
+ const script = document.createElement('script');
4
+ script.async = true;
5
+ script.src = 'https://hm.baidu.com/hm.js?' + encodeURIComponent(integration.siteId);
6
+ script.dataset.edgepressProvider = 'baidu-tongji';
7
+ document.head.append(script);
8
+ }
@@ -0,0 +1,9 @@
1
+ export function load(integration) {
2
+ if (document.querySelector('script[data-edgepress-provider="cloudflare-web-analytics"]')) return;
3
+ const script = document.createElement('script');
4
+ script.async = true;
5
+ script.src = 'https://static.cloudflareinsights.com/beacon.min.js';
6
+ script.dataset.cfBeacon = JSON.stringify({ token: integration.token });
7
+ script.dataset.edgepressProvider = 'cloudflare-web-analytics';
8
+ document.head.append(script);
9
+ }
@@ -0,0 +1,13 @@
1
+ export function load(integration) {
2
+ if (window.__edgepressGaLoaded) return;
3
+ window.__edgepressGaLoaded = true;
4
+ window.dataLayer = window.dataLayer || [];
5
+ window.gtag = window.gtag || function gtag() { window.dataLayer.push(arguments); };
6
+ window.gtag('js', new Date());
7
+ window.gtag('config', integration.measurementId, { anonymize_ip: true });
8
+ const script = document.createElement('script');
9
+ script.async = true;
10
+ script.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(integration.measurementId);
11
+ script.dataset.edgepressProvider = 'google-analytics';
12
+ document.head.append(script);
13
+ }
@@ -0,0 +1,10 @@
1
+ export function load(service) {
2
+ if (document.querySelector('script[data-edgepress-provider="google-tag-manager"]')) return;
3
+ window.dataLayer = window.dataLayer || [];
4
+ window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' });
5
+ const script = document.createElement('script');
6
+ script.async = true;
7
+ script.src = 'https://www.googletagmanager.com/gtm.js?id=' + encodeURIComponent(service.containerId);
8
+ script.dataset.edgepressProvider = 'google-tag-manager';
9
+ document.head.append(script);
10
+ }
@@ -0,0 +1,18 @@
1
+ export function load(service) {
2
+ if (window.__edgepressMetaPixelLoaded) return;
3
+ window.__edgepressMetaPixelLoaded = true;
4
+ window.fbq = window.fbq || function fbq() {
5
+ if (window.fbq.callMethod) window.fbq.callMethod.apply(window.fbq, arguments);
6
+ else window.fbq.queue.push(arguments);
7
+ };
8
+ window.fbq.queue = window.fbq.queue || [];
9
+ window.fbq.loaded = true;
10
+ window.fbq.version = '2.0';
11
+ window.fbq('init', service.pixelId);
12
+ window.fbq('track', 'PageView');
13
+ const script = document.createElement('script');
14
+ script.async = true;
15
+ script.src = 'https://connect.facebook.net/en_US/fbevents.js';
16
+ script.dataset.edgepressProvider = 'meta-pixel';
17
+ document.head.append(script);
18
+ }
@@ -0,0 +1,84 @@
1
+ const form = document.querySelector('[data-edgepress-search]');
2
+
3
+ if (form) {
4
+ const input = form.querySelector('input[type="search"]');
5
+ const status = document.getElementById('edgepress-search-status');
6
+ const results = document.getElementById('edgepress-search-results');
7
+ const language = document.documentElement.lang.toLowerCase();
8
+ const chinese = language.startsWith('zh');
9
+ let indexPromise;
10
+ let timer;
11
+
12
+ function loadIndex() {
13
+ if (!indexPromise) {
14
+ indexPromise = fetch(form.dataset.index, { credentials: 'same-origin' }).then((response) => {
15
+ if (!response.ok) throw new Error('Search index request failed');
16
+ return response.json();
17
+ }).then((items) => items.filter((item) => item.type === 'post'));
18
+ }
19
+ return indexPromise;
20
+ }
21
+
22
+ function resultCard(item) {
23
+ const entry = document.createElement('li');
24
+ const article = document.createElement('article');
25
+ const heading = document.createElement('h2');
26
+ const link = document.createElement('a');
27
+ link.href = item.url;
28
+ link.textContent = item.title;
29
+ heading.append(link);
30
+ article.append(heading);
31
+ if (item.date) {
32
+ const time = document.createElement('time');
33
+ time.dateTime = item.date;
34
+ time.textContent = new Intl.DateTimeFormat(language, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(item.date));
35
+ article.append(time);
36
+ }
37
+ const summary = document.createElement('p');
38
+ summary.textContent = item.summary || '';
39
+ article.append(summary);
40
+ entry.append(article);
41
+ return entry;
42
+ }
43
+
44
+ async function search(value) {
45
+ const query = value.normalize('NFKC').toLocaleLowerCase(language).trim();
46
+ results.replaceChildren();
47
+ if (!query) {
48
+ status.textContent = chinese ? '输入搜索词以查找文章。' : 'Enter a search term to find articles.';
49
+ return;
50
+ }
51
+ status.textContent = chinese ? '正在搜索文章…' : 'Searching articles…';
52
+ try {
53
+ const terms = query.split(/\s+/u).filter(Boolean);
54
+ const items = await loadIndex();
55
+ const matches = items.filter((item) => {
56
+ const text = (item.title + ' ' + (item.content || '') + ' ' + (item.summary || ''))
57
+ .normalize('NFKC').toLocaleLowerCase(language);
58
+ return terms.every((term) => text.includes(term));
59
+ });
60
+ for (const item of matches) results.append(resultCard(item));
61
+ status.textContent = matches.length
62
+ ? (chinese ? `找到 ${matches.length} 篇文章。` : `${matches.length} matching article${matches.length === 1 ? '' : 's'}.`)
63
+ : (chinese ? '没有找到匹配的文章。' : 'No matching articles.');
64
+ } catch {
65
+ status.textContent = chinese ? '无法载入本地文章索引,请稍后重试。' : 'The local article index could not be loaded. Try again later.';
66
+ }
67
+ }
68
+
69
+ form.addEventListener('submit', (event) => {
70
+ event.preventDefault();
71
+ clearTimeout(timer);
72
+ void search(input.value);
73
+ });
74
+ input.addEventListener('input', () => {
75
+ clearTimeout(timer);
76
+ timer = setTimeout(() => void search(input.value), 140);
77
+ });
78
+
79
+ const initialQuery = new URLSearchParams(window.location.search).get('q');
80
+ if (initialQuery) {
81
+ input.value = initialQuery;
82
+ void search(initialQuery);
83
+ }
84
+ }
@@ -0,0 +1,21 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 320" role="img" aria-labelledby="title desc">
2
+ <title id="title">EdgePress Markdown publishing flow</title>
3
+ <desc id="desc">Markdown source passes through rendering and HTML sanitization to a published post.</desc>
4
+ <rect width="960" height="320" rx="24" fill="#f5f3ee"/>
5
+ <g font-family="system-ui, sans-serif" text-anchor="middle" fill="#24352d">
6
+ <rect x="44" y="86" width="230" height="148" rx="18" fill="#fff" stroke="#b8c6bc" stroke-width="3"/>
7
+ <text x="159" y="139" font-size="23" font-weight="700">Markdown source</text>
8
+ <text x="159" y="181" font-size="17">content/posts/en.md</text>
9
+ <text x="159" y="209" font-size="17">headings · lists · links</text>
10
+ <path d="M292 160h78m-16-16 16 16-16 16" fill="none" stroke="#426b57" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
11
+ <rect x="382" y="86" width="230" height="148" rx="18" fill="#e3ece5" stroke="#9db4a4" stroke-width="3"/>
12
+ <text x="497" y="139" font-size="23" font-weight="700">EdgePress build</text>
13
+ <text x="497" y="181" font-size="17">CommonMark + GFM</text>
14
+ <text x="497" y="209" font-size="17">safe HTML output</text>
15
+ <path d="M630 160h78m-16-16 16 16-16 16" fill="none" stroke="#426b57" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
16
+ <rect x="720" y="86" width="196" height="148" rx="18" fill="#fff" stroke="#b8c6bc" stroke-width="3"/>
17
+ <text x="818" y="139" font-size="23" font-weight="700">Published post</text>
18
+ <text x="818" y="181" font-size="17">accessible HTML</text>
19
+ <text x="818" y="209" font-size="17">static site output</text>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .edgepress/
4
+ .wrangler/
5
+ .dev.vars
6
+ *.tgz
@@ -0,0 +1,20 @@
1
+ # My EdgePress site
2
+
3
+ Static site source built with EdgePress. Use Node.js 22.12 or newer.
4
+
5
+ ## Start locally
6
+
7
+ npm install
8
+ edgepress server
9
+
10
+ Edit localized pages in `content/pages/` and Markdown posts in `content/posts/`. Choose a shared layout with `edgepress theme list` and `edgepress theme use <name>`.
11
+
12
+ ## Deploy to Cloudflare Workers
13
+
14
+ Push this source repository to GitHub, then connect the repository to Cloudflare Workers Builds. The `build` and `deploy` scripts in `package.json` generate the static files and deploy the Worker. Set a unique Worker name in `wrangler.jsonc` first.
15
+
16
+ For a local deployment, run `npx wrangler login`, then `edgepress deploy`.
17
+
18
+ ## Use another static web server
19
+
20
+ Run `edgepress generate` and upload the contents of `dist/` to the server document root. OpenResty and Nginx can serve the generated directories as static files. Worker API routes and service bindings need a Worker or an equivalent server-side route.