From 7b17235f7502c587cb94109d5a7b46b8cad302a7 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Tue, 2 Dec 2025 22:22:56 +1100 Subject: [PATCH 1/2] feat: add Webflow plugin --- plugins/webflow/credentials.ts | 3 + plugins/webflow/icon.tsx | 14 +++ plugins/webflow/index.ts | 119 ++++++++++++++++++++++ plugins/webflow/steps/get-site.ts | 126 +++++++++++++++++++++++ plugins/webflow/steps/list-sites.ts | 119 ++++++++++++++++++++++ plugins/webflow/steps/publish-site.ts | 137 ++++++++++++++++++++++++++ plugins/webflow/test.ts | 43 ++++++++ 7 files changed, 561 insertions(+) create mode 100644 plugins/webflow/credentials.ts create mode 100644 plugins/webflow/icon.tsx create mode 100644 plugins/webflow/index.ts create mode 100644 plugins/webflow/steps/get-site.ts create mode 100644 plugins/webflow/steps/list-sites.ts create mode 100644 plugins/webflow/steps/publish-site.ts create mode 100644 plugins/webflow/test.ts diff --git a/plugins/webflow/credentials.ts b/plugins/webflow/credentials.ts new file mode 100644 index 00000000..63e54a5a --- /dev/null +++ b/plugins/webflow/credentials.ts @@ -0,0 +1,3 @@ +export type WebflowCredentials = { + WEBFLOW_API_KEY?: string; +}; diff --git a/plugins/webflow/icon.tsx b/plugins/webflow/icon.tsx new file mode 100644 index 00000000..a119039c --- /dev/null +++ b/plugins/webflow/icon.tsx @@ -0,0 +1,14 @@ +export function WebflowIcon({ className }: { className?: string }) { + return ( + + Webflow + + + ); +} diff --git a/plugins/webflow/index.ts b/plugins/webflow/index.ts new file mode 100644 index 00000000..26b5c9d7 --- /dev/null +++ b/plugins/webflow/index.ts @@ -0,0 +1,119 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { WebflowIcon } from "./icon"; + +const webflowPlugin: IntegrationPlugin = { + type: "webflow", + label: "Webflow", + description: "Publish and manage Webflow sites", + + icon: WebflowIcon, + + formFields: [ + { + id: "apiKey", + label: "API Token", + type: "password", + placeholder: "your-api-token", + configKey: "apiKey", + envVar: "WEBFLOW_API_KEY", + helpText: "Generate an API token from ", + helpLink: { + text: "Webflow Dashboard", + url: "https://webflow.com/dashboard", + }, + }, + ], + + testConfig: { + getTestFunction: async () => { + const { testWebflow } = await import("./test"); + return testWebflow; + }, + }, + + actions: [ + { + slug: "list-sites", + label: "List Sites", + description: "Get all sites accessible with the API token", + category: "Webflow", + stepFunction: "listSitesStep", + stepImportPath: "list-sites", + outputFields: [ + { field: "sites", description: "Array of site objects" }, + { field: "count", description: "Number of sites returned" }, + ], + configFields: [], + }, + { + slug: "get-site", + label: "Get Site", + description: "Get details of a specific Webflow site", + category: "Webflow", + stepFunction: "getSiteStep", + stepImportPath: "get-site", + outputFields: [ + { field: "id", description: "Site ID" }, + { field: "displayName", description: "Display name of the site" }, + { field: "shortName", description: "Short name (subdomain)" }, + { field: "previewUrl", description: "Preview URL" }, + { field: "lastPublished", description: "Last published timestamp" }, + { field: "customDomains", description: "Array of custom domains" }, + ], + configFields: [ + { + key: "siteId", + label: "Site ID", + type: "template-input", + placeholder: "site-id or {{NodeName.id}}", + example: "580e63e98c9a982ac9b8b741", + required: true, + }, + ], + }, + { + slug: "publish-site", + label: "Publish Site", + description: "Publish a site to one or more domains", + category: "Webflow", + stepFunction: "publishSiteStep", + stepImportPath: "publish-site", + outputFields: [ + { field: "publishedDomains", description: "Array of published domain URLs" }, + { field: "publishedToSubdomain", description: "Whether published to Webflow subdomain" }, + ], + configFields: [ + { + key: "siteId", + label: "Site ID", + type: "template-input", + placeholder: "site-id or {{NodeName.id}}", + example: "580e63e98c9a982ac9b8b741", + required: true, + }, + { + key: "publishToWebflowSubdomain", + label: "Publish to Webflow Subdomain", + type: "select", + options: [ + { value: "true", label: "Yes" }, + { value: "false", label: "No" }, + ], + defaultValue: "true", + }, + { + key: "customDomainIds", + label: "Custom Domain IDs (comma-separated)", + type: "template-input", + placeholder: "domain-id-1, domain-id-2", + example: "589a331aa51e760df7ccb89d", + }, + ], + }, + ], +}; + +registerIntegration(webflowPlugin); + +export default webflowPlugin; diff --git a/plugins/webflow/steps/get-site.ts b/plugins/webflow/steps/get-site.ts new file mode 100644 index 00000000..cb99932e --- /dev/null +++ b/plugins/webflow/steps/get-site.ts @@ -0,0 +1,126 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { WebflowCredentials } from "../credentials"; + +const WEBFLOW_API_URL = "https://api.webflow.com/v2"; + +type WebflowSiteResponse = { + id: string; + workspaceId: string; + createdOn: string; + displayName: string; + shortName: string; + lastPublished?: string; + lastUpdated: string; + previewUrl: string; + timeZone: string; + customDomains?: Array<{ + id: string; + url: string; + lastPublished?: string; + }>; +}; + +type GetSiteResult = + | { + success: true; + id: string; + displayName: string; + shortName: string; + previewUrl: string; + lastPublished?: string; + lastUpdated: string; + timeZone: string; + customDomains: Array<{ + id: string; + url: string; + lastPublished?: string; + }>; + } + | { success: false; error: string }; + +export type GetSiteCoreInput = { + siteId: string; +}; + +export type GetSiteInput = StepInput & + GetSiteCoreInput & { + integrationId?: string; + }; + +async function stepHandler( + input: GetSiteCoreInput, + credentials: WebflowCredentials +): Promise { + const apiKey = credentials.WEBFLOW_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "WEBFLOW_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + if (!input.siteId) { + return { + success: false, + error: "Site ID is required", + }; + } + + try { + const response = await fetch(`${WEBFLOW_API_URL}/sites/${input.siteId}`, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { message?: string }; + return { + success: false, + error: errorData.message || `HTTP ${response.status}`, + }; + } + + const site = (await response.json()) as WebflowSiteResponse; + + return { + success: true, + id: site.id, + displayName: site.displayName, + shortName: site.shortName, + previewUrl: site.previewUrl, + lastPublished: site.lastPublished, + lastUpdated: site.lastUpdated, + timeZone: site.timeZone, + customDomains: site.customDomains || [], + }; + } catch (error) { + return { + success: false, + error: `Failed to get site: ${getErrorMessage(error)}`, + }; + } +} + +export async function getSiteStep( + input: GetSiteInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} +getSiteStep.maxRetries = 0; + +export const _integrationType = "webflow"; diff --git a/plugins/webflow/steps/list-sites.ts b/plugins/webflow/steps/list-sites.ts new file mode 100644 index 00000000..5d9682eb --- /dev/null +++ b/plugins/webflow/steps/list-sites.ts @@ -0,0 +1,119 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { WebflowCredentials } from "../credentials"; + +const WEBFLOW_API_URL = "https://api.webflow.com/v2"; + +type WebflowSite = { + id: string; + workspaceId: string; + createdOn: string; + displayName: string; + shortName: string; + lastPublished?: string; + lastUpdated: string; + previewUrl: string; + timeZone: string; + customDomains?: Array<{ + id: string; + url: string; + lastPublished?: string; + }>; +}; + +type ListSitesResult = + | { + success: true; + sites: Array<{ + id: string; + displayName: string; + shortName: string; + previewUrl: string; + lastPublished?: string; + lastUpdated: string; + customDomains: string[]; + }>; + count: number; + } + | { success: false; error: string }; + +export type ListSitesCoreInput = Record; + +export type ListSitesInput = StepInput & + ListSitesCoreInput & { + integrationId?: string; + }; + +async function stepHandler( + _input: ListSitesCoreInput, + credentials: WebflowCredentials +): Promise { + const apiKey = credentials.WEBFLOW_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "WEBFLOW_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + try { + const response = await fetch(`${WEBFLOW_API_URL}/sites`, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + const errorData = (await response.json()) as { message?: string }; + return { + success: false, + error: errorData.message || `HTTP ${response.status}`, + }; + } + + const data = (await response.json()) as { sites: WebflowSite[] }; + + const sites = data.sites.map((site) => ({ + id: site.id, + displayName: site.displayName, + shortName: site.shortName, + previewUrl: site.previewUrl, + lastPublished: site.lastPublished, + lastUpdated: site.lastUpdated, + customDomains: site.customDomains?.map((d) => d.url) || [], + })); + + return { + success: true, + sites, + count: sites.length, + }; + } catch (error) { + return { + success: false, + error: `Failed to list sites: ${getErrorMessage(error)}`, + }; + } +} + +export async function listSitesStep( + input: ListSitesInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} +listSitesStep.maxRetries = 0; + +export const _integrationType = "webflow"; diff --git a/plugins/webflow/steps/publish-site.ts b/plugins/webflow/steps/publish-site.ts new file mode 100644 index 00000000..ed6edee5 --- /dev/null +++ b/plugins/webflow/steps/publish-site.ts @@ -0,0 +1,137 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { WebflowCredentials } from "../credentials"; + +const WEBFLOW_API_URL = "https://api.webflow.com/v2"; + +type PublishResponse = { + customDomains?: Array<{ + id: string; + url: string; + lastPublished?: string; + }>; + publishToWebflowSubdomain?: boolean; +}; + +type PublishSiteResult = + | { + success: true; + publishedDomains: string[]; + publishedToSubdomain: boolean; + } + | { success: false; error: string }; + +export type PublishSiteCoreInput = { + siteId: string; + publishToWebflowSubdomain?: string; + customDomainIds?: string; +}; + +export type PublishSiteInput = StepInput & + PublishSiteCoreInput & { + integrationId?: string; + }; + +async function stepHandler( + input: PublishSiteCoreInput, + credentials: WebflowCredentials +): Promise { + const apiKey = credentials.WEBFLOW_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "WEBFLOW_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + if (!input.siteId) { + return { + success: false, + error: "Site ID is required", + }; + } + + try { + const body: { + publishToWebflowSubdomain?: boolean; + customDomains?: string[]; + } = {}; + + // Parse custom domain IDs if provided + const customDomains = input.customDomainIds + ? input.customDomainIds + .split(",") + .map((id) => id.trim()) + .filter(Boolean) + : []; + + if (customDomains.length > 0) { + body.customDomains = customDomains; + } + + // Default to publishing to subdomain if no custom domains specified + // or if explicitly set to true + const publishToSubdomain = + input.publishToWebflowSubdomain === "false" ? false : true; + + if (publishToSubdomain || customDomains.length === 0) { + body.publishToWebflowSubdomain = true; + } else { + body.publishToWebflowSubdomain = false; + } + + const response = await fetch( + `${WEBFLOW_API_URL}/sites/${input.siteId}/publish`, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + } + ); + + if (!response.ok) { + const errorData = (await response.json()) as { message?: string }; + return { + success: false, + error: errorData.message || `HTTP ${response.status}`, + }; + } + + const result = (await response.json()) as PublishResponse; + + return { + success: true, + publishedDomains: result.customDomains?.map((d) => d.url) || [], + publishedToSubdomain: result.publishToWebflowSubdomain ?? false, + }; + } catch (error) { + return { + success: false, + error: `Failed to publish site: ${getErrorMessage(error)}`, + }; + } +} + +export async function publishSiteStep( + input: PublishSiteInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} +publishSiteStep.maxRetries = 0; + +export const _integrationType = "webflow"; diff --git a/plugins/webflow/test.ts b/plugins/webflow/test.ts new file mode 100644 index 00000000..905ee96c --- /dev/null +++ b/plugins/webflow/test.ts @@ -0,0 +1,43 @@ +const WEBFLOW_API_URL = "https://api.webflow.com/v2"; + +export async function testWebflow(credentials: Record) { + try { + const apiKey = credentials.WEBFLOW_API_KEY; + + if (!apiKey) { + return { + success: false, + error: "WEBFLOW_API_KEY is required", + }; + } + + // Use the list sites endpoint to validate the API key + const response = await fetch(`${WEBFLOW_API_URL}/sites`, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + return { + success: false, + error: "Invalid API key. Please check your Webflow API token.", + }; + } + return { + success: false, + error: `API validation failed: HTTP ${response.status}`, + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} From 4266aac18977a061b182e5622470644e2534652e Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 5 Dec 2025 13:15:53 +1100 Subject: [PATCH 2/2] fix: add webflow plugin import and sanitize URL path inputs --- plugins/index.ts | 1 + plugins/webflow/steps/get-site.ts | 4 +++- plugins/webflow/steps/publish-site.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/index.ts b/plugins/index.ts index c2b41249..ef2ebba4 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -26,6 +26,7 @@ import "./slack"; import "./stripe"; import "./superagent"; import "./v0"; +import "./webflow"; export type { ActionConfigField, diff --git a/plugins/webflow/steps/get-site.ts b/plugins/webflow/steps/get-site.ts index cb99932e..ba8aa6cc 100644 --- a/plugins/webflow/steps/get-site.ts +++ b/plugins/webflow/steps/get-site.ts @@ -73,7 +73,9 @@ async function stepHandler( } try { - const response = await fetch(`${WEBFLOW_API_URL}/sites/${input.siteId}`, { + const response = await fetch( + `${WEBFLOW_API_URL}/sites/${encodeURIComponent(input.siteId)}`, + { method: "GET", headers: { Accept: "application/json", diff --git a/plugins/webflow/steps/publish-site.ts b/plugins/webflow/steps/publish-site.ts index ed6edee5..8a9d2468 100644 --- a/plugins/webflow/steps/publish-site.ts +++ b/plugins/webflow/steps/publish-site.ts @@ -86,7 +86,7 @@ async function stepHandler( } const response = await fetch( - `${WEBFLOW_API_URL}/sites/${input.siteId}/publish`, + `${WEBFLOW_API_URL}/sites/${encodeURIComponent(input.siteId)}/publish`, { method: "POST", headers: {