@topy-ai/maggie 0.7.33 → 0.7.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +16 -3
  3. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +8 -0
  4. package/bundled-skills/maggie-clone/SKILL.md +7 -1
  5. package/bundled-templates/astro-blog/README.md +217 -0
  6. package/bundled-templates/astro-blog/examples/blog.config.ts +30 -0
  7. package/bundled-templates/astro-blog/examples/content.config.ts +28 -0
  8. package/bundled-templates/astro-blog/examples/metadata.ts +37 -0
  9. package/bundled-templates/astro-blog/examples/ops-api.ts +31 -0
  10. package/bundled-templates/astro-blog/examples/route-manifest.ts +21 -0
  11. package/bundled-templates/astro-blog/starter/.env.example +36 -0
  12. package/bundled-templates/astro-blog/starter/README.md +168 -0
  13. package/bundled-templates/astro-blog/starter/astro.config.mjs +19 -0
  14. package/bundled-templates/astro-blog/starter/db/schema.sql +508 -0
  15. package/bundled-templates/astro-blog/starter/package-lock.json +8039 -0
  16. package/bundled-templates/astro-blog/starter/package.json +41 -0
  17. package/bundled-templates/astro-blog/starter/scripts/contract-check.mjs +16 -0
  18. package/bundled-templates/astro-blog/starter/scripts/fixture-seed.mjs +11 -0
  19. package/bundled-templates/astro-blog/starter/scripts/schema-check.mjs +13 -0
  20. package/bundled-templates/astro-blog/starter/scripts/seed-db.mjs +20 -0
  21. package/bundled-templates/astro-blog/starter/src/data/demo.ts +52 -0
  22. package/bundled-templates/astro-blog/starter/src/layouts/BaseLayout.astro +53 -0
  23. package/bundled-templates/astro-blog/starter/src/layouts/OpsLayout.astro +73 -0
  24. package/bundled-templates/astro-blog/starter/src/lib/ai-cmo.ts +14 -0
  25. package/bundled-templates/astro-blog/starter/src/lib/config.ts +15 -0
  26. package/bundled-templates/astro-blog/starter/src/lib/db.ts +95 -0
  27. package/bundled-templates/astro-blog/starter/src/lib/google.ts +28 -0
  28. package/bundled-templates/astro-blog/starter/src/lib/media.ts +12 -0
  29. package/bundled-templates/astro-blog/starter/src/lib/migration-validation.ts +11 -0
  30. package/bundled-templates/astro-blog/starter/src/lib/migration.ts +21 -0
  31. package/bundled-templates/astro-blog/starter/src/lib/ops-auth.ts +21 -0
  32. package/bundled-templates/astro-blog/starter/src/lib/providers/email.ts +45 -0
  33. package/bundled-templates/astro-blog/starter/src/lib/providers/media-storage.ts +37 -0
  34. package/bundled-templates/astro-blog/starter/src/lib/report-delivery.ts +26 -0
  35. package/bundled-templates/astro-blog/starter/src/lib/report-pdf.ts +49 -0
  36. package/bundled-templates/astro-blog/starter/src/lib/repository.ts +72 -0
  37. package/bundled-templates/astro-blog/starter/src/lib/seo-audit.ts +72 -0
  38. package/bundled-templates/astro-blog/starter/src/lib/seo-insights.ts +14 -0
  39. package/bundled-templates/astro-blog/starter/src/lib/seo.ts +33 -0
  40. package/bundled-templates/astro-blog/starter/src/lib/types.ts +58 -0
  41. package/bundled-templates/astro-blog/starter/src/lib/wordpress.ts +847 -0
  42. package/bundled-templates/astro-blog/starter/src/middleware.ts +7 -0
  43. package/bundled-templates/astro-blog/starter/src/pages/404.astro +11 -0
  44. package/bundled-templates/astro-blog/starter/src/pages/about.astro +15 -0
  45. package/bundled-templates/astro-blog/starter/src/pages/api/approval/[token].ts +25 -0
  46. package/bundled-templates/astro-blog/starter/src/pages/api/consent.ts +8 -0
  47. package/bundled-templates/astro-blog/starter/src/pages/api/ops/agency.ts +47 -0
  48. package/bundled-templates/astro-blog/starter/src/pages/api/ops/authors.ts +11 -0
  49. package/bundled-templates/astro-blog/starter/src/pages/api/ops/bulk.ts +58 -0
  50. package/bundled-templates/astro-blog/starter/src/pages/api/ops/calendar/publish-due.ts +12 -0
  51. package/bundled-templates/astro-blog/starter/src/pages/api/ops/config/validate.ts +4 -0
  52. package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state/batch.ts +19 -0
  53. package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state.ts +15 -0
  54. package/bundled-templates/astro-blog/starter/src/pages/api/ops/ctas.ts +5 -0
  55. package/bundled-templates/astro-blog/starter/src/pages/api/ops/entities.ts +23 -0
  56. package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/optimize.ts +5 -0
  57. package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/upload.ts +30 -0
  58. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/resume.ts +28 -0
  59. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/validate.ts +6 -0
  60. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id].ts +7 -0
  61. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/import.ts +458 -0
  62. package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/media.ts +5 -0
  63. package/bundled-templates/astro-blog/starter/src/pages/api/ops/operations.ts +189 -0
  64. package/bundled-templates/astro-blog/starter/src/pages/api/ops/playbooks/[id]/run.ts +55 -0
  65. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/approve.ts +13 -0
  66. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/preview.ts +9 -0
  67. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/publish.ts +13 -0
  68. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id].ts +18 -0
  69. package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/index.ts +28 -0
  70. package/bundled-templates/astro-blog/starter/src/pages/api/ops/providers.ts +6 -0
  71. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/history.ts +3 -0
  72. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/latest.ts +3 -0
  73. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/posts.ts +3 -0
  74. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/project-context.ts +4 -0
  75. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync-updates.ts +43 -0
  76. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync.ts +172 -0
  77. package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/updates.ts +3 -0
  78. package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects/check.ts +5 -0
  79. package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects.ts +32 -0
  80. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver-due.ts +12 -0
  81. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver.ts +6 -0
  82. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/export.ts +17 -0
  83. package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports.ts +31 -0
  84. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/history/[contentId].ts +3 -0
  85. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/policy.ts +4 -0
  86. package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/queue.ts +4 -0
  87. package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/audit.ts +7 -0
  88. package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/insights.ts +5 -0
  89. package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings/site.ts +7 -0
  90. package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings.ts +19 -0
  91. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/auto-detect.ts +13 -0
  92. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/diff.ts +11 -0
  93. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/match.ts +16 -0
  94. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/matching-history.ts +4 -0
  95. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/runs.ts +4 -0
  96. package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap.ts +11 -0
  97. package/bundled-templates/astro-blog/starter/src/pages/api/ops/summary.ts +12 -0
  98. package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics/[id]/faq.ts +12 -0
  99. package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics.ts +11 -0
  100. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/capabilities.ts +5 -0
  101. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migration-plan.ts +21 -0
  102. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/resume.ts +5 -0
  103. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/validate.ts +6 -0
  104. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id].ts +13 -0
  105. package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations.ts +147 -0
  106. package/bundled-templates/astro-blog/starter/src/pages/authors/[slug].astro +12 -0
  107. package/bundled-templates/astro-blog/starter/src/pages/blog/[slug].astro +25 -0
  108. package/bundled-templates/astro-blog/starter/src/pages/blog/index.astro +32 -0
  109. package/bundled-templates/astro-blog/starter/src/pages/blog/page/[page].astro +21 -0
  110. package/bundled-templates/astro-blog/starter/src/pages/ops/index.astro +20 -0
  111. package/bundled-templates/astro-blog/starter/src/pages/ops/operations.astro +33 -0
  112. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/edit.astro +17 -0
  113. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/preview.astro +11 -0
  114. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/index.astro +15 -0
  115. package/bundled-templates/astro-blog/starter/src/pages/ops/posts/new.astro +12 -0
  116. package/bundled-templates/astro-blog/starter/src/pages/ops/reports.astro +12 -0
  117. package/bundled-templates/astro-blog/starter/src/pages/ops/rewrite.astro +13 -0
  118. package/bundled-templates/astro-blog/starter/src/pages/ops/settings/integrations.astro +13 -0
  119. package/bundled-templates/astro-blog/starter/src/pages/ops/settings/site.astro +11 -0
  120. package/bundled-templates/astro-blog/starter/src/pages/ops/sitemap.astro +15 -0
  121. package/bundled-templates/astro-blog/starter/src/pages/ops/topics.astro +13 -0
  122. package/bundled-templates/astro-blog/starter/src/pages/ops/wordpress.astro +34 -0
  123. package/bundled-templates/astro-blog/starter/src/pages/review/[token].astro +13 -0
  124. package/bundled-templates/astro-blog/starter/src/pages/robots.txt.ts +4 -0
  125. package/bundled-templates/astro-blog/starter/src/pages/sitemap.xml.ts +12 -0
  126. package/bundled-templates/astro-blog/starter/src/pages/topics/[slug]/page/[page].astro +10 -0
  127. package/bundled-templates/astro-blog/starter/src/pages/topics/[slug].astro +19 -0
  128. package/bundled-templates/astro-blog/starter/src/pages/topics/index.astro +10 -0
  129. package/bundled-templates/astro-blog/starter/src/styles/global.css +4 -0
  130. package/bundled-templates/astro-blog/starter/tsconfig.json +9 -0
  131. package/bundled-templates/astro-blog/starter/wrangler.jsonc +52 -0
  132. package/bundled-tools/clis/maggie_clone.py +42 -15
  133. package/bundled-tools/clis/maggie_dash.py +41 -25
  134. package/bundled-tools/clis/maggie_design.py +2 -0
  135. package/bundled-tools/clis/maggie_design_system.py +7 -1
  136. package/bundled-tools/clis/site_audit.py +25 -2
  137. package/bundled-tools/runtime/maggie_memory.py +6 -1
  138. package/package.json +1 -1
@@ -0,0 +1,847 @@
1
+ import { createHash } from "node:crypto";
2
+ import { audit, db, id, now } from "./db";
3
+
4
+ export type WordPressImportOptions = {
5
+ maxPosts: number;
6
+ maxTerms: number;
7
+ mediaMode: "featured" | "all";
8
+ preservePublished: boolean;
9
+ includeDrafts: boolean;
10
+ incremental: boolean;
11
+ syncMode: "all" | "modified" | "new";
12
+ detectDeleted: boolean;
13
+ conflictStrategy: "wordpress-wins" | "maggie-wins" | "manual";
14
+ postTypes: string[];
15
+ mapping: {
16
+ categoryToTopic: Record<string, string>;
17
+ authorToAuthor: Record<string, string>;
18
+ tagToTaxonomy: string;
19
+ featuredImageToMedia: boolean;
20
+ seo: Record<string, boolean>;
21
+ };
22
+ };
23
+ export type WordPressCounts = {
24
+ posts: {
25
+ total: number;
26
+ created: number;
27
+ updated: number;
28
+ skipped: number;
29
+ deleted: number;
30
+ conflicts: number;
31
+ };
32
+ categories: number;
33
+ tags: number;
34
+ authors: number;
35
+ media: number;
36
+ redirects: number;
37
+ };
38
+ type WpItem = any;
39
+
40
+ function apiRoot(sourceUrl: string): string {
41
+ const input = new URL(sourceUrl);
42
+ if (input.pathname.includes("/wp-json/wp/v2"))
43
+ return input.toString().replace(/\/$/, "");
44
+ return `${input.origin}${input.pathname.replace(/\/$/, "")}/wp-json/wp/v2`.replace(
45
+ /\/\/wp-json/,
46
+ "/wp-json",
47
+ );
48
+ }
49
+ function headers(): HeadersInit {
50
+ const result: Record<string, string> = { accept: "application/json" };
51
+ if (import.meta.env.WORDPRESS_API_TOKEN)
52
+ result.authorization = `Bearer ${import.meta.env.WORDPRESS_API_TOKEN}`;
53
+ else if (
54
+ import.meta.env.WORDPRESS_USERNAME &&
55
+ import.meta.env.WORDPRESS_APP_PASSWORD
56
+ )
57
+ result.authorization = `Basic ${btoa(`${import.meta.env.WORDPRESS_USERNAME}:${import.meta.env.WORDPRESS_APP_PASSWORD}`)}`;
58
+ return result;
59
+ }
60
+ async function fetchPage(
61
+ root: string,
62
+ resource: string,
63
+ page: number,
64
+ perPage = 100,
65
+ includeDrafts = false,
66
+ ): Promise<{ items: WpItem[]; total: number; totalPages: number }> {
67
+ const url = new URL(`${root}/${resource}`);
68
+ url.searchParams.set("page", String(page));
69
+ url.searchParams.set("per_page", String(perPage));
70
+ if (resource === "posts" || resource === "pages") {
71
+ if (includeDrafts)
72
+ for (const status of ["publish", "draft", "pending", "private", "future"])
73
+ url.searchParams.append("status[]", status);
74
+ else url.searchParams.set("status", "publish");
75
+ url.searchParams.set("_embed", "author,wp:term,wp:featuredmedia");
76
+ }
77
+ const response = await fetch(url, {
78
+ headers: headers(),
79
+ signal: AbortSignal.timeout(30000),
80
+ });
81
+ const raw = await response.text();
82
+ let body: any;
83
+ try {
84
+ body = JSON.parse(raw);
85
+ } catch {
86
+ throw new Error(`WordPress ${resource} returned invalid JSON`);
87
+ }
88
+ if (!response.ok)
89
+ throw new Error(
90
+ `WordPress ${resource} returned ${response.status}: ${JSON.stringify(body).slice(0, 500)}`,
91
+ );
92
+ return {
93
+ items: Array.isArray(body) ? body : [],
94
+ total: Number(response.headers.get("X-WP-Total") || body.length || 0),
95
+ totalPages: Number(response.headers.get("X-WP-TotalPages") || page),
96
+ };
97
+ }
98
+ async function fetchCollection(
99
+ root: string,
100
+ resource: string,
101
+ limit: number,
102
+ includeDrafts = false,
103
+ ): Promise<{ items: WpItem[]; total: number }> {
104
+ const items: WpItem[] = [];
105
+ let page = 1;
106
+ let total = 0;
107
+ let totalPages = 1;
108
+ while (page <= totalPages && items.length < limit) {
109
+ const result = await fetchPage(root, resource, page, 100, includeDrafts);
110
+ total = result.total;
111
+ totalPages = result.totalPages;
112
+ items.push(...result.items.slice(0, limit - items.length));
113
+ page++;
114
+ }
115
+ return { items, total };
116
+ }
117
+ async function fetchPostTypes(
118
+ root: string,
119
+ options: WordPressImportOptions,
120
+ ): Promise<{ items: WpItem[]; total: number }> {
121
+ const resources = (options.postTypes || ["post"])
122
+ .map((type) =>
123
+ type === "page" ? "pages" : type === "post" ? "posts" : type,
124
+ )
125
+ .filter((resource) => /^[a-z0-9_-]+$/.test(resource));
126
+ const results = await Promise.all(
127
+ resources.map((resource) =>
128
+ fetchCollection(
129
+ root,
130
+ resource,
131
+ options.maxPosts,
132
+ options.includeDrafts,
133
+ ).catch(() => ({ items: [] as WpItem[], total: 0 })),
134
+ ),
135
+ );
136
+ return {
137
+ items: results.flatMap((result) => result.items).slice(0, options.maxPosts),
138
+ total: results.reduce((total, result) => total + result.total, 0),
139
+ };
140
+ }
141
+ function text(value: any): string {
142
+ return String(value?.rendered ?? value ?? "")
143
+ .replace(/<[^>]*>/g, " ")
144
+ .replace(/\s+/g, " ")
145
+ .trim();
146
+ }
147
+ function slug(value: string): string {
148
+ return (
149
+ value
150
+ .toLowerCase()
151
+ .normalize("NFKD")
152
+ .replace(/[^\w\s-]/g, "")
153
+ .trim()
154
+ .replace(/[\s_-]+/g, "-")
155
+ .replace(/^-+|-+$/g, "")
156
+ .slice(0, 180) || "untitled"
157
+ );
158
+ }
159
+ function sourceLink(item: WpItem): string | undefined {
160
+ return typeof item.link === "string" && /^https?:\/\//.test(item.link)
161
+ ? item.link
162
+ : undefined;
163
+ }
164
+ function embeddedTerms(post: WpItem): WpItem[] {
165
+ return (post?._embedded?.["wp:term"] || [])
166
+ .flat()
167
+ .filter(
168
+ (term: WpItem) =>
169
+ term?.taxonomy === "category" || term?.taxonomy === "post_tag",
170
+ );
171
+ }
172
+ function embeddedMedia(post: WpItem): WpItem | undefined {
173
+ return post?._embedded?.["wp:featuredmedia"]?.[0];
174
+ }
175
+ function defaultOptions(
176
+ options?: Partial<WordPressImportOptions>,
177
+ ): WordPressImportOptions {
178
+ return {
179
+ maxPosts: Math.min(Math.max(options?.maxPosts || 100, 1), 10000),
180
+ maxTerms: Math.min(Math.max(options?.maxTerms || 1000, 1), 10000),
181
+ mediaMode: options?.mediaMode === "all" ? "all" : "featured",
182
+ preservePublished: options?.preservePublished === true,
183
+ includeDrafts: options?.includeDrafts === true,
184
+ incremental: options?.incremental === true,
185
+ syncMode:
186
+ options?.syncMode === "modified" || options?.syncMode === "new"
187
+ ? options.syncMode
188
+ : "all",
189
+ detectDeleted: options?.detectDeleted === true,
190
+ conflictStrategy:
191
+ options?.conflictStrategy === "wordpress-wins" ||
192
+ options?.conflictStrategy === "maggie-wins"
193
+ ? options.conflictStrategy
194
+ : "manual",
195
+ postTypes:
196
+ Array.isArray(options?.postTypes) && options.postTypes.length
197
+ ? options.postTypes.map(String)
198
+ : ["post"],
199
+ mapping: {
200
+ categoryToTopic: options?.mapping?.categoryToTopic || {},
201
+ authorToAuthor: options?.mapping?.authorToAuthor || {},
202
+ tagToTaxonomy: options?.mapping?.tagToTaxonomy || "tag",
203
+ featuredImageToMedia: options?.mapping?.featuredImageToMedia !== false,
204
+ seo: options?.mapping?.seo || {
205
+ yoast: true,
206
+ rankMath: true,
207
+ aioseo: true,
208
+ customFields: true,
209
+ },
210
+ },
211
+ };
212
+ }
213
+ function checksum(item: WpItem): string {
214
+ return createHash("sha256")
215
+ .update(
216
+ JSON.stringify({
217
+ id: item.id,
218
+ slug: item.slug,
219
+ title: item.title,
220
+ content: item.content,
221
+ modified: item.modified,
222
+ }),
223
+ )
224
+ .digest("hex");
225
+ }
226
+ function normalizedSeo(item: WpItem): {
227
+ seoTitle: string | null;
228
+ metaDescription: string | null;
229
+ focusKeyword: string | null;
230
+ canonicalOverride: string | null;
231
+ robotsIndex: string;
232
+ robotsFollow: string;
233
+ ogImageUrl: string | null;
234
+ schemaType: string;
235
+ } {
236
+ const meta = item.meta || {};
237
+ const yoast = item.yoast_head_json || {};
238
+ const value = (...values: any[]) =>
239
+ values
240
+ .find((candidate) => typeof candidate === "string" && candidate.trim())
241
+ ?.trim() || null;
242
+ const robots =
243
+ value(
244
+ meta._yoast_wpseo_meta_robots_noindex,
245
+ meta.rank_math_robots,
246
+ meta._aioseo_robots,
247
+ ) || "index,follow";
248
+ const customMetadata = Object.fromEntries(
249
+ Object.entries(meta).filter(
250
+ ([key]) => !/^(_yoast|rank_math|_aioseo)/i.test(key),
251
+ ),
252
+ );
253
+ const target = item.id
254
+ ? (db()
255
+ .prepare("SELECT id FROM posts WHERE source='cms' AND source_id=?")
256
+ .get(`wordpress:${item.id}`) as { id: string } | undefined)
257
+ : undefined;
258
+ if (target)
259
+ db()
260
+ .prepare("UPDATE posts SET custom_metadata_json=? WHERE id=?")
261
+ .run(JSON.stringify(customMetadata), target.id);
262
+ return {
263
+ seoTitle: value(
264
+ yoast.title,
265
+ meta._yoast_wpseo_title,
266
+ meta.rank_math_title,
267
+ meta._aioseo_title,
268
+ ),
269
+ metaDescription: value(
270
+ yoast.description,
271
+ meta._yoast_wpseo_metadesc,
272
+ meta.rank_math_description,
273
+ meta._aioseo_description,
274
+ ),
275
+ focusKeyword: value(
276
+ meta._yoast_wpseo_focuskw,
277
+ meta.rank_math_focus_keyword,
278
+ meta._aioseo_keyphrases,
279
+ ),
280
+ canonicalOverride: value(
281
+ yoast.canonical,
282
+ meta._yoast_wpseo_canonical,
283
+ meta.rank_math_canonical_url,
284
+ meta._aioseo_canonical_url,
285
+ ),
286
+ robotsIndex: /noindex/i.test(robots) ? "noindex" : "index",
287
+ robotsFollow: /nofollow/i.test(robots) ? "nofollow" : "follow",
288
+ ogImageUrl: value(
289
+ yoast.og_image?.[0]?.url,
290
+ meta["_yoast_wpseo_opengraph-image"],
291
+ meta.rank_math_facebook_image,
292
+ ),
293
+ schemaType:
294
+ value(
295
+ meta._yoast_wpseo_schema_page_type,
296
+ meta.rank_math_schema_type,
297
+ meta._aioseo_schema_type,
298
+ ) || "Article",
299
+ };
300
+ }
301
+
302
+ export async function inspectWordPressCapabilities(sourceUrl: string): Promise<{
303
+ apiRoot: string;
304
+ namespaces: string[];
305
+ postTypes: Array<{ slug: string; name: string; restBase?: string }>;
306
+ supportsRest: boolean;
307
+ }> {
308
+ const root = apiRoot(sourceUrl).replace(/\/wp\/v2$/, "");
309
+ const response = await fetch(root, {
310
+ headers: headers(),
311
+ signal: AbortSignal.timeout(30000),
312
+ });
313
+ const body = (await response.json().catch(() => ({}))) as any;
314
+ const routes = Object.keys(body?.routes || {});
315
+ const postTypes = [
316
+ { slug: "post", name: "Posts", restBase: "posts" },
317
+ { slug: "page", name: "Pages", restBase: "pages" },
318
+ ].filter(
319
+ (item) =>
320
+ !routes.length ||
321
+ routes.some((route) => route.endsWith(`/${item.restBase}`)),
322
+ );
323
+ return {
324
+ apiRoot: apiRoot(sourceUrl),
325
+ namespaces: Array.isArray(body?.namespaces) ? body.namespaces : [],
326
+ postTypes,
327
+ supportsRest: response.ok,
328
+ };
329
+ }
330
+ export async function inspectWordPress(
331
+ sourceUrl: string,
332
+ options?: Partial<WordPressImportOptions>,
333
+ ): Promise<{
334
+ sourceUrl: string;
335
+ totals: { posts: number; categories: number; tags: number; media: number };
336
+ sample: Array<{ id: number; title: string; slug: string; status: string }>;
337
+ }> {
338
+ const root = apiRoot(sourceUrl);
339
+ const opts = defaultOptions(options);
340
+ const [posts, categories, tags] = await Promise.all([
341
+ fetchPostTypes(root, opts),
342
+ fetchCollection(root, "categories", opts.maxTerms),
343
+ fetchCollection(root, "tags", opts.maxTerms),
344
+ ]);
345
+ return {
346
+ sourceUrl,
347
+ totals: {
348
+ posts: posts.total,
349
+ categories: categories.total,
350
+ tags: tags.total,
351
+ media:
352
+ opts.mediaMode === "all"
353
+ ? (await fetchCollection(root, "media", opts.maxTerms)).total
354
+ : posts.items.filter((post) => embeddedMedia(post)).length,
355
+ },
356
+ sample: posts.items.slice(0, 10).map((post) => ({
357
+ id: Number(post.id),
358
+ title: text(post.title),
359
+ slug: String(post.slug || slug(text(post.title))),
360
+ status: String(post.status || "draft"),
361
+ })),
362
+ };
363
+ }
364
+
365
+ export async function importWordPress(
366
+ migrationId: string,
367
+ sourceUrl: string,
368
+ options?: Partial<WordPressImportOptions>,
369
+ ): Promise<WordPressCounts> {
370
+ const opts = defaultOptions(options);
371
+ const root = apiRoot(sourceUrl);
372
+ const [posts, categories, tags] = await Promise.all([
373
+ fetchPostTypes(root, opts),
374
+ fetchCollection(root, "categories", opts.maxTerms),
375
+ fetchCollection(root, "tags", opts.maxTerms),
376
+ ]);
377
+ const media =
378
+ opts.mediaMode === "all"
379
+ ? (await fetchCollection(root, "media", opts.maxTerms)).items
380
+ : posts.items.map(embeddedMedia).filter(Boolean);
381
+ const timestamp = now();
382
+ const sourceSnapshotChecksum = createHash("sha256")
383
+ .update(JSON.stringify(posts.items.map((post) => checksum(post)).sort()))
384
+ .digest("hex");
385
+ const counts: WordPressCounts = {
386
+ posts: {
387
+ total: posts.total,
388
+ created: 0,
389
+ updated: 0,
390
+ skipped: 0,
391
+ deleted: 0,
392
+ conflicts: 0,
393
+ },
394
+ categories: 0,
395
+ tags: 0,
396
+ authors: 0,
397
+ media: 0,
398
+ redirects: 0,
399
+ };
400
+ const transaction = db().transaction(() => {
401
+ for (const item of categories.items) {
402
+ const sourceId = String(item.id);
403
+ const mappedTopicId = opts.mapping.categoryToTopic[sourceId];
404
+ const topicId =
405
+ mappedTopicId &&
406
+ db().prepare("SELECT id FROM topics WHERE id=?").get(mappedTopicId)
407
+ ? mappedTopicId
408
+ : `topic-wp-category-${sourceId}`;
409
+ const existing = db()
410
+ .prepare(
411
+ "SELECT id FROM wp_terms WHERE taxonomy='category' AND source_id=?",
412
+ )
413
+ .get(sourceId) as { id: string } | undefined;
414
+ if (!mappedTopicId)
415
+ db()
416
+ .prepare(
417
+ "INSERT INTO topics (id,slug,title,description,status,minimum_published_posts,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET title=excluded.title,description=excluded.description,updated_at=excluded.updated_at",
418
+ )
419
+ .run(
420
+ topicId,
421
+ `wp-${slug(String(item.slug || item.name))}-${sourceId}`,
422
+ String(item.name || "Untitled category"),
423
+ text(item.description),
424
+ "draft",
425
+ 0,
426
+ timestamp,
427
+ timestamp,
428
+ );
429
+ const termId = existing?.id || id("wp_term");
430
+ db()
431
+ .prepare(
432
+ "INSERT INTO wp_terms (id,taxonomy,source_id,name,slug,description,topic_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?) ON CONFLICT(taxonomy,source_id) DO UPDATE SET name=excluded.name,slug=excluded.slug,description=excluded.description,topic_id=excluded.topic_id,updated_at=excluded.updated_at",
433
+ )
434
+ .run(
435
+ termId,
436
+ "category",
437
+ sourceId,
438
+ String(item.name || ""),
439
+ String(item.slug || slug(item.name)),
440
+ text(item.description),
441
+ topicId,
442
+ timestamp,
443
+ timestamp,
444
+ );
445
+ counts.categories++;
446
+ recordItem(migrationId, "category", sourceId, topicId, "updated");
447
+ }
448
+ for (const item of tags.items) {
449
+ const sourceId = String(item.id);
450
+ const existing = db()
451
+ .prepare("SELECT id FROM wp_terms WHERE taxonomy='tag' AND source_id=?")
452
+ .get(sourceId) as { id: string } | undefined;
453
+ const termId = existing?.id || id("wp_term");
454
+ db()
455
+ .prepare(
456
+ "INSERT INTO wp_terms (id,taxonomy,source_id,name,slug,description,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(taxonomy,source_id) DO UPDATE SET name=excluded.name,slug=excluded.slug,updated_at=excluded.updated_at",
457
+ )
458
+ .run(
459
+ termId,
460
+ "tag",
461
+ sourceId,
462
+ String(item.name || ""),
463
+ String(item.slug || slug(item.name)),
464
+ text(item.description),
465
+ timestamp,
466
+ timestamp,
467
+ );
468
+ counts.tags++;
469
+ recordItem(migrationId, "tag", sourceId, termId, "updated");
470
+ }
471
+ const authorIds = new Map<string, string>();
472
+ for (const post of posts.items) {
473
+ const author = post._embedded?.author?.[0];
474
+ if (!author?.id) continue;
475
+ const sourceId = String(author.id);
476
+ const authorId = `author-wp-${sourceId}`;
477
+ db()
478
+ .prepare(
479
+ "INSERT INTO authors (id,slug,name,bio,profile_url,credentials_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,bio=excluded.bio,profile_url=excluded.profile_url,updated_at=excluded.updated_at",
480
+ )
481
+ .run(
482
+ authorId,
483
+ `wp-${slug(text(author.name))}-${sourceId}`,
484
+ text(author.name) || `WordPress author ${sourceId}`,
485
+ text(author.description),
486
+ author.link || null,
487
+ "[]",
488
+ timestamp,
489
+ timestamp,
490
+ );
491
+ authorIds.set(sourceId, authorId);
492
+ counts.authors++;
493
+ recordItem(migrationId, "author", sourceId, authorId, "updated");
494
+ }
495
+ const mediaIds = new Map<string, string>();
496
+ for (const item of media) {
497
+ if (!item?.id || !item.source_url) continue;
498
+ const sourceId = String(item.id);
499
+ const existing = db()
500
+ .prepare(
501
+ "SELECT id FROM media_assets WHERE source='wordpress' AND source_id=?",
502
+ )
503
+ .get(sourceId) as { id: string } | undefined;
504
+ const mediaId = existing?.id || id("media");
505
+ const details = item.media_details || {};
506
+ db()
507
+ .prepare(
508
+ "INSERT INTO media_assets (id,source,source_id,source_url,mime_type,alt_text,caption,width,height,metadata_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(source,source_id) DO UPDATE SET source_url=excluded.source_url,mime_type=excluded.mime_type,alt_text=excluded.alt_text,caption=excluded.caption,width=excluded.width,height=excluded.height,metadata_json=excluded.metadata_json,updated_at=excluded.updated_at",
509
+ )
510
+ .run(
511
+ mediaId,
512
+ "wordpress",
513
+ sourceId,
514
+ String(item.source_url),
515
+ item.mime_type || null,
516
+ String(item.alt_text || ""),
517
+ text(item.caption),
518
+ details.width || null,
519
+ details.height || null,
520
+ JSON.stringify({
521
+ title: text(item.title),
522
+ mediaType: item.media_type,
523
+ }),
524
+ timestamp,
525
+ timestamp,
526
+ );
527
+ const stored = db()
528
+ .prepare(
529
+ "SELECT id FROM media_assets WHERE source='wordpress' AND source_id=?",
530
+ )
531
+ .get(sourceId) as { id: string };
532
+ mediaIds.set(sourceId, stored.id);
533
+ counts.media++;
534
+ recordItem(migrationId, "media", sourceId, stored.id, "updated");
535
+ }
536
+ const seen = new Set<string>();
537
+ for (const post of posts.items) {
538
+ const sourceId = String(post.id || "");
539
+ seen.add(sourceId);
540
+ const title = text(post.title);
541
+ const originalSlug = String(post.slug || slug(title));
542
+ if (!sourceId || !title) {
543
+ counts.posts.skipped++;
544
+ recordItem(
545
+ migrationId,
546
+ "post",
547
+ sourceId || "unknown",
548
+ null,
549
+ "skipped",
550
+ "missing id or title",
551
+ );
552
+ continue;
553
+ }
554
+ const sourceIdentity = `wordpress:${sourceId}`;
555
+ const existing = db()
556
+ .prepare(
557
+ "SELECT id,slug,checksum,source_modified_at,updated_at FROM posts WHERE source='cms' AND source_id=?",
558
+ )
559
+ .get(sourceIdentity) as
560
+ { id: string; slug: string; checksum?: string; source_modified_at?: string; updated_at?: string } | undefined;
561
+ const sourceHash = checksum(post);
562
+ const sourceModified = post.modified_gmt || post.modified || null;
563
+ if (opts.syncMode === "new" && existing) {
564
+ counts.posts.skipped++;
565
+ continue;
566
+ }
567
+ if (opts.syncMode === "modified" && existing && (existing.checksum === sourceHash || (sourceModified && existing.source_modified_at && existing.source_modified_at >= sourceModified))) {
568
+ counts.posts.skipped++;
569
+ continue;
570
+ }
571
+ if (existing && sourceModified && existing.updated_at && existing.updated_at > sourceModified && opts.conflictStrategy !== "wordpress-wins") {
572
+ counts.posts.conflicts++;
573
+ db().prepare("INSERT INTO migration_conflicts (id,migration_id,source_id,target_id,resolution,details_json,created_at) VALUES (?,?,?,?,?,?,?)").run(id("conflict"), migrationId, sourceIdentity, existing.id, opts.conflictStrategy === "maggie-wins" ? "target-wins" : "manual", JSON.stringify({ sourceModified, targetUpdated: existing.updated_at }), timestamp);
574
+ counts.posts.skipped++; recordItem(migrationId, "post", sourceId, existing.id, "skipped", opts.conflictStrategy === "maggie-wins" ? "target wins conflict" : "manual review required"); continue;
575
+ }
576
+ let targetSlug = existing?.slug || originalSlug;
577
+ const slugOwner = db()
578
+ .prepare("SELECT id FROM posts WHERE slug=?")
579
+ .get(targetSlug) as { id: string } | undefined;
580
+ if (slugOwner && slugOwner.id !== existing?.id) {
581
+ targetSlug = `wp-${sourceId}-${originalSlug}`;
582
+ counts.posts.conflicts++;
583
+ db()
584
+ .prepare(
585
+ "INSERT INTO migration_conflicts (id,migration_id,source_id,target_id,resolution,details_json,created_at) VALUES (?,?,?,?,?,?,?)",
586
+ )
587
+ .run(
588
+ id("conflict"),
589
+ migrationId,
590
+ sourceIdentity,
591
+ slugOwner.id,
592
+ opts.conflictStrategy === "wordpress-wins"
593
+ ? "source-wins"
594
+ : opts.conflictStrategy === "maggie-wins"
595
+ ? "target-wins"
596
+ : "manual",
597
+ JSON.stringify({ sourceSlug: originalSlug, targetSlug }),
598
+ timestamp,
599
+ );
600
+ }
601
+ const author = post._embedded?.author?.[0];
602
+ const authorId =
603
+ authorIds.get(String(author?.id)) ||
604
+ opts.mapping.authorToAuthor[String(author?.id || post.author || "")] ||
605
+ `author-wp-${String(post.author || "unknown")}`;
606
+ if (!db().prepare("SELECT 1 FROM authors WHERE id=?").get(authorId))
607
+ db()
608
+ .prepare(
609
+ "INSERT OR IGNORE INTO authors (id,slug,name,credentials_json,created_at,updated_at) VALUES (?,?,?,?,?,?)",
610
+ )
611
+ .run(
612
+ authorId,
613
+ `wp-unknown-${post.author || "unknown"}`,
614
+ "WordPress author",
615
+ "[]",
616
+ timestamp,
617
+ );
618
+ const isPublished = opts.preservePublished && post.status === "publish";
619
+ const status = isPublished ? "published" : "draft";
620
+ const publishedAt = isPublished
621
+ ? post.date_gmt || post.date || timestamp
622
+ : null;
623
+ const destination = new URL(
624
+ `/blog/${targetSlug}`,
625
+ import.meta.env.PUBLIC_SITE_URL || "https://example.com",
626
+ ).toString();
627
+ const featured = embeddedMedia(post);
628
+ const params = [
629
+ "cms",
630
+ sourceIdentity,
631
+ targetSlug,
632
+ title,
633
+ text(post.excerpt),
634
+ post.content?.rendered || "",
635
+ destination,
636
+ featured?.source_url || null,
637
+ featured?.alt_text || "",
638
+ authorId,
639
+ "en",
640
+ status,
641
+ publishedAt,
642
+ timestamp,
643
+ timestamp,
644
+ sourceModified,
645
+ ];
646
+ if (existing) {
647
+ saveRevision(existing.id, "WordPress migration", timestamp);
648
+ db()
649
+ .prepare(
650
+ "UPDATE posts SET slug=?,title=?,excerpt=?,content_html=?,canonical_url=?,cover_image_url=?,cover_image_alt=?,author_id=?,language=?,status=?,published_at=?,updated_at=?,checksum=?,source_modified_at=? WHERE id=?",
651
+ )
652
+ .run(
653
+ targetSlug,
654
+ title,
655
+ text(post.excerpt),
656
+ post.content?.rendered || "",
657
+ destination,
658
+ featured?.source_url || null,
659
+ featured?.alt_text || "",
660
+ authorId,
661
+ "en",
662
+ status,
663
+ publishedAt,
664
+ timestamp,
665
+ sourceHash,
666
+ sourceModified,
667
+ existing.id,
668
+ );
669
+ counts.posts.updated++;
670
+ recordItem(migrationId, "post", sourceId, existing.id, "updated");
671
+ } else {
672
+ const postId = id("post");
673
+ db()
674
+ .prepare(
675
+ "INSERT INTO posts (id,source,source_id,slug,title,excerpt,content_html,canonical_url,cover_image_url,cover_image_alt,author_id,language,status,published_at,updated_at,created_at,checksum,source_modified_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
676
+ )
677
+ .run(postId, ...params, sourceHash);
678
+ counts.posts.created++;
679
+ recordItem(migrationId, "post", sourceId, postId, "created");
680
+ }
681
+ const seo = normalizedSeo(post);
682
+ const target = db()
683
+ .prepare("SELECT id FROM posts WHERE source='cms' AND source_id=?")
684
+ .get(sourceIdentity) as { id: string };
685
+ db()
686
+ .prepare(
687
+ "UPDATE posts SET seo_title=?,meta_description=?,focus_keyword=?,canonical_override=?,robots_index=?,robots_follow=?,og_image_url=?,schema_type=? WHERE id=?",
688
+ )
689
+ .run(
690
+ seo.seoTitle,
691
+ seo.metaDescription,
692
+ seo.focusKeyword,
693
+ seo.canonicalOverride,
694
+ seo.robotsIndex,
695
+ seo.robotsFollow,
696
+ seo.ogImageUrl,
697
+ seo.schemaType,
698
+ target.id,
699
+ );
700
+ db().prepare("DELETE FROM post_topics WHERE post_id=?").run(target.id);
701
+ db().prepare("DELETE FROM post_wp_terms WHERE post_id=?").run(target.id);
702
+ for (const term of embeddedTerms(post)) {
703
+ const taxonomy = term.taxonomy === "category" ? "category" : "tag";
704
+ const wpTerm = db()
705
+ .prepare(
706
+ "SELECT id,topic_id FROM wp_terms WHERE taxonomy=? AND source_id=?",
707
+ )
708
+ .get(taxonomy, String(term.id)) as
709
+ { id: string; topic_id?: string } | undefined;
710
+ if (!wpTerm) continue;
711
+ db()
712
+ .prepare(
713
+ "INSERT OR IGNORE INTO post_wp_terms (post_id,term_id) VALUES (?,?)",
714
+ )
715
+ .run(target.id, wpTerm.id);
716
+ if (taxonomy === "category" && wpTerm.topic_id)
717
+ db()
718
+ .prepare(
719
+ "INSERT OR IGNORE INTO post_topics (post_id,topic_id) VALUES (?,?)",
720
+ )
721
+ .run(target.id, wpTerm.topic_id);
722
+ }
723
+ if (
724
+ opts.mapping.featuredImageToMedia &&
725
+ featured?.id &&
726
+ mediaIds.get(String(featured.id))
727
+ )
728
+ db()
729
+ .prepare(
730
+ "INSERT OR IGNORE INTO post_media (post_id,media_id,role) VALUES (?,?,?)",
731
+ )
732
+ .run(target.id, mediaIds.get(String(featured.id)), "featured");
733
+ const oldLink = sourceLink(post);
734
+ if (oldLink) {
735
+ const oldPath = new URL(oldLink).pathname;
736
+ const newPath = `/blog/${targetSlug}`;
737
+ if (oldPath !== newPath) {
738
+ db()
739
+ .prepare(
740
+ "INSERT INTO redirects (id,source_path,target_path,status_code,reason,created_at) VALUES (?,?,?,?,?,?) ON CONFLICT(source_path) DO UPDATE SET target_path=excluded.target_path",
741
+ )
742
+ .run(
743
+ id("redirect"),
744
+ oldPath,
745
+ newPath,
746
+ 301,
747
+ "wordpress migration",
748
+ timestamp,
749
+ );
750
+ counts.redirects++;
751
+ }
752
+ }
753
+ }
754
+ if (opts.detectDeleted && opts.syncMode === "all") {
755
+ const old = db()
756
+ .prepare(
757
+ "SELECT id,source_id FROM posts WHERE source='cms' AND source_id LIKE 'wordpress:%'",
758
+ )
759
+ .all() as Array<{ id: string; source_id: string }>;
760
+ for (const item of old)
761
+ if (!seen.has(item.source_id.replace(/^wordpress:/, ""))) {
762
+ db()
763
+ .prepare(
764
+ "UPDATE posts SET status='archived',updated_at=? WHERE id=?",
765
+ )
766
+ .run(timestamp, item.id);
767
+ counts.posts.deleted++;
768
+ }
769
+ }
770
+ db()
771
+ .prepare(
772
+ "UPDATE wp_migrations SET checkpoint_json=?,source_checksum=?,deleted_count=?,conflict_count=? WHERE id=?",
773
+ )
774
+ .run(
775
+ JSON.stringify({
776
+ processed: posts.items.length,
777
+ total: posts.items.length,
778
+ completed: true,
779
+ }),
780
+ sourceSnapshotChecksum,
781
+ counts.posts.deleted,
782
+ counts.posts.conflicts,
783
+ migrationId,
784
+ );
785
+ audit(
786
+ "wordpress.migration.applied",
787
+ "wordpress_migration",
788
+ migrationId,
789
+ null,
790
+ counts,
791
+ );
792
+ });
793
+ transaction();
794
+ return counts;
795
+ }
796
+ function recordItem(
797
+ migrationId: string,
798
+ entityType: string,
799
+ sourceId: string,
800
+ targetId: string | null,
801
+ action: string,
802
+ error?: string,
803
+ ): void {
804
+ db()
805
+ .prepare(
806
+ "INSERT INTO wp_import_items (id,migration_id,entity_type,source_id,target_id,action,error,created_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(migration_id,entity_type,source_id) DO UPDATE SET target_id=excluded.target_id,action=excluded.action,error=excluded.error",
807
+ )
808
+ .run(
809
+ id("wp_item"),
810
+ migrationId,
811
+ entityType,
812
+ sourceId,
813
+ targetId,
814
+ action,
815
+ error || null,
816
+ now(),
817
+ );
818
+ }
819
+ function saveRevision(postId: string, reason: string, timestamp: string): void {
820
+ const snapshot = db()
821
+ .prepare("SELECT * FROM posts WHERE id=?")
822
+ .get(postId) as Record<string, unknown> | undefined;
823
+ if (!snapshot) return;
824
+ const version =
825
+ Number(
826
+ (
827
+ db()
828
+ .prepare(
829
+ "SELECT MAX(version) version FROM post_revisions WHERE post_id=?",
830
+ )
831
+ .get(postId) as any
832
+ )?.version || 0,
833
+ ) + 1;
834
+ db()
835
+ .prepare(
836
+ "INSERT INTO post_revisions (id,post_id,version,snapshot_json,actor_id,reason,created_at) VALUES (?,?,?,?,?,?,?)",
837
+ )
838
+ .run(
839
+ id("revision"),
840
+ postId,
841
+ version,
842
+ JSON.stringify(snapshot),
843
+ "maggie-migration",
844
+ reason,
845
+ timestamp,
846
+ );
847
+ }