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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/src/adapters.d.ts +25 -0
- package/dist/src/adapters.js +11 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +250 -0
- package/dist/src/core.d.ts +9 -0
- package/dist/src/core.js +603 -0
- package/dist/src/generate.d.ts +2 -0
- package/dist/src/generate.js +555 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +5 -0
- package/dist/src/report.d.ts +7 -0
- package/dist/src/report.js +761 -0
- package/dist/src/types.d.ts +83 -0
- package/dist/src/types.js +1 -0
- package/fixtures/demo-wordpress.xml +111 -0
- package/package.json +59 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, parse, resolve } from "node:path";
|
|
3
|
+
const GENERATOR_NAME = "wp-migrate-core";
|
|
4
|
+
const GENERATOR_VERSION = "0.1.0-demo";
|
|
5
|
+
const SAFE_ELEMENTOR_HREF_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
|
|
6
|
+
export async function generateAstroProject(project, outDir) {
|
|
7
|
+
const outputDirectory = await prepareOutputDirectory(outDir);
|
|
8
|
+
const records = prepareRecords(project);
|
|
9
|
+
const files = new Map([
|
|
10
|
+
["package.json", renderPackageJson(project)],
|
|
11
|
+
["astro.config.mjs", renderAstroConfig()],
|
|
12
|
+
["tsconfig.json", renderTsConfig()],
|
|
13
|
+
["src/content.config.ts", renderContentConfig()],
|
|
14
|
+
["src/pages/[...slug].astro", renderCatchAllPage()],
|
|
15
|
+
["src/layouts/Layout.astro", renderLayout()],
|
|
16
|
+
["src/styles/global.css", renderStyles()],
|
|
17
|
+
["public/robots.txt", renderRobotsTxt()],
|
|
18
|
+
["migration/issues.json", renderIssues(project.issues)],
|
|
19
|
+
["migration/manifest.json", renderManifest(project, records)],
|
|
20
|
+
["README.md", renderReadme(project)]
|
|
21
|
+
]);
|
|
22
|
+
for (const generated of records) {
|
|
23
|
+
files.set(`src/content/${generated.collection}/${generated.fileName}`, renderContentRecord(generated));
|
|
24
|
+
}
|
|
25
|
+
for (const [relativePath, contents] of files) {
|
|
26
|
+
await writeNewFile(outputDirectory, relativePath, contents);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function prepareOutputDirectory(outDir) {
|
|
30
|
+
if (outDir.trim().length === 0) {
|
|
31
|
+
throw new Error("An explicit output directory is required.");
|
|
32
|
+
}
|
|
33
|
+
const outputDirectory = resolve(outDir);
|
|
34
|
+
const root = parse(outputDirectory).root;
|
|
35
|
+
if (outputDirectory === root || outputDirectory === resolve(process.cwd())) {
|
|
36
|
+
throw new Error(`Refusing to generate into unsafe output directory: ${outputDirectory}`);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const details = await stat(outputDirectory);
|
|
40
|
+
if (!details.isDirectory()) {
|
|
41
|
+
throw new Error(`Output path exists and is not a directory: ${outputDirectory}`);
|
|
42
|
+
}
|
|
43
|
+
const existing = await readdir(outputDirectory);
|
|
44
|
+
if (existing.length > 0) {
|
|
45
|
+
throw new Error(`Output directory is not empty: ${outputDirectory}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!isNodeErrorCode(error, "ENOENT")) {
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
return outputDirectory;
|
|
55
|
+
}
|
|
56
|
+
function prepareRecords(project) {
|
|
57
|
+
const usedFileNames = new Set();
|
|
58
|
+
const usedRoutes = new Set();
|
|
59
|
+
return project.records.map((record) => {
|
|
60
|
+
const collection = record.type === "page" ? "pages" : "posts";
|
|
61
|
+
const route = normalizeRoute(record.route ?? `/${record.slug}/`);
|
|
62
|
+
if (usedRoutes.has(route)) {
|
|
63
|
+
throw new Error(`Cannot generate duplicate route: ${route}`);
|
|
64
|
+
}
|
|
65
|
+
usedRoutes.add(route);
|
|
66
|
+
const stem = safeFileStem(record.slug || record.sourceId);
|
|
67
|
+
let fileName = `${stem}.md`;
|
|
68
|
+
let suffix = 2;
|
|
69
|
+
while (usedFileNames.has(`${collection}/${fileName}`)) {
|
|
70
|
+
fileName = `${stem}-${suffix}.md`;
|
|
71
|
+
suffix += 1;
|
|
72
|
+
}
|
|
73
|
+
usedFileNames.add(`${collection}/${fileName}`);
|
|
74
|
+
return {
|
|
75
|
+
record,
|
|
76
|
+
collection,
|
|
77
|
+
fileName,
|
|
78
|
+
route,
|
|
79
|
+
sourceUrl: sourceUrlFor(project, record, route)
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function renderPackageJson(project) {
|
|
84
|
+
const packageName = `${safeFileStem(project.site.title)}-astro`;
|
|
85
|
+
return renderJson({
|
|
86
|
+
name: packageName,
|
|
87
|
+
version: "0.0.0",
|
|
88
|
+
private: true,
|
|
89
|
+
type: "module",
|
|
90
|
+
scripts: {
|
|
91
|
+
dev: "astro dev",
|
|
92
|
+
build: "astro build",
|
|
93
|
+
preview: "astro preview"
|
|
94
|
+
},
|
|
95
|
+
dependencies: {
|
|
96
|
+
astro: "^5.13.0"
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function renderAstroConfig() {
|
|
101
|
+
return `import { defineConfig } from "astro/config";
|
|
102
|
+
|
|
103
|
+
export default defineConfig({
|
|
104
|
+
output: "static",
|
|
105
|
+
trailingSlash: "always"
|
|
106
|
+
});
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
function renderTsConfig() {
|
|
110
|
+
return renderJson({
|
|
111
|
+
extends: "astro/tsconfigs/strict",
|
|
112
|
+
include: [".astro/types.d.ts", "**/*"],
|
|
113
|
+
exclude: ["dist"]
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function renderContentConfig() {
|
|
117
|
+
return `import { defineCollection, z } from "astro:content";
|
|
118
|
+
import { glob } from "astro/loaders";
|
|
119
|
+
|
|
120
|
+
const migrationSchema = z.object({
|
|
121
|
+
title: z.string(),
|
|
122
|
+
route: z.string(),
|
|
123
|
+
author: z.string().optional(),
|
|
124
|
+
publishedAt: z.string().optional(),
|
|
125
|
+
categories: z.array(z.string()).default([])
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export const collections = {
|
|
129
|
+
pages: defineCollection({
|
|
130
|
+
loader: glob({ base: "./src/content/pages", pattern: "**/*.{md,mdx}" }),
|
|
131
|
+
schema: migrationSchema
|
|
132
|
+
}),
|
|
133
|
+
posts: defineCollection({
|
|
134
|
+
loader: glob({ base: "./src/content/posts", pattern: "**/*.{md,mdx}" }),
|
|
135
|
+
schema: migrationSchema
|
|
136
|
+
})
|
|
137
|
+
};
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
function renderCatchAllPage() {
|
|
141
|
+
return `---
|
|
142
|
+
import { getCollection, render } from "astro:content";
|
|
143
|
+
import Layout from "../layouts/Layout.astro";
|
|
144
|
+
|
|
145
|
+
export async function getStaticPaths() {
|
|
146
|
+
const entries = [
|
|
147
|
+
...(await getCollection("pages")),
|
|
148
|
+
...(await getCollection("posts"))
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
return entries.map((entry) => ({
|
|
152
|
+
params: {
|
|
153
|
+
slug: entry.data.route === "/"
|
|
154
|
+
? undefined
|
|
155
|
+
: entry.data.route.replace(/^\\/|\\/$/g, "")
|
|
156
|
+
},
|
|
157
|
+
props: { entry }
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { entry } = Astro.props;
|
|
162
|
+
const { Content } = await render(entry);
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
<Layout
|
|
166
|
+
title={entry.data.title}
|
|
167
|
+
>
|
|
168
|
+
<Content />
|
|
169
|
+
</Layout>
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
function renderLayout() {
|
|
173
|
+
return `---
|
|
174
|
+
import "../styles/global.css";
|
|
175
|
+
|
|
176
|
+
interface Props {
|
|
177
|
+
title: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { title } = Astro.props;
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
<!doctype html>
|
|
184
|
+
<html lang="en">
|
|
185
|
+
<head>
|
|
186
|
+
<meta charset="UTF-8" />
|
|
187
|
+
<meta name="viewport" content="width=device-width" />
|
|
188
|
+
<meta name="generator" content={Astro.generator} />
|
|
189
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
190
|
+
<title>{title}</title>
|
|
191
|
+
</head>
|
|
192
|
+
<body>
|
|
193
|
+
<main>
|
|
194
|
+
<article>
|
|
195
|
+
<slot />
|
|
196
|
+
</article>
|
|
197
|
+
</main>
|
|
198
|
+
</body>
|
|
199
|
+
</html>
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
function renderStyles() {
|
|
203
|
+
return `:root {
|
|
204
|
+
color: #171717;
|
|
205
|
+
background: #f5f5f3;
|
|
206
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
body { margin: 0; }
|
|
210
|
+
main { width: min(920px, calc(100% - 2rem)); margin: 3rem auto; }
|
|
211
|
+
article {
|
|
212
|
+
padding: clamp(1.25rem, 4vw, 3rem);
|
|
213
|
+
background: white;
|
|
214
|
+
border: 1px solid #deded8;
|
|
215
|
+
border-radius: 0.75rem;
|
|
216
|
+
}
|
|
217
|
+
img { max-width: 100%; height: auto; }
|
|
218
|
+
.content-review {
|
|
219
|
+
display: grid;
|
|
220
|
+
gap: 0.35rem;
|
|
221
|
+
margin: 0 0 1rem;
|
|
222
|
+
padding: 1rem;
|
|
223
|
+
border: 1px solid #b98a18;
|
|
224
|
+
border-radius: 0.5rem;
|
|
225
|
+
background: #fff8dc;
|
|
226
|
+
}
|
|
227
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
228
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
function renderRobotsTxt() {
|
|
231
|
+
return "User-agent: *\nDisallow: /\n";
|
|
232
|
+
}
|
|
233
|
+
function renderIssues(issues) {
|
|
234
|
+
return renderJson(issues.map((issue) => ({
|
|
235
|
+
id: issue.id,
|
|
236
|
+
severity: issue.severity,
|
|
237
|
+
code: issue.code,
|
|
238
|
+
sourceId: issue.sourceId,
|
|
239
|
+
...(issue.route === undefined ? {} : { route: issue.route }),
|
|
240
|
+
...(issue.nodeId === undefined ? {} : { nodeId: issue.nodeId }),
|
|
241
|
+
title: issue.title,
|
|
242
|
+
message: issue.message,
|
|
243
|
+
requiredAction: issue.requiredAction
|
|
244
|
+
})));
|
|
245
|
+
}
|
|
246
|
+
function renderManifest(project, records) {
|
|
247
|
+
return renderJson({
|
|
248
|
+
schemaVersion: "0.1",
|
|
249
|
+
generator: {
|
|
250
|
+
name: GENERATOR_NAME,
|
|
251
|
+
version: GENERATOR_VERSION,
|
|
252
|
+
target: "astro"
|
|
253
|
+
},
|
|
254
|
+
sourceSite: project.site,
|
|
255
|
+
summary: project.summary,
|
|
256
|
+
targets: {
|
|
257
|
+
astro: { enabled: true, label: "Astro" },
|
|
258
|
+
next: { enabled: false, label: "Next.js" },
|
|
259
|
+
nuxt: { enabled: false, label: "Nuxt" }
|
|
260
|
+
},
|
|
261
|
+
records: records.map(({ record, collection, fileName, route, sourceUrl }) => ({
|
|
262
|
+
sourceId: record.sourceId,
|
|
263
|
+
wordpressId: record.wordpressId,
|
|
264
|
+
postType: record.type,
|
|
265
|
+
sourceEditor: record.editor,
|
|
266
|
+
route,
|
|
267
|
+
sourceUrl,
|
|
268
|
+
outputFile: `src/content/${collection}/${fileName}`
|
|
269
|
+
}))
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function renderReadme(project) {
|
|
273
|
+
return `# ${project.site.title} — Astro migration handoff
|
|
274
|
+
|
|
275
|
+
Generated from ${project.site.url ?? "a WordPress export"} by ${GENERATOR_NAME} ${GENERATOR_VERSION}.
|
|
276
|
+
|
|
277
|
+
This is a rough migration output, not a production-ready replacement. The generator preserves source content where it can and emits explicit repair markers where it cannot.
|
|
278
|
+
|
|
279
|
+
## Run locally
|
|
280
|
+
|
|
281
|
+
\`\`\`bash
|
|
282
|
+
npm install
|
|
283
|
+
npm run dev
|
|
284
|
+
\`\`\`
|
|
285
|
+
|
|
286
|
+
## Handoff sequence
|
|
287
|
+
|
|
288
|
+
1. Open \`migration/issues.json\` and resolve every blocker.
|
|
289
|
+
2. Review warnings and accepted legacy HTML instead of assuming conversion fidelity.
|
|
290
|
+
3. Compare every generated route with the original WordPress route on desktop and mobile.
|
|
291
|
+
4. Replace forms, dynamic widgets, shortcodes and plugin behavior deliberately.
|
|
292
|
+
5. Run \`npm run build\` only after the repair queue is understood.
|
|
293
|
+
|
|
294
|
+
Generated content lives in \`src/content/pages\` and \`src/content/posts\`. Route mappings and source IDs live in \`migration/manifest.json\`.
|
|
295
|
+
|
|
296
|
+
Astro is the only enabled renderer in 0.1.0-demo. Next.js and Nuxt appear in the migration manifest as planned, disabled targets; this output contains no fake compatibility layer for either framework.
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
function renderContentRecord(generated) {
|
|
300
|
+
const { record, route } = generated;
|
|
301
|
+
const frontmatter = [
|
|
302
|
+
"---",
|
|
303
|
+
`title: ${yamlString(record.title)}`,
|
|
304
|
+
`route: ${yamlString(route)}`,
|
|
305
|
+
...(record.author ? [`author: ${yamlString(record.author)}`] : []),
|
|
306
|
+
...(record.publishedAt ? [`publishedAt: ${yamlString(record.publishedAt)}`] : []),
|
|
307
|
+
`categories: ${JSON.stringify(record.terms.filter((term) => term.domain === "category").map((term) => term.name))}`,
|
|
308
|
+
"---"
|
|
309
|
+
].join("\n");
|
|
310
|
+
const body = ensureTitleH1(renderRecordBody(record), record.title);
|
|
311
|
+
return `${frontmatter}\n\n${body.trim()}\n`;
|
|
312
|
+
}
|
|
313
|
+
function renderRecordBody(record) {
|
|
314
|
+
if (record.rawContent.trim().length > 0) {
|
|
315
|
+
const content = renderSafeRawHtml(record.rawContent);
|
|
316
|
+
if (content === undefined) {
|
|
317
|
+
return renderUnsafeMarkupRepair();
|
|
318
|
+
}
|
|
319
|
+
return record.issues.some((issue) => issue.code === "SHORTCODE_UNSUPPORTED")
|
|
320
|
+
? replaceUnsupportedShortcodes(content)
|
|
321
|
+
: content;
|
|
322
|
+
}
|
|
323
|
+
const renderedNodes = record.nodes.map(renderNode).filter(Boolean).join("\n\n");
|
|
324
|
+
if (renderedNodes.length > 0) {
|
|
325
|
+
return renderedNodes;
|
|
326
|
+
}
|
|
327
|
+
return renderRepairMarker("No renderable content was exported for this record.");
|
|
328
|
+
}
|
|
329
|
+
function renderNode(node) {
|
|
330
|
+
if (node.rawHtml?.trim()) {
|
|
331
|
+
return renderSafeRawHtml(node.rawHtml) ?? renderUnsafeMarkupRepair();
|
|
332
|
+
}
|
|
333
|
+
if (node.source === "elementor" &&
|
|
334
|
+
(node.conversion === "native" || node.conversion === "legacy-html")) {
|
|
335
|
+
const native = renderNativeElementorNode(node);
|
|
336
|
+
if (native !== undefined) {
|
|
337
|
+
return native;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const children = node.children.map(renderNode).filter(Boolean).join("\n");
|
|
341
|
+
if (node.conversion === "manual" || node.conversion === "blocked") {
|
|
342
|
+
const marker = renderRepairMarker(repairMessageForNode(node));
|
|
343
|
+
return children ? `${marker}\n${children}` : marker;
|
|
344
|
+
}
|
|
345
|
+
if (children) {
|
|
346
|
+
return children;
|
|
347
|
+
}
|
|
348
|
+
return renderRepairMarker(repairMessageForNode(node));
|
|
349
|
+
}
|
|
350
|
+
function renderNativeElementorNode(node) {
|
|
351
|
+
const children = node.children.map(renderNode).filter(Boolean).join("\n");
|
|
352
|
+
switch (node.sourceType) {
|
|
353
|
+
case "container":
|
|
354
|
+
case "section":
|
|
355
|
+
return `<section>\n${children}\n</section>`;
|
|
356
|
+
case "column":
|
|
357
|
+
return `<div>\n${children}\n</div>`;
|
|
358
|
+
case "heading": {
|
|
359
|
+
const title = getString(node.attributes, "title");
|
|
360
|
+
const requestedLevel = getString(node.attributes, "header_size");
|
|
361
|
+
const level = /^h[1-6]$/.test(requestedLevel ?? "") ? requestedLevel : "h2";
|
|
362
|
+
return title ? `<${level}>${escapeHtml(title)}</${level}>` : undefined;
|
|
363
|
+
}
|
|
364
|
+
case "text-editor": {
|
|
365
|
+
const content = getString(node.attributes, "editor");
|
|
366
|
+
return content === undefined ? undefined : renderSafeRawHtml(content) ?? renderUnsafeMarkupRepair();
|
|
367
|
+
}
|
|
368
|
+
case "button": {
|
|
369
|
+
const text = getString(node.attributes, "text");
|
|
370
|
+
const href = getNestedString(node.attributes, "link", "url");
|
|
371
|
+
const safeHref = href === undefined ? undefined : safeHrefForElementorButton(href);
|
|
372
|
+
return text && safeHref !== undefined
|
|
373
|
+
? `<p><a href="${escapeHtmlAttribute(safeHref)}">${escapeHtml(text)}</a></p>`
|
|
374
|
+
: renderRepairMarker("This link needs review before publication.");
|
|
375
|
+
}
|
|
376
|
+
case "image":
|
|
377
|
+
return renderRepairMarker("This image needs to be added from a verified local asset before publication.");
|
|
378
|
+
case "divider":
|
|
379
|
+
return "<hr />";
|
|
380
|
+
case "spacer":
|
|
381
|
+
return '<div aria-hidden="true"></div>';
|
|
382
|
+
default:
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function renderRepairMarker(message) {
|
|
387
|
+
return `<aside class="content-review">
|
|
388
|
+
<strong>Content review required</strong>
|
|
389
|
+
<span>${escapeHtml(message)}</span>
|
|
390
|
+
</aside>`;
|
|
391
|
+
}
|
|
392
|
+
function renderUnsafeMarkupRepair() {
|
|
393
|
+
return renderRepairMarker("This part of the page was withheld pending review.");
|
|
394
|
+
}
|
|
395
|
+
function repairMessageForNode(node) {
|
|
396
|
+
if (node.source === "elementor" && node.sourceType === "image") {
|
|
397
|
+
return "This image needs to be added from a verified local asset before publication.";
|
|
398
|
+
}
|
|
399
|
+
if (node.source === "elementor" && node.sourceType === "button") {
|
|
400
|
+
return "This link needs review before publication.";
|
|
401
|
+
}
|
|
402
|
+
return "This part of the page needs review before publication.";
|
|
403
|
+
}
|
|
404
|
+
function ensureTitleH1(body, title) {
|
|
405
|
+
if (hasH1(body)) {
|
|
406
|
+
return body;
|
|
407
|
+
}
|
|
408
|
+
const heading = title.trim() || "Untitled page";
|
|
409
|
+
return `<h1>${escapeHtml(heading)}</h1>\n\n${body}`;
|
|
410
|
+
}
|
|
411
|
+
function hasH1(value) {
|
|
412
|
+
return (/<\s*h1(?:\s|\/?>)/i.test(value) ||
|
|
413
|
+
/^(?: {0,3})#(?!#)\s+\S/m.test(value) ||
|
|
414
|
+
/^(?: {0,3})\S[^\n]*\n(?: {0,3})={3,}\s*$/m.test(value));
|
|
415
|
+
}
|
|
416
|
+
function renderSafeRawHtml(value) {
|
|
417
|
+
return hasUnsafeRawMarkup(value) ? undefined : withholdSourceMedia(value);
|
|
418
|
+
}
|
|
419
|
+
function withholdSourceMedia(value) {
|
|
420
|
+
return value.replace(/<(?:img|source)\b[^>]*>/gi, () => renderRepairMarker("This source media needs to be added as a verified local asset before publication."));
|
|
421
|
+
}
|
|
422
|
+
function replaceUnsupportedShortcodes(value) {
|
|
423
|
+
return value
|
|
424
|
+
.replace(/\[(?!\/)([a-z][a-z0-9_-]*)(?:\s[^\]]*)?\]/gi, (_match, shortcode) => renderRepairMarker(`Shortcode [${shortcode}] needs a deliberate replacement before publication.`))
|
|
425
|
+
.replace(/\[\/[a-z][a-z0-9_-]*\]/gi, "");
|
|
426
|
+
}
|
|
427
|
+
function hasUnsafeRawMarkup(value) {
|
|
428
|
+
const decoded = decodeHtmlEntitiesForSafety(value);
|
|
429
|
+
return (/<\s*\/?\s*(?:applet|base|embed|form|iframe|input|link|math|meta|object|script|select|style|svg|textarea)\b/i.test(decoded) ||
|
|
430
|
+
/\bon[a-z0-9:_-]+\s*=/i.test(decoded) ||
|
|
431
|
+
/\b(?:href|src|srcset|action|formaction|poster|xlink:href)\s*=\s*["']?\s*(?:j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t|v\s*b\s*s\s*c\s*r\s*i\s*p\s*t|d\s*a\s*t\s*a|f\s*i\s*l\s*e)\s*:/i.test(decoded) ||
|
|
432
|
+
/\bstyle\s*=\s*[^>]*(?:expression\s*\(|url\s*\(\s*["']?\s*(?:j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t|v\s*b\s*s\s*c\s*r\s*i\s*p\s*t|d\s*a\s*t\s*a|f\s*i\s*l\s*e)\s*:)/i.test(decoded) ||
|
|
433
|
+
/\[[^\]]*\]\s*\(\s*(?:j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t|v\s*b\s*s\s*c\s*r\s*i\s*p\s*t|d\s*a\s*t\s*a|f\s*i\s*l\s*e)\s*:/i.test(decoded));
|
|
434
|
+
}
|
|
435
|
+
function safeHrefForElementorButton(value) {
|
|
436
|
+
const href = value.trim();
|
|
437
|
+
if (href === "" || /[\u0000-\u001f\u007f-\u009f]/.test(href)) {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
const decodedHref = decodeHtmlEntitiesForSafety(href).trim();
|
|
441
|
+
if (decodedHref === "" || /[\u0000-\u001f\u007f-\u009f]/.test(decodedHref)) {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
const normalized = decodedHref.replace(/\s+/g, "");
|
|
445
|
+
if (normalized.startsWith("//") || normalized.startsWith("\\")) {
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(normalized)?.[1]?.toLowerCase();
|
|
449
|
+
return scheme === undefined || SAFE_ELEMENTOR_HREF_SCHEMES.has(scheme) ? decodedHref : undefined;
|
|
450
|
+
}
|
|
451
|
+
function decodeHtmlEntitiesForSafety(value) {
|
|
452
|
+
let decoded = value;
|
|
453
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
454
|
+
const next = decoded
|
|
455
|
+
.replace(/:/gi, ":")
|
|
456
|
+
.replace(/&newline;/gi, "\n")
|
|
457
|
+
.replace(/&tab;/gi, "\t")
|
|
458
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_match, hexadecimal) => decodeHtmlCodePoint(hexadecimal, 16))
|
|
459
|
+
.replace(/&#([0-9]+);?/g, (_match, decimal) => decodeHtmlCodePoint(decimal, 10))
|
|
460
|
+
.replace(/"/gi, '"')
|
|
461
|
+
.replace(/'/gi, "'")
|
|
462
|
+
.replace(/</gi, "<")
|
|
463
|
+
.replace(/>/gi, ">")
|
|
464
|
+
.replace(/&/gi, "&");
|
|
465
|
+
if (next === decoded) {
|
|
466
|
+
return next;
|
|
467
|
+
}
|
|
468
|
+
decoded = next;
|
|
469
|
+
}
|
|
470
|
+
return decoded;
|
|
471
|
+
}
|
|
472
|
+
function decodeHtmlCodePoint(value, radix) {
|
|
473
|
+
const codePoint = Number.parseInt(value, radix);
|
|
474
|
+
return Number.isSafeInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
|
|
475
|
+
? String.fromCodePoint(codePoint)
|
|
476
|
+
: "\ufffd";
|
|
477
|
+
}
|
|
478
|
+
function getString(values, key) {
|
|
479
|
+
const value = values[key];
|
|
480
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
481
|
+
}
|
|
482
|
+
function getNestedString(values, key, nestedKey) {
|
|
483
|
+
const value = values[key];
|
|
484
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
const nested = Reflect.get(value, nestedKey);
|
|
488
|
+
return typeof nested === "string" && nested.length > 0 ? nested : undefined;
|
|
489
|
+
}
|
|
490
|
+
function normalizeRoute(route) {
|
|
491
|
+
let pathname = route.trim();
|
|
492
|
+
if (/^https?:\/\//i.test(pathname)) {
|
|
493
|
+
pathname = new URL(pathname).pathname;
|
|
494
|
+
}
|
|
495
|
+
pathname = pathname.split(/[?#]/, 1)[0] ?? "/";
|
|
496
|
+
pathname = pathname.replaceAll("\\", "/").replace(/\/{2,}/g, "/");
|
|
497
|
+
const segments = pathname.split("/").filter((segment) => segment && segment !== "." && segment !== "..");
|
|
498
|
+
return segments.length === 0 ? "/" : `/${segments.join("/")}/`;
|
|
499
|
+
}
|
|
500
|
+
function safeFileStem(value) {
|
|
501
|
+
const stem = value
|
|
502
|
+
.normalize("NFKD")
|
|
503
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
504
|
+
.toLowerCase()
|
|
505
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
506
|
+
.replace(/^-+|-+$/g, "")
|
|
507
|
+
.slice(0, 80);
|
|
508
|
+
return stem || "migrated-content";
|
|
509
|
+
}
|
|
510
|
+
function yamlString(value) {
|
|
511
|
+
return JSON.stringify(value);
|
|
512
|
+
}
|
|
513
|
+
function ensureTrailingSlash(value) {
|
|
514
|
+
return value.endsWith("/") ? value : `${value}/`;
|
|
515
|
+
}
|
|
516
|
+
function sourceUrlFor(project, record, route) {
|
|
517
|
+
const candidate = record.route ?? route;
|
|
518
|
+
if (/^https?:\/\//i.test(candidate)) {
|
|
519
|
+
return candidate;
|
|
520
|
+
}
|
|
521
|
+
if (project.site.url !== undefined) {
|
|
522
|
+
try {
|
|
523
|
+
return new URL(candidate, ensureTrailingSlash(project.site.url)).toString();
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// Preserve the route below; malformed source URLs belong in the repair report.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return candidate;
|
|
530
|
+
}
|
|
531
|
+
function escapeHtml(value) {
|
|
532
|
+
return value
|
|
533
|
+
.replaceAll("&", "&")
|
|
534
|
+
.replaceAll("<", "<")
|
|
535
|
+
.replaceAll(">", ">")
|
|
536
|
+
.replaceAll('"', """)
|
|
537
|
+
.replaceAll("'", "'");
|
|
538
|
+
}
|
|
539
|
+
function escapeHtmlAttribute(value) {
|
|
540
|
+
return escapeHtml(value);
|
|
541
|
+
}
|
|
542
|
+
function renderJson(value) {
|
|
543
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
544
|
+
}
|
|
545
|
+
async function writeNewFile(root, relativePath, contents) {
|
|
546
|
+
const destination = resolve(root, relativePath);
|
|
547
|
+
if (!destination.startsWith(`${root}/`)) {
|
|
548
|
+
throw new Error(`Refusing to write outside output directory: ${relativePath}`);
|
|
549
|
+
}
|
|
550
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
551
|
+
await writeFile(destination, contents, { encoding: "utf8", flag: "wx" });
|
|
552
|
+
}
|
|
553
|
+
function isNodeErrorCode(error, code) {
|
|
554
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
555
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MigrationProject } from "./types.js";
|
|
2
|
+
export declare function renderReport(project: MigrationProject): string;
|
|
3
|
+
export interface WriteReportOptions {
|
|
4
|
+
/** Refuse to replace an existing report file. */
|
|
5
|
+
readonly noClobber?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare function writeReport(project: MigrationProject, outputPath: string, options?: WriteReportOptions): Promise<void>;
|