@topy-ai/maggie 0.7.32 → 0.7.34

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 +19 -4
  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,45 @@
1
+ export type EmailProviderId = "resend" | "webhook" | "disabled";
2
+ export type EmailMessage = { from: string; to: string[]; subject: string; html: string; text?: string; replyTo?: string };
3
+ export type EmailResult = { provider: EmailProviderId; status: "sent" | "queued" | "failed"; providerId?: string; reason?: string; error?: string };
4
+
5
+ export interface EmailProvider {
6
+ readonly id: EmailProviderId;
7
+ send(message: EmailMessage): Promise<EmailResult>;
8
+ health(): { configured: boolean; capabilities: string[]; reason?: string };
9
+ }
10
+
11
+ const configuredProvider = (): EmailProviderId => {
12
+ const value = String(import.meta.env.EMAIL_PROVIDER || "resend").toLowerCase();
13
+ return value === "webhook" || value === "disabled" || value === "resend" ? value : "disabled";
14
+ };
15
+
16
+ const resend: EmailProvider = {
17
+ id: "resend",
18
+ health: () => ({ configured: Boolean(import.meta.env.RESEND_API_KEY && import.meta.env.EMAIL_FROM), capabilities: ["send", "html", "reply-to"], reason: !import.meta.env.RESEND_API_KEY ? "RESEND_API_KEY is not configured" : !import.meta.env.EMAIL_FROM ? "EMAIL_FROM is not configured" : undefined }),
19
+ async send(message) {
20
+ const key = import.meta.env.RESEND_API_KEY;
21
+ if (!key || !import.meta.env.EMAIL_FROM) return { provider: "resend", status: "queued", reason: "RESEND_API_KEY and EMAIL_FROM are required" };
22
+ try {
23
+ const response = await fetch("https://api.resend.com/emails", { method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body: JSON.stringify({ from: message.from, to: message.to, subject: message.subject, html: message.html, text: message.text, reply_to: message.replyTo }), signal: AbortSignal.timeout(15000) });
24
+ const payload = await response.json().catch(() => ({})) as { id?: string; message?: string; name?: string };
25
+ if (!response.ok) return { provider: "resend", status: "failed", error: payload.message || payload.name || `Resend returned ${response.status}` };
26
+ return { provider: "resend", status: "sent", providerId: payload.id };
27
+ } catch (error) { return { provider: "resend", status: "failed", error: error instanceof Error ? error.message : "Resend request failed" }; }
28
+ },
29
+ };
30
+
31
+ const webhook: EmailProvider = {
32
+ id: "webhook",
33
+ health: () => ({ configured: Boolean(import.meta.env.EMAIL_WEBHOOK_URL || import.meta.env.REPORT_EMAIL_WEBHOOK_URL), capabilities: ["send", "html"], reason: import.meta.env.EMAIL_WEBHOOK_URL || import.meta.env.REPORT_EMAIL_WEBHOOK_URL ? undefined : "EMAIL_WEBHOOK_URL is not configured" }),
34
+ async send(message) {
35
+ const url = import.meta.env.EMAIL_WEBHOOK_URL || import.meta.env.REPORT_EMAIL_WEBHOOK_URL;
36
+ if (!url) return { provider: "webhook", status: "queued", reason: "EMAIL_WEBHOOK_URL is not configured" };
37
+ try { const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(message), signal: AbortSignal.timeout(15000) }); if (!response.ok) return { provider: "webhook", status: "failed", error: `email webhook returned ${response.status}` }; return { provider: "webhook", status: "sent" }; } catch (error) { return { provider: "webhook", status: "failed", error: error instanceof Error ? error.message : "email webhook request failed" }; }
38
+ },
39
+ };
40
+
41
+ const disabled: EmailProvider = { id: "disabled", health: () => ({ configured: true, capabilities: [], reason: "email provider is disabled or unknown" }), async send() { return { provider: "disabled", status: "queued", reason: "email provider is disabled or unknown" }; } };
42
+ const providers: Record<string, EmailProvider> = { resend, webhook, disabled };
43
+ export const registerEmailProvider = (provider: EmailProvider): void => { providers[provider.id] = provider; };
44
+ export const getEmailProvider = (provider = configuredProvider()): EmailProvider => providers[provider] || providers.disabled;
45
+ export const emailProviderStatus = () => { const provider = getEmailProvider(); return { provider: provider.id, ...provider.health() }; };
@@ -0,0 +1,37 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ export type MediaStorageProviderId = "cloudinary" | "local";
6
+ export type MediaUpload = { data: Buffer; filename: string; mimeType?: string; folder?: string; publicId?: string };
7
+ export type MediaUploadResult = { provider: MediaStorageProviderId; key: string; url: string; bytes: number; mimeType?: string; localPath?: string };
8
+ export interface MediaStorageProvider { readonly id: MediaStorageProviderId; upload(input: MediaUpload): Promise<MediaUploadResult>; health(): { configured: boolean; capabilities: string[]; reason?: string } }
9
+
10
+ const providerName = (): string => String(import.meta.env.MEDIA_STORAGE_PROVIDER || "cloudinary").toLowerCase();
11
+ const sha1 = (value: string) => createHash("sha1").update(value).digest("hex");
12
+
13
+ const cloudinary: MediaStorageProvider = {
14
+ id: "cloudinary",
15
+ health: () => ({ configured: Boolean(import.meta.env.CLOUDINARY_CLOUD_NAME && import.meta.env.CLOUDINARY_API_KEY && import.meta.env.CLOUDINARY_API_SECRET), capabilities: ["upload", "cdn", "transform", "remote-fetch"], reason: !import.meta.env.CLOUDINARY_CLOUD_NAME ? "CLOUDINARY_CLOUD_NAME is not configured" : !import.meta.env.CLOUDINARY_API_KEY || !import.meta.env.CLOUDINARY_API_SECRET ? "Cloudinary API credentials are not configured" : undefined }),
16
+ async upload(input) {
17
+ const cloud = import.meta.env.CLOUDINARY_CLOUD_NAME; const key = import.meta.env.CLOUDINARY_API_KEY; const secret = import.meta.env.CLOUDINARY_API_SECRET;
18
+ if (!cloud || !key || !secret) throw new Error("Cloudinary requires CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET");
19
+ const timestamp = Math.floor(Date.now() / 1000); const folder = input.folder || import.meta.env.CLOUDINARY_UPLOAD_FOLDER || "maggie"; const publicId = input.publicId || input.filename.replace(/[^a-zA-Z0-9/_-]/g, "-").replace(/\.[^.]+$/, ""); const params = { folder, overwrite: "true", public_id: publicId, timestamp: String(timestamp) }; const signature = sha1(Object.entries(params).sort(([a], [b]) => a.localeCompare(b)).map(([name, value]) => `${name}=${value}`).join("&") + secret);
20
+ const type = input.mimeType?.startsWith("video/") ? "video" : input.mimeType?.startsWith("audio/") || input.mimeType === "application/pdf" ? "raw" : "image"; const form = new FormData(); form.set("file", `data:${input.mimeType || "application/octet-stream"};base64,${input.data.toString("base64")}`); for (const [name, value] of Object.entries({ ...params, api_key: key, signature })) form.set(name, value);
21
+ const response = await fetch(`https://api.cloudinary.com/v1_1/${encodeURIComponent(cloud)}/${type}/upload`, { method: "POST", body: form, signal: AbortSignal.timeout(60000) }); const payload = await response.json().catch(() => ({})) as { secure_url?: string; public_id?: string; error?: { message?: string } }; if (!response.ok || !payload.secure_url) throw new Error(payload.error?.message || `Cloudinary returned ${response.status}`); return { provider: "cloudinary", key: payload.public_id || publicId, url: payload.secure_url, bytes: input.data.byteLength, mimeType: input.mimeType };
22
+ },
23
+ };
24
+
25
+ const local: MediaStorageProvider = { id: "local", health: () => ({ configured: true, capabilities: ["upload"] }), async upload(input) { const outputDir = import.meta.env.MEDIA_OUTPUT_DIR || "public/media/uploads"; await mkdir(outputDir, { recursive: true }); const safe = input.filename.replace(/[^a-zA-Z0-9._-]/g, "-"); const localPath = join(outputDir, safe); await writeFile(localPath, input.data); return { provider: "local", key: safe, url: `/${outputDir.replace(/^public\//, "").replace(/\\/g, "/")}/${safe}`, localPath, bytes: input.data.byteLength, mimeType: input.mimeType }; } };
26
+ const providers: Record<string, MediaStorageProvider> = { cloudinary, local };
27
+ export const registerMediaStorageProvider = (provider: MediaStorageProvider): void => { providers[provider.id] = provider; };
28
+ export const getMediaStorageProvider = (provider = providerName()): MediaStorageProvider => {
29
+ const selected = providers[provider];
30
+ if (!selected) throw new Error(`Unknown media storage provider: ${provider}`);
31
+ return selected;
32
+ };
33
+ export const mediaProviderStatus = () => {
34
+ const name = providerName();
35
+ const provider = providers[name];
36
+ return provider ? { provider: provider.id, ...provider.health() } : { provider: name, configured: false, capabilities: [], reason: `Unknown media storage provider: ${name}` };
37
+ };
@@ -0,0 +1,26 @@
1
+ import { db, now } from "./db";
2
+ import { getEmailProvider } from "./providers/email";
3
+
4
+ export type ReportDeliveryResult = { id: string; status: "sent" | "queued" | "failed"; reason?: string; error?: string };
5
+
6
+ export async function deliverReport(delivery: any): Promise<ReportDeliveryResult> {
7
+ const report = db().prepare("SELECT * FROM blog_reports WHERE run_type=? ORDER BY completed_at DESC LIMIT 1").get(delivery.report_type) as any;
8
+ if (!report) return { id: delivery.id, status: "failed", error: "report not found" };
9
+ const summary = JSON.parse(report.summary_json || "{}"); const findings = JSON.parse(report.findings_json || "[]");
10
+ const recipient = String(delivery.recipient).split(",").map((value: string) => value.trim()).filter(Boolean);
11
+ const provider = getEmailProvider();
12
+ const result = await provider.send({ from: import.meta.env.EMAIL_FROM || "Maggie <onboarding@resend.dev>", to: recipient, subject: `Maggie ${delivery.report_type} report`, html: `<h1>${delivery.report_type} report</h1><p>${JSON.stringify(summary)}</p><p>${findings.length} finding(s) recorded.</p>`, text: `${delivery.report_type} report\n${JSON.stringify(summary)}\n${findings.length} finding(s) recorded.`, replyTo: import.meta.env.EMAIL_REPLY_TO || undefined });
13
+ if (result.status === "sent") db().prepare("UPDATE report_deliveries SET last_sent_at=?,last_error=NULL,status='active' WHERE id=?").run(now(), delivery.id);
14
+ else if (result.status === "failed") db().prepare("UPDATE report_deliveries SET status='failed',last_error=? WHERE id=?").run(result.error || "email delivery failed", delivery.id);
15
+ return { id: delivery.id, status: result.status, reason: result.reason, error: result.error };
16
+ }
17
+
18
+ export function isDeliveryDue(delivery: any, timestamp = Date.now()): boolean {
19
+ if (!delivery.schedule) return false;
20
+ const schedule = String(delivery.schedule);
21
+ const last = delivery.last_sent_at ? Date.parse(delivery.last_sent_at) : Date.parse(delivery.created_at || "");
22
+ if (/^\d{4}-\d{2}-\d{2}T/.test(schedule)) return Date.parse(schedule) <= timestamp && !delivery.last_sent_at;
23
+ if (!Number.isFinite(last)) return true;
24
+ const interval = schedule === "weekly" ? 7 * 86400000 : schedule === "monthly" ? 30 * 86400000 : 86400000;
25
+ return timestamp - last >= interval;
26
+ }
@@ -0,0 +1,49 @@
1
+ type ReportFinding = { severity?: string; code?: string; message?: string };
2
+
3
+ const pdfEscape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
4
+
5
+ const wrap = (value: string, width = 92) => {
6
+ const words = value.replace(/\s+/g, " ").trim().split(" ");
7
+ const lines: string[] = [];
8
+ let line = "";
9
+ for (const word of words) {
10
+ if ((line + (line ? " " : "") + word).length > width && line) {
11
+ lines.push(line); line = word;
12
+ } else line += (line ? " " : "") + word;
13
+ }
14
+ if (line) lines.push(line);
15
+ return lines.length ? lines : [""];
16
+ };
17
+
18
+ export const createReportPdf = (title: string, summary: unknown, findings: ReportFinding[]) => {
19
+ const lines = [title, `Summary: ${JSON.stringify(summary)}`];
20
+ findings.forEach((finding, index) => lines.push(`${index + 1}. [${finding.severity || "info"}] ${finding.code || "finding"}: ${finding.message || ""}`));
21
+ const pages: string[][] = [];
22
+ let page: string[] = [];
23
+ for (const line of lines.flatMap((item) => wrap(item))) {
24
+ if (page.length >= 48) { pages.push(page); page = []; }
25
+ page.push(line);
26
+ }
27
+ if (page.length || !pages.length) pages.push(page);
28
+
29
+ const objects: string[] = [];
30
+ const add = (value: string) => { objects.push(value); return objects.length; };
31
+ const font = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
32
+ const pageIds: number[] = [];
33
+ const contentIds: number[] = [];
34
+ pages.forEach((pageLines) => {
35
+ const stream = ["BT", "/F1 10 Tf", "50 760 Td", ...pageLines.flatMap((line, index) => [`(${pdfEscape(line.slice(0, 150))}) Tj`, ...(index < pageLines.length - 1 ? ["0 -14 Td"] : [])]), "ET"].join("\n");
36
+ contentIds.push(add(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`));
37
+ pageIds.push(add(""));
38
+ });
39
+ const pagesId = add("");
40
+ pageIds.forEach((pageId, index) => { objects[pageId - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 ${font} 0 R >> >> /Contents ${contentIds[index]} 0 R >>`; });
41
+ objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pageIds.length} >>`;
42
+ const catalog = add(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
43
+ let output = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
44
+ const offsets = [0];
45
+ objects.forEach((object, index) => { offsets.push(output.length); output += `${index + 1} 0 obj\n${object}\nendobj\n`; });
46
+ const xref = output.length;
47
+ output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n `).join("\n")}\ntrailer\n<< /Size ${objects.length + 1} /Root ${catalog} 0 R >>\nstartxref\n${xref}\n%%EOF`;
48
+ return new TextEncoder().encode(output);
49
+ };
@@ -0,0 +1,72 @@
1
+ import { posts as demoPosts, topics as demoTopics } from "../data/demo";
2
+ import type { Author, BlogPost, Topic } from "./types";
3
+ import { db } from "./db";
4
+
5
+ const useSqlite = () => import.meta.env.BLOG_DATA_SOURCE === "sqlite";
6
+ const useDemo = () => import.meta.env.DEV && import.meta.env.BLOG_DATA_SOURCE !== "sqlite";
7
+ const publicPredicate = "p.status = 'published' AND p.published_at IS NOT NULL";
8
+
9
+ function fromRow(row: any): BlogPost {
10
+ const topicRows = db().prepare("SELECT t.slug FROM topics t JOIN post_topics pt ON pt.topic_id=t.id WHERE pt.post_id=? AND t.status!='archived'").all(row.id) as { slug: string }[];
11
+ const faq = db().prepare("SELECT f.id, f.question, f.answer_markdown AS answer FROM faq_items f JOIN post_faqs pf ON pf.faq_id=f.id WHERE pf.post_id=? AND f.status='published' ORDER BY f.sort_order").all(row.id) as any[];
12
+ const cta = db().prepare("SELECT c.label, c.url FROM ctas c JOIN post_ctas pc ON pc.cta_id=c.id WHERE pc.post_id=? AND c.status='active' LIMIT 1").get(row.id) as any;
13
+ const alternates = db().prepare("SELECT language,url FROM hreflang_links WHERE post_id=?").all(row.id) as Array<{ language: string; url: string }>;
14
+ return { id: row.id, source: row.source, sourceId: row.source_id || undefined, slug: row.slug, title: row.title, excerpt: row.excerpt, contentHtml: row.content_html || "", canonicalUrl: row.canonical_override || row.canonical_url, coverImageUrl: row.cover_image_url || undefined, coverImageAlt: row.cover_image_alt, author: { id: row.author_id, slug: row.author_slug, name: row.author_name, jobTitle: row.job_title || undefined, organisation: row.organisation || undefined, bio: row.bio || undefined, profileUrl: row.profile_url || undefined, credentials: JSON.parse(row.credentials_json || "[]") }, topicSlugs: topicRows.map((item) => item.slug), faq, ctaLabel: cta?.label, ctaUrl: cta?.url, language: row.language, status: row.status, publishedAt: row.published_at || undefined, updatedAt: row.updated_at || undefined, seoTitle: row.seo_title || undefined, metaDescription: row.meta_description || undefined, focusKeyword: row.focus_keyword || undefined, canonicalOverride: row.canonical_override || undefined, robotsIndex: row.robots_index || "index", robotsFollow: row.robots_follow || "follow", schemaType: row.schema_type || "Article", scheduledAt: row.scheduled_at || undefined, alternates };
15
+ }
16
+ function sqlitePosts(where = "", params: unknown[] = []): BlogPost[] {
17
+ const rows = db().prepare(`SELECT p.*, a.slug author_slug, a.name author_name, a.job_title, a.organisation, a.bio, a.profile_url, a.credentials_json FROM posts p JOIN authors a ON a.id=p.author_id WHERE ${publicPredicate} ${where} ORDER BY p.published_at DESC`).all(...params) as any[];
18
+ return rows.map(fromRow);
19
+ }
20
+ export function publishedPosts(): BlogPost[] {
21
+ return useSqlite() ? sqlitePosts() : useDemo() ? demoPosts.filter((post) => post.status === "published" && Boolean(post.publishedAt)) : [];
22
+ }
23
+
24
+ export function postBySlug(slug: string): BlogPost | undefined {
25
+ return useSqlite() ? sqlitePosts("AND p.slug=?", [slug])[0] : publishedPosts().find((post) => post.slug === slug);
26
+ }
27
+
28
+ export function topicBySlug(slug: string): Topic | undefined {
29
+ if (useSqlite()) {
30
+ const row = db().prepare("SELECT * FROM topics WHERE slug=? AND status!='archived'").get(slug) as any;
31
+ if (!row) return undefined;
32
+ const faq = db().prepare("SELECT f.id, f.question, f.answer_markdown AS answer FROM faq_items f JOIN topic_faqs tf ON tf.faq_id=f.id WHERE tf.topic_id=? AND f.status='published' ORDER BY f.sort_order").all(row.id) as any[];
33
+ return { id: row.id, slug: row.slug, title: row.title, description: row.description, status: row.status, faq };
34
+ }
35
+ return useDemo() ? demoTopics.find((topic) => topic.slug === slug && topic.status !== "archived") : undefined;
36
+ }
37
+
38
+ export function allTopics(): Topic[] {
39
+ if (!useSqlite()) return useDemo() ? demoTopics : [];
40
+ return (db().prepare("SELECT * FROM topics WHERE status!='archived' ORDER BY title").all() as any[]).map((row) => ({ id: row.id, slug: row.slug, title: row.title, description: row.description, status: row.status, faq: [] }));
41
+ }
42
+
43
+ export function authorBySlug(slug: string): Author | undefined {
44
+ if (useSqlite()) {
45
+ const row = db().prepare("SELECT * FROM authors WHERE slug=?").get(slug) as any;
46
+ return row ? { id: row.id, slug: row.slug, name: row.name, jobTitle: row.job_title || undefined, organisation: row.organisation || undefined, bio: row.bio || undefined, profileUrl: row.profile_url || undefined, credentials: JSON.parse(row.credentials_json || "[]") } : undefined;
47
+ }
48
+ return useDemo() ? publishedPosts().find((post) => post.author.slug === slug)?.author : undefined;
49
+ }
50
+
51
+ export function postsForAuthor(slug: string): BlogPost[] {
52
+ return useSqlite() ? sqlitePosts("AND a.slug=?", [slug]) : publishedPosts().filter((post) => post.author.slug === slug);
53
+ }
54
+
55
+ export function relatedPosts(post: BlogPost, limit = 3): BlogPost[] {
56
+ return publishedPosts().filter((candidate) => candidate.id !== post.id && candidate.topicSlugs.some((slug) => post.topicSlugs.includes(slug))).slice(0, limit);
57
+ }
58
+
59
+ export function ctaForPlacement(placement: string): { label: string; url: string } | undefined {
60
+ if (!useSqlite()) return useDemo() ? { label: "Get started", url: "/get-started" } : undefined;
61
+ return db().prepare("SELECT label,url FROM ctas WHERE placement=? AND status='active' ORDER BY updated_at DESC LIMIT 1").get(placement) as { label: string; url: string } | undefined;
62
+ }
63
+
64
+ export function postsForTopic(topicSlug: string): BlogPost[] {
65
+ return useSqlite() ? sqlitePosts("AND EXISTS (SELECT 1 FROM post_topics x JOIN topics y ON y.id=x.topic_id WHERE x.post_id=p.id AND y.slug=? )", [topicSlug]) : useDemo() ? publishedPosts().filter((post) => post.topicSlugs.includes(topicSlug)) : [];
66
+ }
67
+
68
+ export function siteUrl(path: string): string {
69
+ const baseUrl = import.meta.env.PUBLIC_SITE_URL;
70
+ if (!baseUrl) throw new Error("PUBLIC_SITE_URL is required; copy .env.example and configure the public site URL");
71
+ return new URL(path, baseUrl).toString();
72
+ }
@@ -0,0 +1,72 @@
1
+ import { db, id, now } from "./db";
2
+
3
+ type Severity = "info" | "warning" | "error";
4
+
5
+ export function runTechnicalAudit(): { runId: string; findings: any[] } {
6
+ const timestamp = now();
7
+ const runId = id("report");
8
+ const findings: any[] = [];
9
+ const add = (postId: string | null, category: string, severity: Severity, code: string, message: string, details: unknown = {}) => {
10
+ findings.push({ id: id("finding"), run_id: runId, post_id: postId, category, severity, code, message, details_json: JSON.stringify(details), created_at: timestamp });
11
+ if (code === "content-decay" && postId) {
12
+ const dueAt = new Date(Date.now() + 14 * 86400000).toISOString();
13
+ db().prepare("INSERT INTO fact_review_reminders (id,post_id,due_at,reason,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(post_id,status) DO UPDATE SET due_at=excluded.due_at,reason=excluded.reason,updated_at=excluded.updated_at").run(id("fact_review"), postId, dueAt, "content decay audit", "pending", timestamp, timestamp);
14
+ }
15
+ };
16
+ const posts = db().prepare("SELECT id,title,excerpt,content_html,canonical_url,canonical_override,seo_title,meta_description,author_id,status,published_at,updated_at,language,cover_image_alt FROM posts").all() as any[];
17
+ const duplicateTitles = new Map<string, any[]>();
18
+ const entities = db().prepare("SELECT entity_type,entity_key,name,same_as_json,schema_json FROM entity_profiles").all() as any[];
19
+ const organization = entities.find((entity) => entity.entity_type === "organization");
20
+ for (const post of posts) {
21
+ const html = String(post.content_html || "");
22
+ const text = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
23
+ const titleKey = String(post.title || "").trim().toLowerCase();
24
+ duplicateTitles.set(titleKey, [...(duplicateTitles.get(titleKey) || []), post]);
25
+ if (!post.author_id) add(post.id, "content-quality", "error", "missing-author", "Post has no author.");
26
+ if (post.status === "published" && !post.published_at) add(post.id, "technical", "error", "published-without-date", "Published post is missing published_at.");
27
+ if (post.published_at && post.updated_at && Date.parse(post.updated_at) < Date.parse(post.published_at)) add(post.id, "technical", "warning", "modified-date-integrity", "updated_at predates published_at.");
28
+ if ((post.excerpt || "").length < 50) add(post.id, "content-quality", "warning", "thin-description", "Excerpt/meta description is short.");
29
+ if (!post.seo_title) add(post.id, "technical", "warning", "missing-seo-title", "SEO title is not explicitly set.");
30
+ if (!post.meta_description) add(post.id, "technical", "warning", "missing-meta-description", "Meta description is not explicitly set.");
31
+ if (post.canonical_override && post.canonical_override !== post.canonical_url) add(post.id, "technical", "warning", "canonical-conflict", "Canonical override differs from stored canonical URL.");
32
+ try { if (!new URL(post.canonical_override || post.canonical_url)) add(post.id, "technical", "error", "invalid-canonical", "Canonical URL is invalid."); } catch { add(post.id, "technical", "error", "invalid-canonical", "Canonical URL is invalid."); }
33
+ if (text.length < 300) add(post.id, "content-quality", "warning", "thin-content", "Post body is shorter than 300 characters.");
34
+ const firstParagraph = html.match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1]?.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() || "";
35
+ if (post.status === "published" && (firstParagraph.length < 40 || firstParagraph.length > 500)) add(post.id, "geo", "info", "answer-first", "Opening paragraph is not a concise answer-first summary.", { length: firstParagraph.length });
36
+ const h1 = (html.match(/<h1\b/gi) || []).length;
37
+ if (h1 !== 1) add(post.id, "technical", "warning", "heading-hierarchy", "Rendered content should contain exactly one h1.", { h1 });
38
+ const images = [...html.matchAll(/<img\b[^>]*>/gi)].map((match) => match[0]);
39
+ for (const image of images) {
40
+ if (!/\balt=["'][^"']*["']/i.test(image) && !post.cover_image_alt) add(post.id, "content-quality", "warning", "missing-image-alt", "Image markup is missing descriptive alt text.");
41
+ if (!/\b(?:width|height)=["'][^"']+["']/i.test(image)) add(post.id, "technical", "warning", "image-dimensions", "Image markup is missing width or height attributes.");
42
+ const source = image.match(/\bsrc=["']([^"']+)["']/i)?.[1] || "";
43
+ if (/\.(?:png|jpe?g)(?:[?#].*)?$/i.test(source)) add(post.id, "performance", "info", "image-not-optimized", "Raster image is not using a WebP/AVIF derivative.", { source });
44
+ }
45
+ if (!/<a\b[^>]+href=["']https?:\/\//i.test(html)) add(post.id, "content-quality", "info", "citation-completeness", "No external source link was detected for citation review.");
46
+ if (post.updated_at && Date.parse(post.updated_at) < Date.now() - 365 * 86400000) add(post.id, "content-quality", "warning", "content-decay", "Content has not been updated in over one year.");
47
+ const faqItems = db().prepare("SELECT question,answer_markdown,status FROM faq_items f JOIN post_faqs pf ON pf.faq_id=f.id WHERE pf.post_id=?").all(post.id) as any[];
48
+ for (const faq of faqItems) if (faq.status === "published" && (String(faq.question).length < 10 || String(faq.answer_markdown).length < 80)) add(post.id, "geo", "warning", "faq-quality", "Published FAQ question or answer is too short for a useful answer.");
49
+ const links = db().prepare("SELECT language,url FROM hreflang_links WHERE post_id=?").all(post.id) as any[];
50
+ const languages = new Set<string>();
51
+ for (const link of links) { if (languages.has(link.language)) add(post.id, "technical", "warning", "hreflang-duplicate", "Post has duplicate hreflang language entries.", link); languages.add(link.language); try { new URL(link.url); } catch { add(post.id, "technical", "error", "hreflang-invalid-url", "hreflang URL is invalid.", link); } }
52
+ if (links.length && !languages.has(post.language)) add(post.id, "technical", "warning", "hreflang-missing-self", "hreflang set does not include the post's own language.", { language: post.language });
53
+ if (organization && post.author_id && !organization.name) add(post.id, "geo", "warning", "entity-incomplete", "Organization entity is missing a stable name.");
54
+ }
55
+ for (const [title, items] of duplicateTitles) if (title && items.length > 1) for (const post of items) add(post.id, "content-quality", "warning", "duplicate-title", "Multiple posts share the same normalized title.", { title, count: items.length });
56
+ const duplicateBodies = new Map<string, any[]>();
57
+ for (const post of posts) { const bodyKey = String(post.content_html || "").replace(/\s+/g, " ").trim(); if (bodyKey.length >= 100) duplicateBodies.set(bodyKey, [...(duplicateBodies.get(bodyKey) || []), post]); }
58
+ for (const [bodyKey, items] of duplicateBodies) if (items.length > 1) for (const post of items) add(post.id, "content-quality", "warning", "duplicate-content", "Multiple posts share the same normalized body content.", { count: items.length, fingerprint: bodyKey.slice(0, 80) });
59
+ const orphaned = db().prepare("SELECT p.id FROM posts p LEFT JOIN post_topics pt ON pt.post_id=p.id WHERE p.status='published' GROUP BY p.id HAVING COUNT(pt.topic_id)=0").all() as Array<{ id: string }>;
60
+ for (const post of orphaned) add(post.id, "technical", "warning", "orphan-page", "Published post has no topic relationship.");
61
+ const redirects = db().prepare("SELECT source_path,target_path FROM redirects").all() as Array<{ source_path: string; target_path: string }>;
62
+ const redirectMap = new Map(redirects.map((row) => [row.source_path, row.target_path]));
63
+ for (const row of redirects) if (redirectMap.has(row.target_path)) add(null, "technical", "warning", "redirect-chain", "Redirect target is itself redirected.", row);
64
+ const topicCounts = db().prepare("SELECT t.id,COUNT(pt.post_id) AS count FROM topics t LEFT JOIN post_topics pt ON pt.topic_id=t.id LEFT JOIN posts p ON p.id=pt.post_id AND p.status='published' GROUP BY t.id").all() as Array<{ id: string; count: number }>;
65
+ const topicRows = db().prepare("SELECT id,title,minimum_published_posts FROM topics WHERE status!='archived'").all() as Array<{ id: string; title: string; minimum_published_posts: number }>;
66
+ for (const topic of topicCounts) { if (topic.count > 12) add(null, "technical", "info", "pagination-required", "Topic exceeds the page size and requires a paginated index.", topic); const definition = topicRows.find((item) => item.id === topic.id); if (definition && topic.count < definition.minimum_published_posts) add(null, "content-quality", "warning", "thin-topic", "Topic has fewer published posts than its configured minimum.", { topicId: topic.id, title: definition.title, count: topic.count, minimum: definition.minimum_published_posts }); }
67
+ const summary = { totalPosts: posts.length, findingCount: findings.length, errors: findings.filter((item) => item.severity === "error").length, warnings: findings.filter((item) => item.severity === "warning").length };
68
+ db().prepare("INSERT INTO blog_reports (id,run_type,status,summary_json,findings_json,started_at,completed_at) VALUES (?,?,?,?,?,?,?)").run(runId, "technical-seo", "completed", JSON.stringify(summary), JSON.stringify(findings), timestamp, timestamp);
69
+ const insert = db().prepare("INSERT INTO seo_findings (id,run_id,post_id,category,severity,code,message,details_json,created_at) VALUES (?,?,?,?,?,?,?,?,?)");
70
+ 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)))();
71
+ return { runId, findings };
72
+ }
@@ -0,0 +1,14 @@
1
+ import { db } from "./db";
2
+
3
+ export function seoInsights() {
4
+ const database = db(); const posts = database.prepare("SELECT id,slug,title,excerpt,content_html,status FROM posts WHERE status='published'").all() as any[]; const bySlug = new Map(posts.map((post) => [post.slug, post])); const internalLinks = posts.flatMap((post) => { const links = [...String(post.content_html || "").matchAll(/href=["'](?:https?:\/\/[^"']+)?\/blog\/([^"'#?]+)/gi)].map((match) => match[1]); return links.filter((slug) => bySlug.has(slug) && slug !== post.slug).map((slug) => ({ from: post.slug, to: slug })); });
5
+ const linked = new Set(internalLinks.map((link) => link.to)); const suggestions = posts.filter((post) => !linked.has(post.slug)).map((post) => ({ postId: post.id, slug: post.slug, reason: "no incoming internal link detected" })); const topics = database.prepare("SELECT t.id,t.slug,t.title,COUNT(pt.post_id) post_count FROM topics t LEFT JOIN post_topics pt ON pt.topic_id=t.id LEFT JOIN posts p ON p.id=pt.post_id AND p.status='published' GROUP BY t.id ORDER BY post_count").all(); const faq = database.prepare("SELECT COUNT(*) total, SUM(CASE WHEN LENGTH(answer_markdown)>=80 THEN 1 ELSE 0 END) quality_answers FROM faq_items WHERE status='published'").get(); const entities = database.prepare("SELECT entity_type,entity_key,name,same_as_json FROM entity_profiles ORDER BY entity_type,name").all(); const crawlerPolicy = database.prepare("SELECT value_json FROM blog_settings WHERE key='aiCrawlerPolicy'").get() as { value_json: string } | undefined;
6
+ const answerFirst = posts.map((post) => { const firstParagraph = String(post.content_html || "").match(/<p[^>]*>([\s\S]*?)<\/p>/i)?.[1]?.replace(/<[^>]+>/g, "").trim() || ""; return { slug: post.slug, answerFirst: firstParagraph.length >= 40 && firstParagraph.length <= 500 }; });
7
+ const refreshRecommendations = posts.map((post) => {
8
+ const text = String(post.content_html || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
9
+ const reasons = [post.excerpt.length < 50 ? "short-description" : null, text.length < 300 ? "thin-content" : null, !post.content_html?.match(/<a\b[^>]+href=["']https?:\/\//i) ? "missing-citations" : null].filter(Boolean);
10
+ return reasons.length ? { postId: post.id, slug: post.slug, reasons, recommendedAction: "review-and-refresh" } : null;
11
+ }).filter(Boolean);
12
+ const relations = database.prepare("SELECT subject_key,predicate,object_key FROM entity_relations ORDER BY subject_key,predicate,object_key").all();
13
+ return { internalLinks: { total: internalLinks.length, links: internalLinks, orphanCandidates: suggestions }, topicCoverage: topics, faqQuality: faq, answerFirst, refreshRecommendations, entities, entityGraph: relations, aiCrawlerPolicy: crawlerPolicy ? JSON.parse(crawlerPolicy.value_json) : { allow: true } };
14
+ }
@@ -0,0 +1,33 @@
1
+ import type { Author, BlogPost, FaqItem, Topic } from "./types";
2
+
3
+ export function postJsonLd(post: BlogPost) {
4
+ return {
5
+ "@context": "https://schema.org",
6
+ "@type": post.schemaType || "Article",
7
+ headline: post.title,
8
+ description: post.excerpt,
9
+ url: post.canonicalUrl,
10
+ mainEntityOfPage: { "@type": "WebPage", "@id": post.canonicalUrl },
11
+ datePublished: post.publishedAt,
12
+ ...(post.updatedAt ? { dateModified: post.updatedAt } : {}),
13
+ author: { "@type": "Person", name: post.author.name, ...(post.author.profileUrl ? { url: post.author.profileUrl } : {}) },
14
+ ...(post.author.organisation ? { publisher: { "@type": "Organization", name: post.author.organisation } } : {}),
15
+ ...(post.coverImageUrl ? { image: [post.coverImageUrl] } : {}),
16
+ };
17
+ }
18
+
19
+ export function topicJsonLd(topic: Topic, url: string) {
20
+ return { "@context": "https://schema.org", "@type": "CollectionPage", name: topic.title, description: topic.description, url };
21
+ }
22
+
23
+ export function faqJsonLd(items: FaqItem[]) {
24
+ return { "@context": "https://schema.org", "@type": "FAQPage", mainEntity: items.map((item) => ({ "@type": "Question", name: item.question, acceptedAnswer: { "@type": "Answer", text: item.answer } })) };
25
+ }
26
+
27
+ export function authorJsonLd(author: Author, url: string) {
28
+ return { "@context": "https://schema.org", "@type": "Person", name: author.name, url, ...(author.jobTitle ? { jobTitle: author.jobTitle } : {}), ...(author.organisation ? { worksFor: { "@type": "Organization", name: author.organisation } } : {}), ...(author.profileUrl ? { sameAs: [author.profileUrl] } : {}), ...(author.credentials?.length ? { knowsAbout: author.credentials } : {}) };
29
+ }
30
+
31
+ export function organizationJsonLd(entity: { name: string; url: string; logo?: string; sameAs?: string[] }) {
32
+ return { "@context": "https://schema.org", "@type": "Organization", name: entity.name, url: entity.url, ...(entity.logo ? { logo: entity.logo } : {}), ...(entity.sameAs?.length ? { sameAs: entity.sameAs } : {}) };
33
+ }
@@ -0,0 +1,58 @@
1
+ export type PublishStatus = "draft" | "review" | "approved" | "scheduled" | "published" | "archived";
2
+
3
+ export type Author = {
4
+ id: string;
5
+ slug: string;
6
+ name: string;
7
+ jobTitle?: string;
8
+ organisation?: string;
9
+ bio?: string;
10
+ profileUrl?: string;
11
+ credentials?: string[];
12
+ };
13
+
14
+ export type FaqItem = {
15
+ id: string;
16
+ question: string;
17
+ answer: string;
18
+ };
19
+
20
+ export type Topic = {
21
+ id: string;
22
+ slug: string;
23
+ title: string;
24
+ description: string;
25
+ status: "draft" | "published" | "archived";
26
+ faq: FaqItem[];
27
+ };
28
+
29
+ export type BlogPost = {
30
+ id: string;
31
+ source: "local" | "api-pull" | "cms" | "manual";
32
+ sourceId?: string;
33
+ slug: string;
34
+ title: string;
35
+ excerpt: string;
36
+ contentHtml: string;
37
+ canonicalUrl: string;
38
+ coverImageUrl?: string;
39
+ coverImageAlt?: string;
40
+ author: Author;
41
+ topicSlugs: string[];
42
+ faq: FaqItem[];
43
+ ctaLabel?: string;
44
+ ctaUrl?: string;
45
+ language: string;
46
+ status: PublishStatus;
47
+ publishedAt?: string;
48
+ updatedAt?: string;
49
+ seoTitle?: string;
50
+ metaDescription?: string;
51
+ focusKeyword?: string;
52
+ canonicalOverride?: string;
53
+ robotsIndex?: "index" | "noindex";
54
+ robotsFollow?: "follow" | "nofollow";
55
+ schemaType?: string;
56
+ scheduledAt?: string;
57
+ alternates?: Array<{ language: string; url: string }>;
58
+ };