diff --git a/apps/landing/astro.config.ts b/apps/landing/astro.config.ts index 79974139..e02d1de6 100644 --- a/apps/landing/astro.config.ts +++ b/apps/landing/astro.config.ts @@ -7,25 +7,32 @@ import react from "@astrojs/react"; import sitemap from "@astrojs/sitemap"; import starlight from "@astrojs/starlight"; import { agentMarkdown } from "@onequery/astro-agent-markdown/astro"; -import { defineConfig, fontProviders } from "astro/config"; +import { defineConfig, envField, fontProviders } from "astro/config"; import { visualizer } from "rollup-plugin-visualizer"; import { DEFAULT_DEV_PORT, DEV_SERVER_HOST, REPOSITORY_URL, -} from "./src/landing/config/landing-config"; +} from "./src/shared/config/site"; const BUNDLE_REPORT_TEMPLATES = ["markdown", "list", "raw-data"] as const; +const BUNDLE_REPORT_TEMPLATE_SET = new Set(BUNDLE_REPORT_TEMPLATES); const REPOSITORY_ROOT = fileURLToPath(new URL("../../", import.meta.url)); type BundleReportTemplate = (typeof BUNDLE_REPORT_TEMPLATES)[number]; +function isBundleReportTemplate( + template: string +): template is BundleReportTemplate { + return BUNDLE_REPORT_TEMPLATE_SET.has(template); +} + function getBundleReportTemplate(): BundleReportTemplate { const template = process.env.ONEQUERY_BUNDLE_REPORT_TEMPLATE ?? "markdown"; - if (BUNDLE_REPORT_TEMPLATES.includes(template as BundleReportTemplate)) { - return template as BundleReportTemplate; + if (isBundleReportTemplate(template)) { + return template; } throw new Error( @@ -53,7 +60,7 @@ function createBundleReportPlugin() { gzipSize: true, projectRoot: REPOSITORY_ROOT, template, - }) as never; + }); } export default defineConfig({ @@ -76,6 +83,15 @@ export default defineConfig({ weights: ["400 700"], }, ], + env: { + schema: { + LANDING_SLACK_WEBHOOK_URL: envField.string({ + access: "secret", + context: "server", + optional: true, + }), + }, + }, integrations: [ partytown({ config: { @@ -174,6 +190,9 @@ export default defineConfig({ : []), ], resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, dedupe: ["react", "react-dom"], }, server: { diff --git a/apps/landing/src/actions/contact-action-state.ts b/apps/landing/src/actions/contact-action-state.ts deleted file mode 100644 index b663bb43..00000000 --- a/apps/landing/src/actions/contact-action-state.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type ContactActionState = { - status: "idle" | "sent"; -}; - -export const INITIAL_CONTACT_ACTION_STATE = { - status: "idle", -} satisfies ContactActionState; - -export const SENT_CONTACT_ACTION_STATE = { - status: "sent", -} satisfies ContactActionState; diff --git a/apps/landing/src/actions/index.ts b/apps/landing/src/actions/index.ts index 6fcff8b9..2a818ccf 100644 --- a/apps/landing/src/actions/index.ts +++ b/apps/landing/src/actions/index.ts @@ -1,24 +1,21 @@ import { ActionError, defineAction } from "astro:actions"; -import { env } from "cloudflare:workers"; - -import { submitContactLead } from "../server/landing-api"; -import { ContactRequestSchema } from "../server/landing-schemas"; -import { LandingNotificationConfigurationError } from "../server/landing/landing-notifications"; -import type { LandingNotificationError } from "../server/landing/landing-notifications"; -import { SENT_CONTACT_ACTION_STATE } from "./contact-action-state"; -import type { ContactActionState } from "./contact-action-state"; - -function readLandingWorkerBindings() { - return { - LANDING_SLACK_WEBHOOK_URL: - typeof env.LANDING_SLACK_WEBHOOK_URL === "string" - ? env.LANDING_SLACK_WEBHOOK_URL - : undefined, - }; -} +import { LANDING_SLACK_WEBHOOK_URL } from "astro:env/server"; + +import { submitContactLead } from "@/server/api"; +import { NotificationConfigurationError } from "@/server/notifications"; +import type { NotificationError } from "@/server/notifications"; +import { ContactRequestSchema } from "@/server/schemas"; + +type ContactActionState = { + status: "sent"; +}; + +const SENT_CONTACT_ACTION_STATE = { + status: "sent", +} satisfies ContactActionState; -function createLandingActionError(error: LandingNotificationError) { - const message = LandingNotificationConfigurationError.is(error) +function createActionError(error: NotificationError) { + const message = NotificationConfigurationError.is(error) ? error.message : "Failed to deliver notification"; @@ -34,12 +31,12 @@ export const server = { input: ContactRequestSchema, handler: async (input, { request }): Promise => { const result = await submitContactLead(input, { - bindings: readLandingWorkerBindings(), request, + slackWebhookUrl: LANDING_SLACK_WEBHOOK_URL, }); if (result.isErr()) { - throw createLandingActionError(result.error); + throw createActionError(result.error); } return SENT_CONTACT_ACTION_STATE; diff --git a/apps/landing/src/components/blog/BlogIndex.astro b/apps/landing/src/components/blog/BlogIndex.astro deleted file mode 100644 index 30ee465f..00000000 --- a/apps/landing/src/components/blog/BlogIndex.astro +++ /dev/null @@ -1,70 +0,0 @@ ---- -import { - getBlogIndexPath, -} from "../../landing/blog/blog-taxonomy"; -import type { BlogCategoryFilter } from "../../landing/blog/blog-taxonomy"; -import type { BlogPostSummary } from "../../landing/blog/blog-types"; -import TrackedLink from "../landing/TrackedLink.astro"; -import BlogThumbnail from "./BlogThumbnail.astro"; - -interface Props { - activeCategory: BlogCategoryFilter; - categories: readonly BlogCategoryFilter[]; - posts: readonly BlogPostSummary[]; -} - -const { activeCategory, categories, posts } = Astro.props; ---- - -
-
-

OneQuery

-

Blog

- -
- -
- {posts.length} posts -
- -
-
- { - posts.map((post, postIndex) => ( -
- - -
- {post.category} - - {post.date} -
-

{post.title}

-

{post.description}

- - {post.readTime} - - -
-
- )) - } -
-
-
diff --git a/apps/landing/src/components/blog/BlogThumbnail.astro b/apps/landing/src/components/blog/BlogThumbnail.astro deleted file mode 100644 index 5ee46d2b..00000000 --- a/apps/landing/src/components/blog/BlogThumbnail.astro +++ /dev/null @@ -1,26 +0,0 @@ ---- -import type { BlogPostSummary } from "../../landing/blog/blog-types"; -import { - BLOG_THUMBNAIL_IMAGE_SIZES, - BLOG_THUMBNAIL_IMAGE_WIDTHS, -} from "./blog-image-presets"; -import OptimizedBlogImage from "./OptimizedBlogImage.astro"; - -interface Props { - post: BlogPostSummary; - priority?: boolean; -} - -const { post, priority = false } = Astro.props; ---- - -
- -
diff --git a/apps/landing/src/components/blog/LinkedText.astro b/apps/landing/src/components/blog/LinkedText.astro deleted file mode 100644 index 85de7644..00000000 --- a/apps/landing/src/components/blog/LinkedText.astro +++ /dev/null @@ -1,24 +0,0 @@ ---- -interface Props { - text: string; -} - -const { text } = Astro.props; -const URL_PATTERN = /(https?:\/\/[^\s.]+(?:\.[^\s.]+)*[^\s.,)])/g; -const segments = text.split(URL_PATTERN).map((part) => ({ - href: part.match(URL_PATTERN) ? part : null, - text: part, -})); ---- - -{ - segments.map((segment) => - segment.href ? ( - - {segment.text} - - ) : ( - segment.text - ) - ) -} diff --git a/apps/landing/src/components/blog/OptimizedBlogImage.astro b/apps/landing/src/components/blog/OptimizedBlogImage.astro deleted file mode 100644 index 1bb3f3e9..00000000 --- a/apps/landing/src/components/blog/OptimizedBlogImage.astro +++ /dev/null @@ -1,41 +0,0 @@ ---- -import { Picture } from "astro:assets"; -import type { ImageMetadata } from "astro"; - -interface Props { - alt: string; - class?: string; - priority?: boolean; - sizes: string; - src: ImageMetadata; - widths: number[]; -} - -const { - alt, - class: className, - priority = false, - sizes, - src, - widths, -} = Astro.props; - -const loadingAttributes = priority - ? {} - : { - decoding: "async" as const, - loading: "lazy" as const, - }; ---- - - diff --git a/apps/landing/src/components/blog/blog-image-presets.ts b/apps/landing/src/components/blog/blog-image-presets.ts deleted file mode 100644 index 5dbe5ce8..00000000 --- a/apps/landing/src/components/blog/blog-image-presets.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const BLOG_POST_IMAGE_SIZES = - "(max-width: 720px) calc(100vw - 48px), 640px"; -export const BLOG_POST_IMAGE_WIDTHS = [320, 640, 960, 1280]; - -export const BLOG_THUMBNAIL_IMAGE_SIZES = - "(max-width: 640px) calc(100vw - 48px), (max-width: 1100px) calc((100vw - 68px) / 2), 360px"; -export const BLOG_THUMBNAIL_IMAGE_WIDTHS = [320, 480, 640, 900, 1254]; diff --git a/apps/landing/src/components/landing/GoogleTag.astro b/apps/landing/src/components/landing/GoogleTag.astro deleted file mode 100644 index a692e47b..00000000 --- a/apps/landing/src/components/landing/GoogleTag.astro +++ /dev/null @@ -1,19 +0,0 @@ ---- -import { googleTagConfig } from "../../landing/analytics/google-tag-config"; -import { - createGoogleTagAfterSwapScript, - createGoogleTagBootstrapScript, - createGoogleTagEventBridgeScript, - createGoogleTagScriptSrc, -} from "../../landing/analytics/google-tag-script"; - -const scriptSrc = createGoogleTagScriptSrc(googleTagConfig); -const eventBridgeScript = createGoogleTagEventBridgeScript(); -const bootstrapScript = createGoogleTagBootstrapScript(googleTagConfig); -const afterSwapScript = createGoogleTagAfterSwapScript(googleTagConfig); ---- - - - + - - - + + + - + diff --git a/apps/landing/src/layouts/BlogLayout.astro b/apps/landing/src/layouts/BlogLayout.astro index 67a1b809..9e42b95f 100644 --- a/apps/landing/src/layouts/BlogLayout.astro +++ b/apps/landing/src/layouts/BlogLayout.astro @@ -1,8 +1,8 @@ --- -import { BLOG_FOOTER_LINKS } from "../data/footer"; -import "../styles/blog.css"; +import { BLOG_FOOTER_LINKS } from "@/shared/navigation/footer"; +import "@/features/blog/styles.css"; import MarketingLayout from "./MarketingLayout.astro"; -import type { PageMetadata } from "./layout-types"; +import type { PageMetadata } from "./types"; type Props = PageMetadata; diff --git a/apps/landing/src/layouts/MarketingLayout.astro b/apps/landing/src/layouts/MarketingLayout.astro index d30a947a..e98536f7 100644 --- a/apps/landing/src/layouts/MarketingLayout.astro +++ b/apps/landing/src/layouts/MarketingLayout.astro @@ -1,9 +1,9 @@ --- -import type { FooterLink } from "../data/footer"; -import SiteFooter from "../components/landing/SiteFooter.astro"; -import SiteHeader from "../components/landing/SiteHeader.astro"; +import type { FooterLink } from "@/shared/navigation/footer"; +import SiteFooter from "@/shared/components/Footer.astro"; +import SiteHeader from "@/shared/components/Header.astro"; import BaseLayout from "./BaseLayout.astro"; -import type { PageMetadata } from "./layout-types"; +import type { PageMetadata } from "./types"; interface Props extends PageMetadata { footerLinks?: ReadonlyArray; diff --git a/apps/landing/src/layouts/layout-types.ts b/apps/landing/src/layouts/types.ts similarity index 88% rename from apps/landing/src/layouts/layout-types.ts rename to apps/landing/src/layouts/types.ts index 4b9a1066..8b023013 100644 --- a/apps/landing/src/layouts/layout-types.ts +++ b/apps/landing/src/layouts/types.ts @@ -1,4 +1,4 @@ -import type { StructuredData } from "../landing/seo/structured-data"; +import type { StructuredData } from "@/shared/seo/schema"; export type MetaTag = | { diff --git a/apps/landing/src/pages/.well-known/api-catalog.ts b/apps/landing/src/pages/.well-known/api-catalog.ts index 42a72226..9666c7f7 100644 --- a/apps/landing/src/pages/.well-known/api-catalog.ts +++ b/apps/landing/src/pages/.well-known/api-catalog.ts @@ -1,5 +1,6 @@ import type { APIRoute } from "astro"; +// oxlint's type-aware resolver misses "@/..." imports inside hidden route directories. import { createApiCatalogResponse } from "../../server/api-catalog"; export const prerender = false; diff --git a/apps/landing/src/pages/404.astro b/apps/landing/src/pages/404.astro index bb9e3e3d..a3d224af 100644 --- a/apps/landing/src/pages/404.astro +++ b/apps/landing/src/pages/404.astro @@ -1,7 +1,7 @@ --- -import BlogLayout from "../layouts/BlogLayout.astro"; -import { createCanonicalUrl } from "../landing/seo/structured-data"; -import TrackedLink from "../components/landing/TrackedLink.astro"; +import BlogLayout from "@/layouts/BlogLayout.astro"; +import { createCanonicalUrl } from "@/shared/seo/schema"; +import TrackedLink from "@/shared/components/TrackedLink.astro"; --- handleContactRequest({ - bindings: { - LANDING_SLACK_WEBHOOK_URL: - typeof env.LANDING_SLACK_WEBHOOK_URL === "string" - ? env.LANDING_SLACK_WEBHOOK_URL - : undefined, - }, request, + slackWebhookUrl: LANDING_SLACK_WEBHOOK_URL, }); diff --git a/apps/landing/src/pages/api/product-updates.ts b/apps/landing/src/pages/api/product-updates.ts index fff7806f..6a8527bd 100644 --- a/apps/landing/src/pages/api/product-updates.ts +++ b/apps/landing/src/pages/api/product-updates.ts @@ -1,17 +1,12 @@ import type { APIRoute } from "astro"; -import { env } from "cloudflare:workers"; +import { LANDING_SLACK_WEBHOOK_URL } from "astro:env/server"; -import { handleProductUpdatesRequest } from "../../server/landing-api"; +import { handleProductUpdatesRequest } from "@/server/api"; export const prerender = false; export const POST: APIRoute = ({ request }) => handleProductUpdatesRequest({ - bindings: { - LANDING_SLACK_WEBHOOK_URL: - typeof env.LANDING_SLACK_WEBHOOK_URL === "string" - ? env.LANDING_SLACK_WEBHOOK_URL - : undefined, - }, request, + slackWebhookUrl: LANDING_SLACK_WEBHOOK_URL, }); diff --git a/apps/landing/src/pages/blog/[postSlug].astro b/apps/landing/src/pages/blog/[postSlug].astro index 2007d39f..ed36c2e5 100644 --- a/apps/landing/src/pages/blog/[postSlug].astro +++ b/apps/landing/src/pages/blog/[postSlug].astro @@ -1,19 +1,24 @@ --- import type { CollectionEntry } from "astro:content"; import { render } from "astro:content"; -import BlogPostView from "../../components/blog/BlogPost.astro"; -import BlogLayout from "../../layouts/BlogLayout.astro"; +import BlogPostView from "@/features/blog/components/Post.astro"; +import BlogLayout from "@/layouts/BlogLayout.astro"; import { getBlogPostEntries, getBlogPostSummaries, toBlogPost, -} from "../../landing/blog/blog-collection"; -import { getBlogPostShareImageMetadata } from "../../landing/blog/blog-images"; -import { getBlogPostShareMetadata } from "../../landing/blog/blog-share-metadata"; +} from "@/features/blog/collection"; +import { + getBlogShareImage, + getPreferredBlogShareImage, +} from "@/features/blog/images"; +import { ONEQUERY } from "@/shared/seo/constants"; import { createBlogPostStructuredData, + createCanonicalUrl, getBlogPostKeywords, -} from "../../landing/seo/structured-data"; + toIsoDateTime, +} from "@/shared/seo/schema"; interface Props { entry: CollectionEntry<"blog">; @@ -30,18 +35,24 @@ const { entry } = Astro.props; const { Content, headings } = await render(entry); const post = toBlogPost(entry, headings); const postSummaries = await getBlogPostSummaries(); -const imageMetadata = await getBlogPostShareImageMetadata(post, Astro.site); -const metadata = getBlogPostShareMetadata(post, Astro.site, imageMetadata); -const structuredData = createBlogPostStructuredData( - post, - Astro.site, - imageMetadata +const shareImage = await getBlogShareImage( + getPreferredBlogShareImage(post), + Astro.site ); +const canonicalUrl = createCanonicalUrl(`/blog/${post.slug}`, Astro.site); +const imageAlt = `${post.title} - ${ONEQUERY.BLOG_NAME}`; +const publishedTime = toIsoDateTime(post.publishedAt); +const title = `${post.title} | ${ONEQUERY.BLOG_NAME}`; +const structuredData = createBlogPostStructuredData({ + image: shareImage, + post, + site: Astro.site, +}); const articleMetaTags = [ - ...(metadata.publishedTime + ...(publishedTime ? [ { - content: metadata.publishedTime, + content: publishedTime, property: "article:published_time", }, ] @@ -51,25 +62,25 @@ const articleMetaTags = [ property: "article:section", }, { - content: "OneQuery Maintainers", + content: ONEQUERY.AUTHOR_NAME, name: "author", }, ]; ---
diff --git a/apps/landing/src/pages/connectors/[connectorSlug].astro b/apps/landing/src/pages/connectors/[connectorSlug].astro new file mode 100644 index 00000000..d630fcc7 --- /dev/null +++ b/apps/landing/src/pages/connectors/[connectorSlug].astro @@ -0,0 +1,244 @@ +--- +import type { DataSourceConnector } from "@/features/connectors/data"; +import { + DATA_SOURCE_CONNECTORS, + getConnectorFaqs, + getConnectorInterfaceDescription, + getConnectorInterfaceLabel, + getConnectorKeywords, + getConnectorMetaDescription, + getConnectorPath, + getRelatedConnectors, +} from "@/features/connectors/data"; +import MarketingLayout from "@/layouts/MarketingLayout.astro"; +import { BrandIcon, hasBrandIcon } from "@/shared/icons/brands"; +import { + createCanonicalUrl, + createConnectorPageStructuredData, +} from "@/shared/seo/schema"; +import "@/features/connectors/styles.css"; + +export function getStaticPaths() { + return DATA_SOURCE_CONNECTORS.map((connector) => ({ + params: { + connectorSlug: connector.slug, + }, + props: { + connector, + }, + })); +} + +interface Props { + connector: DataSourceConnector; +} + +const { connector } = Astro.props; +const title = `${connector.label} Connector | OneQuery`; +const description = getConnectorMetaDescription(connector); +const canonicalUrl = createCanonicalUrl(getConnectorPath(connector), Astro.site); +const interfaceLabel = getConnectorInterfaceLabel(connector); +const interfaceDescription = getConnectorInterfaceDescription(connector); +const relatedConnectors = getRelatedConnectors(connector); +const faqs = getConnectorFaqs(connector); +const credentialLabel = connector.credentialType.replace(/_/gu, " "); +const setupSurface = + connector.availability === "Dashboard + CLI" + ? "Dashboard and CLI" + : "CLI"; +const structuredData = createConnectorPageStructuredData({ + connector, + description, + faqs, + relatedConnectors, + site: Astro.site, + title, +}); +--- + + +
+ + +
+
+

{connector.category} connector

+

{connector.label} connector for governed AI agent access

+

{connector.description}

+ +
+ { + connector.capabilities.map((capability) => ( + {capability} + )) + } +
+ + +
+ + +
+ +
+

Direct answer

+

+ OneQuery supports {connector.label} for governed agent access. +

+

+ Teams use the {connector.label} connector to give AI agents { + interfaceDescription + } while OneQuery keeps credentials centralized, limits access to + approved sources, and preserves audit logs for review. +

+
+ +
+
+

Agent workflow

+

What this connector enables

+

+ {connector.label} becomes an approved OneQuery source instead of a + secret copied into an agent prompt, shell session, or model tool. The + agent receives a governed access path, and the source credentials stay + behind OneQuery. +

+
    +
  • + Use {interfaceDescription} for {connector.category.toLowerCase()} + context. +
  • +
  • + Keep {connector.label} credentials centralized and out of agent + runtimes. +
  • +
  • + Review agent access through OneQuery audit history instead of + reconstructing direct service usage. +
  • +
+
+ +
+

Setup checklist

+

Prepare the {connector.label} connection

+

+ Use {credentialLabel} credentials and connect the source through the { + setupSurface + }. Keep credentials scoped to the data the agent is allowed to read. +

+
    + {connector.guideSteps.map((step) =>
  1. {step}
  2. )} +
+
+
+ +
+

FAQ

+

{connector.label} connector questions

+
+ { + faqs.map((faq) => ( +
+

{faq.question}

+

{faq.answer}

+
+ )) + } +
+
+ + +
+
diff --git a/apps/landing/src/pages/index.astro b/apps/landing/src/pages/index.astro index f7e600b6..582989bf 100644 --- a/apps/landing/src/pages/index.astro +++ b/apps/landing/src/pages/index.astro @@ -1,76 +1,74 @@ --- -import ControlPlaneDiagram from "../components/landing/ControlPlaneDiagram.astro"; -import DownloadCommand from "../components/landing/DownloadCommand.astro"; -import HeroProductSurface from "../components/landing/HeroProductSurface.astro"; -import OpenClawSection from "../components/landing/OpenClawSection.astro"; -import ProductUpdatesSection from "../components/landing/ProductUpdatesSection.astro"; -import RoadmapSection from "../components/landing/RoadmapSection.astro"; -import TerminalSurface from "../components/landing/TerminalSurface.astro"; -import TrackedLink from "../components/landing/TrackedLink.astro"; -import MarketingLayout from "../layouts/MarketingLayout.astro"; -import "../styles/landing.css"; +import ControlPlaneDiagram from "@/features/home/components/ControlPlaneDiagram.astro"; +import DownloadCommand from "@/features/home/components/DownloadCommand.astro"; +import HeroProductSurface from "@/features/home/components/HeroProductSurface.astro"; +import OpenClawSection from "@/features/home/components/OpenClawSection.astro"; +import ProductUpdatesSection from "@/features/home/components/ProductUpdatesSection.astro"; +import RoadmapSection from "@/features/home/components/RoadmapSection.astro"; +import TerminalSurface from "@/features/home/components/TerminalSurface.astro"; +import TrackedLink from "@/shared/components/TrackedLink.astro"; +import MarketingLayout from "@/layouts/MarketingLayout.astro"; +import "@/features/home/styles.css"; import { getOpenClawDemoPosterImage, OPEN_CLAW_DEMO_VIDEO, -} from "../landing/openclaw-demo-video"; +} from "@/features/home/demo-video"; import { NPM_PACKAGE_URL, SECTION_IDS, SELF_HOST_DOCS_URL, -} from "../landing/config/landing-config"; +} from "@/shared/config/site"; import { createCanonicalUrl, - createLandingPageStructuredData, + createHomePageStructuredData, normalizeSiteUrl, - ONEQUERY_DEFAULT_DESCRIPTION, toAbsoluteSiteUrl, -} from "../landing/seo/structured-data"; -import { - getOneQueryPublicShareImageMetadata, - getOneQueryStructuredShareImageMetadata, - ONEQUERY_DEFAULT_SHARE_IMAGE_ALT, -} from "../landing/seo/site-images"; +} from "@/shared/seo/schema"; +import { ONEQUERY } from "@/shared/seo/constants"; +import { getOneQueryShareImage } from "@/shared/seo/images"; import { HERO_SIGNALS, INSTALL_STEPS, QUERY_DETAILS_SNIPPET, QUERY_TERMINAL_LINES, QUICKSTART_TERMINAL_LINES, -} from "../data/home"; +} from "@/features/home/content"; -// Keep the SEO landing page prerendered; dynamic signup posts to the API route. +// Keep the home page prerendered; dynamic signup posts to the API route. export const prerender = true; -const landingTitle = "OneQuery | Governed Data Access for AI Agents"; -const landingImageAlt = ONEQUERY_DEFAULT_SHARE_IMAGE_ALT; +const title = ONEQUERY.DEFAULT_PAGE_TITLE; +const imageAlt = ONEQUERY.SHARE_IMAGE_ALT; const siteUrl = normalizeSiteUrl(Astro.site); -const landingCanonicalUrl = createCanonicalUrl("/", siteUrl); -const landingShareImage = getOneQueryPublicShareImageMetadata(siteUrl); -const landingStructuredShareImage = - getOneQueryStructuredShareImageMetadata(siteUrl); -const landingImageUrl = landingShareImage.url; +const canonicalUrl = createCanonicalUrl("/", siteUrl); +const shareImage = getOneQueryShareImage(siteUrl); +const imageUrl = shareImage.url; const openClawDemoPosterImage = await getOpenClawDemoPosterImage(); -const landingStructuredData = createLandingPageStructuredData({ - description: ONEQUERY_DEFAULT_DESCRIPTION, - imageAlt: landingImageAlt, - imageHeight: landingStructuredShareImage.height, - imageUrl: landingStructuredShareImage.url, - imageWidth: landingStructuredShareImage.width, +const structuredData = createHomePageStructuredData({ + description: ONEQUERY.SITE_DESCRIPTION, + image: { + alt: imageAlt, + height: shareImage.height, + url: shareImage.url, + width: shareImage.width, + }, site: siteUrl, - title: landingTitle, + title, video: { contentUrl: OPEN_CLAW_DEMO_VIDEO.mp4Src, description: OPEN_CLAW_DEMO_VIDEO.description, duration: OPEN_CLAW_DEMO_VIDEO.duration, name: OPEN_CLAW_DEMO_VIDEO.name, - pageUrl: `${landingCanonicalUrl}#demo`, - thumbnailHeight: OPEN_CLAW_DEMO_VIDEO.posterHeight, - thumbnailUrl: openClawDemoPosterImage.src, - thumbnailWidth: OPEN_CLAW_DEMO_VIDEO.posterWidth, + pageUrl: `${canonicalUrl}#demo`, + thumbnail: { + height: OPEN_CLAW_DEMO_VIDEO.posterHeight, + url: openClawDemoPosterImage.src, + width: OPEN_CLAW_DEMO_VIDEO.posterWidth, + }, uploadDate: OPEN_CLAW_DEMO_VIDEO.uploadDate, }, }); -const landingMetaTags = [ +const metaTags = [ { content: toAbsoluteSiteUrl(OPEN_CLAW_DEMO_VIDEO.mp4Src, siteUrl), property: "og:video", @@ -93,7 +91,7 @@ const landingMetaTags = [ }, ]; -type LandingCtaAction = { +type CtaAction = { className: string; href: string; label: string; @@ -103,7 +101,7 @@ type LandingCtaAction = { trackingSection: string; }; -const heroActions: ReadonlyArray = [ +const heroActions: ReadonlyArray = [ { className: "button button-primary", href: `#${SECTION_IDS.install}`, @@ -120,7 +118,7 @@ const heroActions: ReadonlyArray = [ }, ] as const; -const finalCtaActions: ReadonlyArray = [ +const finalCtaActions: ReadonlyArray = [ { className: "button button-primary", href: NPM_PACKAGE_URL, @@ -143,16 +141,16 @@ const finalCtaActions: ReadonlyArray = [ ---
diff --git a/apps/landing/src/pages/install.sh.ts b/apps/landing/src/pages/install.sh.ts index 40a54d8f..384abe3c 100644 --- a/apps/landing/src/pages/install.sh.ts +++ b/apps/landing/src/pages/install.sh.ts @@ -1,6 +1,6 @@ import type { APIRoute } from "astro"; -import { createInstallScriptResponse } from "../tooling/install-script"; +import { createInstallScriptResponse } from "@/tooling/install"; export const prerender = false; diff --git a/apps/landing/src/server/api-catalog.ts b/apps/landing/src/server/api-catalog.ts index cd94eefa..cc288ff6 100644 --- a/apps/landing/src/server/api-catalog.ts +++ b/apps/landing/src/server/api-catalog.ts @@ -1,27 +1,30 @@ -import { LANDING_API_PREFIX } from "../landing/config/landing-api"; +import { REPOSITORY_URL } from "@/shared/config/site"; +import { ONEQUERY } from "@/shared/seo/constants"; -const API_CATALOG_PATH = "/.well-known/api-catalog"; +const API_PREFIX = "/api"; +const API_CATALOG_PATH = "/.well-known/api-catalog/"; const API_CATALOG_CONTENT_TYPE = 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"'; -const ONEQUERY_REPOSITORY_URL = "https://github.com/wordbricks/onequery"; -const ONEQUERY_README_URL = `${ONEQUERY_REPOSITORY_URL}/blob/main/README.md`; -const ONEQUERY_DOCS_URL = "https://onequery.dev/docs/"; -const ONEQUERY_PROTO_README_URL = `${ONEQUERY_REPOSITORY_URL}/blob/main/proto/README.md`; -const ONEQUERY_CLI_PROTO_URL = - "https://raw.githubusercontent.com/wordbricks/onequery/main/proto/onequery/cli/v1/cli.proto"; +const API_CATALOG_LINKS = { + CLI_PROTO: + "https://raw.githubusercontent.com/wordbricks/onequery/main/proto/onequery/cli/v1/cli.proto", + DOCS: `${ONEQUERY.SITE_URL}/docs/`, + PROTO_README: `${REPOSITORY_URL}/blob/main/proto/README.md`, + README: `${REPOSITORY_URL}/blob/main/README.md`, +} as const; export const AGENT_DISCOVERY_LINK_HEADER = [ `<${API_CATALOG_PATH}>; rel="api-catalog"; type="application/linkset+json"`, - `<${ONEQUERY_CLI_PROTO_URL}>; rel="service-desc"; type="text/plain"`, - `<${ONEQUERY_DOCS_URL}>; rel="service-doc"; type="text/html"`, - `<${ONEQUERY_README_URL}>; rel="describedby"; type="text/html"`, + `<${API_CATALOG_LINKS.CLI_PROTO}>; rel="service-desc"; type="text/plain"`, + `<${API_CATALOG_LINKS.DOCS}>; rel="service-doc"; type="text/html"`, + `<${API_CATALOG_LINKS.README}>; rel="describedby"; type="text/html"`, ].join(", "); export function buildApiCatalogLinkset(origin: string) { const homepageUrl = `${origin}/`; const catalogUrl = `${origin}${API_CATALOG_PATH}`; - const productUpdatesApiUrl = `${origin}${LANDING_API_PREFIX}/product-updates/`; - const contactApiUrl = `${origin}${LANDING_API_PREFIX}/contact/`; + const productUpdatesApiUrl = `${origin}${API_PREFIX}/product-updates/`; + const contactApiUrl = `${origin}${API_PREFIX}/contact/`; return { linkset: [ @@ -35,21 +38,21 @@ export function buildApiCatalogLinkset(origin: string) { ], describedby: [ { - href: ONEQUERY_README_URL, + href: API_CATALOG_LINKS.README, title: "OneQuery README", type: "text/html", }, ], "service-desc": [ { - href: ONEQUERY_CLI_PROTO_URL, + href: API_CATALOG_LINKS.CLI_PROTO, title: "OneQuery CLI Connect RPC protobuf schema", type: "text/plain", }, ], "service-doc": [ { - href: ONEQUERY_DOCS_URL, + href: API_CATALOG_LINKS.DOCS, title: "OneQuery documentation", type: "text/html", }, @@ -69,10 +72,10 @@ export function buildApiCatalogLinkset(origin: string) { ], }, { - anchor: ONEQUERY_CLI_PROTO_URL, + anchor: API_CATALOG_LINKS.CLI_PROTO, describedby: [ { - href: ONEQUERY_PROTO_README_URL, + href: API_CATALOG_LINKS.PROTO_README, title: "OneQuery protobuf workspace documentation", type: "text/html", }, diff --git a/apps/landing/src/server/landing-api.ts b/apps/landing/src/server/api.ts similarity index 51% rename from apps/landing/src/server/landing-api.ts rename to apps/landing/src/server/api.ts index 2c168ffd..8eb1c489 100644 --- a/apps/landing/src/server/landing-api.ts +++ b/apps/landing/src/server/api.ts @@ -1,65 +1,56 @@ import { Result } from "better-result"; import { z } from "zod"; -import { - ContactRequestSchema, - ProductUpdatesRequestSchema, -} from "./landing-schemas"; import { createContactNotification, createProductUpdatesNotification, - deliverLandingNotification, - LandingNotificationConfigurationError, -} from "./landing/landing-notifications"; -import type { - LandingNotificationDelivery, - LandingNotificationError, -} from "./landing/landing-notifications"; - -type LandingErrorResponseBase = { + deliverNotification, + NotificationConfigurationError, +} from "./notifications"; +import type { NotificationDelivery, NotificationError } from "./notifications"; +import { ContactRequestSchema, ProductUpdatesRequestSchema } from "./schemas"; + +type ErrorResponseBase = { code: Code; message: string; }; -export type LandingInternalErrorResponse = - LandingErrorResponseBase<"internal_error">; +export type InternalErrorResponse = ErrorResponseBase<"internal_error">; -export type LandingValidationErrorResponse = - LandingErrorResponseBase<"validation_error"> & { - fieldErrors: Record; - }; +export type ValidationErrorResponse = ErrorResponseBase<"validation_error"> & { + fieldErrors: Record; +}; -export type LandingServiceUnavailableErrorResponse = - LandingErrorResponseBase<"service_unavailable">; +export type ServiceUnavailableErrorResponse = + ErrorResponseBase<"service_unavailable">; -export type LandingProductUpdatesResponse = { +export type ProductUpdatesResponse = { email: string; }; -export type LandingContactResponse = Record; - -export type LandingProductUpdatesInput = z.infer< - typeof ProductUpdatesRequestSchema ->; +export type ContactResponse = Record; -export type LandingContactInput = z.infer; +export type ProductUpdatesInput = z.infer; -export type LandingApiErrorResponse = - | LandingInternalErrorResponse - | LandingServiceUnavailableErrorResponse - | LandingValidationErrorResponse; +export type ContactInput = z.infer; -export interface LandingWorkerBindings { - // Local dev can intentionally omit the webhook binding and use the loopback - // fallback sink, but deployed environments still require it. - LANDING_SLACK_WEBHOOK_URL?: string; -} +export type ApiErrorResponse = + | InternalErrorResponse + | ServiceUnavailableErrorResponse + | ValidationErrorResponse; -type LandingRequestContext = { - bindings: LandingWorkerBindings; +type RequestContext = { request: Request; + // Local dev can intentionally omit the webhook and use the loopback fallback + // sink, but deployed environments still require it. + slackWebhookUrl?: string; }; +type LeadSubmission = ( + input: Input, + context: RequestContext +) => Promise>; + function isLoopbackHostname(hostname: string) { return ( hostname === "localhost" || @@ -68,10 +59,10 @@ function isLoopbackHostname(hostname: string) { ); } -function resolveLandingNotificationDelivery(input: { +function resolveNotificationDelivery(input: { hostname: string; slackWebhookUrl: string | undefined; -}): LandingNotificationDelivery { +}): NotificationDelivery { const webhookUrl = input.slackWebhookUrl?.trim(); if (webhookUrl) { return { @@ -91,26 +82,26 @@ function resolveLandingNotificationDelivery(input: { }; } -function resolveLandingNotificationDeliveryFromRequest({ - bindings, +function resolveNotificationDeliveryFromRequest({ request, -}: LandingRequestContext) { - return resolveLandingNotificationDelivery({ + slackWebhookUrl, +}: RequestContext) { + return resolveNotificationDelivery({ hostname: new URL(request.url).hostname, - slackWebhookUrl: bindings.LANDING_SLACK_WEBHOOK_URL, + slackWebhookUrl, }); } -type LandingValidationIssue = { +type ValidationIssue = { message: string; path: readonly PropertyKey[]; }; -type LandingValidationError = { - issues: readonly LandingValidationIssue[]; +type ValidationError = { + issues: readonly ValidationIssue[]; }; -function readLandingValidationFieldKey(path: readonly PropertyKey[]) { +function readValidationFieldKey(path: readonly PropertyKey[]) { if (path.length === 0) { return "_form"; } @@ -118,13 +109,13 @@ function readLandingValidationFieldKey(path: readonly PropertyKey[]) { return path.map(String).join("."); } -function readLandingValidationErrorResponse( - error: LandingValidationError -): LandingValidationErrorResponse { +function readValidationErrorResponse( + error: ValidationError +): ValidationErrorResponse { const fieldErrors: Record = {}; for (const issue of error.issues) { - const fieldKey = readLandingValidationFieldKey(issue.path); + const fieldKey = readValidationFieldKey(issue.path); const existingMessages = fieldErrors[fieldKey] ?? []; fieldErrors[fieldKey] = [...existingMessages, issue.message]; @@ -157,7 +148,7 @@ function createJsonResponse( } function createValidationErrorResponse( - body: LandingValidationErrorResponse, + body: ValidationErrorResponse, requestId: string ) { return createJsonResponse(body, { @@ -167,7 +158,7 @@ function createValidationErrorResponse( } function createInternalErrorResponse(requestId: string) { - return createJsonResponse( + return createJsonResponse( { code: "internal_error", message: "Internal server error", @@ -180,14 +171,14 @@ function createInternalErrorResponse(requestId: string) { } function createServiceUnavailableResponse( - error: LandingNotificationError, + error: NotificationError, requestId: string ) { - const message = LandingNotificationConfigurationError.is(error) + const message = NotificationConfigurationError.is(error) ? error.message : "Failed to deliver notification"; - return createJsonResponse( + return createJsonResponse( { code: "service_unavailable", message, @@ -199,9 +190,7 @@ function createServiceUnavailableResponse( ); } -function createBodyValidationError( - message: string -): LandingValidationErrorResponse { +function createBodyValidationError(message: string): ValidationErrorResponse { return { code: "validation_error", fieldErrors: { @@ -243,7 +232,7 @@ async function readRequestBody(request: Request) { async function readValidatedRequestBody( request: Request, schema: T -): Promise | LandingValidationErrorResponse> { +): Promise | ValidationErrorResponse> { const body = await readRequestBody(request); if (isValidationErrorResponse(body)) { return body; @@ -251,7 +240,7 @@ async function readValidatedRequestBody( const result = schema.safeParse(body); if (!result.success) { - return readLandingValidationErrorResponse(result.error); + return readValidationErrorResponse(result.error); } return result.data; @@ -259,7 +248,7 @@ async function readValidatedRequestBody( function isValidationErrorResponse( input: unknown -): input is LandingValidationErrorResponse { +): input is ValidationErrorResponse { return ( typeof input === "object" && input !== null && @@ -273,12 +262,12 @@ function createRequestId() { } export async function submitProductUpdatesLead( - input: LandingProductUpdatesInput, - context: LandingRequestContext + input: ProductUpdatesInput, + context: RequestContext ) { const normalizedEmail = input.email.toLowerCase(); - const result = await deliverLandingNotification({ - delivery: resolveLandingNotificationDeliveryFromRequest(context), + const result = await deliverNotification({ + delivery: resolveNotificationDeliveryFromRequest(context), notificationType: "product_updates", payload: createProductUpdatesNotification(normalizedEmail), }); @@ -286,18 +275,18 @@ export async function submitProductUpdatesLead( return Result.err(result.error); } - return Result.ok({ + return Result.ok({ email: normalizedEmail, }); } export async function submitContactLead( - input: LandingContactInput, - context: LandingRequestContext + input: ContactInput, + context: RequestContext ) { const normalizedEmail = input.email.toLowerCase(); - const result = await deliverLandingNotification({ - delivery: resolveLandingNotificationDeliveryFromRequest(context), + const result = await deliverNotification({ + delivery: resolveNotificationDeliveryFromRequest(context), notificationType: "contact", payload: createContactNotification({ email: normalizedEmail, @@ -309,33 +298,30 @@ export async function submitContactLead( return Result.err(result.error); } - return Result.ok({}); + return Result.ok({}); } -export async function handleProductUpdatesRequest({ - bindings, - request, -}: LandingRequestContext) { +async function handleLeadRequest(input: { + context: RequestContext; + schema: TSchema; + submit: LeadSubmission, Body>; +}) { const requestId = createRequestId(); + const { context, schema, submit } = input; + const { request } = context; try { - const input = await readValidatedRequestBody( - request, - ProductUpdatesRequestSchema - ); - if (isValidationErrorResponse(input)) { - return createValidationErrorResponse(input, requestId); + const requestBody = await readValidatedRequestBody(request, schema); + if (isValidationErrorResponse(requestBody)) { + return createValidationErrorResponse(requestBody, requestId); } - const result = await submitProductUpdatesLead(input, { - bindings, - request, - }); + const result = await submit(requestBody, context); if (result.isErr()) { return createServiceUnavailableResponse(result.error, requestId); } - return createJsonResponse(result.value, { + return createJsonResponse(result.value, { requestId, status: 200, }); @@ -353,40 +339,18 @@ export async function handleProductUpdatesRequest({ } } -export async function handleContactRequest({ - bindings, - request, -}: LandingRequestContext) { - const requestId = createRequestId(); - - try { - const input = await readValidatedRequestBody(request, ContactRequestSchema); - if (isValidationErrorResponse(input)) { - return createValidationErrorResponse(input, requestId); - } - - const result = await submitContactLead(input, { - bindings, - request, - }); - if (result.isErr()) { - return createServiceUnavailableResponse(result.error, requestId); - } +export function handleProductUpdatesRequest(context: RequestContext) { + return handleLeadRequest({ + context, + schema: ProductUpdatesRequestSchema, + submit: submitProductUpdatesLead, + }); +} - return createJsonResponse(result.value, { - requestId, - status: 200, - }); - } catch (error) { - console.error( - { - err: error, - event: "landing.request.failed", - path: new URL(request.url).pathname, - requestId, - }, - "landing request failed" - ); - return createInternalErrorResponse(requestId); - } +export function handleContactRequest(context: RequestContext) { + return handleLeadRequest({ + context, + schema: ContactRequestSchema, + submit: submitContactLead, + }); } diff --git a/apps/landing/src/server/app.test.ts b/apps/landing/src/server/app.test.ts index 334d8007..9135b655 100644 --- a/apps/landing/src/server/app.test.ts +++ b/apps/landing/src/server/app.test.ts @@ -1,20 +1,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - handleContactRequest, - handleProductUpdatesRequest, -} from "./landing-api"; +import { handleContactRequest, handleProductUpdatesRequest } from "./api"; import type { - LandingServiceUnavailableErrorResponse, - LandingValidationErrorResponse, - LandingWorkerBindings, -} from "./landing-api"; + ServiceUnavailableErrorResponse, + ValidationErrorResponse, +} from "./api"; import { createContactNotification, createProductUpdatesNotification, -} from "./landing/landing-notifications"; +} from "./notifications"; const originalFetch = globalThis.fetch; +const WEBHOOK_URL = "https://example.com/hooks/landing"; function installFetchMock(fetchMock: typeof globalThis.fetch) { globalThis.fetch = fetchMock; @@ -32,14 +29,12 @@ describe("landing API handlers", () => { installFetchMock(fetchSpy); const response = await handleProductUpdatesRequest({ - bindings: { - LANDING_SLACK_WEBHOOK_URL: "https://example.com/hooks/landing", - } satisfies LandingWorkerBindings, request: new Request("https://landing.onequery.dev/api/product-updates", { body: JSON.stringify({ email: "team@example.com" }), headers: { "content-type": "application/json" }, method: "POST", }), + slackWebhookUrl: WEBHOOK_URL, }); expect(response.status).toBe(200); @@ -54,21 +49,19 @@ describe("landing API handlers", () => { installFetchMock(fetchSpy); const response = await handleProductUpdatesRequest({ - bindings: { - LANDING_SLACK_WEBHOOK_URL: "https://example.com/hooks/landing", - }, request: new Request("https://landing.onequery.dev/api/product-updates", { body: JSON.stringify({ email: " TEST@Example.COM " }), headers: { "content-type": "application/json" }, method: "POST", }), + slackWebhookUrl: WEBHOOK_URL, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ email: "test@example.com", }); - expect(fetchSpy).toHaveBeenCalledWith("https://example.com/hooks/landing", { + expect(fetchSpy).toHaveBeenCalledWith(WEBHOOK_URL, { body: JSON.stringify( createProductUpdatesNotification("test@example.com") ), @@ -83,22 +76,20 @@ describe("landing API handlers", () => { installFetchMock(fetchSpy); const response = await handleProductUpdatesRequest({ - bindings: { - LANDING_SLACK_WEBHOOK_URL: "https://example.com/hooks/landing", - }, request: new Request("https://landing.onequery.dev/api/product-updates", { body: new URLSearchParams({ email: " FORM@Example.COM ", }), method: "POST", }), + slackWebhookUrl: WEBHOOK_URL, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ email: "form@example.com", }); - expect(fetchSpy).toHaveBeenCalledWith("https://example.com/hooks/landing", { + expect(fetchSpy).toHaveBeenCalledWith(WEBHOOK_URL, { body: JSON.stringify( createProductUpdatesNotification("form@example.com") ), @@ -113,9 +104,6 @@ describe("landing API handlers", () => { installFetchMock(fetchSpy); const response = await handleContactRequest({ - bindings: { - LANDING_SLACK_WEBHOOK_URL: "https://example.com/hooks/landing", - }, request: new Request("https://landing.onequery.dev/api/contact", { body: JSON.stringify({ email: " TEAM@Example.COM ", @@ -125,11 +113,12 @@ describe("landing API handlers", () => { headers: { "content-type": "application/json" }, method: "POST", }), + slackWebhookUrl: WEBHOOK_URL, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({}); - expect(fetchSpy).toHaveBeenCalledWith("https://example.com/hooks/landing", { + expect(fetchSpy).toHaveBeenCalledWith(WEBHOOK_URL, { body: JSON.stringify( createContactNotification({ email: "team@example.com", @@ -147,7 +136,6 @@ describe("landing API handlers", () => { installFetchMock(fetchSpy); const response = await handleContactRequest({ - bindings: {}, request: new Request("https://landing.onequery.dev/api/contact", { body: JSON.stringify({ email: "team@example.com", @@ -160,7 +148,7 @@ describe("landing API handlers", () => { }); expect(response.status).toBe(400); - const body = (await response.json()) as LandingValidationErrorResponse; + const body: ValidationErrorResponse = await response.json(); expect(body).toEqual({ code: "validation_error", fieldErrors: { @@ -177,7 +165,6 @@ describe("landing API handlers", () => { installFetchMock(fetchSpy); const response = await handleProductUpdatesRequest({ - bindings: {}, request: new Request("https://landing.onequery.dev/api/product-updates", { body: JSON.stringify({ email: "team@example.com" }), headers: { "content-type": "application/json" }, @@ -186,8 +173,7 @@ describe("landing API handlers", () => { }); expect(response.status).toBe(503); - const body = - (await response.json()) as LandingServiceUnavailableErrorResponse; + const body: ServiceUnavailableErrorResponse = await response.json(); expect(body).toEqual({ code: "service_unavailable", message: "Landing ingest is not configured", diff --git a/apps/landing/src/server/landing/landing-notifications.test.ts b/apps/landing/src/server/notifications.test.ts similarity index 90% rename from apps/landing/src/server/landing/landing-notifications.test.ts rename to apps/landing/src/server/notifications.test.ts index e8324709..777fdf6b 100644 --- a/apps/landing/src/server/landing/landing-notifications.test.ts +++ b/apps/landing/src/server/notifications.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { deliverLandingNotification } from "./landing-notifications"; +import { deliverNotification } from "./notifications"; const originalFetch = globalThis.fetch; @@ -13,7 +13,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("deliverLandingNotification", () => { +describe("deliverNotification", () => { it("accepts local loopback requests without a configured webhook", async () => { const fetchSpy = vi.fn(); installFetchMock(fetchSpy); @@ -22,7 +22,7 @@ describe("deliverLandingNotification", () => { blocks: [], }; - const result = await deliverLandingNotification({ + const result = await deliverNotification({ delivery: { kind: "local-dev-null-sink", }, @@ -38,7 +38,7 @@ describe("deliverLandingNotification", () => { const fetchSpy = vi.fn(); installFetchMock(fetchSpy); - const result = await deliverLandingNotification({ + const result = await deliverNotification({ delivery: { kind: "unconfigured", }, @@ -65,7 +65,7 @@ describe("deliverLandingNotification", () => { blocks: [], }; - const result = await deliverLandingNotification({ + const result = await deliverNotification({ delivery: { kind: "slack-webhook", webhookUrl: "https://example.com/hooks/landing", @@ -87,7 +87,7 @@ describe("deliverLandingNotification", () => { fetchSpy.mockRejectedValue(new Error("boom")); installFetchMock(fetchSpy); - const result = await deliverLandingNotification({ + const result = await deliverNotification({ delivery: { kind: "slack-webhook", webhookUrl: "https://example.com/hooks/landing", @@ -114,7 +114,7 @@ describe("deliverLandingNotification", () => { ); installFetchMock(fetchSpy); - const result = await deliverLandingNotification({ + const result = await deliverNotification({ delivery: { kind: "slack-webhook", webhookUrl: "https://example.com/hooks/landing", diff --git a/apps/landing/src/server/landing/landing-notifications.ts b/apps/landing/src/server/notifications.ts similarity index 81% rename from apps/landing/src/server/landing/landing-notifications.ts rename to apps/landing/src/server/notifications.ts index 61fdd1e0..8ef29450 100644 --- a/apps/landing/src/server/landing/landing-notifications.ts +++ b/apps/landing/src/server/notifications.ts @@ -2,7 +2,7 @@ import { Result, TaggedError } from "better-result"; const LEAD_CAPTURE_SOURCE = "onequery_landing"; -export type LandingNotificationDelivery = +export type NotificationDelivery = | { kind: "local-dev-null-sink"; } @@ -14,30 +14,30 @@ export type LandingNotificationDelivery = kind: "unconfigured"; }; -export class LandingNotificationConfigurationError extends TaggedError( - "LandingNotificationConfigurationError" +export class NotificationConfigurationError extends TaggedError( + "NotificationConfigurationError" )<{ message: string; }>() {} -export class LandingNotificationRequestError extends TaggedError( - "LandingNotificationRequestError" +export class NotificationRequestError extends TaggedError( + "NotificationRequestError" )<{ message: string; cause: unknown; }>() {} -export class LandingNotificationResponseError extends TaggedError( - "LandingNotificationResponseError" +export class NotificationResponseError extends TaggedError( + "NotificationResponseError" )<{ message: string; status: number; }>() {} -export type LandingNotificationError = - | LandingNotificationConfigurationError - | LandingNotificationRequestError - | LandingNotificationResponseError; +export type NotificationError = + | NotificationConfigurationError + | NotificationRequestError + | NotificationResponseError; type SlackPlainText = { type: "plain_text"; @@ -69,12 +69,12 @@ type SlackContextBlock = { elements: readonly SlackMarkdownText[]; }; -type LandingNotificationPayload = { +type NotificationPayload = { text: string; blocks: readonly (SlackContextBlock | SlackHeaderBlock | SlackSectionBlock)[]; }; -type LandingNotificationType = "contact" | "product_updates"; +type NotificationType = "contact" | "product_updates"; function escapeSlackText(value: string) { return value @@ -85,7 +85,7 @@ function escapeSlackText(value: string) { export function createProductUpdatesNotification( email: string -): LandingNotificationPayload { +): NotificationPayload { return { text: `New product updates signup: ${email}`, blocks: [ @@ -108,7 +108,7 @@ export function createContactNotification(input: { email: string; message: string; name: string; -}): LandingNotificationPayload { +}): NotificationPayload { return { text: `New contact request from ${input.name} (${input.email})`, blocks: [ @@ -138,11 +138,11 @@ export function createContactNotification(input: { }; } -export async function deliverLandingNotification(input: { - delivery: LandingNotificationDelivery; - notificationType: LandingNotificationType; - payload: LandingNotificationPayload; -}): Promise> { +export async function deliverNotification(input: { + delivery: NotificationDelivery; + notificationType: NotificationType; + payload: NotificationPayload; +}): Promise> { const { delivery, notificationType, payload } = input; if (delivery.kind === "local-dev-null-sink") { @@ -167,7 +167,7 @@ export async function deliverLandingNotification(input: { "landing notification delivery is unconfigured" ); return Result.err( - new LandingNotificationConfigurationError({ + new NotificationConfigurationError({ message: "Landing ingest is not configured", }) ); @@ -181,7 +181,7 @@ export async function deliverLandingNotification(input: { method: "POST", }), catch: (cause: unknown) => - new LandingNotificationRequestError({ + new NotificationRequestError({ cause, message: `Failed to send landing notification: ${toErrorMessage(cause)}`, }), @@ -222,7 +222,7 @@ export async function deliverLandingNotification(input: { "landing notification webhook rejected" ); return Result.err( - new LandingNotificationResponseError({ + new NotificationResponseError({ message: "Failed to deliver notification", status: response.status, }) diff --git a/apps/landing/src/server/landing-schemas.ts b/apps/landing/src/server/schemas.ts similarity index 100% rename from apps/landing/src/server/landing-schemas.ts rename to apps/landing/src/server/schemas.ts diff --git a/apps/landing/src/landing/analytics/landing-analytics.ts b/apps/landing/src/shared/analytics/events.ts similarity index 87% rename from apps/landing/src/landing/analytics/landing-analytics.ts rename to apps/landing/src/shared/analytics/events.ts index da6fe26a..d4c8a31d 100644 --- a/apps/landing/src/landing/analytics/landing-analytics.ts +++ b/apps/landing/src/shared/analytics/events.ts @@ -15,9 +15,15 @@ declare global { } function getDefinedEventParams(params: AnalyticsEventParams) { - return Object.fromEntries( - Object.entries(params).filter(([, value]) => value !== undefined) - ) as GoogleTagEventParams; + const eventParams: GoogleTagEventParams = {}; + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + eventParams[key] = value; + } + } + + return eventParams; } function trackEvent(name: string, params: AnalyticsEventParams = {}) { @@ -36,7 +42,7 @@ function trackEvent(name: string, params: AnalyticsEventParams = {}) { window.dataLayer.push(["event", name, eventParams]); } -export function trackLandingCtaClick( +export function trackCtaClick( ctaId: string, location: string, destination?: string diff --git a/apps/landing/src/landing/analytics/google-tag-config.ts b/apps/landing/src/shared/analytics/google-tag.ts similarity index 100% rename from apps/landing/src/landing/analytics/google-tag-config.ts rename to apps/landing/src/shared/analytics/google-tag.ts diff --git a/apps/landing/src/landing/analytics/google-tag-script.ts b/apps/landing/src/shared/analytics/script.ts similarity index 95% rename from apps/landing/src/landing/analytics/google-tag-script.ts rename to apps/landing/src/shared/analytics/script.ts index b8391fe1..fad14eea 100644 --- a/apps/landing/src/landing/analytics/google-tag-script.ts +++ b/apps/landing/src/shared/analytics/script.ts @@ -1,4 +1,4 @@ -import type { GoogleTagConfig } from "./google-tag-config"; +import type { GoogleTagConfig } from "./google-tag"; function toInlineScriptLiteral(value: string) { return JSON.stringify(value).replaceAll("<", "\\u003c"); diff --git a/apps/landing/src/components/landing/SiteFooter.astro b/apps/landing/src/shared/components/Footer.astro similarity index 88% rename from apps/landing/src/components/landing/SiteFooter.astro rename to apps/landing/src/shared/components/Footer.astro index bc1d82ab..31fdb256 100644 --- a/apps/landing/src/components/landing/SiteFooter.astro +++ b/apps/landing/src/shared/components/Footer.astro @@ -1,6 +1,6 @@ --- -import { FOOTER_LINKS } from "../../data/footer"; -import type { FooterLink } from "../../data/footer"; +import { FOOTER_LINKS } from "@/shared/navigation/footer"; +import type { FooterLink } from "@/shared/navigation/footer"; import FooterContactButton from "./FooterContactButton.astro"; import TrackedLink from "./TrackedLink.astro"; diff --git a/apps/landing/src/components/landing/FooterContactButton.astro b/apps/landing/src/shared/components/FooterContactButton.astro similarity index 100% rename from apps/landing/src/components/landing/FooterContactButton.astro rename to apps/landing/src/shared/components/FooterContactButton.astro diff --git a/apps/landing/src/components/landing/FooterContactButton.test.ts b/apps/landing/src/shared/components/FooterContactButton.test.ts similarity index 100% rename from apps/landing/src/components/landing/FooterContactButton.test.ts rename to apps/landing/src/shared/components/FooterContactButton.test.ts diff --git a/apps/landing/src/components/landing/SiteHeader.astro b/apps/landing/src/shared/components/Header.astro similarity index 79% rename from apps/landing/src/components/landing/SiteHeader.astro rename to apps/landing/src/shared/components/Header.astro index 9c6a07eb..0c93722c 100644 --- a/apps/landing/src/components/landing/SiteHeader.astro +++ b/apps/landing/src/shared/components/Header.astro @@ -1,11 +1,22 @@ --- -import OneQueryIcon from "../../assets/onequery-icon.svg"; -import { NAVIGATION_ITEMS } from "../../data/navigation"; -import { REPOSITORY_URL } from "../../landing/config/landing-config"; -import { BrandIcon } from "../../landing/content/brand-icons"; +import OneQueryIcon from "@/assets/onequery-icon.svg"; +import { REPOSITORY_URL } from "@/shared/config/site"; +import { BrandIcon } from "@/shared/icons/brands"; import TrackedLink from "./TrackedLink.astro"; -type NavigationItem = (typeof NAVIGATION_ITEMS)[number]; +type NavigationItem = { + href: string; + label: string; +}; + +const NAVIGATION_ITEMS = [ + { href: "/", label: "Product" }, + { href: "/#demo", label: "Demo" }, + { href: "/#install", label: "Install" }, + { href: "/docs/", label: "Docs" }, + { href: "/connectors/", label: "Connectors" }, + { href: "/blog/", label: "Blog" }, +] satisfies ReadonlyArray; function normalizePathname(pathname: string) { if (pathname !== "/" && pathname.endsWith("/")) { diff --git a/apps/landing/src/components/landing/TrackedLink.astro b/apps/landing/src/shared/components/TrackedLink.astro similarity index 100% rename from apps/landing/src/components/landing/TrackedLink.astro rename to apps/landing/src/shared/components/TrackedLink.astro diff --git a/apps/landing/src/landing/config/landing-config.ts b/apps/landing/src/shared/config/site.ts similarity index 96% rename from apps/landing/src/landing/config/landing-config.ts rename to apps/landing/src/shared/config/site.ts index ec0bfebc..39c9630e 100644 --- a/apps/landing/src/landing/config/landing-config.ts +++ b/apps/landing/src/shared/config/site.ts @@ -1,4 +1,4 @@ -import type { BrandIconName } from "../content/brand-icons"; +import type { BrandIconName } from "@/shared/icons/brands"; export const SECTION_IDS = { install: "install", diff --git a/apps/landing/src/landing/content/brand-icons.test.tsx b/apps/landing/src/shared/icons/brands.test.tsx similarity index 88% rename from apps/landing/src/landing/content/brand-icons.test.tsx rename to apps/landing/src/shared/icons/brands.test.tsx index 3b6b33ef..a9158518 100644 --- a/apps/landing/src/landing/content/brand-icons.test.tsx +++ b/apps/landing/src/shared/icons/brands.test.tsx @@ -1,7 +1,7 @@ import { listPublicSourceProviders } from "@onequery/db/source-providers"; import { describe, expect, it } from "vitest"; -import { hasBrandIcon } from "./brand-icons"; +import { hasBrandIcon } from "./brands"; describe("brand icons", () => { it("has a landing icon for every public source provider", () => { diff --git a/apps/landing/src/landing/content/brand-icons.tsx b/apps/landing/src/shared/icons/brands.tsx similarity index 100% rename from apps/landing/src/landing/content/brand-icons.tsx rename to apps/landing/src/shared/icons/brands.tsx diff --git a/apps/landing/src/data/footer.ts b/apps/landing/src/shared/navigation/footer.ts similarity index 93% rename from apps/landing/src/data/footer.ts rename to apps/landing/src/shared/navigation/footer.ts index 8a2287a7..cc6611a3 100644 --- a/apps/landing/src/data/footer.ts +++ b/apps/landing/src/shared/navigation/footer.ts @@ -2,7 +2,7 @@ import { CLI_SOURCE_URL, NPM_PACKAGE_URL, REPOSITORY_URL, -} from "../landing/config/landing-config"; +} from "@/shared/config/site"; export type FooterLink = { href: string; diff --git a/apps/landing/src/shared/seo/constants.ts b/apps/landing/src/shared/seo/constants.ts new file mode 100644 index 00000000..2ca0a994 --- /dev/null +++ b/apps/landing/src/shared/seo/constants.ts @@ -0,0 +1,79 @@ +export type SeoImage = { + height: number; + url: string; + width: number; +}; + +export type ShareImage = SeoImage & { + type: string; +}; + +type OneQueryConstants = { + AUTHOR_NAME: string; + BLOG_NAME: string; + BLOG_POSTS_ITEM_LIST_NAME: string; + DEFAULT_KEYWORDS: string; + DEFAULT_PAGE_TITLE: string; + ICON_IMAGE_ALT: string; + IMAGES: { + ICON: SeoImage; + SHARE: ShareImage; + }; + NAME: string; + SHARE_IMAGE_ALT: string; + SITE_DESCRIPTION: string; + SITE_URL: string; +}; + +export const ONEQUERY = { + AUTHOR_NAME: "OneQuery Maintainers", + BLOG_NAME: "OneQuery Blog", + BLOG_POSTS_ITEM_LIST_NAME: "OneQuery Blog posts", + DEFAULT_KEYWORDS: + "OneQuery, AI agent access control, production context, production keys, Claude Code, centralized credentials, audit logs, capability grants, safe production access", + DEFAULT_PAGE_TITLE: "OneQuery | Governed Data Access for AI Agents", + ICON_IMAGE_ALT: "OneQuery icon", + IMAGES: { + ICON: { + height: 512, + url: "/onequery-icon.png", + width: 512, + }, + SHARE: { + height: 630, + type: "image/png", + url: "/og.png", + width: 1200, + }, + }, + NAME: "OneQuery", + SHARE_IMAGE_ALT: "OneQuery - Governed Data Access for AI Agents", + SITE_DESCRIPTION: + "OneQuery gives AI agents production context without production keys, using approved sources, centralized credentials, enforced limits, and full audit logs.", + SITE_URL: "https://onequery.dev", +} as const satisfies OneQueryConstants; + +export const SEO_PATHS = { + BLOG: "/blog", + CONNECTORS: "/connectors", +} as const; + +export const SCHEMA_FRAGMENTS = { + ARTICLE: "article", + BLOG: "blog", + BREADCRUMB: "breadcrumb", + CONNECTOR: "connector", + CONNECTORS: "connectors", + DEMO_VIDEO: "demo-video", + FAQ: "faq", + ORGANIZATION: "organization", + POSTS: "posts", + SETUP_CHECKLIST: "setup-checklist", + SOFTWARE: "software", + WEBPAGE: "webpage", + WEBSITE: "website", +} as const; + +export const SCHEMA_URLS = { + DESCENDING_ITEM_LIST_ORDER: "https://schema.org/ItemListOrderDescending", +} as const; diff --git a/apps/landing/src/shared/seo/images.ts b/apps/landing/src/shared/seo/images.ts new file mode 100644 index 00000000..1f237c81 --- /dev/null +++ b/apps/landing/src/shared/seo/images.ts @@ -0,0 +1,12 @@ +import { ONEQUERY } from "./constants"; +import type { ShareImage } from "./constants"; +import { toAbsoluteSiteUrl } from "./schema"; + +type SiteInput = string | URL | null | undefined; + +export function getOneQueryShareImage(site?: SiteInput): ShareImage { + return { + ...ONEQUERY.IMAGES.SHARE, + url: toAbsoluteSiteUrl(ONEQUERY.IMAGES.SHARE.url, site), + }; +} diff --git a/apps/landing/src/shared/seo/schema.test.ts b/apps/landing/src/shared/seo/schema.test.ts new file mode 100644 index 00000000..490f3676 --- /dev/null +++ b/apps/landing/src/shared/seo/schema.test.ts @@ -0,0 +1,237 @@ +import type { ImageMetadata } from "astro"; +import { describe, expect, it } from "vitest"; + +import type { BlogPost } from "@/features/blog/types"; +import { + DATA_SOURCE_CONNECTORS, + getConnectorFaqs, + getRelatedConnectors, +} from "@/features/connectors/data"; +import { NPM_PACKAGE_URL } from "@/shared/config/site"; + +import { ONEQUERY } from "./constants"; +import { + createBlogPostStructuredData, + createCanonicalUrl, + createConnectorIndexStructuredData, + createConnectorPageStructuredData, + createHomePageStructuredData, +} from "./schema"; + +function getConnector(key: string) { + const connector = DATA_SOURCE_CONNECTORS.find( + (candidate) => candidate.key === key + ); + + if (!connector) { + throw new Error(`Expected connector "${key}" to exist.`); + } + + return connector; +} + +describe("createCanonicalUrl", () => { + it("normalizes page and endpoint URLs for public SEO links", () => { + expect(createCanonicalUrl("/blog")).toBe("https://onequery.dev/blog/"); + expect(createCanonicalUrl("/sitemap.xml")).toBe( + "https://onequery.dev/sitemap.xml" + ); + }); +}); + +describe("createHomePageStructuredData", () => { + it("emits landing-page schema with npm as the install target", () => { + const schema = createHomePageStructuredData({ + description: "Landing description", + image: { + ...ONEQUERY.IMAGES.SHARE, + alt: "OneQuery share image", + }, + title: "OneQuery", + video: { + contentUrl: "/_astro/openclaw-demo-video.hash.mp4", + description: "Demo video description", + duration: "PT20S", + name: "OneQuery OpenClaw agent access demo", + pageUrl: "https://onequery.dev/#demo", + thumbnail: { + height: 900, + url: "/_astro/openclaw-demo-poster.hash.avif", + width: 1400, + }, + uploadDate: "2026-05-22T00:00:00.000Z", + }, + }); + const graph = schema["@graph"]; + + expect(Array.isArray(graph)).toBe(true); + expect(graph).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + "@type": "VideoObject", + "@id": "https://onequery.dev/#demo-video", + contentUrl: + "https://onequery.dev/_astro/openclaw-demo-video.hash.mp4", + }), + expect.objectContaining({ + "@type": "WebPage", + hasPart: { + "@id": "https://onequery.dev/#demo-video", + }, + significantLink: expect.arrayContaining([NPM_PACKAGE_URL]), + video: { + "@id": "https://onequery.dev/#demo-video", + }, + }), + expect.objectContaining({ + "@type": "SoftwareApplication", + installUrl: NPM_PACKAGE_URL, + }), + ]) + ); + }); +}); + +describe("createConnectorIndexStructuredData", () => { + it("emits CollectionPage and ItemList schema for connector discovery", () => { + const connectors = DATA_SOURCE_CONNECTORS.slice(0, 2); + const schema = createConnectorIndexStructuredData({ + connectors, + description: "Supported OneQuery connectors.", + title: "OneQuery Connectors", + }); + const graph = schema["@graph"]; + + expect(Array.isArray(graph)).toBe(true); + expect(graph).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + "@type": "CollectionPage", + "@id": "https://onequery.dev/connectors/#webpage", + }), + expect.objectContaining({ + "@type": "ItemList", + "@id": "https://onequery.dev/connectors/#connectors", + numberOfItems: connectors.length, + itemListElement: expect.arrayContaining([ + expect.objectContaining({ + item: expect.objectContaining({ + "@id": "https://onequery.dev/connectors/postgresql/#connector", + name: "OneQuery PostgreSQL connector", + }), + }), + ]), + }), + ]) + ); + }); +}); + +describe("createConnectorPageStructuredData", () => { + it("emits connector WebPage, FAQPage, and setup checklist schema", () => { + const connector = getConnector("ga"); + const schema = createConnectorPageStructuredData({ + connector, + description: "Use the Google Analytics connector in OneQuery.", + faqs: getConnectorFaqs(connector), + relatedConnectors: getRelatedConnectors(connector), + title: "Google Analytics Connector | OneQuery", + }); + const graph = schema["@graph"]; + + expect(Array.isArray(graph)).toBe(true); + expect(graph).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + "@type": "WebPage", + "@id": "https://onequery.dev/connectors/google-analytics/#webpage", + mainEntity: { + "@id": + "https://onequery.dev/connectors/google-analytics/#connector", + }, + }), + expect.objectContaining({ + "@type": "SoftwareApplication", + "@id": "https://onequery.dev/connectors/google-analytics/#connector", + name: "OneQuery Google Analytics connector", + }), + expect.objectContaining({ + "@type": "FAQPage", + "@id": "https://onequery.dev/connectors/google-analytics/#faq", + mainEntity: expect.arrayContaining([ + expect.objectContaining({ + "@type": "Question", + }), + ]), + }), + expect.objectContaining({ + "@type": "ItemList", + "@id": + "https://onequery.dev/connectors/google-analytics/#setup-checklist", + numberOfItems: connector.guideSteps.length, + }), + ]) + ); + }); +}); + +describe("createBlogPostStructuredData", () => { + it("emits BlogPosting schema from existing post fields", () => { + const coverImage = { + format: "png", + height: 630, + src: "/_astro/debug-production-agent-runs-with-onequery-icon.png", + width: 1200, + } satisfies ImageMetadata; + const post: BlogPost = { + body: "## Evidence loop\n\nEvidence paragraph", + category: "Engineering", + coverImage: { + alt: "Debugging production on Cloudflare with Codex cover image.", + src: coverImage, + }, + date: "May 6, 2026", + description: + "How Codex can use OneQuery-connected Cloudflare logs to inspect production failures, separate evidence from guesses, and make targeted code changes.", + headings: [ + { + depth: 2, + slug: "evidence-loop", + text: "Evidence loop", + }, + ], + publishedAt: "2026-05-06", + readTime: "7 min read", + slug: "debug-production-agent-runs-with-onequery", + title: "Debugging production on Cloudflare with Codex.", + }; + const schema = createBlogPostStructuredData({ + image: { + height: coverImage.height, + url: coverImage.src, + width: coverImage.width, + }, + post, + }); + const graph = schema["@graph"]; + + expect(Array.isArray(graph)).toBe(true); + expect(graph).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + "@type": "BlogPosting", + headline: post.title, + datePublished: "2026-05-06T00:00:00.000Z", + articleSection: post.category, + hasPart: [ + expect.objectContaining({ + "@id": + "https://onequery.dev/blog/debug-production-agent-runs-with-onequery/#evidence-loop", + name: "Evidence loop", + }), + ], + }), + ]) + ); + }); +}); diff --git a/apps/landing/src/shared/seo/schema.ts b/apps/landing/src/shared/seo/schema.ts new file mode 100644 index 00000000..1389dd60 --- /dev/null +++ b/apps/landing/src/shared/seo/schema.ts @@ -0,0 +1,850 @@ +import type { BlogPost, BlogPostSummary } from "@/features/blog/types"; +import type { + ConnectorFaq, + DataSourceConnector, +} from "@/features/connectors/data"; +import { + getConnectorInterfaceDescription, + getConnectorPath, +} from "@/features/connectors/data"; +import { + NPM_PACKAGE_URL, + REPOSITORY_URL, + SELF_HOST_DOCS_URL, +} from "@/shared/config/site"; + +import { + ONEQUERY, + SCHEMA_FRAGMENTS, + SCHEMA_URLS, + SEO_PATHS, +} from "./constants"; +import type { SeoImage } from "./constants"; + +export type StructuredData = Record; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; +const READ_TIME_MINUTES_PATTERN = /\d+/u; +const CORE_TOPICS = [ + "AI agent data access", + "production data access", + "centralized credentials", + "audit logs", + "read-only query validation", + "safe production debugging", +] as const; + +type SiteInput = string | URL | null | undefined; + +type HomePageStructuredDataInput = { + description: string; + image: SeoImage & { alt: string }; + site?: SiteInput; + title: string; + video?: DemoVideoStructuredDataInput; +}; + +type DemoVideoStructuredDataInput = { + contentUrl: string; + description: string; + duration: string; + name: string; + pageUrl: string; + thumbnail: SeoImage; + uploadDate: string; +}; + +type BlogIndexStructuredDataInput = { + breadcrumbName: string; + description: string; + itemListName: string; + pathname: string; + postImages: Readonly>; + posts: readonly BlogPostSummary[]; + site?: SiteInput; + title: string; +}; + +type BlogPostStructuredDataInput = { + image: SeoImage; + post: BlogPost; + site?: SiteInput; +}; + +type BreadcrumbListItem = { + "@type": "ListItem"; + item: string; + name: string; + position: number; +}; + +type ConnectorIndexStructuredDataInput = { + connectors: readonly DataSourceConnector[]; + description: string; + site?: SiteInput; + title: string; +}; + +type ConnectorPageStructuredDataInput = { + connector: DataSourceConnector; + description: string; + faqs: readonly ConnectorFaq[]; + relatedConnectors: readonly DataSourceConnector[]; + site?: SiteInput; + title: string; +}; + +export function normalizeSiteUrl(site: SiteInput = ONEQUERY.SITE_URL) { + const rawSite = site instanceof URL ? site.toString() : site; + const siteUrl = rawSite && rawSite.length > 0 ? rawSite : ONEQUERY.SITE_URL; + + return siteUrl.replace(/\/+$/u, ""); +} + +export function toAbsoluteSiteUrl(pathOrUrl: string, site?: SiteInput) { + if (/^https?:\/\//u.test(pathOrUrl)) { + return pathOrUrl; + } + + const siteUrl = normalizeSiteUrl(site); + const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`; + + return `${siteUrl}${path}`; +} + +export function createCanonicalUrl(pathname: string, site?: SiteInput) { + const siteUrl = normalizeSiteUrl(site); + const path = pathname.startsWith("/") ? pathname : `/${pathname}`; + const hasFileExtension = /\/[^/]+\.[^/]+$/u.test(path); + const normalizedPath = + path === "/" || path.endsWith("/") || hasFileExtension ? path : `${path}/`; + + return `${siteUrl}${normalizedPath}`; +} + +export function toIsoDateTime(date: string) { + if (!ISO_DATE_PATTERN.test(date)) { + return undefined; + } + + const parsedDate = new Date(`${date}T00:00:00.000Z`); + + if (Number.isNaN(parsedDate.getTime())) { + return undefined; + } + + const isoDateTime = parsedDate.toISOString(); + + return isoDateTime.startsWith(date) ? isoDateTime : undefined; +} + +export function getBlogPostKeywords(post: Pick) { + return [ + ONEQUERY.NAME, + "AI agents", + "production data access", + "governed data access", + "agent safety", + post.category, + ].join(", "); +} + +function createGraph(graph: StructuredData[]): StructuredData { + return { + "@context": "https://schema.org", + "@graph": graph, + }; +} + +function getNodeId(baseUrl: string, fragment: string) { + return `${baseUrl}#${fragment}`; +} + +function getOrganizationId(site: SiteInput) { + return getNodeId(`${normalizeSiteUrl(site)}/`, SCHEMA_FRAGMENTS.ORGANIZATION); +} + +function getWebsiteId(site: SiteInput) { + return getNodeId(`${normalizeSiteUrl(site)}/`, SCHEMA_FRAGMENTS.WEBSITE); +} + +function getSoftwareApplicationId(site: SiteInput) { + return getNodeId(`${normalizeSiteUrl(site)}/`, SCHEMA_FRAGMENTS.SOFTWARE); +} + +function getDemoVideoId(site: SiteInput) { + return getNodeId(`${normalizeSiteUrl(site)}/`, SCHEMA_FRAGMENTS.DEMO_VIDEO); +} + +function getBlogPostId(postUrl: string) { + return getNodeId(postUrl, SCHEMA_FRAGMENTS.ARTICLE); +} + +function getBlogPostUrl(slug: string, site: SiteInput) { + return createCanonicalUrl(`${SEO_PATHS.BLOG}/${slug}`, site); +} + +function getBlogPostImage( + imagesBySlug: Readonly>, + slug: string +) { + const image = imagesBySlug[slug]; + + if (!image) { + throw new Error(`Missing structured data image for blog post "${slug}".`); + } + + return image; +} + +function createImageObject(input: { + alt?: string; + height: number; + site: SiteInput; + url: string; + width: number; +}) { + return { + "@type": "ImageObject", + url: toAbsoluteSiteUrl(input.url, input.site), + ...(input.alt ? { caption: input.alt } : {}), + width: input.width, + height: input.height, + }; +} + +function createOneQueryOrganization(site: SiteInput): StructuredData { + const siteUrl = normalizeSiteUrl(site); + + return { + "@type": "Organization", + "@id": getOrganizationId(siteUrl), + name: ONEQUERY.NAME, + url: `${siteUrl}/`, + logo: createImageObject({ + ...ONEQUERY.IMAGES.ICON, + alt: ONEQUERY.ICON_IMAGE_ALT, + site: siteUrl, + }), + sameAs: [REPOSITORY_URL], + knowsAbout: [...CORE_TOPICS], + }; +} + +function createOneQueryWebsite(site: SiteInput): StructuredData { + const siteUrl = normalizeSiteUrl(site); + + return { + "@type": "WebSite", + "@id": getWebsiteId(siteUrl), + name: ONEQUERY.NAME, + url: `${siteUrl}/`, + description: ONEQUERY.SITE_DESCRIPTION, + inLanguage: "en", + publisher: { + "@id": getOrganizationId(siteUrl), + }, + }; +} + +function createOneQuerySoftwareApplication(input: { + description: string; + site: SiteInput; +}): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + + return { + "@type": "SoftwareApplication", + "@id": getSoftwareApplicationId(siteUrl), + name: ONEQUERY.NAME, + url: `${siteUrl}/`, + applicationCategory: "DeveloperApplication", + operatingSystem: "Web, CLI, Self-hosted gateway", + description: input.description, + image: createImageObject({ + ...ONEQUERY.IMAGES.ICON, + alt: ONEQUERY.ICON_IMAGE_ALT, + site: siteUrl, + }), + publisher: { + "@id": getOrganizationId(siteUrl), + }, + sameAs: [REPOSITORY_URL], + codeRepository: REPOSITORY_URL, + installUrl: NPM_PACKAGE_URL, + softwareHelp: SELF_HOST_DOCS_URL, + featureList: [ + "Governed production data access for AI agents", + "Centralized credentials", + "Read-only query validation", + "Audit logs for agent data access", + ], + }; +} + +function createDemoVideoStructuredData( + input: DemoVideoStructuredDataInput & { site: SiteInput } +): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + const thumbnailUrl = toAbsoluteSiteUrl(input.thumbnail.url, siteUrl); + const pageUrl = toAbsoluteSiteUrl(input.pageUrl, siteUrl); + + return { + "@type": "VideoObject", + "@id": getDemoVideoId(siteUrl), + name: input.name, + description: input.description, + thumbnailUrl: [thumbnailUrl], + uploadDate: input.uploadDate, + duration: input.duration, + contentUrl: toAbsoluteSiteUrl(input.contentUrl, siteUrl), + url: pageUrl, + inLanguage: "en", + publisher: { + "@id": getOrganizationId(siteUrl), + }, + isPartOf: { + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.WEBPAGE), + }, + mainEntityOfPage: { + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.WEBPAGE), + }, + thumbnail: createImageObject({ + height: input.thumbnail.height, + site: siteUrl, + url: thumbnailUrl, + width: input.thumbnail.width, + }), + }; +} + +function createSiteGraph(site: SiteInput): StructuredData[] { + return [createOneQueryOrganization(site), createOneQueryWebsite(site)]; +} + +function createConnectorFeatureList(connector: DataSourceConnector) { + return [ + `${connector.label} ${getConnectorInterfaceDescription(connector)}`, + `${connector.availability} setup for approved source access`, + "Centralized credentials for AI agent workflows", + `Audit-ready source access through ${ONEQUERY.NAME}`, + ]; +} + +function createConnectorSoftwareApplication( + connector: DataSourceConnector, + site: SiteInput +): StructuredData { + const siteUrl = normalizeSiteUrl(site); + const connectorUrl = createCanonicalUrl(getConnectorPath(connector), siteUrl); + const connectorId = getNodeId(connectorUrl, SCHEMA_FRAGMENTS.CONNECTOR); + + return { + "@type": "SoftwareApplication", + "@id": connectorId, + name: `${ONEQUERY.NAME} ${connector.label} connector`, + url: connectorUrl, + applicationCategory: "DeveloperApplication", + applicationSubCategory: connector.category, + operatingSystem: + connector.availability === "Dashboard + CLI" + ? "Web, CLI, Self-hosted gateway" + : "CLI, Self-hosted gateway", + description: connector.description, + featureList: createConnectorFeatureList(connector), + publisher: { + "@id": getOrganizationId(siteUrl), + }, + isPartOf: { + "@id": getSoftwareApplicationId(siteUrl), + }, + }; +} + +export function createHomePageStructuredData( + input: HomePageStructuredDataInput +): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + const videoReference = input.video + ? { + "@id": getDemoVideoId(siteUrl), + } + : undefined; + + return createGraph([ + ...createSiteGraph(siteUrl), + createOneQuerySoftwareApplication({ + description: input.description, + site: siteUrl, + }), + ...(input.video + ? [ + createDemoVideoStructuredData({ + ...input.video, + site: siteUrl, + }), + ] + : []), + { + "@type": "WebPage", + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.WEBPAGE), + url: `${siteUrl}/`, + name: input.title, + description: input.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + about: { + "@id": getSoftwareApplicationId(siteUrl), + }, + ...(videoReference + ? { + hasPart: videoReference, + video: videoReference, + } + : {}), + primaryImageOfPage: createImageObject({ + alt: input.image.alt, + height: input.image.height, + site: siteUrl, + url: input.image.url, + width: input.image.width, + }), + breadcrumb: { + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.BREADCRUMB), + }, + significantLink: [ + createCanonicalUrl(SEO_PATHS.BLOG, siteUrl), + NPM_PACKAGE_URL, + SELF_HOST_DOCS_URL, + ], + }, + { + "@type": "BreadcrumbList", + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.BREADCRUMB), + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY.NAME, + item: `${siteUrl}/`, + }, + ], + }, + ]); +} + +export function createBlogIndexStructuredData( + input: BlogIndexStructuredDataInput +): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + const blogUrl = createCanonicalUrl(SEO_PATHS.BLOG, siteUrl); + const pageUrl = createCanonicalUrl(input.pathname, siteUrl); + const breadcrumbItems: BreadcrumbListItem[] = [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY.NAME, + item: `${siteUrl}/`, + }, + { + "@type": "ListItem", + position: 2, + name: ONEQUERY.BLOG_NAME, + item: blogUrl, + }, + ]; + + if (pageUrl !== blogUrl) { + breadcrumbItems.push({ + "@type": "ListItem", + position: 3, + name: input.breadcrumbName, + item: pageUrl, + }); + } + + return createGraph([ + ...createSiteGraph(siteUrl), + { + "@type": "Blog", + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), + name: ONEQUERY.BLOG_NAME, + url: blogUrl, + description: input.description, + inLanguage: "en", + publisher: { + "@id": getOrganizationId(siteUrl), + }, + blogPost: input.posts.map((post) => ({ + "@id": getBlogPostId(getBlogPostUrl(post.slug, siteUrl)), + })), + }, + { + "@type": "CollectionPage", + "@id": getNodeId(pageUrl, SCHEMA_FRAGMENTS.WEBPAGE), + url: pageUrl, + name: input.title, + description: input.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + mainEntity: { + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), + }, + breadcrumb: { + "@id": getNodeId(pageUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + }, + }, + { + "@type": "ItemList", + "@id": getNodeId(pageUrl, SCHEMA_FRAGMENTS.POSTS), + name: input.itemListName, + itemListOrder: SCHEMA_URLS.DESCENDING_ITEM_LIST_ORDER, + numberOfItems: input.posts.length, + itemListElement: input.posts.map((post, index) => ({ + "@type": "ListItem", + position: index + 1, + item: createBlogPostSummaryStructuredData( + post, + siteUrl, + getBlogPostImage(input.postImages, post.slug) + ), + })), + }, + { + "@type": "BreadcrumbList", + "@id": getNodeId(pageUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + itemListElement: breadcrumbItems, + }, + ]); +} + +export function createConnectorIndexStructuredData( + input: ConnectorIndexStructuredDataInput +): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + const connectorsUrl = createCanonicalUrl(SEO_PATHS.CONNECTORS, siteUrl); + + return createGraph([ + ...createSiteGraph(siteUrl), + createOneQuerySoftwareApplication({ + description: ONEQUERY.SITE_DESCRIPTION, + site: siteUrl, + }), + { + "@type": "CollectionPage", + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.WEBPAGE), + url: connectorsUrl, + name: input.title, + description: input.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + about: { + "@id": getSoftwareApplicationId(siteUrl), + }, + mainEntity: { + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.CONNECTORS), + }, + breadcrumb: { + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + }, + }, + { + "@type": "ItemList", + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.CONNECTORS), + name: `${ONEQUERY.NAME} supported data source connectors`, + numberOfItems: input.connectors.length, + itemListElement: input.connectors.map((connector, index) => ({ + "@type": "ListItem", + position: index + 1, + item: createConnectorSoftwareApplication(connector, siteUrl), + })), + }, + { + "@type": "BreadcrumbList", + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY.NAME, + item: `${siteUrl}/`, + }, + { + "@type": "ListItem", + position: 2, + name: "Connectors", + item: connectorsUrl, + }, + ], + }, + ]); +} + +export function createConnectorPageStructuredData( + input: ConnectorPageStructuredDataInput +): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + const connectorsUrl = createCanonicalUrl(SEO_PATHS.CONNECTORS, siteUrl); + const connectorUrl = createCanonicalUrl( + getConnectorPath(input.connector), + siteUrl + ); + const connectorId = getNodeId(connectorUrl, SCHEMA_FRAGMENTS.CONNECTOR); + + return createGraph([ + ...createSiteGraph(siteUrl), + createOneQuerySoftwareApplication({ + description: ONEQUERY.SITE_DESCRIPTION, + site: siteUrl, + }), + createConnectorSoftwareApplication(input.connector, siteUrl), + { + "@type": "WebPage", + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.WEBPAGE), + url: connectorUrl, + name: input.title, + description: input.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + about: { + "@id": connectorId, + }, + mainEntity: { + "@id": connectorId, + }, + hasPart: [ + { + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.FAQ), + }, + { + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.SETUP_CHECKLIST), + }, + ], + relatedLink: input.relatedConnectors.map((connector) => + createCanonicalUrl(getConnectorPath(connector), siteUrl) + ), + breadcrumb: { + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + }, + }, + { + "@type": "FAQPage", + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.FAQ), + mainEntity: input.faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { + "@type": "Answer", + text: faq.answer, + }, + })), + }, + { + "@type": "ItemList", + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.SETUP_CHECKLIST), + name: `${input.connector.label} connector setup checklist`, + numberOfItems: input.connector.guideSteps.length, + itemListElement: input.connector.guideSteps.map((step, index) => ({ + "@type": "ListItem", + position: index + 1, + name: step, + })), + }, + { + "@type": "BreadcrumbList", + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY.NAME, + item: `${siteUrl}/`, + }, + { + "@type": "ListItem", + position: 2, + name: "Connectors", + item: connectorsUrl, + }, + { + "@type": "ListItem", + position: 3, + name: input.connector.label, + item: connectorUrl, + }, + ], + }, + ]); +} + +export function createBlogPostStructuredData( + input: BlogPostStructuredDataInput +): StructuredData { + const { image, post } = input; + const siteUrl = normalizeSiteUrl(input.site); + const blogUrl = createCanonicalUrl(SEO_PATHS.BLOG, siteUrl); + const postUrl = getBlogPostUrl(post.slug, siteUrl); + const publishedTime = toIsoDateTime(post.publishedAt); + const postSections = post.headings.filter((heading) => heading.depth === 2); + + return createGraph([ + ...createSiteGraph(siteUrl), + { + "@type": "Blog", + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), + name: ONEQUERY.BLOG_NAME, + url: blogUrl, + publisher: { + "@id": getOrganizationId(siteUrl), + }, + }, + { + "@type": "BlogPosting", + "@id": getBlogPostId(postUrl), + mainEntityOfPage: { + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.WEBPAGE), + }, + headline: post.title, + description: post.description, + image: createImageObject({ + alt: `${post.title} - ${ONEQUERY.BLOG_NAME}`, + height: image.height, + site: siteUrl, + url: image.url, + width: image.width, + }), + ...(publishedTime + ? { + datePublished: publishedTime, + dateModified: publishedTime, + } + : {}), + author: { + "@id": getOrganizationId(siteUrl), + name: ONEQUERY.AUTHOR_NAME, + }, + publisher: { + "@id": getOrganizationId(siteUrl), + }, + isPartOf: { + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), + }, + articleSection: post.category, + keywords: getBlogPostKeywords(post), + inLanguage: "en", + isAccessibleForFree: true, + timeRequired: readTimeToDuration(post.readTime), + wordCount: countWords(getPostText(post)), + about: [...CORE_TOPICS], + hasPart: postSections.map((heading) => ({ + "@type": "WebPageElement", + "@id": `${postUrl}#${heading.slug}`, + name: heading.text, + url: `${postUrl}#${heading.slug}`, + })), + }, + { + "@type": "WebPage", + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.WEBPAGE), + url: postUrl, + name: `${post.title} | ${ONEQUERY.BLOG_NAME}`, + description: post.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + mainEntity: { + "@id": getBlogPostId(postUrl), + }, + breadcrumb: { + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + }, + }, + { + "@type": "BreadcrumbList", + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY.NAME, + item: `${siteUrl}/`, + }, + { + "@type": "ListItem", + position: 2, + name: ONEQUERY.BLOG_NAME, + item: blogUrl, + }, + { + "@type": "ListItem", + position: 3, + name: post.title, + item: postUrl, + }, + ], + }, + ]); +} + +function createBlogPostSummaryStructuredData( + post: BlogPostSummary, + site: SiteInput, + image: SeoImage +): StructuredData { + const siteUrl = normalizeSiteUrl(site); + const postUrl = getBlogPostUrl(post.slug, siteUrl); + const publishedTime = toIsoDateTime(post.publishedAt); + + return { + "@type": "BlogPosting", + "@id": getBlogPostId(postUrl), + url: postUrl, + headline: post.title, + description: post.description, + image: createImageObject({ + alt: `${post.title} - ${ONEQUERY.BLOG_NAME}`, + height: image.height, + site: siteUrl, + url: image.url, + width: image.width, + }), + ...(publishedTime ? { datePublished: publishedTime } : {}), + author: { + "@id": getOrganizationId(siteUrl), + name: ONEQUERY.AUTHOR_NAME, + }, + publisher: { + "@id": getOrganizationId(siteUrl), + }, + articleSection: post.category, + keywords: getBlogPostKeywords(post), + inLanguage: "en", + }; +} + +function getPostText(post: BlogPost) { + return [post.title, post.description, post.body].join(" "); +} + +function countWords(text: string) { + return text.trim().split(/\s+/u).filter(Boolean).length; +} + +function readTimeToDuration(readTime: string) { + const match = READ_TIME_MINUTES_PATTERN.exec(readTime); + + if (!match) { + return undefined; + } + + return `PT${match[0]}M`; +} diff --git a/apps/landing/src/styles/base.css b/apps/landing/src/shared/styles/base.css similarity index 100% rename from apps/landing/src/styles/base.css rename to apps/landing/src/shared/styles/base.css diff --git a/apps/landing/src/tooling/install-script.test.ts b/apps/landing/src/tooling/install.test.ts similarity index 98% rename from apps/landing/src/tooling/install-script.test.ts rename to apps/landing/src/tooling/install.test.ts index e509c004..571f7a64 100644 --- a/apps/landing/src/tooling/install-script.test.ts +++ b/apps/landing/src/tooling/install.test.ts @@ -4,7 +4,7 @@ import { createInstallScriptAsset, createInstallScriptResponse, shouldServeInstallScriptRequest, -} from "./install-script"; +} from "./install"; describe("createInstallScriptAsset", () => { it("emits install.sh from the canonical runtime installer", () => { diff --git a/apps/landing/src/tooling/install-script.ts b/apps/landing/src/tooling/install.ts similarity index 100% rename from apps/landing/src/tooling/install-script.ts rename to apps/landing/src/tooling/install.ts diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json index 1129d579..a29569d7 100644 --- a/apps/landing/tsconfig.json +++ b/apps/landing/tsconfig.json @@ -1,5 +1,10 @@ { "extends": "@onequery/tsconfig/base.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, "include": [ ".astro/types.d.ts", "src", diff --git a/apps/landing/vitest.config.ts b/apps/landing/vitest.config.ts index 5e2ab91c..fe4da646 100644 --- a/apps/landing/vitest.config.ts +++ b/apps/landing/vitest.config.ts @@ -1,7 +1,14 @@ /// +import { fileURLToPath } from "node:url"; + import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, test: { hideSkippedTests: true, silent: "passed-only",