From ce4570f8b6c5dc171212f21da3ace389ff939812 Mon Sep 17 00:00:00 2001 From: yuriassuncx Date: Wed, 27 May 2026 11:58:13 -0300 Subject: [PATCH 01/52] feat: add Spire webhook integration for Blog CMS --- blog/actions/importSpirePost.ts | 196 +++++++++++++++++++++++ blog/manifest.gen.ts | 14 +- blog/mod.ts | 6 + blog/types.ts | 9 ++ spire/manifest.gen.ts | 72 +++++---- spire/sections/SpireDashboard.tsx | 64 ++++++++ spire/sections/SpirePendingApprovals.tsx | 189 ++++++++++++++++++++++ 7 files changed, 510 insertions(+), 40 deletions(-) create mode 100644 blog/actions/importSpirePost.ts create mode 100644 spire/sections/SpireDashboard.tsx create mode 100644 spire/sections/SpirePendingApprovals.tsx diff --git a/blog/actions/importSpirePost.ts b/blog/actions/importSpirePost.ts new file mode 100644 index 000000000..7ef595c77 --- /dev/null +++ b/blog/actions/importSpirePost.ts @@ -0,0 +1,196 @@ +import { AppContext } from "../mod.ts"; +import { BlogPost } from "../types.ts"; +import { spirePostToBlogPost } from "../../spire/loaders/BlogPostPage.ts"; +import { join } from "std/path/mod.ts"; +import { SpirePost, Block } from "../../spire/types.ts"; + +export interface Props { + postId: string; + postSlug: string; + blogSlug: string; + event: "post.published" | "post.unpublished" | "post.saved"; +} + +/** + * @title Import Spire Post Webhook + * @description Securely receives a post update webhook from Spire, fetches the full post, compiles it to HTML, and saves it natively in Deco. + */ +export default async function importSpirePost( + { postSlug, blogSlug, event }: Props, + req: Request, + _ctx: AppContext, +): Promise<{ success: boolean; path?: string; message?: string }> { + try { + // 1. Security Authorization Header Verification + const expectedSecret = + (typeof _ctx.spireWebhookSecret === "string" + ? _ctx.spireWebhookSecret + : _ctx.spireWebhookSecret?.get?.()) || + Deno.env.get("SPIRE_WEBHOOK_SECRET"); + if (expectedSecret) { + const authHeader = req.headers.get("Authorization"); + if (!authHeader || authHeader !== `Bearer ${expectedSecret}`) { + return { + success: false, + message: "Unauthorized: Webhook token verification failed." + }; + } + } else { + console.warn("[Webhook] Warning: SPIRE_WEBHOOK_SECRET is not configured. Webhook request authorized without verification (development mode)."); + } + + if (event === "post.unpublished") { + // Handle unpublishing/deletion: Remove block file + const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts"); + const filePath = join(blocksDir, `${postSlug}.json`); + try { + await Deno.remove(filePath); + console.info(`[Webhook] Successfully removed unpublished post at: ${filePath}`); + return { success: true, message: "Post unpublished successfully" }; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return { success: true, message: "Post already unpublished or not found" }; + } + throw err; + } + } + + // 2. Fetch full post content from Spire API + const response = await fetch(`https://spire.blog/api/blog/${blogSlug}/posts/${postSlug}`); + if (!response.ok) { + throw new Error(`Failed to fetch post from Spire: ${response.status} ${response.statusText}`); + } + + const { post } = (await response.json()) as { post?: SpirePost }; + if (!post) { + throw new Error("No post content returned from Spire API"); + } + + // 3. Convert SpirePost schema to BlogPost + const baseBlogPost = spirePostToBlogPost(post, {}); + + // 4. Compile Spire content blocks into a clean semantic HTML string for Deco Admin WYSIWYG + const htmlContent = compileBlocksToHtml(post.version.blocks); + + const blogPost: BlogPost = { + ...baseBlogPost, + content: htmlContent, + spirePostId: post.id, + spireWarning: "This post is automatically synchronized by Spire. Any manual edits made in this form will be overwritten during the next sync.", + }; + + // 5. Build Deco Block Resolvable + const resolvable = { + __resolveType: "blog/loaders/Blogpost.ts", + post: blogPost, + }; + + // 6. Write natively to filesystem as local JSON block (.deco/blocks/collections/blog/posts/.json) + const blocksDir = join(Deno.cwd(), ".deco", "blocks", "collections", "blog", "posts"); + await Deno.mkdir(blocksDir, { recursive: true }); + const filePath = join(blocksDir, `${postSlug}.json`); + await Deno.writeTextFile(filePath, JSON.stringify(resolvable, null, 2)); + + console.info(`[Webhook] Successfully imported post and saved to ${filePath}`); + + return { + success: true, + path: filePath, + message: "Post imported and compiled successfully" + }; + } catch (error) { + console.error("[Webhook] Error importing Spire post:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Internal Server Error" + }; + } +} +export interface SpireBlockContent { + html?: string; + text?: string; + level?: string | number; + style?: string; + items?: string | string[]; + quote?: string; + attribution?: string; + variant?: string; + title?: string; + body?: string; + steps?: string | Array<{ title?: string; description?: string }>; + url?: string; + alt?: string; + caption?: string; + language?: string; + code?: string; + href?: string; +} + +/** + * Compiles Spire's modular block arrays to semantic standard HTML + */ +function compileBlocksToHtml(blocks?: Block[]): string { + if (!blocks || !Array.isArray(blocks)) return ""; + + const sorted = [...blocks].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + + return sorted.map((block) => { + const content = (block.content || {}) as SpireBlockContent; + switch (block.type) { + case "paragraph": + return content.html ? content.html : `

${content.text || ""}

`; + case "heading": { + const level = content.level || "2"; + return `${content.text || ""}`; + } + case "list": { + const style = content.style === "ordered" ? "ol" : "ul"; + const items = Array.isArray(content.items) + ? content.items + : typeof content.items === "string" + ? JSON.parse(content.items) as string[] + : []; + const listItems = items.map((item: string) => `
  • ${item}
  • `).join(""); + return `<${style}>${listItems}`; + } + case "divider": + return "
    "; + case "quote": + return `

    ${content.quote || content.text || ""}

    ${content.attribution ? `${content.attribution}` : ""}
    `; + case "callout": { + const variant = content.variant || "info"; + return `
    ${content.title || ""}

    ${content.body || ""}

    `; + } + case "checklist": { + const checkItems = Array.isArray(content.items) + ? content.items + : typeof content.items === "string" + ? JSON.parse(content.items) as string[] + : []; + const checkList = checkItems.map((item: string) => `
  • ${item}
  • `).join(""); + return ``; + } + case "steps": { + const stepItems = Array.isArray(content.steps) + ? content.steps + : typeof content.steps === "string" + ? JSON.parse(content.steps) as Array<{ title?: string; description?: string }> + : []; + const stepList = stepItems.map((step: { title?: string; description?: string }, index: number) => `
  • ${index + 1}. ${step.title || ""}${step.description ? `

    ${step.description}

    ` : ""}
  • `).join(""); + return `
      ${content.title ? `

      ${content.title}

      ` : ""}${stepList}
    `; + } + case "image": + return `
    ${content.alt || ${content.caption ? `
    ${content.caption}
    ` : ""}
    `; + case "video": + return `
    ${content.caption ? `
    ${content.caption}
    ` : ""}
    `; + case "code": + return `
    ${content.code || ""}
    `; + case "cta": + return ``; + default: + if (content.html) return content.html; + if (content.text) return `

    ${content.text}

    `; + return ""; + } + }).join("\n"); +} diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts index ce8f6fe2b..b34853a6c 100644 --- a/blog/manifest.gen.ts +++ b/blog/manifest.gen.ts @@ -2,9 +2,10 @@ // This file SHOULD be checked into source version control. // This file is automatically updated during development when running `dev.ts`. -import * as $$$$$$$$$0 from "./actions/submitRating.ts"; -import * as $$$$$$$$$1 from "./actions/submitReview.ts"; -import * as $$$$$$$$$2 from "./actions/submitView.ts"; +import * as $$$$$$$$$0 from "./actions/importSpirePost.ts"; +import * as $$$$$$$$$1 from "./actions/submitRating.ts"; +import * as $$$$$$$$$2 from "./actions/submitReview.ts"; +import * as $$$$$$$$$3 from "./actions/submitView.ts"; import * as $$$0 from "./loaders/Author.ts"; import * as $$$4 from "./loaders/Blogpost.ts"; import * as $$$1 from "./loaders/BlogPostItem.ts"; @@ -54,9 +55,10 @@ const manifest = { "blog/sections/Template.tsx": $$$$$$2, }, "actions": { - "blog/actions/submitRating.ts": $$$$$$$$$0, - "blog/actions/submitReview.ts": $$$$$$$$$1, - "blog/actions/submitView.ts": $$$$$$$$$2, + "blog/actions/importSpirePost.ts": $$$$$$$$$0, + "blog/actions/submitRating.ts": $$$$$$$$$1, + "blog/actions/submitReview.ts": $$$$$$$$$2, + "blog/actions/submitView.ts": $$$$$$$$$3, }, "name": "blog", "baseUrl": import.meta.url, diff --git a/blog/mod.ts b/blog/mod.ts index f4e9baf8e..e1a6641b2 100644 --- a/blog/mod.ts +++ b/blog/mod.ts @@ -1,4 +1,5 @@ import manifest, { Manifest } from "./manifest.gen.ts"; +import { Secret } from "../website/loaders/secret.ts"; import { PreviewContainer } from "../utils/preview.tsx"; import { type App, type FnContext } from "@deco/deco"; export type State = { @@ -7,6 +8,11 @@ export type State = { * @description The slug of the BlogPostPage to embed. Use :category and :slug. */ pageSlug?: string; + /** + * @title Spire Webhook Secret + * @description Secret token to verify incoming webhooks from Spire. Create a Secret here and paste it in your Spire dashboard settings alongside your Deco Webhook URL. + */ + spireWebhookSecret?: Secret; }; export type AppContext = FnContext; /** diff --git a/blog/types.ts b/blog/types.ts index 53cce1fdc..ac8ca3d36 100644 --- a/blog/types.ts +++ b/blog/types.ts @@ -20,6 +20,15 @@ export interface Category { } export interface BlogPost { + /** + * @title AI-MANAGED POST (SPIRE) + * @description This post is automatically synchronized by Spire AI. Any manual edits made in this form will be overwritten during the next Spire synchronization. + */ + spireWarning?: string; + /** + * @hide true + */ + spirePostId?: string; title: string; excerpt: string; /** diff --git a/spire/manifest.gen.ts b/spire/manifest.gen.ts index 0d922cb58..f199f8ea9 100644 --- a/spire/manifest.gen.ts +++ b/spire/manifest.gen.ts @@ -5,25 +5,27 @@ import * as $$$1 from "./loaders/BlogpostList.ts"; import * as $$$2 from "./loaders/BlogpostListing.ts"; import * as $$$0 from "./loaders/BlogPostPage.ts"; -import * as $$$$$$3 from "./sections/blocks/BlockImage.tsx"; -import * as $$$$$$4 from "./sections/blocks/Callout.tsx"; -import * as $$$$$$5 from "./sections/blocks/CardGroup.tsx"; -import * as $$$$$$6 from "./sections/blocks/Checklist.tsx"; -import * as $$$$$$7 from "./sections/blocks/Code.tsx"; -import * as $$$$$$8 from "./sections/blocks/Comparison.tsx"; -import * as $$$$$$9 from "./sections/blocks/Cta.tsx"; -import * as $$$$$$10 from "./sections/blocks/Divider.tsx"; -import * as $$$$$$11 from "./sections/blocks/Heading.tsx"; -import * as $$$$$$12 from "./sections/blocks/List.tsx"; -import * as $$$$$$13 from "./sections/blocks/Paragraph.tsx"; -import * as $$$$$$14 from "./sections/blocks/Quote.tsx"; -import * as $$$$$$15 from "./sections/blocks/Stat.tsx"; -import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx"; -import * as $$$$$$17 from "./sections/blocks/Steps.tsx"; -import * as $$$$$$18 from "./sections/blocks/Video.tsx"; +import * as $$$$$$5 from "./sections/blocks/BlockImage.tsx"; +import * as $$$$$$6 from "./sections/blocks/Callout.tsx"; +import * as $$$$$$7 from "./sections/blocks/CardGroup.tsx"; +import * as $$$$$$8 from "./sections/blocks/Checklist.tsx"; +import * as $$$$$$9 from "./sections/blocks/Code.tsx"; +import * as $$$$$$10 from "./sections/blocks/Comparison.tsx"; +import * as $$$$$$11 from "./sections/blocks/Cta.tsx"; +import * as $$$$$$12 from "./sections/blocks/Divider.tsx"; +import * as $$$$$$13 from "./sections/blocks/Heading.tsx"; +import * as $$$$$$14 from "./sections/blocks/List.tsx"; +import * as $$$$$$15 from "./sections/blocks/Paragraph.tsx"; +import * as $$$$$$16 from "./sections/blocks/Quote.tsx"; +import * as $$$$$$17 from "./sections/blocks/Stat.tsx"; +import * as $$$$$$18 from "./sections/blocks/StatGroup.tsx"; +import * as $$$$$$19 from "./sections/blocks/Steps.tsx"; +import * as $$$$$$20 from "./sections/blocks/Video.tsx"; import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx"; import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx"; -import * as $$$$$$2 from "./sections/Template.tsx"; +import * as $$$$$$2 from "./sections/SpireDashboard.tsx"; +import * as $$$$$$3 from "./sections/SpirePendingApprovals.tsx"; +import * as $$$$$$4 from "./sections/Template.tsx"; const manifest = { "loaders": { @@ -32,25 +34,27 @@ const manifest = { "spire/loaders/BlogPostPage.ts": $$$0, }, "sections": { - "spire/sections/blocks/BlockImage.tsx": $$$$$$3, - "spire/sections/blocks/Callout.tsx": $$$$$$4, - "spire/sections/blocks/CardGroup.tsx": $$$$$$5, - "spire/sections/blocks/Checklist.tsx": $$$$$$6, - "spire/sections/blocks/Code.tsx": $$$$$$7, - "spire/sections/blocks/Comparison.tsx": $$$$$$8, - "spire/sections/blocks/Cta.tsx": $$$$$$9, - "spire/sections/blocks/Divider.tsx": $$$$$$10, - "spire/sections/blocks/Heading.tsx": $$$$$$11, - "spire/sections/blocks/List.tsx": $$$$$$12, - "spire/sections/blocks/Paragraph.tsx": $$$$$$13, - "spire/sections/blocks/Quote.tsx": $$$$$$14, - "spire/sections/blocks/Stat.tsx": $$$$$$15, - "spire/sections/blocks/StatGroup.tsx": $$$$$$16, - "spire/sections/blocks/Steps.tsx": $$$$$$17, - "spire/sections/blocks/Video.tsx": $$$$$$18, + "spire/sections/blocks/BlockImage.tsx": $$$$$$5, + "spire/sections/blocks/Callout.tsx": $$$$$$6, + "spire/sections/blocks/CardGroup.tsx": $$$$$$7, + "spire/sections/blocks/Checklist.tsx": $$$$$$8, + "spire/sections/blocks/Code.tsx": $$$$$$9, + "spire/sections/blocks/Comparison.tsx": $$$$$$10, + "spire/sections/blocks/Cta.tsx": $$$$$$11, + "spire/sections/blocks/Divider.tsx": $$$$$$12, + "spire/sections/blocks/Heading.tsx": $$$$$$13, + "spire/sections/blocks/List.tsx": $$$$$$14, + "spire/sections/blocks/Paragraph.tsx": $$$$$$15, + "spire/sections/blocks/Quote.tsx": $$$$$$16, + "spire/sections/blocks/Stat.tsx": $$$$$$17, + "spire/sections/blocks/StatGroup.tsx": $$$$$$18, + "spire/sections/blocks/Steps.tsx": $$$$$$19, + "spire/sections/blocks/Video.tsx": $$$$$$20, "spire/sections/Seo/SeoBlogPost.tsx": $$$$$$0, "spire/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1, - "spire/sections/Template.tsx": $$$$$$2, + "spire/sections/SpireDashboard.tsx": $$$$$$2, + "spire/sections/SpirePendingApprovals.tsx": $$$$$$3, + "spire/sections/Template.tsx": $$$$$$4, }, "name": "spire", "baseUrl": import.meta.url, diff --git a/spire/sections/SpireDashboard.tsx b/spire/sections/SpireDashboard.tsx new file mode 100644 index 000000000..4d87d68fe --- /dev/null +++ b/spire/sections/SpireDashboard.tsx @@ -0,0 +1,64 @@ +import { useId } from "preact/hooks"; + +export interface Props { + /** + * @title Spire Blog Slug + * @description Your Spire account blog slug (e.g., "miess") + */ + blogSlug: string; + /** + * @title Spire Dashboard Base URL + * @description The base URL of your Spire instance (default: https://spire.blog) + */ + spireUrl?: string; + /** + * @title Container Height + * @description Height of the embedded dashboard (default: 800px) + */ + height?: string; +} + +/** + * @title Spire Dashboard + * @description Embeds your Spire AI Blog Dashboard natively inside Deco Admin for seamless content generation and campaign management. + */ +export default function SpireDashboard({ + blogSlug, + spireUrl = "https://spire.blog", + height = "800px", +}: Props) { + const id = useId(); + + if (!blogSlug) { + return ( +
    +

    Configuration Required

    +

    + Please configure your Spire Blog Slug in the section properties to embed the dashboard. +

    +
    + ); + } + + // Construct the embedded URL passing the embed=true search param + const embeddedUrl = `${spireUrl}/app/${blogSlug}?embed=true`; + + return ( +
    + {/* Seamless Iframe container */} +
    +