@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,41 @@
1
+ {
2
+ "name": "maggie-astro-blog-starter",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "astro dev",
7
+ "build": "astro check && astro build",
8
+ "build:cloudflare": "MAGGIE_DEPLOY_PROVIDER=cloudflare astro check && MAGGIE_DEPLOY_PROVIDER=cloudflare astro build",
9
+ "preview": "astro preview",
10
+ "preview:cloudflare": "MAGGIE_DEPLOY_PROVIDER=cloudflare astro preview",
11
+ "deploy:cloudflare": "npm run build:cloudflare && npx wrangler deploy",
12
+ "deploy:cloudflare:staging": "CLOUDFLARE_ENV=staging npm run build:cloudflare && npx wrangler deploy --env staging",
13
+ "deploy:cloudflare:production": "CLOUDFLARE_ENV=production npm run build:cloudflare && npx wrangler deploy --env production",
14
+ "db:seed": "node scripts/seed-db.mjs",
15
+ "fixture:seed": "node scripts/fixture-seed.mjs",
16
+ "schema:check": "node scripts/schema-check.mjs",
17
+ "contract:check": "node scripts/contract-check.mjs",
18
+ "maggie:routes": "maggie routes .",
19
+ "maggie:schema": "maggie schema .",
20
+ "maggie:seo": "maggie seo .",
21
+ "maggie:migration": "maggie migration .",
22
+ "maggie:env": "maggie env .",
23
+ "maggie:ci": "maggie doctor . --strict --ci --require-bootstrap",
24
+ "maggie:doctor": "maggie doctor . --strict --ci --require-bootstrap"
25
+ },
26
+ "dependencies": {
27
+ "@astrojs/cloudflare": "^14.2.5",
28
+ "@astrojs/node": "^11.1.4",
29
+ "astro": "^7.2.9",
30
+ "better-sqlite3": "^12.6.2",
31
+ "sharp": "^0.35.4",
32
+ "typescript": "^5.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@astrojs/check": "^0.9.10",
36
+ "@tailwindcss/vite": "^4.1.13",
37
+ "@types/better-sqlite3": "^7.6.13",
38
+ "tailwindcss": "^4.1.13",
39
+ "wrangler": "^4.127.0"
40
+ }
41
+ }
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const root = new URL("..", import.meta.url).pathname;
5
+ const requiredFiles = ["src/pages/sitemap.xml.ts", "src/pages/robots.txt.ts", "src/pages/about.astro", "src/pages/authors/[slug].astro", "src/pages/blog/[slug].astro", "src/middleware.ts", "src/pages/api/ops/operations.ts", "src/pages/api/ops/bulk.ts", "src/pages/api/ops/agency.ts", "src/pages/api/ops/entities.ts", "src/pages/api/ops/migrations/import.ts", "src/pages/api/ops/migrations/[id]/resume.ts", "src/pages/api/ops/migrations/[id]/validate.ts", "src/pages/api/ops/wordpress/migration-plan.ts", "src/pages/api/ops/wordpress/migrations/[id]/validate.ts", "src/pages/api/ops/reports/deliver-due.ts", "src/pages/api/ops/media/optimize.ts", "src/pages/api/ops/media/upload.ts", "src/pages/api/ops/redirects/check.ts", "src/pages/api/ops/pull/project-context.ts", "src/pages/api/ops/pull/sync-updates.ts", "src/pages/api/ops/sitemap/matching-history.ts", "src/pages/api/ops/content-tracking/report-state.ts", "src/pages/api/ops/content-tracking/report-state/batch.ts", "src/pages/api/ops/playbooks/[id]/run.ts", "src/pages/api/ops/providers.ts"];
6
+ const checks = {
7
+ required_routes: requiredFiles.every((file) => readFileSync(join(root, file), "utf8")),
8
+ published_sitemap_guard: readFileSync(join(root, "src/pages/sitemap.xml.ts"), "utf8").includes("publishedPosts"),
9
+ private_ops_guard: readFileSync(join(root, "src/middleware.ts"), "utf8").includes("opsAuth"),
10
+ server_only_integrations: readFileSync(join(root, "src/lib/ai-cmo.ts"), "utf8").includes("AI_CMO_API_KEY"),
11
+ migration_idempotency: readFileSync(join(root, "src/pages/api/ops/migrations/import.ts"), "utf8").includes("sourceId") && readFileSync(join(root, "src/pages/api/ops/migrations/import.ts"), "utf8").includes("checksum"),
12
+ metadata_contract: readFileSync(join(root, "src/layouts/BaseLayout.astro"), "utf8").includes("canonical") && readFileSync(join(root, "src/lib/seo.ts"), "utf8").includes("@context"),
13
+ provider_contract: readFileSync(join(root, "src/lib/providers/email.ts"), "utf8").includes("EmailProvider") && readFileSync(join(root, "src/lib/providers/media-storage.ts"), "utf8").includes("MediaStorageProvider"),
14
+ };
15
+ console.log(JSON.stringify({ ok: Object.values(checks).every(Boolean), checks }));
16
+ if (!Object.values(checks).every(Boolean)) process.exit(1);
@@ -0,0 +1,11 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ const path = process.env.BLOG_DB_PATH || ".data/maggie.sqlite";
6
+ mkdirSync(dirname(path), { recursive: true });
7
+ const database = new Database(path);
8
+ database.exec("CREATE TABLE IF NOT EXISTS blog_settings (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at TEXT NOT NULL)");
9
+ const now = new Date().toISOString();
10
+ database.prepare("INSERT INTO blog_settings (key,value_json,updated_at) VALUES ('fixtureMode','true',?) ON CONFLICT(key) DO UPDATE SET value_json='true',updated_at=excluded.updated_at").run(now);
11
+ console.log(JSON.stringify({ ok: true, fixtureMode: true, path }));
@@ -0,0 +1,13 @@
1
+ import Database from "better-sqlite3";
2
+ import { existsSync } from "node:fs";
3
+ const path = process.env.BLOG_DB_PATH || ".data/maggie.sqlite";
4
+ if (!existsSync(path)) { console.error(`Database not found: ${path}`); process.exit(2); }
5
+ const database = new Database(path, { readonly: true });
6
+ const required = ["posts", "post_revisions", "content_calendar", "bulk_operations", "approval_links", "approval_comments", "tasks", "workspaces", "clients", "sites", "projects", "workspace_templates", "workspace_members", "playbooks", "migration_sources", "rewrite_queue_items", "not_found_logs", "notifications", "report_deliveries", "seo_findings", "fact_review_reminders", "content_citations", "query_page_map", "entity_profiles", "entity_relations", "indexability_checks", "sitemap_snapshots", "hreflang_links", "core_web_vitals"];
7
+ const tables = new Set(database.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((row) => row.name));
8
+ const missing = required.filter((name) => !tables.has(name));
9
+ const mediaColumns = new Set(database.prepare("PRAGMA table_info(media_assets)").all().map((row) => row.name));
10
+ const requiredMediaColumns = ["storage_provider", "storage_key", "delivery_url", "upload_status", "upload_error"];
11
+ const missingMediaColumns = requiredMediaColumns.filter((name) => !mediaColumns.has(name));
12
+ console.log(JSON.stringify({ ok: missing.length === 0 && missingMediaColumns.length === 0, missing, missingMediaColumns }));
13
+ process.exit(missing.length || missingMediaColumns.length ? 1 : 0);
@@ -0,0 +1,20 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync, readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+
5
+ const path = process.env.BLOG_DB_PATH || ".data/maggie.sqlite";
6
+ mkdirSync(dirname(path), { recursive: true });
7
+ const database = new Database(path);
8
+ database.exec(readFileSync(resolve("db/schema.sql"), "utf8"));
9
+ const timestamp = new Date().toISOString();
10
+ const transaction = database.transaction(() => {
11
+ database.prepare("INSERT OR IGNORE INTO authors (id,slug,name,job_title,organisation,bio,credentials_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)").run("author-demo", "maggie-team", "Maggie Team", "Editorial team", "Example Site", "The people responsible for researching, reviewing, and maintaining this guide.", JSON.stringify(["Editorial review", "Technical review"]), timestamp, timestamp);
12
+ database.prepare("INSERT OR IGNORE INTO topics (id,slug,title,description,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run("topic-technical-seo", "technical-seo", "Technical SEO", "Practical guidance for crawlability, metadata, and durable content architecture.", "published", timestamp, timestamp);
13
+ database.prepare("INSERT OR IGNORE INTO topics (id,slug,title,description,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)").run("topic-content-ops", "content-operations", "Content Operations", "Reliable workflows for creating, reviewing, updating, and measuring content.", "published", timestamp, timestamp);
14
+ database.prepare("INSERT OR IGNORE INTO posts (id,source,slug,title,excerpt,content_html,canonical_url,cover_image_alt,author_id,language,status,published_at,updated_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)").run("post-foundation", "manual", "stable-blog-foundation", "How to build a stable blog foundation", "A practical model for post identity, publication state, canonical URLs, and technical SEO.", "<p>A durable blog starts with a stable content model and a clear publication boundary.</p>", "https://example.com/blog/stable-blog-foundation", "Abstract editorial workspace", "author-demo", "en", "published", timestamp, timestamp, timestamp);
15
+ database.prepare("INSERT OR IGNORE INTO post_topics (post_id,topic_id) VALUES (?,?)").run("post-foundation", "topic-technical-seo");
16
+ database.prepare("INSERT OR IGNORE INTO post_topics (post_id,topic_id) VALUES (?,?)").run("post-foundation", "topic-content-ops");
17
+ });
18
+ transaction();
19
+ database.close();
20
+ console.log(`Seeded ${path}`);
@@ -0,0 +1,52 @@
1
+ import type { Author, BlogPost, Topic } from "../lib/types";
2
+
3
+ const author: Author = {
4
+ id: "author-demo",
5
+ slug: "maggie-team",
6
+ name: "Maggie Team",
7
+ jobTitle: "Editorial team",
8
+ organisation: "Example Site",
9
+ bio: "The people responsible for researching, reviewing, and maintaining this guide.",
10
+ credentials: ["Editorial review", "Technical review"],
11
+ };
12
+
13
+ export const topics: Topic[] = [
14
+ {
15
+ id: "topic-seo",
16
+ slug: "technical-seo",
17
+ title: "Technical SEO",
18
+ description: "Practical guidance for crawlability, metadata, and durable content architecture.",
19
+ status: "published",
20
+ faq: [{ id: "faq-topic-seo", question: "What is technical SEO?", answer: "It is the set of technical signals that help people and crawlers access and understand a site." }],
21
+ },
22
+ {
23
+ id: "topic-content",
24
+ slug: "content-operations",
25
+ title: "Content Operations",
26
+ description: "Reliable workflows for creating, reviewing, updating, and measuring content.",
27
+ status: "published",
28
+ faq: [{ id: "faq-topic-content", question: "Why use a content workflow?", answer: "A visible workflow separates drafting, review, approval, publishing, and measurement." }],
29
+ },
30
+ ];
31
+
32
+ export const posts: BlogPost[] = [
33
+ {
34
+ id: "post-foundation",
35
+ source: "manual",
36
+ slug: "stable-blog-foundation",
37
+ title: "How to build a stable blog foundation",
38
+ excerpt: "A practical model for post identity, publication state, canonical URLs, and technical SEO.",
39
+ contentHtml: "<p>A durable blog starts with a stable content model and a clear publication boundary.</p><h2>Start with identity</h2><p>Keep the post ID, slug, source identity, and canonical URL separate. A title can change; a published URL should not change silently.</p><h2>Make publication explicit</h2><p>Only approved content with a publication date should appear in public routes and sitemaps.</p>",
40
+ canonicalUrl: "https://example.com/blog/stable-blog-foundation",
41
+ coverImageAlt: "Abstract editorial workspace",
42
+ author,
43
+ topicSlugs: ["technical-seo", "content-operations"],
44
+ faq: [{ id: "faq-foundation", question: "What makes a blog foundation stable?", answer: "Typed content, explicit status, deterministic URLs, and one shared publication query." }],
45
+ ctaLabel: "Plan your content system",
46
+ ctaUrl: "https://example.com/get-started",
47
+ language: "en",
48
+ status: "published",
49
+ publishedAt: "2026-08-28T00:00:00Z",
50
+ updatedAt: "2026-08-28T00:00:00Z",
51
+ },
52
+ ];
@@ -0,0 +1,53 @@
1
+ ---
2
+ import { settingBoolean } from "../lib/db";
3
+ import "../styles/global.css";
4
+
5
+ interface Props {
6
+ title: string;
7
+ description: string;
8
+ canonical: string;
9
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
10
+ ogType?: "website" | "article";
11
+ imageUrl?: string;
12
+ noindex?: boolean;
13
+ robots?: string;
14
+ alternates?: Array<{ language: string; url: string }>;
15
+ }
16
+ const { title, description, canonical, jsonLd, ogType = "website", imageUrl, noindex = false, robots, alternates = [] } = Astro.props;
17
+ const siteName = import.meta.env.PUBLIC_SITE_NAME || "Example Site";
18
+ const json = jsonLd ? JSON.stringify(jsonLd).replace(/</g, "\\u003c") : "";
19
+ const ga4Id = settingBoolean("ga4Enabled") ? import.meta.env.GA4_MEASUREMENT_ID : undefined;
20
+ const analyticsConsent = Astro.cookies.get("analytics_consent")?.value === "granted";
21
+ ---
22
+ <!doctype html>
23
+ <html lang={import.meta.env.PUBLIC_SITE_LOCALE?.slice(0, 2) || "en"}>
24
+ <head>
25
+ <meta charset="UTF-8" />
26
+ <meta name="viewport" content="width=device-width" />
27
+ <title>{title} | {siteName}</title>
28
+ <meta name="description" content={description} />
29
+ <link rel="canonical" href={canonical} />
30
+ {alternates.map((alternate) => <link rel="alternate" hreflang={alternate.language} href={alternate.url} />)}
31
+ <meta name="robots" content={robots || (noindex ? "noindex,nofollow" : "index,follow")} />
32
+ <meta property="og:type" content={ogType} />
33
+ <meta property="og:title" content={title} />
34
+ <meta property="og:description" content={description} />
35
+ <meta property="og:url" content={canonical} />
36
+ {imageUrl && <meta property="og:image" content={imageUrl} />}
37
+ <meta name="twitter:card" content="summary" />
38
+ <meta name="twitter:title" content={title} />
39
+ <meta name="twitter:description" content={description} />
40
+ {imageUrl && <meta name="twitter:image" content={imageUrl} />}
41
+ {import.meta.env.GSC_VERIFICATION_TOKEN && <meta name="google-site-verification" content={import.meta.env.GSC_VERIFICATION_TOKEN} />}
42
+ {ga4Id && analyticsConsent && <><script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${ga4Id}`}></script><script is:inline define:vars={{ ga4Id }}>window.dataLayer = window.dataLayer || []; window.gtag = function(){window.dataLayer.push(arguments)}; window.gtag('js', new Date()); window.gtag('config', ga4Id);</script></>}
43
+ {jsonLd && <script type="application/ld+json" is:inline set:html={json} />}
44
+ </head>
45
+ <body>
46
+ <header><nav aria-label="Primary"><a href="/blog">Blog</a> <a href="/topics">Topics</a></nav></header>
47
+ <main><slot /></main>
48
+ <footer><a href="/blog">Blog</a>{ga4Id && !analyticsConsent && <button id="analytics-consent" type="button">Allow analytics</button>}</footer>
49
+ </body>
50
+ </html>
51
+ {ga4Id && !analyticsConsent && <script is:inline>
52
+ document.querySelector("#analytics-consent")?.addEventListener("click", async () => { await fetch("/api/consent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ analytics: true }) }); location.reload(); });
53
+ </script>}
@@ -0,0 +1,73 @@
1
+ ---
2
+ interface Props {
3
+ title: string;
4
+ description: string;
5
+ canonical: string;
6
+ }
7
+ const { title, description, canonical } = Astro.props;
8
+ const pathname = Astro.url.pathname;
9
+ const siteName = import.meta.env.PUBLIC_SITE_NAME || "Maggie";
10
+ const navigation = [
11
+ { label: "Overview", href: "/ops", icon: "⌂" },
12
+ { label: "Content", href: "/ops/posts", icon: "✦" },
13
+ { label: "Topics & FAQs", href: "/ops/topics", icon: "#" },
14
+ { label: "Sitemap & matching", href: "/ops/sitemap", icon: "↗" },
15
+ { label: "Rewrite queue", href: "/ops/rewrite", icon: "✎" },
16
+ { label: "Reports", href: "/ops/reports", icon: "▥" },
17
+ ];
18
+ const operations = [
19
+ { label: "Calendar & operations", href: "/ops/operations" },
20
+ { label: "WordPress migration", href: "/ops/wordpress" },
21
+ { label: "Integrations", href: "/ops/settings/integrations" },
22
+ { label: "Site settings", href: "/ops/settings/site" },
23
+ ];
24
+ ---
25
+ <!doctype html>
26
+ <html lang={import.meta.env.PUBLIC_SITE_LOCALE?.slice(0, 2) || "en"}>
27
+ <head>
28
+ <meta charset="UTF-8" />
29
+ <meta name="viewport" content="width=device-width" />
30
+ <title>{title} · {siteName} Ops</title>
31
+ <meta name="description" content={description} />
32
+ <link rel="canonical" href={canonical} />
33
+ <meta name="robots" content="noindex,nofollow" />
34
+ <style is:global>
35
+ :root { --maggie-ink: #20232b; --maggie-muted: #737785; --maggie-line: #e6e7eb; --maggie-panel: #fff; --maggie-canvas: #f7f7f8; --maggie-accent: #6d5dfc; --maggie-accent-soft: #eeecff; --maggie-success: #238a62; --maggie-warn: #b7791f; font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; }
36
+ * { box-sizing: border-box; } body { margin: 0; color: var(--maggie-ink); background: var(--maggie-canvas); } a { color: inherit; text-decoration: none; }
37
+ .maggie-shell { min-height: 100vh; display: flex; } .maggie-sidebar { width: 248px; flex: 0 0 248px; min-height: 100vh; background: var(--maggie-panel); border-right: 1px solid var(--maggie-line); padding: 24px 14px; display: flex; flex-direction: column; }
38
+ .maggie-brand { display: flex; align-items: center; gap: 10px; padding: 0 12px 28px; font-weight: 750; letter-spacing: -.02em; } .maggie-mark { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; color: #fff; background: linear-gradient(135deg,#6657ff,#ba68f0); font-size: 18px; } .maggie-brand small { display: block; color: var(--maggie-muted); font-size: 10px; font-weight: 600; letter-spacing: .09em; text-transform: uppercase; }
39
+ .maggie-nav-label { padding: 16px 12px 7px; color: #a0a3ad; font-size: 10px; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } .maggie-nav { display: grid; gap: 3px; } .maggie-nav a { display: flex; align-items: center; gap: 11px; border-radius: 8px; padding: 10px 12px; color: #666a77; font-size: 13px; font-weight: 550; } .maggie-nav a:hover { background: #f1f1f5; color: var(--maggie-ink); } .maggie-nav a[aria-current="page"] { color: #5547e8; background: var(--maggie-accent-soft); font-weight: 700; } .maggie-nav-icon { width: 18px; text-align: center; font-size: 15px; }
40
+ .maggie-sidebar-foot { margin-top: auto; border-top: 1px solid var(--maggie-line); padding: 18px 12px 0; color: var(--maggie-muted); font-size: 12px; } .maggie-sidebar-foot a { display: block; margin-top: 10px; color: var(--maggie-accent); font-weight: 650; }
41
+ .maggie-main { min-width: 0; flex: 1; } .maggie-topbar { height: 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 40px; background: var(--maggie-panel); border-bottom: 1px solid var(--maggie-line); } .maggie-breadcrumb { color: var(--maggie-muted); font-size: 13px; } .maggie-breadcrumb strong { color: var(--maggie-ink); } .maggie-top-actions { display: flex; align-items: center; gap: 18px; color: var(--maggie-muted); font-size: 13px; } .maggie-view-site { color: var(--maggie-accent); font-weight: 650; } .maggie-avatar { width: 31px; height: 31px; display: grid; place-items: center; border-radius: 50%; color: #fff; background: #252832; font-size: 12px; font-weight: 700; }
42
+ .maggie-content { width: min(1180px, 100%); margin: 0 auto; padding: 38px 40px 64px; } .maggie-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 28px; } .maggie-page-head h1 { margin: 0 0 7px; font-size: 27px; letter-spacing: -.035em; } .maggie-page-head p { margin: 0; color: var(--maggie-muted); font-size: 14px; } .maggie-primary { display: inline-flex; align-items: center; gap: 7px; border: 0; border-radius: 8px; padding: 10px 15px; color: #fff; background: var(--maggie-accent); font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; } .maggie-primary:hover { filter: brightness(.95); }
43
+ .maggie-grid { display: grid; gap: 16px; } .maggie-metrics { grid-template-columns: repeat(4, minmax(0, 1fr)); } .maggie-two-col { grid-template-columns: minmax(0, 1.25fr) minmax(280px, .75fr); margin-top: 22px; } .maggie-card { min-width: 0; padding: 21px; border: 1px solid var(--maggie-line); border-radius: 11px; background: var(--maggie-panel); box-shadow: 0 1px 1px rgb(25 28 36 / 2%); } .maggie-card h2 { margin: 0; font-size: 15px; letter-spacing: -.015em; } .maggie-card p { color: var(--maggie-muted); font-size: 13px; line-height: 1.55; } .maggie-metric-label { color: var(--maggie-muted); font-size: 12px; } .maggie-metric-value { margin-top: 10px; font-size: 30px; font-weight: 750; letter-spacing: -.04em; } .maggie-metric-note { margin-top: 6px; color: var(--maggie-success); font-size: 11px; font-weight: 650; }
44
+ .maggie-card-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding-bottom: 16px; border-bottom: 1px solid var(--maggie-line); } .maggie-card-head a { color: var(--maggie-accent); font-size: 12px; font-weight: 650; } .maggie-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; } .maggie-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 0; border-bottom: 1px solid var(--maggie-line); font-size: 13px; } .maggie-list li:last-child { border-bottom: 0; padding-bottom: 0; } .maggie-list small { display: block; margin-top: 4px; color: var(--maggie-muted); font-size: 11px; } .maggie-pill { border-radius: 99px; padding: 4px 8px; background: #f1f1f4; color: var(--maggie-muted); font-size: 10px; font-weight: 700; } .maggie-pill.warn { color: var(--maggie-warn); background: #fff5de; } .maggie-action-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 17px; } .maggie-action { display: block; border: 1px solid var(--maggie-line); border-radius: 8px; padding: 13px; background: #fff; } .maggie-action:hover { border-color: #bbb4ff; background: #faf9ff; } .maggie-action strong { display: block; font-size: 12px; } .maggie-action span { display: block; margin-top: 4px; color: var(--maggie-muted); font-size: 11px; line-height: 1.4; }
45
+ .maggie-mobile-menu { display: none; } @media (max-width: 850px) { .maggie-sidebar { width: 204px; flex-basis: 204px; } .maggie-topbar { padding: 0 24px; } .maggie-content { padding: 30px 24px 52px; } .maggie-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
46
+ @media (max-width: 620px) { .maggie-sidebar { display: none; } .maggie-sidebar.is-open { display: flex; position: fixed; inset: 0 auto 0 0; z-index: 20; box-shadow: 12px 0 30px rgb(25 28 36 / 12%); } .maggie-mobile-menu { display: inline; margin-right: 10px; border: 0; background: transparent; font-size: 20px; cursor: pointer; } .maggie-topbar { height: 60px; padding: 0 16px; } .maggie-top-actions { gap: 10px; } .maggie-view-site { display: none; } .maggie-content { padding: 26px 16px 42px; } .maggie-page-head { display: block; } .maggie-page-head .maggie-primary { margin-top: 18px; } .maggie-metrics, .maggie-two-col { grid-template-columns: 1fr; } .maggie-card { padding: 17px; } }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div class="maggie-shell">
51
+ <aside class="maggie-sidebar" aria-label="Maggie Ops navigation">
52
+ <a class="maggie-brand" href="/ops"><span class="maggie-mark" aria-hidden="true">✦</span><span>{siteName}<small>Content operations</small></span></a>
53
+ <div class="maggie-nav-label">Workspace</div>
54
+ <nav class="maggie-nav">
55
+ {navigation.map((item) => <a href={item.href} aria-current={pathname === item.href || (item.href !== "/ops" && pathname.startsWith(item.href)) ? "page" : undefined}><span class="maggie-nav-icon" aria-hidden="true">{item.icon}</span>{item.label}</a>)}
56
+ </nav>
57
+ <div class="maggie-nav-label">Manage</div>
58
+ <nav class="maggie-nav">{operations.map((item) => <a href={item.href} aria-current={pathname === item.href ? "page" : undefined}>{item.label}</a>)}</nav>
59
+ <div class="maggie-sidebar-foot">Private workspace<a href="/blog">View public blog ↗</a></div>
60
+ </aside>
61
+ <div class="maggie-main">
62
+ <header class="maggie-topbar"><div class="maggie-breadcrumb"><button id="maggie-menu" class="maggie-mobile-menu" aria-label="Open navigation" aria-expanded="false">☰</button><span>Maggie Ops / </span><strong>{title.replace(" · Maggie Ops", "")}</strong></div><div class="maggie-top-actions"><a class="maggie-view-site" href="/blog">View site ↗</a><span>Operator</span><span class="maggie-avatar" aria-label="Operator account">M</span></div></header>
63
+ <main class="maggie-content"><slot /></main>
64
+ </div>
65
+ </div>
66
+ </body>
67
+ </html>
68
+ <script>
69
+ const menu = document.querySelector<HTMLButtonElement>("#maggie-menu");
70
+ const sidebar = document.querySelector<HTMLElement>(".maggie-sidebar");
71
+ menu?.addEventListener("click", () => { const open = sidebar?.classList.toggle("is-open") || false; menu.setAttribute("aria-expanded", String(open)); menu.setAttribute("aria-label", open ? "Close navigation" : "Open navigation"); });
72
+ sidebar?.querySelectorAll("a").forEach((link) => link.addEventListener("click", () => { sidebar.classList.remove("is-open"); menu?.setAttribute("aria-expanded", "false"); }));
73
+ </script>
@@ -0,0 +1,14 @@
1
+ import { json } from "./ops-auth";
2
+ import { settingBoolean } from "./db";
3
+
4
+ export async function aiCmoRequest(path: string, method: "GET" | "POST", body?: unknown): Promise<Response> {
5
+ const key = import.meta.env.AI_CMO_API_KEY;
6
+ if (!settingBoolean("aiCmoEnabled")) return json({ error: "AI CMO integration is disabled" }, 409);
7
+ if (!key) return json({ error: "AI CMO integration is not configured" }, 503);
8
+ const base = (import.meta.env.AI_CMO_BASE_URL || "https://api.cmo.so").replace(/\/$/, "");
9
+ try {
10
+ const response = await fetch(`${base}${path}`, { method, headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
11
+ const text = await response.text();
12
+ return new Response(text, { status: response.status, headers: { "content-type": response.headers.get("content-type") || "application/json" } });
13
+ } catch (error) { return json({ error: error instanceof Error ? error.message : "AI CMO request failed" }, 502); }
14
+ }
@@ -0,0 +1,15 @@
1
+ import { settingValue } from "./db";
2
+
3
+ export type BlogRuntimeConfig = {
4
+ site: { name: string; baseUrl: string; locale: string; timezone: string; blogPath: string; postsPerPage: number; gridColumns: number };
5
+ integrations: { aiCmo: { enabled: boolean; baseUrl: string; hasKey: boolean }; gsc: { enabled: boolean; hasVerificationToken: boolean }; ga4: { enabled: boolean; measurementId?: string; consentRequired: boolean } };
6
+ };
7
+
8
+ export function runtimeConfig(): BlogRuntimeConfig {
9
+ const baseUrl = import.meta.env.PUBLIC_SITE_URL;
10
+ if (!baseUrl) throw new Error("PUBLIC_SITE_URL is required; configure the canonical public site URL before building");
11
+ try { new URL(baseUrl); } catch { throw new Error("PUBLIC_SITE_URL must be an absolute URL"); }
12
+ const postsPerPage = settingValue("postsPerPage", Number(import.meta.env.PUBLIC_POSTS_PER_PAGE || 9));
13
+ const gridColumns = settingValue("gridColumns", Number(import.meta.env.PUBLIC_GRID_COLUMNS || 3));
14
+ return { site: { name: import.meta.env.PUBLIC_SITE_NAME || "Example Site", baseUrl, locale: import.meta.env.PUBLIC_SITE_LOCALE || "en-GB", timezone: import.meta.env.PUBLIC_SITE_TIMEZONE || "UTC", blogPath: "/blog", postsPerPage, gridColumns }, integrations: { aiCmo: { enabled: Boolean(import.meta.env.AI_CMO_API_KEY), baseUrl: import.meta.env.AI_CMO_BASE_URL || "https://api.cmo.so", hasKey: Boolean(import.meta.env.AI_CMO_API_KEY) }, gsc: { enabled: Boolean(import.meta.env.GSC_VERIFICATION_TOKEN), hasVerificationToken: Boolean(import.meta.env.GSC_VERIFICATION_TOKEN) }, ga4: { enabled: Boolean(import.meta.env.GA4_MEASUREMENT_ID), measurementId: import.meta.env.GA4_MEASUREMENT_ID, consentRequired: true } } };
15
+ }
@@ -0,0 +1,95 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ const schema = `PRAGMA foreign_keys = ON;
6
+ CREATE TABLE IF NOT EXISTS authors (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, job_title TEXT, organisation TEXT, bio TEXT, profile_url TEXT, credentials_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
7
+ CREATE TABLE IF NOT EXISTS posts (id TEXT PRIMARY KEY, source TEXT NOT NULL CHECK (source IN ('local','api-pull','cms','manual')), source_id TEXT, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, excerpt TEXT NOT NULL DEFAULT '', content_markdown TEXT, content_html TEXT, canonical_url TEXT NOT NULL UNIQUE, cover_image_url TEXT, cover_image_alt TEXT NOT NULL DEFAULT '', author_id TEXT NOT NULL REFERENCES authors(id), language TEXT NOT NULL DEFAULT 'en', status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','review','approved','scheduled','published','archived')), published_at TEXT, updated_at TEXT, created_at TEXT NOT NULL, content_hash TEXT, seo_title TEXT, meta_description TEXT, focus_keyword TEXT, canonical_override TEXT, robots_index TEXT NOT NULL DEFAULT 'index', robots_follow TEXT NOT NULL DEFAULT 'follow', og_image_url TEXT, schema_type TEXT NOT NULL DEFAULT 'Article', custom_metadata_json TEXT NOT NULL DEFAULT '{}', scheduled_at TEXT, checksum TEXT, source_modified_at TEXT, CHECK (status <> 'published' OR published_at IS NOT NULL));
8
+ CREATE UNIQUE INDEX IF NOT EXISTS posts_source_identity ON posts (source, source_id) WHERE source_id IS NOT NULL;
9
+ CREATE TABLE IF NOT EXISTS topics (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, description TEXT NOT NULL, seo_title TEXT, seo_description TEXT, status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published','archived')), minimum_published_posts INTEGER NOT NULL DEFAULT 3, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
10
+ CREATE TABLE IF NOT EXISTS post_topics (post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE, PRIMARY KEY (post_id, topic_id));
11
+ CREATE TABLE IF NOT EXISTS faq_items (id TEXT PRIMARY KEY, question TEXT NOT NULL, answer_markdown TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'draft', created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
12
+ CREATE TABLE IF NOT EXISTS post_faqs (post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, faq_id TEXT NOT NULL REFERENCES faq_items(id) ON DELETE CASCADE, PRIMARY KEY (post_id, faq_id));
13
+ CREATE TABLE IF NOT EXISTS topic_faqs (topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE, faq_id TEXT NOT NULL REFERENCES faq_items(id) ON DELETE CASCADE, PRIMARY KEY (topic_id, faq_id));
14
+ CREATE TABLE IF NOT EXISTS ctas (id TEXT PRIMARY KEY, label TEXT NOT NULL, url TEXT NOT NULL, placement TEXT NOT NULL, tracking_key TEXT, status TEXT NOT NULL DEFAULT 'draft', created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
15
+ CREATE TABLE IF NOT EXISTS post_ctas (post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, cta_id TEXT NOT NULL REFERENCES ctas(id) ON DELETE CASCADE, PRIMARY KEY (post_id, cta_id));
16
+ CREATE TABLE IF NOT EXISTS topic_ctas (topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE, cta_id TEXT NOT NULL REFERENCES ctas(id) ON DELETE CASCADE, PRIMARY KEY (topic_id, cta_id));
17
+ CREATE TABLE IF NOT EXISTS content_sync_state (source TEXT PRIMARY KEY, cursor TEXT, etag TEXT, last_success_at TEXT, last_error TEXT, updated_at TEXT NOT NULL);
18
+ CREATE TABLE IF NOT EXISTS content_delivery_state (post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE, remote_content_id TEXT, remote_version TEXT, delivery_status TEXT NOT NULL DEFAULT 'not_delivered', last_reported_url TEXT, last_reported_hash TEXT, last_error TEXT, updated_at TEXT NOT NULL);
19
+ CREATE TABLE IF NOT EXISTS sitemap_sources (id TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, source_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
20
+ CREATE TABLE IF NOT EXISTS sitemap_runs (id TEXT PRIMARY KEY, source_id TEXT REFERENCES sitemap_sources(id), status TEXT NOT NULL, total_urls INTEGER NOT NULL DEFAULT 0, matched_posts INTEGER NOT NULL DEFAULT 0, eligible_posts INTEGER NOT NULL DEFAULT 0, unmatched_urls INTEGER NOT NULL DEFAULT 0, error TEXT, started_at TEXT NOT NULL, completed_at TEXT);
21
+ CREATE TABLE IF NOT EXISTS blog_reports (id TEXT PRIMARY KEY, run_type TEXT NOT NULL, status TEXT NOT NULL, summary_json TEXT NOT NULL DEFAULT '{}', findings_json TEXT NOT NULL DEFAULT '[]', started_at TEXT, completed_at TEXT, error TEXT);
22
+ CREATE TABLE IF NOT EXISTS blog_settings (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, updated_at TEXT NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS audit_events (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL, action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, previous_state TEXT, next_state TEXT, reason TEXT, correlation_id TEXT NOT NULL, created_at TEXT NOT NULL);
24
+ CREATE TABLE IF NOT EXISTS wp_migrations (id TEXT PRIMARY KEY, source_url TEXT NOT NULL, mode TEXT NOT NULL CHECK (mode IN ('preview','apply')), status TEXT NOT NULL CHECK (status IN ('queued','running','completed','failed','cancelled')), options_json TEXT NOT NULL DEFAULT '{}', counts_json TEXT NOT NULL DEFAULT '{}', error TEXT, started_at TEXT NOT NULL, completed_at TEXT, checkpoint_json TEXT NOT NULL DEFAULT '{}', source_checksum TEXT, deleted_count INTEGER NOT NULL DEFAULT 0, conflict_count INTEGER NOT NULL DEFAULT 0);
25
+ CREATE TABLE IF NOT EXISTS wp_import_items (id TEXT PRIMARY KEY, migration_id TEXT NOT NULL REFERENCES wp_migrations(id) ON DELETE CASCADE, entity_type TEXT NOT NULL, source_id TEXT NOT NULL, target_id TEXT, action TEXT NOT NULL, error TEXT, created_at TEXT NOT NULL, UNIQUE (migration_id, entity_type, source_id));
26
+ CREATE TABLE IF NOT EXISTS wp_terms (id TEXT PRIMARY KEY, taxonomy TEXT NOT NULL CHECK (taxonomy IN ('category','tag')), source_id TEXT NOT NULL, name TEXT NOT NULL, slug TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', topic_id TEXT REFERENCES topics(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (taxonomy, source_id));
27
+ CREATE TABLE IF NOT EXISTS post_wp_terms (post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, term_id TEXT NOT NULL REFERENCES wp_terms(id) ON DELETE CASCADE, PRIMARY KEY (post_id, term_id));
28
+ CREATE TABLE IF NOT EXISTS media_assets (id TEXT PRIMARY KEY, source TEXT NOT NULL, source_id TEXT NOT NULL, source_url TEXT NOT NULL, local_url TEXT, mime_type TEXT, alt_text TEXT NOT NULL DEFAULT '', caption TEXT NOT NULL DEFAULT '', width INTEGER, height INTEGER, metadata_json TEXT NOT NULL DEFAULT '{}', optimized_formats_json TEXT NOT NULL DEFAULT '[]', optimization_status TEXT NOT NULL DEFAULT 'pending', storage_provider TEXT NOT NULL DEFAULT 'local', storage_key TEXT, delivery_url TEXT, upload_status TEXT NOT NULL DEFAULT 'pending', upload_error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE (source, source_id));
29
+ CREATE TABLE IF NOT EXISTS post_media (post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, media_id TEXT NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE, role TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (post_id, media_id, role));
30
+ CREATE TABLE IF NOT EXISTS redirects (id TEXT PRIMARY KEY, source_path TEXT NOT NULL UNIQUE, target_path TEXT NOT NULL, status_code INTEGER NOT NULL DEFAULT 301, reason TEXT NOT NULL, created_at TEXT NOT NULL);
31
+ CREATE TABLE IF NOT EXISTS post_revisions (id TEXT PRIMARY KEY, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, version INTEGER NOT NULL, snapshot_json TEXT NOT NULL, actor_id TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, UNIQUE(post_id, version));
32
+ CREATE TABLE IF NOT EXISTS content_calendar (id TEXT PRIMARY KEY, post_id TEXT NOT NULL UNIQUE REFERENCES posts(id) ON DELETE CASCADE, scheduled_at TEXT NOT NULL, campaign TEXT, notes TEXT, reminder_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
33
+ CREATE TABLE IF NOT EXISTS bulk_operations (id TEXT PRIMARY KEY, operation TEXT NOT NULL, filter_json TEXT NOT NULL, changes_json TEXT NOT NULL, preview_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL, actor_id TEXT NOT NULL, created_at TEXT NOT NULL, completed_at TEXT, error TEXT);
34
+ CREATE TABLE IF NOT EXISTS media_folders (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL);
35
+ CREATE TABLE IF NOT EXISTS media_folder_items (media_id TEXT NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE, folder_id TEXT NOT NULL REFERENCES media_folders(id) ON DELETE CASCADE, PRIMARY KEY(media_id, folder_id));
36
+ CREATE TABLE IF NOT EXISTS migration_sources (id TEXT PRIMARY KEY, kind TEXT NOT NULL, location TEXT NOT NULL, options_json TEXT NOT NULL DEFAULT '{}', checksum TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
37
+ CREATE TABLE IF NOT EXISTS migration_conflicts (id TEXT PRIMARY KEY, migration_id TEXT NOT NULL REFERENCES wp_migrations(id) ON DELETE CASCADE, source_id TEXT NOT NULL, target_id TEXT, resolution TEXT NOT NULL DEFAULT 'manual', details_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, resolved_at TEXT);
38
+ CREATE TABLE IF NOT EXISTS workspaces (id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, logo_url TEXT, brand_colors_json TEXT NOT NULL DEFAULT '{}', custom_domain TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
39
+ CREATE TABLE IF NOT EXISTS clients (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, name TEXT NOT NULL, email TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
40
+ CREATE TABLE IF NOT EXISTS sites (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, client_id TEXT REFERENCES clients(id) ON DELETE SET NULL, name TEXT NOT NULL, domain TEXT NOT NULL, settings_json TEXT NOT NULL DEFAULT '{}', credentials_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(workspace_id, domain));
41
+ CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, site_id TEXT REFERENCES sites(id) ON DELETE SET NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
42
+ CREATE TABLE IF NOT EXISTS workspace_templates (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, name TEXT NOT NULL, template_type TEXT NOT NULL, content_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(workspace_id,name));
43
+ CREATE TABLE IF NOT EXISTS workspace_members (workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id TEXT NOT NULL, role TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(workspace_id, user_id));
44
+ CREATE TABLE IF NOT EXISTS approval_links (id TEXT PRIMARY KEY, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', reviewer_name TEXT, reviewer_email TEXT, comment TEXT, approved_at TEXT, created_at TEXT NOT NULL);
45
+ CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'open', assignee_id TEXT, due_at TEXT, linked_type TEXT, linked_id TEXT, approval_status TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
46
+ CREATE TABLE IF NOT EXISTS task_comments (id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, author_id TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL);
47
+ CREATE TABLE IF NOT EXISTS playbooks (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', steps_json TEXT NOT NULL, gates_json TEXT NOT NULL DEFAULT '[]', report_format TEXT NOT NULL DEFAULT 'json', created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
48
+ CREATE TABLE IF NOT EXISTS playbook_runs (id TEXT PRIMARY KEY, playbook_id TEXT NOT NULL REFERENCES playbooks(id), site_id TEXT REFERENCES sites(id), status TEXT NOT NULL DEFAULT 'queued', state_json TEXT NOT NULL DEFAULT '{}', started_at TEXT, completed_at TEXT, error TEXT);
49
+ CREATE TABLE IF NOT EXISTS seo_findings (id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES blog_reports(id) ON DELETE CASCADE, post_id TEXT REFERENCES posts(id) ON DELETE CASCADE, category TEXT NOT NULL, severity TEXT NOT NULL, code TEXT NOT NULL, message TEXT NOT NULL, details_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL);
50
+ CREATE TABLE IF NOT EXISTS fact_review_reminders (id TEXT PRIMARY KEY, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, due_at TEXT NOT NULL, reason TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','completed','snoozed')), completed_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(post_id,status));
51
+ CREATE TABLE IF NOT EXISTS rewrite_queue_items (id TEXT PRIMARY KEY, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, reason TEXT NOT NULL DEFAULT 'manual', status TEXT NOT NULL DEFAULT 'queued', priority INTEGER NOT NULL DEFAULT 0, requested_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(post_id, status));
52
+ CREATE TABLE IF NOT EXISTS not_found_logs (path TEXT PRIMARY KEY, hits INTEGER NOT NULL DEFAULT 1, referrer TEXT, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL);
53
+ CREATE TABLE IF NOT EXISTS notifications (id TEXT PRIMARY KEY, user_id TEXT, type TEXT NOT NULL, entity_type TEXT, entity_id TEXT, message TEXT NOT NULL, read_at TEXT, created_at TEXT NOT NULL);
54
+ CREATE TABLE IF NOT EXISTS approval_comments (id TEXT PRIMARY KEY, approval_link_id TEXT NOT NULL REFERENCES approval_links(id) ON DELETE CASCADE, paragraph_key TEXT, body TEXT NOT NULL, author_name TEXT, created_at TEXT NOT NULL);
55
+ CREATE TABLE IF NOT EXISTS report_deliveries (id TEXT PRIMARY KEY, workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, report_type TEXT NOT NULL, recipient TEXT NOT NULL, format TEXT NOT NULL, schedule TEXT, status TEXT NOT NULL DEFAULT 'active', last_sent_at TEXT, last_error TEXT, created_at TEXT NOT NULL);
56
+ CREATE TABLE IF NOT EXISTS content_citations (id TEXT PRIMARY KEY, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, source_url TEXT NOT NULL, source_title TEXT, verified_at TEXT, created_at TEXT NOT NULL);
57
+ CREATE TABLE IF NOT EXISTS query_page_map (id TEXT PRIMARY KEY, query TEXT NOT NULL, post_id TEXT REFERENCES posts(id) ON DELETE CASCADE, intent TEXT, priority INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE(query, post_id));
58
+ CREATE TABLE IF NOT EXISTS brand_mentions (id TEXT PRIMARY KEY, query TEXT NOT NULL, source_url TEXT NOT NULL, source_name TEXT, mentioned INTEGER NOT NULL DEFAULT 0, checked_at TEXT NOT NULL);
59
+ CREATE TABLE IF NOT EXISTS entity_profiles (id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, same_as_json TEXT NOT NULL DEFAULT '[]', schema_json TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL);
60
+ CREATE TABLE IF NOT EXISTS entity_relations (subject_key TEXT NOT NULL REFERENCES entity_profiles(entity_key) ON DELETE CASCADE, predicate TEXT NOT NULL, object_key TEXT NOT NULL REFERENCES entity_profiles(entity_key) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(subject_key,predicate,object_key));
61
+ CREATE TABLE IF NOT EXISTS indexability_checks (id TEXT PRIMARY KEY, url TEXT NOT NULL, status TEXT NOT NULL, robots TEXT, canonical TEXT, checked_at TEXT NOT NULL, details_json TEXT NOT NULL DEFAULT '{}');
62
+ CREATE TABLE IF NOT EXISTS sitemap_snapshots (id TEXT PRIMARY KEY, source_url TEXT NOT NULL, urls_json TEXT NOT NULL, checksum TEXT NOT NULL, created_at TEXT NOT NULL);
63
+ CREATE TABLE IF NOT EXISTS hreflang_links (post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, language TEXT NOT NULL, url TEXT NOT NULL, PRIMARY KEY(post_id, language));
64
+ CREATE TABLE IF NOT EXISTS core_web_vitals (id TEXT PRIMARY KEY, url TEXT NOT NULL, period_start TEXT NOT NULL, lcp REAL, inp REAL, cls REAL, source TEXT NOT NULL, created_at TEXT NOT NULL);`;
65
+
66
+ let instance: Database.Database | undefined;
67
+ export function db(): Database.Database {
68
+ if (instance) return instance;
69
+ const path = import.meta.env.BLOG_DB_PATH || ".data/maggie.sqlite";
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ instance = new Database(path);
72
+ instance.exec(schema);
73
+ const columns = ["seo_title TEXT", "meta_description TEXT", "focus_keyword TEXT", "canonical_override TEXT", "robots_index TEXT NOT NULL DEFAULT 'index'", "robots_follow TEXT NOT NULL DEFAULT 'follow'", "og_image_url TEXT", "schema_type TEXT NOT NULL DEFAULT 'Article'", "custom_metadata_json TEXT NOT NULL DEFAULT '{}'", "scheduled_at TEXT", "checksum TEXT", "source_modified_at TEXT"];
74
+ for (const column of columns) { try { instance.exec(`ALTER TABLE posts ADD COLUMN ${column}`); } catch { /* already present */ } }
75
+ for (const column of ["optimized_formats_json TEXT NOT NULL DEFAULT '[]'", "optimization_status TEXT NOT NULL DEFAULT 'pending'", "storage_provider TEXT NOT NULL DEFAULT 'local'", "storage_key TEXT", "delivery_url TEXT", "upload_status TEXT NOT NULL DEFAULT 'pending'", "upload_error TEXT"]) { try { instance.exec(`ALTER TABLE media_assets ADD COLUMN ${column}`); } catch { /* already present */ } }
76
+ for (const column of ["checkpoint_json TEXT NOT NULL DEFAULT '{}'", "source_checksum TEXT", "deleted_count INTEGER NOT NULL DEFAULT 0", "conflict_count INTEGER NOT NULL DEFAULT 0"]) { try { instance.exec(`ALTER TABLE wp_migrations ADD COLUMN ${column}`); } catch { /* already present */ } }
77
+ return instance;
78
+ }
79
+ export function now(): string { return new Date().toISOString(); }
80
+ export function id(prefix: string): string { return `${prefix}_${crypto.randomUUID()}`; }
81
+ export function settingBoolean(key: string, fallback = true): boolean {
82
+ if (import.meta.env.BLOG_DATA_SOURCE !== "sqlite") return fallback;
83
+ const row = db().prepare("SELECT value_json FROM blog_settings WHERE key=?").get(key) as { value_json: string } | undefined;
84
+ if (!row) return fallback;
85
+ try { return JSON.parse(row.value_json) === true; } catch { return fallback; }
86
+ }
87
+ export function settingValue<T>(key: string, fallback: T): T {
88
+ if (import.meta.env.BLOG_DATA_SOURCE !== "sqlite") return fallback;
89
+ const row = db().prepare("SELECT value_json FROM blog_settings WHERE key=?").get(key) as { value_json: string } | undefined;
90
+ if (!row) return fallback;
91
+ try { return JSON.parse(row.value_json) as T; } catch { return fallback; }
92
+ }
93
+ export function audit(action: string, entityType: string, entityId: string, previousState: unknown, nextState: unknown): void {
94
+ db().prepare("INSERT INTO audit_events (id, actor_id, action, entity_type, entity_id, previous_state, next_state, correlation_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(id("audit"), "maggie-ops", action, entityType, entityId, JSON.stringify(previousState ?? null), JSON.stringify(nextState ?? null), crypto.randomUUID(), now());
95
+ }
@@ -0,0 +1,28 @@
1
+ type ProviderResult = { provider: "gsc" | "ga4"; data: unknown };
2
+
3
+ async function request(url: string, token: string, body: unknown): Promise<unknown> {
4
+ const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15000);
5
+ try {
6
+ const response = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: controller.signal });
7
+ const text = await response.text(); let data: unknown; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text.slice(0, 5000) }; }
8
+ if (!response.ok) throw new Error(`Provider returned ${response.status}: ${JSON.stringify(data).slice(0, 1000)}`);
9
+ return data;
10
+ } finally { clearTimeout(timeout); }
11
+ }
12
+
13
+ export async function runProviderReport(runType: "visibility" | "analytics", startDate: string, endDate: string): Promise<ProviderResult> {
14
+ if (runType === "visibility") {
15
+ const token = import.meta.env.GSC_ACCESS_TOKEN; const siteUrl = import.meta.env.GSC_SITE_URL;
16
+ if (!token || !siteUrl) throw new Error("GSC_ACCESS_TOKEN and GSC_SITE_URL are required");
17
+ const data = await request(`https://searchconsole.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`, token, { startDate, endDate, dimensions: ["query", "page"], rowLimit: 1000 });
18
+ return { provider: "gsc", data };
19
+ }
20
+ const token = import.meta.env.GA4_ACCESS_TOKEN; const propertyId = import.meta.env.GA4_PROPERTY_ID;
21
+ if (!token || !propertyId) throw new Error("GA4_ACCESS_TOKEN and GA4_PROPERTY_ID are required");
22
+ const data = await request(`https://analyticsdata.googleapis.com/v1beta/properties/${encodeURIComponent(propertyId)}:runReport`, token, { dateRanges: [{ startDate, endDate }], dimensions: [{ name: "pagePath" }], metrics: [{ name: "sessions" }, { name: "engagementRate" }, { name: "conversions" }], limit: 1000 });
23
+ return { provider: "ga4", data };
24
+ }
25
+
26
+ export function providerConfigured(runType: "visibility" | "analytics"): boolean {
27
+ return runType === "visibility" ? Boolean(import.meta.env.GSC_ACCESS_TOKEN && import.meta.env.GSC_SITE_URL) : Boolean(import.meta.env.GA4_ACCESS_TOKEN && import.meta.env.GA4_PROPERTY_ID);
28
+ }
@@ -0,0 +1,12 @@
1
+ import sharp from "sharp";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { db, now } from "./db";
5
+
6
+ export async function optimizeMedia(mediaId: string, formats: Array<"webp" | "avif"> = ["webp", "avif"]): Promise<{ mediaId: string; outputs: Record<string, string> }> {
7
+ const media = db().prepare("SELECT id,source_url,local_url FROM media_assets WHERE id=?").get(mediaId) as { id: string; source_url: string; local_url?: string } | undefined;
8
+ if (!media) throw new Error("media not found");
9
+ const input = media.local_url && !/^https?:\/\//.test(media.local_url) ? await readFile(media.local_url) : await fetch(media.source_url, { signal: AbortSignal.timeout(30000) }).then(async (response) => { if (!response.ok) throw new Error(`media source returned ${response.status}`); return Buffer.from(await response.arrayBuffer()); }); const outputDir = import.meta.env.MEDIA_OUTPUT_DIR || "public/media/optimized"; await mkdir(outputDir, { recursive: true }); const outputs: Record<string, string> = {};
10
+ for (const format of formats.filter((item, index, all) => ["webp", "avif"].includes(item) && all.indexOf(item) === index)) { const filename = `${media.id}.${format}`; await sharp(input).toFormat(format, format === "webp" ? { quality: 82 } : { quality: 78 }).toFile(join(outputDir, filename)); outputs[format] = `/${outputDir.replace(/^public\//, "").replace(/\\/g, "/")}/${filename}`; }
11
+ db().prepare("UPDATE media_assets SET optimized_formats_json=?,optimization_status='completed',local_url=?,updated_at=? WHERE id=?").run(JSON.stringify(Object.keys(outputs)), outputs.webp || outputs.avif || null, now(), mediaId); return { mediaId, outputs };
12
+ }
@@ -0,0 +1,11 @@
1
+ import { db, id, now } from "./db";
2
+
3
+ export function validateMigration(migrationId: string) {
4
+ const timestamp = now(); const runId = id("report"); const findings: any[] = []; const add = (postId: string, severity: "warning" | "error", code: string, message: string) => findings.push({ id: id("finding"), run_id: runId, post_id: postId, category: "migration", severity, code, message, details_json: "{}", created_at: timestamp });
5
+ const items = db().prepare("SELECT target_id FROM wp_import_items WHERE migration_id=? AND entity_type='post' AND target_id IS NOT NULL").all(migrationId) as Array<{ target_id: string }>;
6
+ for (const item of items) { const post = db().prepare("SELECT id,title,slug,content_html,canonical_url,author_id,status,seo_title,meta_description FROM posts WHERE id=?").get(item.target_id) as any; if (!post) { add(item.target_id, "error", "missing-target", "Imported target post no longer exists."); continue; } if (!post.title || !post.slug) add(post.id, "error", "missing-identity", "Imported post is missing title or slug."); if (!post.canonical_url || !/^https?:\/\//.test(post.canonical_url)) add(post.id, "error", "invalid-canonical", "Imported post does not have an absolute canonical URL."); if (!post.author_id) add(post.id, "error", "missing-author", "Imported post has no author."); if (!post.content_html && post.status === "published") add(post.id, "error", "published-empty-content", "Published imported post has no content."); if (!post.seo_title) add(post.id, "warning", "missing-seo-title", "Imported post has no normalized SEO title."); if (!post.meta_description) add(post.id, "warning", "missing-meta-description", "Imported post has no normalized meta description."); }
7
+ const summary = { migrationId, importedPosts: items.length, findingCount: findings.length, errors: findings.filter((item) => item.severity === "error").length, warnings: findings.filter((item) => item.severity === "warning").length };
8
+ db().prepare("INSERT INTO blog_reports (id,run_type,status,summary_json,findings_json,started_at,completed_at) VALUES (?,?,?,?,?,?,?)").run(runId, `migration:${migrationId}`, "completed", JSON.stringify(summary), JSON.stringify(findings), timestamp, timestamp);
9
+ const insert = db().prepare("INSERT INTO seo_findings (id,run_id,post_id,category,severity,code,message,details_json,created_at) VALUES (?,?,?,?,?,?,?,?,?)"); const tx = db().transaction(() => findings.forEach((item) => insert.run(item.id, item.run_id, item.post_id, item.category, item.severity, item.code, item.message, item.details_json, item.created_at))); tx();
10
+ return { runId, summary, findings };
11
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export type MigrationRecord = { sourceId: string; title: string; slug: string; excerpt: string; html: string; canonicalUrl?: string; publishedAt?: string; sourceModified?: string; status?: string; seo?: Record<string, string> };
4
+ export type MigrationFormat = "json" | "csv" | "wxr" | "sitemap" | "media-archive";
5
+
6
+ export function slugify(value: string): string { return value.toLowerCase().normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 180) || "untitled"; }
7
+ function strip(value: string): string { return value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); }
8
+ function csvRows(input: string): Record<string, string>[] { const lines = input.split(/\r?\n/).filter(Boolean); if (!lines.length) return []; const headers = lines.shift()!.split(",").map((v) => v.trim().replace(/^"|"$/g, "")); return lines.map((line) => { const values = line.match(/("(?:[^"]|"")*"|[^,]*)/g)?.filter((v) => v !== "").map((v) => v.replace(/^"|"$/g, "").replace(/""/g, '"')) || []; return Object.fromEntries(headers.map((header, i) => [header, values[i] || ""])); }); }
9
+ export function parseMigration(input: string, format: MigrationFormat, baseUrl?: string): MigrationRecord[] {
10
+ if (format === "media-archive") {
11
+ let parsed: any; try { parsed = JSON.parse(input); } catch { throw new Error("media archive input must be valid JSON"); }
12
+ const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed.items) ? parsed.items : [];
13
+ return rows.map((row: any, index: number) => ({ sourceId: String(row.sourceId || row.id || row.sourceUrl || index + 1), title: String(row.altText || row.title || `Media ${index + 1}`), slug: slugify(String(row.sourceId || row.id || index + 1)), excerpt: "", html: "", canonicalUrl: row.sourceUrl, seo: { media_archive: JSON.stringify({ sourceUrl: row.sourceUrl || "", localUrl: row.localUrl || "", mimeType: row.mimeType || "", altText: row.altText || "", caption: row.caption || "", width: row.width || null, height: row.height || null }) } }));
14
+ }
15
+ if (format === "sitemap") { const urls = [...input.matchAll(/<loc>([\s\S]*?)<\/loc>/gi)].map((m) => strip(m[1])); return urls.map((url, index) => ({ sourceId: url, title: decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).pop() || `Imported post ${index + 1}`).replace(/[-_]/g, " "), slug: slugify(new URL(url).pathname.split("/").filter(Boolean).pop() || `imported-${index + 1}`), excerpt: "", html: "", canonicalUrl: url, status: "draft" })); }
16
+ if (format === "csv") return csvRows(input).map((row, index) => ({ sourceId: row.id || row.url || String(index + 1), title: row.title || "Untitled", slug: slugify(row.slug || row.title || `imported-${index + 1}`), excerpt: row.excerpt || row.description || "", html: row.html || row.content || "", canonicalUrl: row.url || (baseUrl ? new URL(`/blog/${slugify(row.slug || row.title || `imported-${index + 1}`)}`, baseUrl).toString() : undefined), publishedAt: row.published_at || row.date || undefined, sourceModified: row.modified_at || row.updated_at || row.modified || undefined, status: row.status || "draft", seo: { seo_title: row.seo_title || row.meta_title || "", meta_description: row.meta_description || "", focus_keyword: row.focus_keyword || "", canonical_override: row.canonical || "", robots_index: row.robots_index || "index", robots_follow: row.robots_follow || "follow", og_image_url: row.og_image_url || "", schema_type: row.schema_type || "Article", custom_metadata_json: row.custom_metadata_json || "{}" } }));
17
+ if (format === "wxr") { const items = [...input.matchAll(/<item>([\s\S]*?)<\/item>/gi)].map((m) => m[1]); return items.map((item, index) => { const value = (tag: string) => strip(item.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i"))?.[1] || ""); const title = value("title") || `Imported post ${index + 1}`; return { sourceId: value("wp:post_id") || String(index + 1), title, slug: slugify(value("wp:post_name") || title), excerpt: value("excerpt:encoded"), html: value("content:encoded"), canonicalUrl: value("link") || undefined, publishedAt: value("wp:post_date_gmt") || undefined, sourceModified: value("wp:post_modified_gmt") || undefined, status: value("wp:status") || "draft" }; }); }
18
+ let parsed: any; try { parsed = JSON.parse(input); } catch { throw new Error("JSON migration input is invalid"); } const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed.posts) ? parsed.posts : [];
19
+ return rows.map((row: any, index: number) => { const title = String(row.title?.rendered || row.title || `Imported post ${index + 1}`); return { sourceId: String(row.id || row.url || index + 1), title, slug: slugify(row.slug || title), excerpt: strip(String(row.excerpt?.rendered || row.excerpt || "")), html: String(row.content?.rendered || row.html || row.content || ""), canonicalUrl: row.link || row.url, publishedAt: row.date_gmt || row.publishedAt || row.date, sourceModified: row.modified_gmt || row.updatedAt || row.modified || undefined, status: row.status || "draft", seo: { seo_title: row.seo_title || row.meta_title || "", meta_description: row.meta_description || "", focus_keyword: row.focus_keyword || "", canonical_override: row.canonical_override || "", robots_index: row.robots_index || "index", robots_follow: row.robots_follow || "follow", og_image_url: row.og_image_url || "", schema_type: row.schema_type || "Article", custom_metadata_json: typeof row.custom_metadata_json === "string" ? row.custom_metadata_json : JSON.stringify(row.custom_metadata || row.meta || {}) } }; });
20
+ }
21
+ export function checksum(record: MigrationRecord): string { return createHash("sha256").update(JSON.stringify(record)).digest("hex"); }
@@ -0,0 +1,21 @@
1
+ const permissions: Record<string, string[]> = {
2
+ "agency-owner": ["read", "edit", "approve", "publish", "integrations", "migration", "rewrite", "export", "billing"],
3
+ "account-manager": ["read", "edit", "approve", "publish", "migration", "rewrite", "export"],
4
+ "seo-strategist": ["read", "edit", "approve", "rewrite", "export"],
5
+ writer: ["read", "edit"], editor: ["read", "edit", "approve"], developer: ["read", "edit", "integrations"],
6
+ "client-reviewer": ["read", "approve"], "read-only-analyst": ["read", "export"],
7
+ };
8
+ export function opsAuth(request: Request, pathname = ""): Response | null {
9
+ const provider = import.meta.env.OPS_AUTH_PROVIDER;
10
+ if (!provider) return new Response("Maggie Ops authentication is not configured.", { status: 503 });
11
+ if (provider !== "local") return new Response("Configure the host OIDC/session adapter for Maggie Ops.", { status: 501 });
12
+ const expected = import.meta.env.OPS_LOCAL_TOKEN;
13
+ const cookie = request.headers.get("cookie")?.match(/(?:^|;\s*)maggie_ops_token=([^;]+)/)?.[1];
14
+ const actual = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || request.headers.get("x-maggie-ops-token") || cookie;
15
+ if (!expected || actual !== expected) return new Response("Unauthorized", { status: 401 });
16
+ const role = import.meta.env.OPS_LOCAL_ROLE || "agency-owner";
17
+ const method = request.method.toUpperCase(); const permission = method === "GET" ? "read" : pathname.includes("/wordpress") || pathname.includes("/migrations") ? "migration" : pathname.includes("/rewrite") ? "rewrite" : pathname.includes("/settings") || pathname.includes("/pull/") || pathname.includes("/sitemap/") || pathname.includes("/content-tracking/") ? "integrations" : pathname.includes("/approve") || pathname.includes("/approval") ? "approve" : pathname.includes("/publish") || pathname.includes("/calendar/publish") ? "publish" : pathname.includes("/export") ? "export" : "edit";
18
+ if (!permissions[role]?.includes(permission)) return new Response("Forbidden", { status: 403 });
19
+ return null;
20
+ }
21
+ export function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } }); }