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,416 @@
|
|
|
1
|
+
import { access, lstat, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { addVisualEvidence } from './report-visuals.js';
|
|
4
|
+
|
|
5
|
+
async function htmlFiles(directory) {
|
|
6
|
+
let entries;
|
|
7
|
+
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
8
|
+
catch (error) { if (error.code === 'ENOENT') throw new Error('Build output is missing. Run npm run build first.'); throw error; }
|
|
9
|
+
const result = [];
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const file = resolve(directory, entry.name);
|
|
12
|
+
if (entry.isDirectory()) result.push(...await htmlFiles(file));
|
|
13
|
+
else if (entry.isFile() && extname(entry.name).toLowerCase() === '.html') result.push(file);
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function allFiles(directory) {
|
|
19
|
+
let entries;
|
|
20
|
+
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
21
|
+
catch { return []; }
|
|
22
|
+
const result = [];
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const file = resolve(directory, entry.name);
|
|
25
|
+
if (entry.isDirectory()) result.push(...await allFiles(file));
|
|
26
|
+
else if (entry.isFile()) result.push(file);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasAccessibleName(attributes, inner) {
|
|
32
|
+
if (/\baria-label\s*=\s*["'][^"']+\s*["']/i.test(attributes)) return true;
|
|
33
|
+
if (inner.replace(/<[^>]*>/g, '').replace(/ | /g, ' ').trim()) return true;
|
|
34
|
+
return /<img\b[^>]*\balt\s*=\s*["'][^"']+["']/i.test(inner);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function exists(path) {
|
|
38
|
+
try { await access(path); return true; }
|
|
39
|
+
catch { return false; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function contentDocuments(output, config) {
|
|
43
|
+
const documents = new Map();
|
|
44
|
+
for (const locale of config.i18n.locales) {
|
|
45
|
+
const prefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
|
|
46
|
+
let entries;
|
|
47
|
+
try { entries = JSON.parse(await readFile(resolve(output, prefix + 'search.json'), 'utf8')); }
|
|
48
|
+
catch { continue; }
|
|
49
|
+
if (!Array.isArray(entries)) continue;
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!['post', 'page'].includes(entry.type) || typeof entry.url !== 'string') continue;
|
|
52
|
+
let pathname;
|
|
53
|
+
try { pathname = decodeURIComponent(new URL(entry.url, 'https://edgepress.invalid').pathname); }
|
|
54
|
+
catch { continue; }
|
|
55
|
+
const segments = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
56
|
+
const target = resolve(output, ...segments, 'index.html');
|
|
57
|
+
documents.set(target, { type: entry.type, title: String(entry.title || ''), url: entry.url });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return documents;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function markdownFeatureCoverage(root, files, sourceByFile) {
|
|
64
|
+
const expected = [
|
|
65
|
+
['headings 2 through 6', /<h2\b[\s\S]*?<h3\b[\s\S]*?<h4\b[\s\S]*?<h5\b[\s\S]*?<h6\b/i],
|
|
66
|
+
['Setext heading syntax', /<h2\b[^>]*>Setext (?:heading level two|二级标题示例)<\/h2>/i],
|
|
67
|
+
['paragraphs and hard line breaks', /<p\b[\s\S]*?<br\s*\/?\s*>/i],
|
|
68
|
+
['hyphen, asterisk, and underscore thematic breaks', (html) => (html.match(/<hr\b/gi) || []).length >= 3],
|
|
69
|
+
['emphasis and strong emphasis', /<em\b[\s\S]*?<\/em>[\s\S]*?<strong\b[\s\S]*?<\/strong>/i],
|
|
70
|
+
['strikethrough', /<del\b[\s\S]*?<\/del>/i],
|
|
71
|
+
['inline and fenced code', /<code\b[\s\S]*?<\/code>[\s\S]*?<pre\b[\s\S]*?<code\b[^>]*class="language-js"/i],
|
|
72
|
+
['tilde-fenced and four-space-indented code', /class="language-text"[\s\S]*?<pre\b[^>]*><code[^>]*>const indented =/i],
|
|
73
|
+
['code spans containing a literal backtick', (html) => html.includes('<code>`</code>')],
|
|
74
|
+
['escaped Markdown punctuation and character references', /\\\*[\s\S]*?<[\s\S]*?&/i],
|
|
75
|
+
['inline and reference links', /href="\/quick-start\/"/i],
|
|
76
|
+
['GFM automatic links', /href="https:\/\/www\.markdownguide\.org\//i],
|
|
77
|
+
['angle-bracket, www, and email automatic links', /href="https:\/\/commonmark\.org"[\s\S]*?href="http:\/\/www\.example\.com"[\s\S]*?href="mailto:team@example\.com"/i],
|
|
78
|
+
['images with alternative text', /<img\b[^>]*src="\/edgepress-markdown-guide\.svg"[^>]*alt="[^"]+"/i],
|
|
79
|
+
['nested lists with inline formatting', /<li>[\s\S]*?<strong\b[\s\S]*?<ol\b[\s\S]*?<li>/i],
|
|
80
|
+
['task lists with accessible status names', /markdown-task-status" role="img" aria-label="[^"]+"/i],
|
|
81
|
+
['lists nested inside block quotes', /<blockquote>[\s\S]*?<ul\b[\s\S]*?<\/blockquote>/i],
|
|
82
|
+
['nested block quotes', /<blockquote>[\s\S]*?<blockquote>/i],
|
|
83
|
+
['tables and inline table syntax', /<table\b[\s\S]*?<th\b[\s\S]*?<em\b[\s\S]*?<\/table>/i],
|
|
84
|
+
['safe semantic inline HTML', /<kbd>Ctrl<\/kbd>/i],
|
|
85
|
+
['sanitization of active script markup', (html) => !/<script\b[^>]*>alert\(/i.test(html)]
|
|
86
|
+
];
|
|
87
|
+
const guides = files.filter((file) => /markdown-syntax-guide\/index\.html$/i.test(file.replace(/\\/g, '/')));
|
|
88
|
+
const documents = guides.map((file) => {
|
|
89
|
+
const html = sourceByFile.get(file) || '';
|
|
90
|
+
const content = html.match(/<div class="post-content">([\s\S]*?)<\/div>/i)?.[1] || '';
|
|
91
|
+
const checks = expected.map(([feature, matcher]) => ({ feature, passed: typeof matcher === 'function' ? matcher(content) : matcher.test(content) }));
|
|
92
|
+
return { path: relative(root, file).replace(/\\/g, '/'), checks, passed: checks.filter((item) => item.passed).length };
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
status: guides.length && documents.every((document) => document.checks.every((item) => item.passed)) ? 'pass' : 'fail',
|
|
96
|
+
checks: expected.length * Math.max(1, guides.length),
|
|
97
|
+
passed: documents.reduce((total, document) => total + document.passed, 0),
|
|
98
|
+
documents
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isInside(root, path) {
|
|
103
|
+
const rel = relative(root, path);
|
|
104
|
+
return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function resolveLocalLink(output, pageFile, href) {
|
|
108
|
+
let url;
|
|
109
|
+
try { url = new URL(href, 'https://edgepress.invalid/' + relative(output, pageFile).split(sep).join('/')); }
|
|
110
|
+
catch { return null; }
|
|
111
|
+
if (url.origin !== 'https://edgepress.invalid') return null;
|
|
112
|
+
let pathname;
|
|
113
|
+
try { pathname = decodeURIComponent(url.pathname); }
|
|
114
|
+
catch { return { broken: true, target: href }; }
|
|
115
|
+
const clean = pathname.replace(/^\/+/, '');
|
|
116
|
+
const candidates = [];
|
|
117
|
+
if (!clean || clean.endsWith('/')) candidates.push(resolve(output, clean, 'index.html'));
|
|
118
|
+
else if (extname(clean)) candidates.push(resolve(output, clean));
|
|
119
|
+
else candidates.push(resolve(output, clean + '.html'), resolve(output, clean, 'index.html'));
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (!candidate.startsWith(output + sep)) continue;
|
|
122
|
+
if (await exists(candidate)) return { file: candidate, fragment: url.hash.slice(1) };
|
|
123
|
+
}
|
|
124
|
+
return { broken: true, target: href };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function auditSummary(checks, findings) {
|
|
128
|
+
const errors = findings.filter((issue) => issue.severity === 'error').length;
|
|
129
|
+
const warnings = findings.length - errors;
|
|
130
|
+
return {
|
|
131
|
+
status: errors ? 'fail' : warnings ? 'warning' : 'pass',
|
|
132
|
+
score: checks ? Math.floor((checks - findings.length) / checks * 100) : 100,
|
|
133
|
+
checks,
|
|
134
|
+
errors,
|
|
135
|
+
warnings
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function markdownReport(report) {
|
|
140
|
+
const lines = [
|
|
141
|
+
'# EdgePress accessibility and agent-friendliness audit',
|
|
142
|
+
'',
|
|
143
|
+
'Overall status: **' + report.status + '**',
|
|
144
|
+
'',
|
|
145
|
+
'| Audit | Status | Score | Checks | Errors | Warnings |',
|
|
146
|
+
'| --- | --- | ---: | ---: | ---: | ---: |',
|
|
147
|
+
'| Accessibility | ' + report.accessibility.status + ' | ' + report.accessibility.score + '% | ' + report.accessibility.checks + ' | ' + report.accessibility.errors + ' | ' + report.accessibility.warnings + ' |',
|
|
148
|
+
'| Agent friendliness | ' + report.agentFriendliness.status + ' | ' + report.agentFriendliness.score + '% | ' + report.agentFriendliness.checks + ' | ' + report.agentFriendliness.errors + ' | ' + report.agentFriendliness.warnings + ' |',
|
|
149
|
+
'| Markdown rendering | ' + report.markdownRendering.status + ' | ' + Math.floor(report.markdownRendering.passed / Math.max(1, report.markdownRendering.checks) * 100) + '% | ' + report.markdownRendering.checks + ' | ' + (report.markdownRendering.checks - report.markdownRendering.passed) + ' | 0 |',
|
|
150
|
+
'| Report reading structure | ' + (report.visualEvidence?.reportAccessibility?.status || 'pending') + ' | ' +
|
|
151
|
+
Math.floor((report.visualEvidence?.reportAccessibility?.passed || 0) / Math.max(1, report.visualEvidence?.reportAccessibility?.checks || 0) * 100) + '% | ' +
|
|
152
|
+
(report.visualEvidence?.reportAccessibility?.checks || 0) + ' | ' +
|
|
153
|
+
((report.visualEvidence?.reportAccessibility?.checks || 0) - (report.visualEvidence?.reportAccessibility?.passed || 0)) + ' | 0 |',
|
|
154
|
+
'',
|
|
155
|
+
'Generated documents: ' + report.pages + ' | Posts: ' + report.contentSummary.posts + ' | Pages: ' + report.contentSummary.pages +
|
|
156
|
+
' | System routes: ' + report.contentSummary.system + ' | Errors: ' + report.errors + ' | Warnings: ' + report.warnings,
|
|
157
|
+
'HTML bytes: ' + report.metrics.htmlBytes + ' | Stylesheet references: ' + report.metrics.stylesheets +
|
|
158
|
+
' | Potentially render-blocking stylesheets: ' + report.metrics.blockingStylesheets +
|
|
159
|
+
' | Script references: ' + report.metrics.scripts + ' | Blocking scripts: ' + report.metrics.blockingScripts,
|
|
160
|
+
'',
|
|
161
|
+
report.visualEvidence?.status === 'complete'
|
|
162
|
+
? 'Accessible PDF evidence: [page-check.pdf](page-check.pdf) | HTML report: [page-check-visual.html](page-check-visual.html) | Screenshots: ' +
|
|
163
|
+
report.visualEvidence.screenshots.map((item) => '[' + item.title + ' · ' + item.viewport + ' (' + item.viewportWidth + '/' + item.documentWidth + ' px)](' + item.path.replace(/^tools\//, '') + ')').join(' · ')
|
|
164
|
+
: 'Visual evidence: ' + (report.visualEvidence?.reason || 'not generated'),
|
|
165
|
+
'',
|
|
166
|
+
'## Findings',
|
|
167
|
+
''
|
|
168
|
+
];
|
|
169
|
+
if (!report.issues.length) lines.push('No findings.');
|
|
170
|
+
for (const issue of report.issues) lines.push('- **' + issue.severity.toUpperCase() + ' · ' + issue.category + '** ' + issue.page + ' — ' + issue.message);
|
|
171
|
+
lines.push('', '## Markdown rendering', '', 'Verified ' + report.markdownRendering.passed + ' of ' + report.markdownRendering.checks +
|
|
172
|
+
' syntax combinations in the generated tutorial post.');
|
|
173
|
+
for (const document of report.markdownRendering.documents) {
|
|
174
|
+
lines.push('', '### ' + document.path, '', ...document.checks.map((item) => '- [' + (item.passed ? 'x' : ' ') + '] ' + item.feature));
|
|
175
|
+
}
|
|
176
|
+
lines.push('', '## Documents reviewed', '', '| Type | Count |', '| --- | ---: |', '| Posts | ' + report.contentSummary.posts + ' |',
|
|
177
|
+
'| Customizable pages and homepages | ' + report.contentSummary.pages + ' |', '| System routes | ' + report.contentSummary.system + ' |');
|
|
178
|
+
lines.push('', '## Scope', '', ...report.scope.map((item) => '- ' + item), '', 'This report is written outside the public output directory.');
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function checkPages(config) {
|
|
183
|
+
const output = config.resolvedPaths.output;
|
|
184
|
+
const files = await htmlFiles(output);
|
|
185
|
+
const contentMap = await contentDocuments(output, config);
|
|
186
|
+
const issues = [];
|
|
187
|
+
const auditFindings = { accessibility: [], agentFriendliness: [] };
|
|
188
|
+
const auditChecks = { accessibility: 0, agentFriendliness: 0 };
|
|
189
|
+
const pageIds = new Map();
|
|
190
|
+
const sourceByFile = new Map();
|
|
191
|
+
const metrics = { htmlBytes: 0, stylesheets: 0, blockingStylesheets: 0, scripts: 0, blockingScripts: 0 };
|
|
192
|
+
const inspect = (category, fails, severity, page, message) => {
|
|
193
|
+
auditChecks[category] += 1;
|
|
194
|
+
if (!fails) return;
|
|
195
|
+
const finding = { severity, category, page, message };
|
|
196
|
+
issues.push(finding);
|
|
197
|
+
auditFindings[category].push(finding);
|
|
198
|
+
};
|
|
199
|
+
const addFinding = (severity, category, page, message) => issues.push({ severity, category, page, message });
|
|
200
|
+
|
|
201
|
+
for (const file of files) {
|
|
202
|
+
const html = await readFile(file, 'utf8');
|
|
203
|
+
sourceByFile.set(file, html);
|
|
204
|
+
pageIds.set(file, new Set([...html.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1])));
|
|
205
|
+
metrics.htmlBytes += Buffer.byteLength(html);
|
|
206
|
+
}
|
|
207
|
+
const markdownRendering = markdownFeatureCoverage(output, files, sourceByFile);
|
|
208
|
+
if (!markdownRendering.documents.length) {
|
|
209
|
+
addFinding('error', 'markdown-rendering', 'content/posts', 'The Markdown syntax guide post was not generated.');
|
|
210
|
+
}
|
|
211
|
+
for (const document of markdownRendering.documents) {
|
|
212
|
+
for (const check of document.checks.filter((item) => !item.passed)) {
|
|
213
|
+
addFinding('error', 'markdown-rendering', document.path, 'Markdown syntax did not render: ' + check.feature + '.');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const file of files) {
|
|
217
|
+
const html = sourceByFile.get(file);
|
|
218
|
+
const page = relative(output, file).split(sep).join('/');
|
|
219
|
+
inspect('accessibility', !/<html\b[^>]*\blang\s*=\s*["'][^"']+["']/i.test(html), 'error', page, 'Missing document language (html lang).');
|
|
220
|
+
inspect('accessibility', !/<main\b/i.test(html), 'error', page, 'Missing main landmark.');
|
|
221
|
+
const h1Count = [...html.matchAll(/<h1\b/gi)].length;
|
|
222
|
+
inspect('accessibility', h1Count !== 1, 'warning', page, 'Expected exactly one h1 heading.');
|
|
223
|
+
inspect('agentFriendliness', h1Count !== 1, 'warning', page, 'A single page-level heading helps identify the page topic.');
|
|
224
|
+
for (const match of html.matchAll(/<nav\b([^>]*)>/gi)) {
|
|
225
|
+
inspect('accessibility', !/\baria-label\s*=\s*["'][^"']+/.test(match[1]), 'warning', page, 'Navigation landmark has no accessible label.');
|
|
226
|
+
}
|
|
227
|
+
inspect('agentFriendliness', !/<title>\s*[^<]+\s*<\/title>/i.test(html), 'error', page, 'Missing or empty page title.');
|
|
228
|
+
inspect('agentFriendliness', !/<meta\b[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["'][^"']+["']/i.test(html), 'warning', page, 'Missing meta description.');
|
|
229
|
+
const canonical = html.match(/<link\b[^>]*\brel\s*=\s*["']canonical["'][^>]*>/i)?.[0] ?? '';
|
|
230
|
+
const canonicalHref = canonical.match(/\bhref\s*=\s*["']([^"']*)["']/i)?.[1] ?? '';
|
|
231
|
+
inspect('agentFriendliness', !canonicalHref || (config.site.url && !canonicalHref.startsWith(config.site.url)), 'warning', page, 'Missing or non-canonical URL metadata.');
|
|
232
|
+
const jsonLd = html.match(/<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/i)?.[1];
|
|
233
|
+
let validStructuredData = false;
|
|
234
|
+
try { validStructuredData = Boolean(jsonLd && JSON.parse(jsonLd)?.['@context'] === 'https://schema.org'); } catch { validStructuredData = false; }
|
|
235
|
+
inspect('agentFriendliness', !validStructuredData, 'warning', page, 'Missing or invalid Schema.org JSON-LD.');
|
|
236
|
+
|
|
237
|
+
const ids = [...html.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1]);
|
|
238
|
+
inspect('accessibility', new Set(ids).size !== ids.length, 'warning', page, 'Duplicate HTML id values were found.');
|
|
239
|
+
let previousHeading = 0;
|
|
240
|
+
let skippedHeadingLevel = false;
|
|
241
|
+
for (const match of html.matchAll(/<h([1-6])\b/gi)) {
|
|
242
|
+
const level = Number(match[1]);
|
|
243
|
+
if (previousHeading && level > previousHeading + 1) skippedHeadingLevel = true;
|
|
244
|
+
previousHeading = level;
|
|
245
|
+
}
|
|
246
|
+
inspect('accessibility', skippedHeadingLevel, 'warning', page, 'Heading levels skip one or more steps.');
|
|
247
|
+
|
|
248
|
+
for (const match of html.matchAll(/<img\b([^>]*)>/gi)) {
|
|
249
|
+
inspect('accessibility', !/\balt\s*=/.test(match[1]), 'error', page, 'Image is missing an alt attribute.');
|
|
250
|
+
}
|
|
251
|
+
for (const match of html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
|
|
252
|
+
inspect('accessibility', !hasAccessibleName(match[1], match[2]), 'error', page, 'Link has no accessible name.');
|
|
253
|
+
if (/\btarget\s*=\s*["']_blank["']/i.test(match[1]) && !/\brel\s*=\s*["'][^"']*noopener/i.test(match[1])) {
|
|
254
|
+
inspect('accessibility', true, 'warning', page, 'A new-tab link should include rel="noopener".');
|
|
255
|
+
}
|
|
256
|
+
const href = match[1].match(/\bhref\s*=\s*["']([^"']+)\s*["']/i)?.[1];
|
|
257
|
+
if (!href || /^(?:mailto:|tel:|javascript:)/i.test(href)) continue;
|
|
258
|
+
const target = await resolveLocalLink(output, file, href);
|
|
259
|
+
if (target?.broken) {
|
|
260
|
+
inspect('accessibility', true, 'error', page, 'Broken internal link: ' + href);
|
|
261
|
+
inspect('agentFriendliness', true, 'error', page, 'Broken internal link: ' + href);
|
|
262
|
+
} else if (target?.file) {
|
|
263
|
+
inspect('agentFriendliness', false, 'warning', page, 'Internal link resolves: ' + href);
|
|
264
|
+
if (target.fragment && extname(target.file).toLowerCase() === '.html') {
|
|
265
|
+
let idsForTarget = pageIds.get(target.file);
|
|
266
|
+
if (!idsForTarget) {
|
|
267
|
+
const targetHtml = sourceByFile.get(target.file) ?? await readFile(target.file, 'utf8');
|
|
268
|
+
idsForTarget = new Set([...targetHtml.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map((item) => item[1]));
|
|
269
|
+
pageIds.set(target.file, idsForTarget);
|
|
270
|
+
}
|
|
271
|
+
inspect('accessibility', !idsForTarget.has(target.fragment), 'warning', page, 'Link fragment not found: ' + href);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
for (const match of html.matchAll(/<link\b([^>]*)>/gi)) {
|
|
277
|
+
if (!/\brel\s*=\s*["'][^"']*\bstylesheet\b[^"']*["']/i.test(match[1])) continue;
|
|
278
|
+
metrics.stylesheets += 1;
|
|
279
|
+
const media = match[1].match(/\bmedia\s*=\s*["']([^"']+)["']/i)?.[1].trim().toLowerCase();
|
|
280
|
+
if (!media || media === 'all') metrics.blockingStylesheets += 1;
|
|
281
|
+
const href = match[1].match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? '';
|
|
282
|
+
if (/\.css(?:[?#]|$)/i.test(href) && !/\.[a-f0-9]{16}\.css(?:[?#]|$)/i.test(href)) {
|
|
283
|
+
addFinding('warning', 'performance', page, 'Stylesheet URL is not content-fingerprinted: ' + href);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const match of html.matchAll(/<script\b([^>]*)>/gi)) {
|
|
287
|
+
if (!/\bsrc\s*=/.test(match[1])) continue;
|
|
288
|
+
metrics.scripts += 1;
|
|
289
|
+
if (!/\b(?:async|defer)\b/i.test(match[1]) && !/\btype\s*=\s*["']module["']/i.test(match[1])) {
|
|
290
|
+
metrics.blockingScripts += 1;
|
|
291
|
+
addFinding('warning', 'performance', page, 'External script may block rendering; use defer, async, or a module script.');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const all = await allFiles(output);
|
|
297
|
+
const hashedAssets = all.filter((file) => /\.[a-f0-9]{16}\.(?:css|js)$/i.test(file))
|
|
298
|
+
.map((file) => relative(output, file).split(sep).join('/'));
|
|
299
|
+
const styleFiles = all.filter((file) => extname(file).toLowerCase() === '.css');
|
|
300
|
+
if (styleFiles.length) {
|
|
301
|
+
const styles = (await Promise.all(styleFiles.map((file) => readFile(file, 'utf8')))).join('\n');
|
|
302
|
+
inspect('accessibility', !/:focus(?:-visible)?\s*\{[^}]*outline/i.test(styles), 'warning', 'theme', 'No visible keyboard focus outline was found in the generated CSS.');
|
|
303
|
+
} else {
|
|
304
|
+
inspect('accessibility', true, 'warning', 'theme', 'No stylesheet was found to inspect for keyboard focus styles.');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let robots = '';
|
|
308
|
+
let sitemap = '';
|
|
309
|
+
let llms = '';
|
|
310
|
+
try { robots = await readFile(resolve(output, 'robots.txt'), 'utf8'); } catch { /* Checked below. */ }
|
|
311
|
+
try { sitemap = await readFile(resolve(output, 'sitemap.xml'), 'utf8'); } catch { /* Checked below. */ }
|
|
312
|
+
inspect('agentFriendliness', !robots.includes('User-agent: *') || !robots.includes('Sitemap:'), 'warning', 'robots.txt', 'robots.txt should identify the sitemap and a default crawler rule.');
|
|
313
|
+
for (const crawler of config.site.agentSeo.enabled ? config.site.agentSeo.agentCrawlers : []) {
|
|
314
|
+
inspect('agentFriendliness', !robots.includes('User-agent: ' + crawler), 'warning', 'robots.txt', 'robots.txt does not include the configured agent crawler: ' + crawler);
|
|
315
|
+
}
|
|
316
|
+
inspect('agentFriendliness', !sitemap.includes('<urlset') || !sitemap.includes('<url>'), 'warning', 'sitemap.xml', 'The XML sitemap is missing or contains no URLs.');
|
|
317
|
+
if (config.site.agentSeo.enabled && config.site.agentSeo.llmsTxt) {
|
|
318
|
+
try { llms = await readFile(resolve(output, 'llms.txt'), 'utf8'); } catch { /* Checked below. */ }
|
|
319
|
+
inspect('agentFriendliness', !llms.startsWith('# ') || !llms.includes('## Project pages'), 'warning', 'llms.txt', 'The configured llms.txt discovery file is missing or incomplete.');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!config.site.url || new URL(config.site.url).hostname.endsWith('example.com')) {
|
|
323
|
+
addFinding('warning', 'publication-readiness', 'config.yml', 'Replace the example site URL with the published site URL.');
|
|
324
|
+
}
|
|
325
|
+
if (!config.privacy.controller.name.trim() || !config.privacy.controller.contact.trim()) {
|
|
326
|
+
addFinding('warning', 'publication-readiness', 'config.yml', 'Add the real site operator name and privacy contact before publishing the privacy policy.');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const accessibility = auditSummary(auditChecks.accessibility, auditFindings.accessibility);
|
|
330
|
+
const agentFriendliness = auditSummary(auditChecks.agentFriendliness, auditFindings.agentFriendliness);
|
|
331
|
+
const homepagePaths = new Set(config.i18n.locales.map((locale) => locale === config.i18n.defaultLocale ? 'index.html' : locale + '/index.html'));
|
|
332
|
+
const documents = files.map((file) => {
|
|
333
|
+
const path = relative(output, file).split(sep).join('/');
|
|
334
|
+
const metadata = contentMap.get(file);
|
|
335
|
+
const isHomepage = homepagePaths.has(path);
|
|
336
|
+
const type = metadata?.type || (isHomepage ? 'homepage' : 'system');
|
|
337
|
+
const documentIssues = issues.filter((issue) => issue.page === path);
|
|
338
|
+
return {
|
|
339
|
+
path,
|
|
340
|
+
type,
|
|
341
|
+
title: metadata?.title || sourceByFile.get(file).match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i)?.[1]?.replace(/<[^>]+>/g, '').trim() || path,
|
|
342
|
+
status: documentIssues.some((issue) => issue.severity === 'error') ? 'fail' :
|
|
343
|
+
documentIssues.length ? 'warning' : 'pass'
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
const contentSummary = {
|
|
347
|
+
posts: documents.filter((document) => document.type === 'post').length,
|
|
348
|
+
pages: documents.filter((document) => document.type === 'page' || document.type === 'homepage').length,
|
|
349
|
+
system: documents.filter((document) => document.type === 'system').length
|
|
350
|
+
};
|
|
351
|
+
const errors = issues.filter((issue) => issue.severity === 'error').length;
|
|
352
|
+
const warnings = issues.length - errors;
|
|
353
|
+
const report = {
|
|
354
|
+
generatedAt: new Date().toISOString(),
|
|
355
|
+
status: errors ? 'fail' : warnings ? 'warning' : 'pass',
|
|
356
|
+
pages: files.length,
|
|
357
|
+
contentSummary,
|
|
358
|
+
documents,
|
|
359
|
+
markdownRendering,
|
|
360
|
+
errors,
|
|
361
|
+
warnings,
|
|
362
|
+
accessibility,
|
|
363
|
+
agentFriendliness,
|
|
364
|
+
metrics,
|
|
365
|
+
hashedAssets,
|
|
366
|
+
issues,
|
|
367
|
+
scope: [
|
|
368
|
+
'Accessibility checks inspect generated HTML and CSS. They do not measure color contrast, screen-reader behavior, keyboard interactions in a browser, or dynamic vendor widgets.',
|
|
369
|
+
'Agent-friendliness checks inspect metadata, structured data, internal links, robots.txt, sitemap.xml, and llms.txt. They do not guarantee crawler indexing or answer quality.',
|
|
370
|
+
'This automated report is not a legal-compliance assessment or a substitute for manual review.',
|
|
371
|
+
'Screenshots capture generated local pages at desktop and mobile viewports. They do not replace manual keyboard, zoom, contrast, or assistive-technology review.'
|
|
372
|
+
]
|
|
373
|
+
};
|
|
374
|
+
const requestedDirectory = resolve(config.root, 'tools');
|
|
375
|
+
await mkdir(requestedDirectory, { recursive: true });
|
|
376
|
+
const directoryInfo = await lstat(requestedDirectory);
|
|
377
|
+
if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) {
|
|
378
|
+
throw new Error('Page-check report directory must be a real directory: ' + requestedDirectory);
|
|
379
|
+
}
|
|
380
|
+
const root = await realpath(config.root);
|
|
381
|
+
const directory = await realpath(requestedDirectory);
|
|
382
|
+
if (!isInside(root, directory)) throw new Error('Page-check report directory must stay inside the project root');
|
|
383
|
+
const jsonPath = resolve(directory, 'page-check.json');
|
|
384
|
+
const markdownPath = resolve(directory, 'page-check.md');
|
|
385
|
+
for (const file of [jsonPath, markdownPath]) {
|
|
386
|
+
try {
|
|
387
|
+
const info = await lstat(file);
|
|
388
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Refusing an unsafe page-check report path: ' + file);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error.code !== 'ENOENT') throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
for (const file of [resolve(directory, 'page-check.pdf'), resolve(directory, 'page-check-visual.html')]) {
|
|
394
|
+
try {
|
|
395
|
+
const info = await lstat(file);
|
|
396
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Refusing an unsafe visual report path: ' + file);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (error.code !== 'ENOENT') throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
report.visualEvidence = await addVisualEvidence(config, report);
|
|
403
|
+
if (report.visualEvidence.status !== 'complete') {
|
|
404
|
+
issues.push({ severity: 'warning', category: 'visual-evidence', page: 'report', message: report.visualEvidence.reason || 'Visual evidence was not generated.' });
|
|
405
|
+
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
report.visualEvidence = { status: 'failed', reason: error.message };
|
|
408
|
+
issues.push({ severity: 'warning', category: 'visual-evidence', page: 'report', message: 'Screenshot or PDF generation failed: ' + error.message });
|
|
409
|
+
}
|
|
410
|
+
report.errors = issues.filter((issue) => issue.severity === 'error').length;
|
|
411
|
+
report.warnings = issues.filter((issue) => issue.severity === 'warning').length;
|
|
412
|
+
report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
|
|
413
|
+
await writeFile(jsonPath, JSON.stringify(report, null, 2), 'utf8');
|
|
414
|
+
await writeFile(markdownPath, markdownReport(report), 'utf8');
|
|
415
|
+
return report;
|
|
416
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { renderMarkdown } from './markdown.js';
|
|
5
|
+
|
|
6
|
+
export async function createExtensions(config) {
|
|
7
|
+
const filters = new Map();
|
|
8
|
+
const generators = new Map();
|
|
9
|
+
const renderers = new Map([['.md', { render: (source, context) => {
|
|
10
|
+
const locale = context.document.locale;
|
|
11
|
+
const base = context.config.i18n.translationsByLocale?.[context.config.i18n.defaultLocale];
|
|
12
|
+
const selected = context.config.i18n.translationsByLocale?.[locale];
|
|
13
|
+
return renderMarkdown(source, {
|
|
14
|
+
...context.config.markdown,
|
|
15
|
+
taskLabels: {
|
|
16
|
+
complete: selected?.markdownTaskComplete ?? base?.markdownTaskComplete ?? 'Task complete',
|
|
17
|
+
incomplete: selected?.markdownTaskIncomplete ?? base?.markdownTaskIncomplete ?? 'Task incomplete'
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}, cacheable: true }]]);
|
|
21
|
+
|
|
22
|
+
function registerFilter(name, fn) {
|
|
23
|
+
if (typeof fn !== 'function') throw new TypeError('A filter must be a function');
|
|
24
|
+
const list = filters.get(name) ?? [];
|
|
25
|
+
list.push(fn);
|
|
26
|
+
filters.set(name, list);
|
|
27
|
+
}
|
|
28
|
+
function registerGenerator(name, fn) {
|
|
29
|
+
if (generators.has(name)) throw new Error('Generator already registered: ' + name);
|
|
30
|
+
if (typeof fn !== 'function') throw new TypeError('A generator must be a function');
|
|
31
|
+
generators.set(name, fn);
|
|
32
|
+
}
|
|
33
|
+
function registerRenderer(extension, fn) {
|
|
34
|
+
if (typeof fn !== 'function') throw new TypeError('A renderer must be a function');
|
|
35
|
+
const ext = extension.startsWith('.') ? extension.toLowerCase() : '.' + extension.toLowerCase();
|
|
36
|
+
renderers.set(ext, { render: fn, cacheable: false });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const api = { registerFilter, registerGenerator, registerRenderer };
|
|
40
|
+
const realRoot = await realpath(config.root);
|
|
41
|
+
for (const pluginPath of config.plugins) {
|
|
42
|
+
if (typeof pluginPath !== 'string' || !pluginPath.trim() || isAbsolute(pluginPath)) {
|
|
43
|
+
throw new Error('Each plugin must be a non-empty relative module path');
|
|
44
|
+
}
|
|
45
|
+
const pluginFile = resolve(config.root, pluginPath);
|
|
46
|
+
const pluginRelative = relative(config.root, pluginFile);
|
|
47
|
+
if (pluginRelative === '..' || pluginRelative.startsWith('..' + sep) || isAbsolute(pluginRelative)) {
|
|
48
|
+
throw new Error('Plugin paths must stay inside the project root: ' + pluginPath);
|
|
49
|
+
}
|
|
50
|
+
const realPluginFile = await realpath(pluginFile);
|
|
51
|
+
const realPluginRelative = relative(realRoot, realPluginFile);
|
|
52
|
+
if (realPluginRelative === '..' || realPluginRelative.startsWith('..' + sep) || isAbsolute(realPluginRelative)) {
|
|
53
|
+
throw new Error('Plugin paths must resolve inside the project root, including through symbolic links: ' + pluginPath);
|
|
54
|
+
}
|
|
55
|
+
const url = pathToFileURL(pluginFile).href;
|
|
56
|
+
const plugin = (await import(url)).default;
|
|
57
|
+
if (typeof plugin !== 'function') throw new Error('Plugin must have a default function export: ' + pluginPath);
|
|
58
|
+
await plugin(api);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
generators,
|
|
63
|
+
renderer(extension) {
|
|
64
|
+
return renderers.get(extension.toLowerCase()) ?? null;
|
|
65
|
+
},
|
|
66
|
+
async filter(name, value, context = {}) {
|
|
67
|
+
let current = value;
|
|
68
|
+
for (const fn of filters.get(name) ?? []) {
|
|
69
|
+
const next = await fn(current, context);
|
|
70
|
+
if (next !== undefined) current = next;
|
|
71
|
+
}
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/preview.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { access, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { buildSite } from './build.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { checkPages } from './page-check.js';
|
|
7
|
+
|
|
8
|
+
const watchedDirectories = ['content', 'languages', 'plugins', 'src', 'static', 'themes'];
|
|
9
|
+
const watchedFiles = ['config.yml', 'edgepress.config.mjs', 'wrangler.jsonc'];
|
|
10
|
+
const ignoredNames = new Set(['.edgepress', '.git', '.wrangler', 'dist', 'node_modules', 'tools']);
|
|
11
|
+
|
|
12
|
+
async function sourceSnapshot(root) {
|
|
13
|
+
const values = [];
|
|
14
|
+
for (const name of watchedFiles) {
|
|
15
|
+
try {
|
|
16
|
+
const info = await stat(resolve(root, name));
|
|
17
|
+
values.push(name + ':' + info.size + ':' + info.mtimeMs);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== 'ENOENT') throw error;
|
|
20
|
+
values.push(name + ':missing');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function visit(directory, relativePath) {
|
|
24
|
+
let entries;
|
|
25
|
+
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
26
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
27
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.isSymbolicLink() || ignoredNames.has(entry.name)) continue;
|
|
30
|
+
const file = resolve(directory, entry.name);
|
|
31
|
+
const relativeFile = relativePath ? relativePath + '/' + entry.name : entry.name;
|
|
32
|
+
if (entry.isDirectory()) await visit(file, relativeFile);
|
|
33
|
+
else if (entry.isFile()) {
|
|
34
|
+
const info = await stat(file);
|
|
35
|
+
values.push(relativeFile + ':' + info.size + ':' + info.mtimeMs);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const name of watchedDirectories) await visit(resolve(root, name), name);
|
|
40
|
+
return values.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cliArgs(args) {
|
|
44
|
+
const options = [...args];
|
|
45
|
+
if (!options.some((value) => value === '--ip' || value.startsWith('--ip='))) options.push('--ip', '127.0.0.1');
|
|
46
|
+
if (!options.some((value) => value === '--port' || value.startsWith('--port='))) options.push('--port', '8787');
|
|
47
|
+
return options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main() {
|
|
51
|
+
const config = await loadConfig();
|
|
52
|
+
const root = config.root;
|
|
53
|
+
const wrangler = resolve(root, 'node_modules/wrangler/bin/wrangler.js');
|
|
54
|
+
await access(wrangler);
|
|
55
|
+
|
|
56
|
+
async function rebuild() {
|
|
57
|
+
await buildSite(root, { preview: true });
|
|
58
|
+
const currentConfig = await loadConfig(root);
|
|
59
|
+
const report = await checkPages(currentConfig);
|
|
60
|
+
console.log('Accessibility and agent-friendliness report: ' + report.status +
|
|
61
|
+
' (' + report.accessibility.status + ', ' + report.agentFriendliness.status + ')');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await rebuild();
|
|
65
|
+
let snapshot = await sourceSnapshot(root);
|
|
66
|
+
const commandOffset = ['preview', 'dev', 'server'].includes(process.argv[2]) ? 3 : 2;
|
|
67
|
+
const args = [wrangler, 'dev', ...cliArgs(process.argv.slice(commandOffset))];
|
|
68
|
+
let child;
|
|
69
|
+
let stopped = false;
|
|
70
|
+
let restarting = false;
|
|
71
|
+
let scanning = false;
|
|
72
|
+
let rebuilding = false;
|
|
73
|
+
let rebuildAgain = false;
|
|
74
|
+
let debounce;
|
|
75
|
+
|
|
76
|
+
function startWrangler() {
|
|
77
|
+
if (stopped) return;
|
|
78
|
+
child = spawn(process.execPath, args, { cwd: root, env: process.env, stdio: 'inherit', windowsHide: true });
|
|
79
|
+
const started = child;
|
|
80
|
+
started.once('error', (error) => {
|
|
81
|
+
console.error('Could not start Wrangler preview: ' + error.message);
|
|
82
|
+
if (!restarting) stop();
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
});
|
|
85
|
+
started.once('exit', (code) => {
|
|
86
|
+
if (child === started) child = null;
|
|
87
|
+
if (stopped || restarting) return;
|
|
88
|
+
console.error('Wrangler preview exited' + (code === null ? '' : ' with code ' + code) + '.');
|
|
89
|
+
stop();
|
|
90
|
+
if (code !== 0) process.exitCode = code ?? 1;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function stopWrangler() {
|
|
95
|
+
const current = child;
|
|
96
|
+
child = null;
|
|
97
|
+
if (!current || current.exitCode !== null || current.signalCode !== null) return;
|
|
98
|
+
await new Promise((resolveExit) => {
|
|
99
|
+
const timeout = setTimeout(() => {
|
|
100
|
+
if (current.exitCode === null) current.kill('SIGKILL');
|
|
101
|
+
resolveExit();
|
|
102
|
+
}, 5000);
|
|
103
|
+
current.once('exit', () => {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
resolveExit();
|
|
106
|
+
});
|
|
107
|
+
current.kill();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
startWrangler();
|
|
112
|
+
|
|
113
|
+
async function scheduleBuild() {
|
|
114
|
+
if (stopped) return;
|
|
115
|
+
if (rebuilding) {
|
|
116
|
+
rebuildAgain = true;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
rebuilding = true;
|
|
120
|
+
do {
|
|
121
|
+
rebuildAgain = false;
|
|
122
|
+
restarting = true;
|
|
123
|
+
try {
|
|
124
|
+
await stopWrangler();
|
|
125
|
+
await rebuild();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error('Preview rebuild failed; the last successful output remains available.\n' + (error?.stack || error));
|
|
128
|
+
} finally {
|
|
129
|
+
restarting = false;
|
|
130
|
+
startWrangler();
|
|
131
|
+
}
|
|
132
|
+
} while (rebuildAgain && !stopped);
|
|
133
|
+
rebuilding = false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const poll = setInterval(async () => {
|
|
137
|
+
if (scanning || stopped) return;
|
|
138
|
+
scanning = true;
|
|
139
|
+
try {
|
|
140
|
+
const next = await sourceSnapshot(root);
|
|
141
|
+
if (next !== snapshot) {
|
|
142
|
+
snapshot = next;
|
|
143
|
+
clearTimeout(debounce);
|
|
144
|
+
debounce = setTimeout(scheduleBuild, 400);
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('Preview source scan failed: ' + error.message);
|
|
148
|
+
} finally {
|
|
149
|
+
scanning = false;
|
|
150
|
+
}
|
|
151
|
+
}, 900);
|
|
152
|
+
|
|
153
|
+
function stop() {
|
|
154
|
+
if (stopped) return;
|
|
155
|
+
stopped = true;
|
|
156
|
+
clearInterval(poll);
|
|
157
|
+
clearTimeout(debounce);
|
|
158
|
+
void stopWrangler();
|
|
159
|
+
}
|
|
160
|
+
process.once('SIGINT', stop);
|
|
161
|
+
process.once('SIGTERM', stop);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main().catch((error) => {
|
|
165
|
+
console.error(error?.stack || error);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
});
|