wp-migrate-core 0.1.0-demo

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.
@@ -0,0 +1,761 @@
1
+ import { dirname } from "node:path";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ const severityOrder = {
4
+ blocker: 0,
5
+ warning: 1
6
+ };
7
+ const severityLabels = {
8
+ blocker: "Blocker",
9
+ warning: "Needs review"
10
+ };
11
+ const sourceKindLabels = {
12
+ classic: "Classic editor",
13
+ gutenberg: "Gutenberg",
14
+ elementor: "Elementor",
15
+ mixed: "Mixed editor"
16
+ };
17
+ const targetDescriptions = {
18
+ astro: "Implemented for this demo. Its generated files are a starting point and still need manual verification.",
19
+ next: "No Next.js output is included in this demonstration release.",
20
+ nuxt: "No Nuxt output is included in this demonstration release."
21
+ };
22
+ function escapeHtml(value) {
23
+ return String(value ?? "")
24
+ .replaceAll("&", "&")
25
+ .replaceAll("<", "&lt;")
26
+ .replaceAll(">", "&gt;")
27
+ .replaceAll('"', "&quot;")
28
+ .replaceAll("'", "&#039;");
29
+ }
30
+ function humanize(value) {
31
+ const words = value.replace(/[._:/-]+/g, " ").replace(/\s+/g, " ").trim();
32
+ return words ? words[0].toUpperCase() + words.slice(1) : "Site-wide";
33
+ }
34
+ function frameworkNeutralCopy(value) {
35
+ return value
36
+ .replace(/an Astro content collection/gi, "a target content source")
37
+ .replace(/an Astro data source/gi, "a target data source")
38
+ .replace(/an Astro component/gi, "a target component")
39
+ .replace(/by Astro\b/gi, "in the generated site")
40
+ .replace(/\bAstro runtime\b/gi, "generated-site runtime");
41
+ }
42
+ function safeSourceUrl(value) {
43
+ try {
44
+ const url = new URL(value);
45
+ if (url.protocol !== "http:" && url.protocol !== "https:")
46
+ return undefined;
47
+ url.username = "";
48
+ url.password = "";
49
+ url.search = "";
50
+ url.hash = "";
51
+ return url.href;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ function safeRoute(value) {
58
+ if (value === undefined)
59
+ return undefined;
60
+ const absoluteUrl = safeSourceUrl(value);
61
+ if (absoluteUrl !== undefined)
62
+ return absoluteUrl;
63
+ const pathname = value.trim().split(/[?#]/, 1)[0] ?? "";
64
+ return pathname.startsWith("/") ? pathname : undefined;
65
+ }
66
+ function formatDate(value) {
67
+ const date = new Date(value);
68
+ if (Number.isNaN(date.valueOf())) {
69
+ return value;
70
+ }
71
+ return new Intl.DateTimeFormat("en", {
72
+ dateStyle: "medium",
73
+ timeStyle: "short",
74
+ timeZone: "UTC"
75
+ }).format(date) + " UTC";
76
+ }
77
+ function renderTargetCard(project, target) {
78
+ void project;
79
+ const labels = { astro: "Astro", next: "Next.js", nuxt: "Nuxt" };
80
+ const enabled = target === "astro";
81
+ const state = enabled ? "Implemented" : "Not implemented";
82
+ return `
83
+ <article class="target-card${enabled ? " target-card--enabled" : ""}" aria-label="${escapeHtml(labels[target])} target: ${state}">
84
+ <div class="target-card__top">
85
+ <h3>${escapeHtml(labels[target])}</h3>
86
+ <span class="target-state target-state--${enabled ? "available" : "unavailable"}">${state}</span>
87
+ </div>
88
+ <p>${escapeHtml(targetDescriptions[target])}</p>
89
+ ${enabled ? "" : '<span class="target-card__notice">Not implemented in 0.1.0-demo</span>'}
90
+ </article>`;
91
+ }
92
+ function renderSourceBreakdown(project) {
93
+ const sourceKinds = { classic: 0, gutenberg: 0, elementor: 0, mixed: 0 };
94
+ for (const record of project.records)
95
+ sourceKinds[record.editor] += 1;
96
+ const entries = Object.entries(sourceKinds)
97
+ .filter(([, count]) => count > 0)
98
+ .sort((a, b) => b[1] - a[1]);
99
+ if (entries.length === 0) {
100
+ return '<p class="muted">No posts or pages were included in this scan.</p>';
101
+ }
102
+ return `<ul class="source-breakdown">
103
+ ${entries.map(([kind, count]) => `<li><span>${escapeHtml(sourceKindLabels[kind])}</span><strong>${count}</strong></li>`).join("\n")}
104
+ </ul>`;
105
+ }
106
+ function renderIssue(issue, recordsById) {
107
+ const record = recordsById.get(issue.sourceId);
108
+ const route = safeRoute(issue.route ?? record?.route);
109
+ const source = record ? sourceKindLabels[record.editor] : "Site-wide";
110
+ const affectedItem = record?.title || route || "Site-wide setting";
111
+ const title = frameworkNeutralCopy(issue.title);
112
+ const message = frameworkNeutralCopy(issue.message);
113
+ const requiredAction = frameworkNeutralCopy(issue.requiredAction);
114
+ const searchText = [title, message, requiredAction, issue.code, affectedItem, route, source]
115
+ .filter((value) => Boolean(value))
116
+ .join(" ")
117
+ .toLocaleLowerCase();
118
+ return `
119
+ <article
120
+ class="issue-card issue-card--${escapeHtml(issue.severity)}"
121
+ data-issue
122
+ data-severity="${escapeHtml(issue.severity)}"
123
+ data-source="${escapeHtml(source.toLocaleLowerCase())}"
124
+ data-search="${escapeHtml(searchText)}"
125
+ >
126
+ <div class="issue-card__heading">
127
+ <div>
128
+ <div class="badges">
129
+ <span class="badge badge--${escapeHtml(issue.severity)}">${escapeHtml(severityLabels[issue.severity])}</span>
130
+ </div>
131
+ <h3>${escapeHtml(title)}</h3>
132
+ </div>
133
+ <span class="source-label">${escapeHtml(source)}</span>
134
+ </div>
135
+
136
+ <dl class="affected-item">
137
+ <div>
138
+ <dt>Affected item</dt>
139
+ <dd>${escapeHtml(affectedItem)}</dd>
140
+ </div>
141
+ ${route ? `<div><dt>Current URL</dt><dd><code>${escapeHtml(route)}</code></dd></div>` : ""}
142
+ </dl>
143
+
144
+ <p class="issue-message">${escapeHtml(message)}</p>
145
+
146
+ <div class="next-action">
147
+ <span>Next action</span>
148
+ <p>${escapeHtml(requiredAction)}</p>
149
+ </div>
150
+
151
+ <details class="technical-details">
152
+ <summary>Check details</summary>
153
+ <dl>
154
+ <div><dt>Check</dt><dd><code>${escapeHtml(issue.code)}</code></dd></div>
155
+ <div><dt>Content record</dt><dd><code>${escapeHtml(issue.sourceId)}</code></dd></div>
156
+ ${issue.nodeId ? `<div><dt>Source element</dt><dd><code>${escapeHtml(issue.nodeId)}</code></dd></div>` : ""}
157
+ </dl>
158
+ <p class="muted">Raw source snippets, node attributes and post metadata are intentionally omitted from this report.</p>
159
+ </details>
160
+ </article>`;
161
+ }
162
+ function renderIssueFilters(project) {
163
+ const counts = {
164
+ all: project.issues.length,
165
+ blocker: 0,
166
+ warning: 0
167
+ };
168
+ for (const issue of project.issues) {
169
+ counts[issue.severity] += 1;
170
+ }
171
+ const sources = [...new Set(project.issues.map((issue) => {
172
+ const record = project.records.find((candidate) => candidate.sourceId === issue.sourceId);
173
+ return record ? sourceKindLabels[record.editor] : "Site-wide";
174
+ }))].sort((a, b) => a.localeCompare(b));
175
+ const button = (filter, label) => `
176
+ <button
177
+ class="filter-button${filter === "all" ? " is-selected" : ""}"
178
+ type="button"
179
+ data-severity-filter="${filter}"
180
+ aria-pressed="${filter === "all" ? "true" : "false"}"
181
+ >${label} <span>${counts[filter]}</span></button>`;
182
+ return `
183
+ <div class="filter-panel" data-filter-panel>
184
+ <div class="filter-row" role="group" aria-label="Filter by importance">
185
+ ${button("all", "All")}
186
+ ${button("blocker", "Blockers")}
187
+ ${button("warning", "Needs review")}
188
+ </div>
189
+
190
+ <div class="filter-fields">
191
+ <label class="search-field">
192
+ <span>Search issues</span>
193
+ <input type="search" data-issue-search placeholder="Page, URL, check or message" autocomplete="off">
194
+ </label>
195
+ <label>
196
+ <span>Source</span>
197
+ <select data-source-filter>
198
+ <option value="all">All sources</option>
199
+ ${sources.map((source) => `<option value="${escapeHtml(source.toLocaleLowerCase())}">${escapeHtml(source)}</option>`).join("\n")}
200
+ </select>
201
+ </label>
202
+ </div>
203
+
204
+ <div class="filter-result">
205
+ <p data-result-count aria-live="polite"></p>
206
+ <button class="link-button" type="button" data-clear-filters>Clear filters</button>
207
+ </div>
208
+ </div>`;
209
+ }
210
+ function reportScript() {
211
+ return `
212
+ (() => {
213
+ const cards = Array.from(document.querySelectorAll('[data-issue]'));
214
+ const severityButtons = Array.from(document.querySelectorAll('[data-severity-filter]'));
215
+ const sourceSelect = document.querySelector('[data-source-filter]');
216
+ const searchInput = document.querySelector('[data-issue-search]');
217
+ const count = document.querySelector('[data-result-count]');
218
+ const empty = document.querySelector('[data-filter-empty]');
219
+ const clear = document.querySelector('[data-clear-filters]');
220
+ const blockerAction = document.querySelector('[data-show-blockers]');
221
+ const prefersReducedMotion = typeof window.matchMedia === 'function'
222
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
223
+
224
+ if (!cards.length || !sourceSelect || !searchInput || !count) return;
225
+
226
+ let severity = 'all';
227
+
228
+ const selectSeverity = (next) => {
229
+ severity = next;
230
+ severityButtons.forEach((button) => {
231
+ const selected = button.dataset.severityFilter === severity;
232
+ button.classList.toggle('is-selected', selected);
233
+ button.setAttribute('aria-pressed', String(selected));
234
+ });
235
+ };
236
+
237
+ const apply = () => {
238
+ const source = sourceSelect.value;
239
+ const query = searchInput.value.trim().toLocaleLowerCase();
240
+ let visible = 0;
241
+
242
+ cards.forEach((card) => {
243
+ const matchesSeverity = severity === 'all' || card.dataset.severity === severity;
244
+ const matchesSource = source === 'all' || card.dataset.source === source;
245
+ const matchesSearch = !query || (card.dataset.search || '').includes(query);
246
+ const show = matchesSeverity && matchesSource && matchesSearch;
247
+ card.hidden = !show;
248
+ if (show) visible += 1;
249
+ });
250
+
251
+ count.textContent = visible === cards.length
252
+ ? cards.length + (cards.length === 1 ? ' issue' : ' issues')
253
+ : visible + ' of ' + cards.length + ' issues';
254
+ if (empty) empty.hidden = visible !== 0;
255
+ };
256
+
257
+ severityButtons.forEach((button) => button.addEventListener('click', () => {
258
+ selectSeverity(button.dataset.severityFilter || 'all');
259
+ apply();
260
+ }));
261
+ sourceSelect.addEventListener('change', apply);
262
+ searchInput.addEventListener('input', apply);
263
+
264
+ clear?.addEventListener('click', () => {
265
+ selectSeverity('all');
266
+ sourceSelect.value = 'all';
267
+ searchInput.value = '';
268
+ apply();
269
+ searchInput.focus();
270
+ });
271
+
272
+ blockerAction?.addEventListener('click', () => {
273
+ selectSeverity('blocker');
274
+ sourceSelect.value = 'all';
275
+ searchInput.value = '';
276
+ apply();
277
+ document.querySelector('#repair-queue')?.scrollIntoView({
278
+ behavior: prefersReducedMotion ? 'auto' : 'smooth',
279
+ block: 'start'
280
+ });
281
+ });
282
+
283
+ apply();
284
+ })();`;
285
+ }
286
+ export function renderReport(project) {
287
+ const recordsById = new Map(project.records.map((record) => [record.sourceId, record]));
288
+ const openIssues = project.issues;
289
+ const openBlockers = openIssues.filter((issue) => issue.severity === "blocker");
290
+ const openReviews = openIssues.filter((issue) => issue.severity === "warning");
291
+ const sortedIssues = [...project.issues].sort((left, right) => {
292
+ const severityDifference = severityOrder[left.severity] - severityOrder[right.severity];
293
+ if (severityDifference !== 0)
294
+ return severityDifference;
295
+ return left.title.localeCompare(right.title);
296
+ });
297
+ const sourceUrl = safeSourceUrl(project.site.url ?? "") ?? safeSourceUrl(project.source.url ?? "");
298
+ const siteUrl = sourceUrl ?? "Source URL unavailable";
299
+ const blockerPhrase = `${openBlockers.length} ${openBlockers.length === 1 ? "blocker" : "blockers"}`;
300
+ const reviewPhrase = `${openReviews.length} ${openReviews.length === 1 ? "item" : "items"}`;
301
+ const reviewMetricText = openReviews.length === 0
302
+ ? "No review items were flagged."
303
+ : `${reviewPhrase} ${openReviews.length === 1 ? "needs" : "need"} review.`;
304
+ const status = openBlockers.length > 0 ? "Blocked" : openIssues.length > 0 ? "Needs review" : "No issues flagged";
305
+ const statusClass = openBlockers.length > 0 ? "blocked" : openIssues.length > 0 ? "review" : "ready";
306
+ const outcomeTitle = openBlockers.length > 0
307
+ ? `${blockerPhrase} ${openBlockers.length === 1 ? "requires" : "require"} resolution`
308
+ : openIssues.length > 0
309
+ ? `${reviewPhrase} ${openReviews.length === 1 ? "needs" : "need"} review`
310
+ : "No issues were flagged by this scan";
311
+ const outcomeText = openBlockers.length > 0
312
+ ? `${openReviews.length > 0 ? `${reviewPhrase} also ${openReviews.length === 1 ? "needs" : "need"} review. ` : ""}This limited scan does not establish a complete migration.`
313
+ : openIssues.length > 0
314
+ ? "No blockers were flagged by the selected checks. Review these items before treating the generated output as complete."
315
+ : "The selected checks did not flag issues. Visual, behavioural, URL and metadata verification is still required.";
316
+ return `<!doctype html>
317
+ <html lang="en">
318
+ <head>
319
+ <meta charset="utf-8">
320
+ <meta name="viewport" content="width=device-width, initial-scale=1">
321
+ <meta name="color-scheme" content="light">
322
+ <title>Migration review — ${escapeHtml(project.site.title)}</title>
323
+ <style>
324
+ :root {
325
+ color-scheme: light;
326
+ --page: #f4f6f8;
327
+ --surface: #ffffff;
328
+ --surface-subtle: #f8fafc;
329
+ --text: #18212f;
330
+ --muted: #617083;
331
+ --border: #d8dee7;
332
+ --border-strong: #b9c3d0;
333
+ --blue: #1359c5;
334
+ --blue-soft: #e9f1ff;
335
+ --green: #18724a;
336
+ --green-soft: #e8f6ef;
337
+ --amber: #8a5200;
338
+ --amber-soft: #fff3d8;
339
+ --red: #a5292a;
340
+ --red-soft: #fdecec;
341
+ --shadow: 0 1px 2px rgba(22, 34, 50, .06), 0 8px 24px rgba(22, 34, 50, .045);
342
+ --radius: 12px;
343
+ }
344
+
345
+ * { box-sizing: border-box; }
346
+ html { scroll-behavior: smooth; }
347
+ body {
348
+ margin: 0;
349
+ background: var(--page);
350
+ color: var(--text);
351
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
352
+ font-size: 15px;
353
+ line-height: 1.55;
354
+ }
355
+ button, input, select { font: inherit; }
356
+ button, select { cursor: pointer; }
357
+ button:disabled { cursor: not-allowed; }
358
+ a { color: var(--blue); }
359
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
360
+ code { overflow-wrap: anywhere; }
361
+ [hidden] { display: none !important; }
362
+
363
+ .skip-link {
364
+ position: fixed;
365
+ z-index: 10;
366
+ left: 16px;
367
+ top: 12px;
368
+ transform: translateY(-150%);
369
+ padding: 8px 12px;
370
+ border-radius: 8px;
371
+ background: var(--text);
372
+ color: #fff;
373
+ }
374
+ .skip-link:focus { transform: translateY(0); }
375
+ :focus-visible { outline: 3px solid rgba(19, 89, 197, .28); outline-offset: 2px; }
376
+
377
+ .topbar {
378
+ border-bottom: 1px solid var(--border);
379
+ background: rgba(255, 255, 255, .96);
380
+ }
381
+ .topbar__inner,
382
+ main {
383
+ width: min(1160px, calc(100% - 40px));
384
+ margin: 0 auto;
385
+ }
386
+ .topbar__inner {
387
+ min-height: 64px;
388
+ display: flex;
389
+ align-items: center;
390
+ justify-content: space-between;
391
+ gap: 24px;
392
+ }
393
+ .product { font-weight: 750; letter-spacing: -.01em; }
394
+ .report-meta { color: var(--muted); font-size: 13px; text-align: right; }
395
+ main { padding: 42px 0 72px; }
396
+
397
+ .page-heading { margin-bottom: 24px; }
398
+ .eyebrow {
399
+ margin: 0 0 5px;
400
+ color: var(--muted);
401
+ font-size: 13px;
402
+ font-weight: 650;
403
+ }
404
+ h1, h2, h3, p { margin-top: 0; }
405
+ h1 { margin-bottom: 7px; font-size: clamp(29px, 4vw, 42px); line-height: 1.12; letter-spacing: -.035em; }
406
+ h2 { margin-bottom: 6px; font-size: 22px; line-height: 1.25; letter-spacing: -.02em; }
407
+ h3 { margin-bottom: 0; font-size: 17px; line-height: 1.35; }
408
+ .site-url { margin: 0; color: var(--muted); overflow-wrap: anywhere; }
409
+ .external-link-label { white-space: nowrap; font-size: 13px; }
410
+ .local-notice {
411
+ display: flex;
412
+ align-items: flex-start;
413
+ gap: 10px;
414
+ margin: 0 0 28px;
415
+ padding: 12px 14px;
416
+ border: 1px solid #c8d6ea;
417
+ border-radius: 8px;
418
+ background: var(--blue-soft);
419
+ color: #23436e;
420
+ font-size: 14px;
421
+ }
422
+ .local-notice strong { color: var(--text); }
423
+
424
+ .status-chip,
425
+ .target-state,
426
+ .badge {
427
+ display: inline-flex;
428
+ align-items: center;
429
+ width: fit-content;
430
+ border-radius: 999px;
431
+ font-size: 12px;
432
+ font-weight: 700;
433
+ line-height: 1;
434
+ white-space: nowrap;
435
+ }
436
+ .status-chip { padding: 7px 10px; }
437
+ .status-chip--blocked { background: var(--red-soft); color: var(--red); }
438
+ .status-chip--review { background: var(--amber-soft); color: var(--amber); }
439
+ .status-chip--ready { background: var(--green-soft); color: var(--green); }
440
+
441
+ .outcome {
442
+ display: flex;
443
+ justify-content: space-between;
444
+ gap: 32px;
445
+ align-items: center;
446
+ margin-bottom: 34px;
447
+ padding: 22px 24px;
448
+ border: 1px solid var(--border);
449
+ border-left: 4px solid var(--border-strong);
450
+ border-radius: var(--radius);
451
+ background: var(--surface);
452
+ box-shadow: var(--shadow);
453
+ }
454
+ .outcome--blocked { border-left-color: var(--red); }
455
+ .outcome--review { border-left-color: var(--amber); }
456
+ .outcome--ready { border-left-color: var(--green); }
457
+ .outcome h2 { margin: 8px 0 5px; }
458
+ .outcome p { max-width: 720px; margin-bottom: 0; color: var(--muted); }
459
+
460
+ .button {
461
+ min-height: 40px;
462
+ padding: 9px 14px;
463
+ border: 1px solid var(--blue);
464
+ border-radius: 8px;
465
+ background: var(--blue);
466
+ color: #fff;
467
+ font-weight: 700;
468
+ white-space: nowrap;
469
+ }
470
+ .button:hover { background: #0e4cae; }
471
+ .button--quiet {
472
+ border-color: var(--border);
473
+ background: var(--surface-subtle);
474
+ color: var(--muted);
475
+ font-weight: 600;
476
+ }
477
+
478
+ .section { margin-top: 38px; scroll-margin-top: 20px; }
479
+ .section-heading { margin-bottom: 15px; }
480
+ .section-heading p { margin: 0; color: var(--muted); }
481
+
482
+ .metric-grid {
483
+ display: grid;
484
+ grid-template-columns: repeat(4, minmax(0, 1fr));
485
+ gap: 12px;
486
+ margin-bottom: 12px;
487
+ }
488
+ .metric,
489
+ .panel,
490
+ .target-card,
491
+ .issue-card {
492
+ border: 1px solid var(--border);
493
+ border-radius: var(--radius);
494
+ background: var(--surface);
495
+ box-shadow: var(--shadow);
496
+ }
497
+ .metric { padding: 17px 18px; }
498
+ .metric span { display: block; margin-bottom: 5px; color: var(--muted); font-size: 13px; }
499
+ .metric strong { display: block; font-size: 25px; line-height: 1.15; letter-spacing: -.025em; }
500
+ .metric small { display: block; margin-top: 6px; color: var(--muted); }
501
+
502
+ .source-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 12px; }
503
+ .panel { padding: 20px; }
504
+ .panel h3 { margin-bottom: 14px; }
505
+ .source-breakdown { margin: 0; padding: 0; list-style: none; }
506
+ .source-breakdown li {
507
+ display: flex;
508
+ align-items: center;
509
+ justify-content: space-between;
510
+ gap: 24px;
511
+ padding: 9px 0;
512
+ border-top: 1px solid var(--border);
513
+ }
514
+ .source-breakdown li:first-child { border-top: 0; padding-top: 0; }
515
+ .source-breakdown li:last-child { padding-bottom: 0; }
516
+ .source-details { margin: 0; }
517
+ .source-details div { display: grid; grid-template-columns: 125px 1fr; gap: 16px; padding: 7px 0; }
518
+ .source-details dt { color: var(--muted); }
519
+ .source-details dd { min-width: 0; margin: 0; font-weight: 600; overflow-wrap: anywhere; }
520
+
521
+ .target-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
522
+ .target-card { position: relative; min-height: 190px; padding: 20px; }
523
+ .target-card--enabled { border-color: #9eb9e2; box-shadow: 0 0 0 1px #dce8fb, var(--shadow); }
524
+ .target-card__top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
525
+ .target-card p { min-height: 70px; margin: 18px 0 12px; color: var(--muted); }
526
+ .target-state { padding: 6px 8px; }
527
+ .target-state--available { background: var(--green-soft); color: var(--green); }
528
+ .target-state--unavailable { background: #edf0f4; color: #5e6978; }
529
+ .target-card__notice { display: block; color: var(--muted); font-size: 13px; font-weight: 650; }
530
+
531
+ .filter-panel {
532
+ margin: 17px 0 14px;
533
+ padding: 16px;
534
+ border: 1px solid var(--border);
535
+ border-radius: var(--radius);
536
+ background: var(--surface);
537
+ box-shadow: var(--shadow);
538
+ }
539
+ .filter-row { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 15px; }
540
+ .filter-button {
541
+ padding: 7px 11px;
542
+ border: 1px solid var(--border);
543
+ border-radius: 999px;
544
+ background: #fff;
545
+ color: var(--text);
546
+ }
547
+ .filter-button:hover { border-color: var(--border-strong); }
548
+ .filter-button.is-selected { border-color: var(--blue); background: var(--blue-soft); color: #124faa; font-weight: 700; }
549
+ .filter-button span { margin-left: 4px; color: var(--muted); font-size: 12px; }
550
+ .filter-fields { display: grid; grid-template-columns: minmax(220px, 1.6fr) minmax(170px, .8fr); gap: 12px; }
551
+ .filter-fields label { display: grid; gap: 5px; color: var(--muted); font-size: 13px; font-weight: 650; }
552
+ .filter-fields input,
553
+ .filter-fields select {
554
+ width: 100%;
555
+ height: 42px;
556
+ padding: 8px 10px;
557
+ border: 1px solid var(--border-strong);
558
+ border-radius: 8px;
559
+ background: #fff;
560
+ color: var(--text);
561
+ font-weight: 400;
562
+ }
563
+ .filter-result { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 24px; margin-top: 12px; }
564
+ .filter-result p { margin: 0; color: var(--muted); font-size: 13px; }
565
+ .link-button { padding: 3px 0; border: 0; background: transparent; color: var(--blue); font-weight: 650; }
566
+ .link-button:hover { text-decoration: underline; }
567
+
568
+ .issue-list { display: grid; gap: 12px; }
569
+ .issue-card { padding: 20px 22px 19px; border-left-width: 4px; }
570
+ .issue-card--blocker { border-left-color: var(--red); }
571
+ .issue-card--review { border-left-color: var(--amber); }
572
+ .issue-card--warning { border-left-color: #7a8796; }
573
+ .issue-card__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
574
+ .issue-card__heading h3 { margin-top: 9px; font-size: 18px; }
575
+ .badges { display: flex; flex-wrap: wrap; gap: 6px; }
576
+ .badge { padding: 5px 8px; }
577
+ .badge--blocker { background: var(--red-soft); color: var(--red); }
578
+ .badge--review { background: var(--amber-soft); color: var(--amber); }
579
+ .badge--warning, .badge--status { background: #edf0f4; color: #596675; }
580
+ .source-label { color: var(--muted); font-size: 13px; white-space: nowrap; }
581
+
582
+ .affected-item { display: flex; flex-wrap: wrap; gap: 10px 28px; margin: 16px 0 0; }
583
+ .affected-item div { display: flex; flex-wrap: wrap; gap: 6px; }
584
+ .affected-item dt { color: var(--muted); }
585
+ .affected-item dt::after { content: ":"; }
586
+ .affected-item dd { margin: 0; font-weight: 650; }
587
+ .issue-message { max-width: 870px; margin: 15px 0 0; }
588
+ .next-action { margin-top: 17px; padding: 12px 14px; border-radius: 8px; background: var(--surface-subtle); }
589
+ .next-action span { display: block; margin-bottom: 3px; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
590
+ .next-action p { margin: 0; font-weight: 600; }
591
+
592
+ .technical-details { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }
593
+ .technical-details summary { width: fit-content; color: var(--blue); cursor: pointer; font-weight: 650; }
594
+ .technical-details dl { margin: 13px 0 9px; }
595
+ .technical-details dl div { display: flex; flex-wrap: wrap; gap: 7px; margin: 5px 0; }
596
+ .technical-details dt { color: var(--muted); }
597
+ .technical-details dt::after { content: ":"; }
598
+ .technical-details dd { margin: 0; }
599
+ .empty-state { padding: 34px 20px; border: 1px dashed var(--border-strong); border-radius: var(--radius); text-align: center; background: rgba(255,255,255,.6); }
600
+ .empty-state h3 { margin-bottom: 4px; }
601
+ .empty-state p { margin: 0; color: var(--muted); }
602
+ .empty-state .link-button { margin-top: 9px; }
603
+ .muted { color: var(--muted); }
604
+ .footer-note { margin: 35px 0 0; color: var(--muted); font-size: 13px; }
605
+
606
+ @media (prefers-reduced-motion: reduce) {
607
+ html { scroll-behavior: auto; }
608
+ }
609
+
610
+ @media (max-width: 820px) {
611
+ .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
612
+ .source-grid, .target-grid { grid-template-columns: 1fr; }
613
+ .target-card { min-height: 0; }
614
+ .target-card p { min-height: 0; }
615
+ .filter-fields { grid-template-columns: 1fr 1fr; }
616
+ .search-field { grid-column: 1 / -1; }
617
+ }
618
+
619
+ @media (max-width: 560px) {
620
+ .topbar__inner, main { width: min(100% - 24px, 1160px); }
621
+ .topbar__inner { min-height: 58px; }
622
+ .report-meta { display: none; }
623
+ main { padding-top: 28px; }
624
+ .outcome { align-items: stretch; flex-direction: column; gap: 17px; padding: 18px; }
625
+ .outcome .button { width: 100%; }
626
+ .metric-grid, .filter-fields { grid-template-columns: 1fr; }
627
+ .search-field { grid-column: auto; }
628
+ .source-details div { grid-template-columns: 1fr; gap: 1px; }
629
+ .issue-card { padding: 17px 16px; }
630
+ .issue-card__heading { flex-direction: column; gap: 8px; }
631
+ .source-label { white-space: normal; }
632
+ .filter-result { align-items: flex-start; }
633
+ }
634
+
635
+ @media print {
636
+ body { background: #fff; }
637
+ .topbar, .filter-panel, .button { display: none !important; }
638
+ main { width: 100%; padding: 0; }
639
+ .metric, .panel, .target-card, .issue-card, .outcome { box-shadow: none; break-inside: avoid; }
640
+ .technical-details:not([open]) { display: none; }
641
+ }
642
+ </style>
643
+ </head>
644
+ <body>
645
+ <a class="skip-link" href="#main">Skip to report</a>
646
+ <header class="topbar">
647
+ <div class="topbar__inner">
648
+ <div class="product">WP Migrate Core</div>
649
+ <div class="report-meta">Local migration report · Version 0.1.0-demo</div>
650
+ </div>
651
+ </header>
652
+
653
+ <main id="main">
654
+ <header class="page-heading">
655
+ <p class="eyebrow">Migration review</p>
656
+ <h1>${escapeHtml(project.site.title)}</h1>
657
+ <p class="site-url">${sourceUrl ? `<a href="${escapeHtml(sourceUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(siteUrl)} <span class="external-link-label">(opens in a new tab)</span></a>` : escapeHtml(siteUrl)}</p>
658
+ </header>
659
+
660
+ <aside class="local-notice" aria-label="Local report notice">
661
+ <strong>Keep this report local.</strong>
662
+ <span>It may contain source titles, URLs and migration identifiers. Review it before sharing; it is not a public site page.</span>
663
+ </aside>
664
+
665
+ <section class="outcome outcome--${statusClass}" aria-labelledby="outcome-heading">
666
+ <div>
667
+ <span class="status-chip status-chip--${statusClass}">${status}</span>
668
+ <h2 id="outcome-heading">${escapeHtml(outcomeTitle)}</h2>
669
+ <p>${escapeHtml(outcomeText)}</p>
670
+ </div>
671
+ ${openBlockers.length > 0 ? '<button class="button" type="button" data-show-blockers>Show blockers</button>' : ""}
672
+ </section>
673
+
674
+ <section class="section" aria-labelledby="source-heading">
675
+ <div class="section-heading">
676
+ <h2 id="source-heading">Source summary</h2>
677
+ <p>What this limited scan detected in the WordPress export. It does not enumerate every WordPress feature.</p>
678
+ </div>
679
+
680
+ <div class="metric-grid">
681
+ <article class="metric"><span>Content items</span><strong>${project.summary.records}</strong><small>Posts and pages included in this scan</small></article>
682
+ <article class="metric"><span>Detected constructs</span><strong>${project.summary.nodes}</strong><small>Blocks, sections and widgets recognized by the parser</small></article>
683
+ <article class="metric"><span>Supported constructs detected</span><strong>${project.summary.nativeNodes}</strong><small>Classified as directly supported by this demo</small></article>
684
+ <article class="metric"><span>Blockers</span><strong>${openBlockers.length}</strong><small>${escapeHtml(reviewMetricText)}</small></article>
685
+ </div>
686
+
687
+ <div class="source-grid">
688
+ <article class="panel">
689
+ <h3>Editors detected</h3>
690
+ ${renderSourceBreakdown(project)}
691
+ </article>
692
+ <article class="panel">
693
+ <h3>Report details</h3>
694
+ <dl class="source-details">
695
+ <div><dt>Generated</dt><dd>${escapeHtml(formatDate(new Date().toISOString()))}</dd></div>
696
+ <div><dt>Open blockers</dt><dd>${openBlockers.length}</dd></div>
697
+ <div><dt>Needs review</dt><dd>${openReviews.length}</dd></div>
698
+ </dl>
699
+ </article>
700
+ </div>
701
+ </section>
702
+
703
+ <section class="section" aria-labelledby="targets-heading">
704
+ <div class="section-heading">
705
+ <h2 id="targets-heading">Output target</h2>
706
+ <p>Astro is the only implemented output target. Every generated output still needs verification against the source.</p>
707
+ </div>
708
+ <div class="target-grid">
709
+ ${renderTargetCard(project, "astro")}
710
+ ${renderTargetCard(project, "next")}
711
+ ${renderTargetCard(project, "nuxt")}
712
+ </div>
713
+ </section>
714
+
715
+ <section class="section" id="repair-queue" aria-labelledby="repair-heading">
716
+ <div class="section-heading">
717
+ <h2 id="repair-heading">Repair queue</h2>
718
+ <p>Selected unsupported or ambiguous constructs appear here. An empty queue is not a completeness guarantee.</p>
719
+ </div>
720
+
721
+ ${project.issues.length > 0 ? renderIssueFilters(project) : ""}
722
+
723
+ ${project.issues.length === 0 ? `
724
+ <div class="empty-state">
725
+ <h3>No items were flagged</h3>
726
+ <p>The selected checks did not flag blockers or review items. Verify the generated site against the source before release.</p>
727
+ </div>` : `
728
+ <div class="issue-list" data-issue-list>
729
+ ${sortedIssues.map((issue) => renderIssue(issue, recordsById)).join("\n")}
730
+ </div>
731
+ <div class="empty-state" data-filter-empty hidden>
732
+ <h3>No matching issues</h3>
733
+ <p>No repair items match the current filters.</p>
734
+ <button class="link-button" type="button" data-clear-filters>Clear filters</button>
735
+ </div>`}
736
+ </section>
737
+
738
+ <p class="footer-note">This local report is a limited scan summary, not confirmation of a complete migration. Verify visual, behavioural, URL and metadata outcomes against the original site.</p>
739
+ </main>
740
+ <script>${reportScript()}</script>
741
+ </body>
742
+ </html>`;
743
+ }
744
+ function isNodeErrorCode(error, code) {
745
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
746
+ }
747
+ export async function writeReport(project, outputPath, options = {}) {
748
+ await mkdir(dirname(outputPath), { recursive: true });
749
+ try {
750
+ await writeFile(outputPath, renderReport(project), {
751
+ encoding: "utf8",
752
+ ...(options.noClobber ? { flag: "wx" } : {})
753
+ });
754
+ }
755
+ catch (error) {
756
+ if (options.noClobber && isNodeErrorCode(error, "EEXIST")) {
757
+ throw new Error(`Refusing to overwrite existing output: ${outputPath}. Choose a different --out path or remove the existing file intentionally.`);
758
+ }
759
+ throw error;
760
+ }
761
+ }