From d1228bad6c34d40060bf4ffad7913aa82af88ab8 Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 14:22:49 +0900 Subject: [PATCH 1/6] feat(landing): add connector SEO pages --- .../connectors/ConnectorBrowser.astro | 8 +- apps/landing/src/data/connectors.test.ts | 40 +- apps/landing/src/data/connectors.ts | 141 +++++++ .../src/landing/seo/structured-data.test.ts | 102 +++++ .../src/landing/seo/structured-data.ts | 221 +++++++++++ apps/landing/src/pages/connectors.astro | 16 +- .../pages/connectors/[connectorSlug].astro | 244 ++++++++++++ apps/landing/src/styles/connectors.css | 370 ++++++++++++++++++ 8 files changed, 1136 insertions(+), 6 deletions(-) create mode 100644 apps/landing/src/pages/connectors/[connectorSlug].astro diff --git a/apps/landing/src/components/connectors/ConnectorBrowser.astro b/apps/landing/src/components/connectors/ConnectorBrowser.astro index fad96120..0051e71e 100644 --- a/apps/landing/src/components/connectors/ConnectorBrowser.astro +++ b/apps/landing/src/components/connectors/ConnectorBrowser.astro @@ -1,5 +1,6 @@ --- import type { DataSourceConnector } from "../../data/connectors"; +import { getConnectorPath } from "../../data/connectors"; import { BrandIcon, hasBrandIcon } from "../../landing/content/brand-icons"; interface Props { @@ -65,8 +66,9 @@ const categoryOptions = [
{ connectors.map((connector) => ( -
- + )) } diff --git a/apps/landing/src/data/connectors.test.ts b/apps/landing/src/data/connectors.test.ts index a8171508..d6f7d28d 100644 --- a/apps/landing/src/data/connectors.test.ts +++ b/apps/landing/src/data/connectors.test.ts @@ -1,7 +1,23 @@ import { listPublicSourceProviders } from "@onequery/db/source-providers"; import { describe, expect, it } from "vitest"; -import { DATA_SOURCE_CONNECTORS } from "./connectors"; +import { + DATA_SOURCE_CONNECTORS, + getConnectorFaqs, + getConnectorPath, +} from "./connectors"; + +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("DATA_SOURCE_CONNECTORS", () => { it("is derived from every public source provider", () => { @@ -26,4 +42,26 @@ describe("DATA_SOURCE_CONNECTORS", () => { ]) ); }); + + it("derives unique SEO slugs for connector landing pages", () => { + const slugs = DATA_SOURCE_CONNECTORS.map((connector) => connector.slug); + + expect(new Set(slugs).size).toBe(slugs.length); + expect(getConnectorPath(getConnector("postgres"))).toBe( + "/connectors/postgresql/" + ); + expect(getConnectorPath(getConnector("ga"))).toBe( + "/connectors/google-analytics/" + ); + }); + + it("creates connector FAQ copy from provider metadata", () => { + const connector = getConnector("github"); + const faqs = getConnectorFaqs(connector); + + expect(faqs).toHaveLength(3); + expect(faqs[0]?.question).toContain("GitHub"); + expect(faqs[1]?.answer).toContain("credentials"); + expect(faqs[2]?.answer).toContain(connector.guideSteps[0]); + }); }); diff --git a/apps/landing/src/data/connectors.ts b/apps/landing/src/data/connectors.ts index 031471be..2087c496 100644 --- a/apps/landing/src/data/connectors.ts +++ b/apps/landing/src/data/connectors.ts @@ -5,13 +5,24 @@ export type ConnectorAvailability = "Dashboard + CLI" | "CLI"; export type ConnectorCapability = "API" | "Query" | "Connector" | "Workflow"; +export type ConnectorInterface = "api" | "query"; + export type DataSourceConnector = { availability: ConnectorAvailability; capabilities: ReadonlyArray; category: string; + credentialType: string; description: string; + guideSteps: readonly string[]; + interfaces: ReadonlyArray; key: string; label: string; + slug: string; +}; + +export type ConnectorFaq = { + answer: string; + question: string; }; function sourceProviderAvailability( @@ -38,6 +49,14 @@ function sourceProviderCapabilities( return capabilities; } +function connectorSlug(label: string) { + return label + .toLowerCase() + .replace(/&/gu, " and ") + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} + function sourceProviderConnector( provider: PublicSourceProvider ): DataSourceConnector { @@ -45,12 +64,134 @@ function sourceProviderConnector( availability: sourceProviderAvailability(provider), capabilities: sourceProviderCapabilities(provider), category: provider.publicCategory, + credentialType: provider.credentialType, description: provider.guideSummary, + guideSteps: provider.guideSteps, + interfaces: provider.interfaces, key: provider.id, label: provider.label, + slug: connectorSlug(provider.label), }; } export const DATA_SOURCE_CONNECTORS = listPublicSourceProviders().map( sourceProviderConnector ); + +export function getConnectorPath(connector: Pick) { + return `/connectors/${connector.slug}/`; +} + +export function getConnectorInterfaceLabel( + connector: Pick +) { + const hasQuery = connector.interfaces.includes("query"); + const hasApi = connector.interfaces.includes("api"); + + if (hasQuery && hasApi) { + return "Query and API"; + } + + if (hasQuery) { + return "Query"; + } + + return "API"; +} + +export function getConnectorInterfaceDescription( + connector: Pick +) { + const hasQuery = connector.interfaces.includes("query"); + const hasApi = connector.interfaces.includes("api"); + + if (hasQuery && hasApi) { + return "SQL-style query workflows and bounded source API calls"; + } + + if (hasQuery) { + return "SQL-style query workflows for structured data access"; + } + + return "bounded source API calls for endpoint-specific context"; +} + +export function getConnectorKeywords(connector: DataSourceConnector) { + return [ + `${connector.label} connector`, + `${connector.label} OneQuery`, + `${connector.label} AI agent data access`, + `${connector.category} connector`, + "OneQuery connector", + "governed AI agent access", + "centralized credentials", + ].join(", "); +} + +export function getConnectorMetaDescription(connector: DataSourceConnector) { + const description = `Use the ${connector.label} connector in OneQuery for governed AI agent access with centralized credentials, limits, and audit logs.`; + + if (description.length <= 160) { + return description; + } + + return `Use the ${connector.label} connector in OneQuery for governed AI agent access with centralized credentials and audit logs.`; +} + +export function getRelatedConnectors( + connector: DataSourceConnector, + connectors: readonly DataSourceConnector[] = DATA_SOURCE_CONNECTORS +) { + const sameCategoryConnectors = connectors.filter( + (candidate) => + candidate.key !== connector.key && + candidate.category === connector.category + ); + + if (sameCategoryConnectors.length >= 3) { + return sameCategoryConnectors.slice(0, 3); + } + + const categoryKeys = new Set( + sameCategoryConnectors.map((candidate) => candidate.key) + ); + const relatedByCapability = connectors.filter( + (candidate) => + candidate.key !== connector.key && + !categoryKeys.has(candidate.key) && + candidate.capabilities.some((capability) => + connector.capabilities.includes(capability) + ) + ); + + return [...sameCategoryConnectors, ...relatedByCapability].slice(0, 3); +} + +export function getConnectorFaqs( + connector: DataSourceConnector +): ConnectorFaq[] { + const interfaceDescription = getConnectorInterfaceDescription(connector); + const setupSurface = + connector.availability === "Dashboard + CLI" + ? "the OneQuery dashboard or CLI" + : "the OneQuery CLI"; + const credentialLabel = connector.credentialType.replace(/_/gu, " "); + const firstSetupStep = connector.guideSteps[0]; + + return [ + { + answer: `The OneQuery ${connector.label} connector makes ${connector.category.toLowerCase()} context from ${connector.label} available to AI agents through ${interfaceDescription}. ${connector.description}`, + question: `What is the OneQuery ${connector.label} connector?`, + }, + { + answer: `Agents call OneQuery instead of receiving raw ${connector.label} credentials. OneQuery keeps credentials centralized, applies source boundaries, and records access in audit logs while exposing ${interfaceDescription}.`, + question: `How do AI agents access ${connector.label} through OneQuery?`, + }, + { + answer: firstSetupStep + ? `Prepare ${credentialLabel} credentials and connect ${connector.label} from ${setupSurface}. Start with this setup step: ${firstSetupStep}` + : `Prepare ${credentialLabel} credentials and connect ${connector.label} from ${setupSurface}.`, + question: `How do I set up the ${connector.label} connector?`, + }, + ]; +} diff --git a/apps/landing/src/landing/seo/structured-data.test.ts b/apps/landing/src/landing/seo/structured-data.test.ts index 3b744c0d..d10d2418 100644 --- a/apps/landing/src/landing/seo/structured-data.test.ts +++ b/apps/landing/src/landing/seo/structured-data.test.ts @@ -1,14 +1,33 @@ import type { ImageMetadata } from "astro"; import { describe, expect, it } from "vitest"; +import { + DATA_SOURCE_CONNECTORS, + getConnectorFaqs, + getRelatedConnectors, +} from "../../data/connectors"; import type { BlogPost } from "../blog/blog-types"; import { NPM_PACKAGE_URL } from "../config/landing-config"; import { createBlogPostStructuredData, createCanonicalUrl, + createConnectorIndexStructuredData, + createConnectorPageStructuredData, createLandingPageStructuredData, } from "./structured-data"; +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/"); @@ -67,6 +86,89 @@ describe("createLandingPageStructuredData", () => { }); }); +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 = { diff --git a/apps/landing/src/landing/seo/structured-data.ts b/apps/landing/src/landing/seo/structured-data.ts index 4b0a556a..ec7d8580 100644 --- a/apps/landing/src/landing/seo/structured-data.ts +++ b/apps/landing/src/landing/seo/structured-data.ts @@ -1,3 +1,8 @@ +import type { ConnectorFaq, DataSourceConnector } from "../../data/connectors"; +import { + getConnectorInterfaceDescription, + getConnectorPath, +} from "../../data/connectors"; import type { BlogPost, BlogPostSummary } from "../blog/blog-types"; import { NPM_PACKAGE_URL, @@ -67,6 +72,22 @@ type BlogIndexStructuredDataInput = { title: string; }; +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; @@ -277,6 +298,44 @@ 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", + ]; +} + +function createConnectorSoftwareApplication( + connector: DataSourceConnector, + site: SiteInput +): StructuredData { + const siteUrl = normalizeSiteUrl(site); + const connectorUrl = createCanonicalUrl(getConnectorPath(connector), siteUrl); + + return { + "@type": "SoftwareApplication", + "@id": `${connectorUrl}#connector`, + name: `OneQuery ${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 createLandingPageStructuredData( input: LandingPageStructuredDataInput ): StructuredData { @@ -438,6 +497,168 @@ export function createBlogIndexStructuredData( ]); } +export function createConnectorIndexStructuredData( + input: ConnectorIndexStructuredDataInput +): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + const connectorsUrl = createCanonicalUrl("/connectors", siteUrl); + + return createGraph([ + ...createSiteGraph(siteUrl), + createOneQuerySoftwareApplication({ + description: ONEQUERY_DEFAULT_DESCRIPTION, + site: siteUrl, + }), + { + "@type": "CollectionPage", + "@id": `${connectorsUrl}#webpage`, + url: connectorsUrl, + name: input.title, + description: input.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + about: { + "@id": getSoftwareApplicationId(siteUrl), + }, + mainEntity: { + "@id": `${connectorsUrl}#connectors`, + }, + breadcrumb: { + "@id": `${connectorsUrl}#breadcrumb`, + }, + }, + { + "@type": "ItemList", + "@id": `${connectorsUrl}#connectors`, + name: "OneQuery 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": `${connectorsUrl}#breadcrumb`, + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY_SITE_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("/connectors", siteUrl); + const connectorUrl = createCanonicalUrl( + getConnectorPath(input.connector), + siteUrl + ); + + return createGraph([ + ...createSiteGraph(siteUrl), + createOneQuerySoftwareApplication({ + description: ONEQUERY_DEFAULT_DESCRIPTION, + site: siteUrl, + }), + createConnectorSoftwareApplication(input.connector, siteUrl), + { + "@type": "WebPage", + "@id": `${connectorUrl}#webpage`, + url: connectorUrl, + name: input.title, + description: input.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + about: { + "@id": `${connectorUrl}#connector`, + }, + mainEntity: { + "@id": `${connectorUrl}#connector`, + }, + hasPart: [ + { + "@id": `${connectorUrl}#faq`, + }, + { + "@id": `${connectorUrl}#setup-checklist`, + }, + ], + relatedLink: input.relatedConnectors.map((connector) => + createCanonicalUrl(getConnectorPath(connector), siteUrl) + ), + breadcrumb: { + "@id": `${connectorUrl}#breadcrumb`, + }, + }, + { + "@type": "FAQPage", + "@id": `${connectorUrl}#faq`, + mainEntity: input.faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { + "@type": "Answer", + text: faq.answer, + }, + })), + }, + { + "@type": "ItemList", + "@id": `${connectorUrl}#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": `${connectorUrl}#breadcrumb`, + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY_SITE_NAME, + item: `${siteUrl}/`, + }, + { + "@type": "ListItem", + position: 2, + name: "Connectors", + item: connectorsUrl, + }, + { + "@type": "ListItem", + position: 3, + name: input.connector.label, + item: connectorUrl, + }, + ], + }, + ]); +} + export function createBlogPostStructuredData( post: BlogPost, site?: SiteInput, diff --git a/apps/landing/src/pages/connectors.astro b/apps/landing/src/pages/connectors.astro index fd9b8732..07d0a7f2 100644 --- a/apps/landing/src/pages/connectors.astro +++ b/apps/landing/src/pages/connectors.astro @@ -2,20 +2,30 @@ import ConnectorBrowser from "../components/connectors/ConnectorBrowser.astro"; import { DATA_SOURCE_CONNECTORS } from "../data/connectors"; import MarketingLayout from "../layouts/MarketingLayout.astro"; -import { createCanonicalUrl } from "../landing/seo/structured-data"; +import { + createCanonicalUrl, + createConnectorIndexStructuredData, +} from "../landing/seo/structured-data"; import "../styles/connectors.css"; const title = "OneQuery Connectors | Supported Data Sources"; const description = "See the databases, warehouses, observability tools, analytics systems, and developer workflows currently supported by OneQuery."; +const canonicalUrl = createCanonicalUrl("/connectors", Astro.site); +const structuredData = createConnectorIndexStructuredData({ + connectors: DATA_SOURCE_CONNECTORS, + description, + site: Astro.site, + title, +}); ---
diff --git a/apps/landing/src/pages/connectors/[connectorSlug].astro b/apps/landing/src/pages/connectors/[connectorSlug].astro new file mode 100644 index 00000000..c5928267 --- /dev/null +++ b/apps/landing/src/pages/connectors/[connectorSlug].astro @@ -0,0 +1,244 @@ +--- +import type { DataSourceConnector } from "../../data/connectors"; +import { + DATA_SOURCE_CONNECTORS, + getConnectorFaqs, + getConnectorInterfaceDescription, + getConnectorInterfaceLabel, + getConnectorKeywords, + getConnectorMetaDescription, + getConnectorPath, + getRelatedConnectors, +} from "../../data/connectors"; +import MarketingLayout from "../../layouts/MarketingLayout.astro"; +import { BrandIcon, hasBrandIcon } from "../../landing/content/brand-icons"; +import { + createCanonicalUrl, + createConnectorPageStructuredData, +} from "../../landing/seo/structured-data"; +import "../../styles/connectors.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/styles/connectors.css b/apps/landing/src/styles/connectors.css index f182ac5e..f3e22e66 100644 --- a/apps/landing/src/styles/connectors.css +++ b/apps/landing/src/styles/connectors.css @@ -228,6 +228,7 @@ border-radius: 28px; background: rgba(255, 255, 255, 0.86); box-shadow: var(--shadow-ring); + text-decoration: none; transition: border-color 160ms ease, transform 160ms ease, @@ -319,6 +320,334 @@ white-space: nowrap; } +.connector-detail-main { + width: min(100%, 1180px); + margin: 0 auto; + flex: 1; + padding-bottom: 68px; +} + +.connector-breadcrumb { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 30px 0 12px; + color: var(--connector-soft); + font-size: 14px; + line-height: 20px; +} + +.connector-breadcrumb a { + color: var(--connector-muted); + text-decoration: none; +} + +.connector-breadcrumb a:hover { + color: var(--connector-ink); +} + +.connector-detail-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 46px; + align-items: start; + padding: 34px 0 52px; + border-bottom: 1px solid var(--connector-line); +} + +.connector-detail-hero-copy { + min-width: 0; +} + +.connector-detail-hero h1 { + max-width: 860px; + margin: 12px 0 0; + color: var(--connector-ink); + font-size: 58px; + font-weight: 650; + line-height: 0.98; + letter-spacing: 0; +} + +.connector-detail-lede { + max-width: 760px; + margin: 24px 0 0; + color: var(--connector-muted); + font-size: 19px; + line-height: 1.55; +} + +.connector-detail-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 26px; +} + +.connector-detail-tags span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--connector-line); + border-radius: 999px; + background: var(--wash); + color: var(--connector-muted); + font-size: 12px; + font-weight: 650; + line-height: 18px; + text-transform: uppercase; +} + +.connector-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 32px; +} + +.connector-detail-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 16px; + border: 1px solid var(--connector-line); + border-radius: 8px; + background: var(--surface); + color: var(--connector-ink); + font-size: 14px; + font-weight: 650; + line-height: 20px; + text-decoration: none; +} + +.connector-detail-button:hover { + border-color: var(--connector-line-strong); +} + +.connector-detail-button-primary { + background: var(--connector-ink); + color: #fff; +} + +.connector-detail-summary { + display: grid; + gap: 24px; + padding: 18px; + border: 1px solid var(--connector-line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--shadow-ring); +} + +.connector-detail-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + border-radius: 8px; + background: var(--surface-muted); + color: var(--connector-ink); +} + +.connector-detail-icon { + width: 42px; + height: 42px; + color: currentColor; +} + +.connector-detail-summary dl { + display: grid; + gap: 16px; + margin: 0; +} + +.connector-detail-summary dl div { + display: grid; + gap: 4px; +} + +.connector-detail-summary dt { + color: var(--connector-soft); + font-size: 12px; + font-weight: 650; + line-height: 16px; + text-transform: uppercase; +} + +.connector-detail-summary dd { + margin: 0; + color: var(--connector-ink); + font-size: 17px; + line-height: 1.35; +} + +.connector-detail-summary code { + padding: 2px 5px; + border-radius: 6px; + background: var(--wash); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 0.9em; +} + +.connector-answer-section { + padding: 46px 0; + border-bottom: 1px solid var(--connector-line); +} + +.connector-answer-section h2, +.connector-detail-panel h2, +.connector-related-section h2 { + margin: 10px 0 0; + color: var(--connector-ink); + font-size: 31px; + font-weight: 650; + line-height: 1.14; + letter-spacing: 0; +} + +.connector-answer-section p:not(.eyebrow), +.connector-detail-panel p, +.connector-related-card span span { + color: var(--connector-muted); + font-size: 17px; + line-height: 1.58; +} + +.connector-answer-section p:not(.eyebrow) { + max-width: 880px; + margin: 18px 0 0; +} + +.connector-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 42px; + padding: 46px 0; +} + +.connector-detail-panel { + min-width: 0; + padding-top: 22px; + border-top: 1px solid var(--connector-line); +} + +.connector-detail-panel p { + margin: 18px 0 0; +} + +.connector-detail-list, +.connector-setup-list { + display: grid; + gap: 12px; + margin: 20px 0 0; + padding-left: 20px; + color: var(--connector-muted); + font-size: 16px; + line-height: 1.58; +} + +.connector-faq-section { + padding-bottom: 44px; +} + +.connector-faq-list { + display: grid; + gap: 0; + margin-top: 24px; +} + +.connector-faq-list section { + padding: 20px 0; + border-top: 1px solid var(--connector-line); +} + +.connector-faq-list h3 { + margin: 0; + color: var(--connector-ink); + font-size: 20px; + font-weight: 650; + line-height: 1.25; + letter-spacing: 0; +} + +.connector-faq-list p { + max-width: 900px; +} + +.connector-related-section { + display: grid; + gap: 22px; + padding-top: 44px; + border-top: 1px solid var(--connector-line); +} + +.connector-related-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.connector-related-card { + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + gap: 14px; + align-items: start; + min-height: 136px; + padding: 14px; + border: 1px solid var(--connector-line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.7); + color: inherit; + text-decoration: none; +} + +.connector-related-card:hover { + border-color: var(--connector-line-strong); +} + +.connector-related-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 8px; + background: var(--surface-muted); + color: var(--connector-ink); +} + +.connector-related-icon { + width: 24px; + height: 24px; +} + +.connector-related-card strong, +.connector-related-card span span { + display: block; +} + +.connector-related-card strong { + color: var(--connector-ink); + font-size: 17px; + line-height: 1.28; +} + +.connector-related-card span span { + display: -webkit-box; + margin-top: 7px; + overflow: hidden; + font-size: 14px; + line-clamp: 3; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + @media (max-width: 1100px) { .connectors-browser { grid-template-columns: 1fr; @@ -334,6 +663,15 @@ .connectors-search { grid-column: 1 / -1; } + + .connector-detail-hero { + grid-template-columns: 1fr; + } + + .connector-detail-summary { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + } } @media (max-width: 860px) { @@ -350,6 +688,15 @@ .connector-grid { grid-template-columns: repeat(2, minmax(260px, 1fr)); } + + .connector-detail-hero h1 { + font-size: 44px; + } + + .connector-detail-grid, + .connector-related-grid { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { @@ -371,6 +718,29 @@ min-height: 112px; border-radius: 22px; } + + .connector-detail-hero { + padding-top: 24px; + padding-bottom: 40px; + } + + .connector-detail-hero h1 { + font-size: 36px; + } + + .connector-detail-lede { + font-size: 17px; + } + + .connector-detail-summary { + grid-template-columns: 1fr; + } + + .connector-answer-section h2, + .connector-detail-panel h2, + .connector-related-section h2 { + font-size: 26px; + } } @media (prefers-reduced-motion: reduce) { From 90be11faf5a96446961cce3677533c11a1595584 Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 14:59:37 +0900 Subject: [PATCH 2/6] refactor(landing): reorganize app structure --- apps/landing/astro.config.ts | 5 +- apps/landing/src/actions/index.ts | 19 +-- apps/landing/src/content.config.ts | 2 +- .../blog/collection.ts} | 2 +- .../blog/components/Image.astro} | 0 .../blog/components/Index.astro} | 12 +- .../blog/components}/LinkedText.astro | 0 .../blog/components/Post.astro} | 4 +- .../blog/components/Thumbnail.astro} | 8 +- .../blog/components/image-presets.ts} | 0 .../blog/images.ts} | 7 +- .../blog/index-page.ts} | 11 +- .../blog/schema.ts} | 2 +- .../blog/share.ts} | 7 +- .../blog.css => features/blog/styles.css} | 0 .../blog/taxonomy.test.ts} | 2 +- .../blog/taxonomy.ts} | 0 .../blog-types.ts => features/blog/types.ts} | 2 +- .../connectors/Browser.astro} | 6 +- .../connectors/data.test.ts} | 2 +- .../connectors/data.ts} | 0 .../connectors/styles.css} | 0 .../components}/ControlPlaneDiagram.astro | 6 +- .../home/components}/DownloadCommand.astro | 4 +- .../home/components}/HeroProductSurface.astro | 4 +- .../home/components}/OpenClawSection.astro | 2 +- .../components}/ProductUpdatesSection.astro | 0 .../home/components}/RoadmapSection.astro | 4 +- .../home/components}/TerminalSurface.astro | 2 +- .../home.ts => features/home/content.ts} | 4 +- .../home/demo-video.ts} | 6 +- .../home/download/store.test.ts} | 4 +- .../home/download/store.ts} | 2 +- .../home/hero/store.test.ts} | 2 +- .../home/hero/store.ts} | 0 .../src/{data => features/home}/roadmap.ts | 0 .../landing.css => features/home/styles.css} | 0 .../home/terminal/types.ts} | 0 .../landing/src/landing/config/landing-api.ts | 1 - apps/landing/src/layouts/BaseLayout.astro | 14 +- apps/landing/src/layouts/BlogLayout.astro | 6 +- .../landing/src/layouts/MarketingLayout.astro | 8 +- .../src/layouts/{layout-types.ts => types.ts} | 2 +- apps/landing/src/pages/404.astro | 6 +- apps/landing/src/pages/api/contact.ts | 2 +- apps/landing/src/pages/api/product-updates.ts | 2 +- apps/landing/src/pages/blog/[postSlug].astro | 12 +- .../blog/category/[categorySlug]/index.astro | 12 +- apps/landing/src/pages/blog/index.astro | 6 +- apps/landing/src/pages/connectors.astro | 10 +- .../pages/connectors/[connectorSlug].astro | 12 +- apps/landing/src/pages/index.astro | 86 ++++++------ apps/landing/src/pages/install.sh.ts | 2 +- apps/landing/src/server/api-catalog.ts | 6 +- .../src/server/{landing-api.ts => api.ts} | 132 ++++++++---------- apps/landing/src/server/app.test.ts | 22 ++- ...ications.test.ts => notifications.test.ts} | 14 +- ...ding-notifications.ts => notifications.ts} | 46 +++--- .../server/{landing-schemas.ts => schemas.ts} | 0 .../analytics/events.ts} | 2 +- .../analytics/google-tag.ts} | 0 .../analytics/script.ts} | 2 +- .../components/Footer.astro} | 4 +- .../components}/FooterContactButton.astro | 0 .../components}/FooterContactButton.test.ts | 0 .../components}/GoogleTag.astro | 4 +- .../components/Header.astro} | 8 +- .../components}/TrackedLink.astro | 0 apps/landing/src/shared/config/api.ts | 1 + .../config/site.ts} | 2 +- .../icons/brands.test.tsx} | 2 +- .../icons/brands.tsx} | 0 .../src/{data => shared/navigation}/footer.ts | 2 +- .../navigation/main.ts} | 0 .../site-images.ts => shared/seo/images.ts} | 4 +- .../seo/schema.test.ts} | 15 +- .../seo/schema.ts} | 33 +++-- apps/landing/src/{ => shared}/styles/base.css | 0 .../transitions/use-text-swap-controller.ts | 0 .../use-transitioned-store-state.ts | 0 ...install-script.test.ts => install.test.ts} | 2 +- .../tooling/{install-script.ts => install.ts} | 0 apps/landing/tsconfig.json | 5 + apps/landing/vitest.config.ts | 7 + 84 files changed, 316 insertions(+), 309 deletions(-) rename apps/landing/src/{landing/blog/blog-collection.ts => features/blog/collection.ts} (99%) rename apps/landing/src/{components/blog/OptimizedBlogImage.astro => features/blog/components/Image.astro} (100%) rename apps/landing/src/{components/blog/BlogIndex.astro => features/blog/components/Index.astro} (82%) rename apps/landing/src/{components/blog => features/blog/components}/LinkedText.astro (100%) rename apps/landing/src/{components/blog/BlogPost.astro => features/blog/components/Post.astro} (95%) rename apps/landing/src/{components/blog/BlogThumbnail.astro => features/blog/components/Thumbnail.astro} (70%) rename apps/landing/src/{components/blog/blog-image-presets.ts => features/blog/components/image-presets.ts} (100%) rename apps/landing/src/{landing/blog/blog-images.ts => features/blog/images.ts} (90%) rename apps/landing/src/{landing/blog/blog-index-page.ts => features/blog/index-page.ts} (89%) rename apps/landing/src/{landing/blog/blog-content-schema.ts => features/blog/schema.ts} (91%) rename apps/landing/src/{landing/blog/blog-share-metadata.ts => features/blog/share.ts} (88%) rename apps/landing/src/{styles/blog.css => features/blog/styles.css} (100%) rename apps/landing/src/{landing/blog/blog-taxonomy.test.ts => features/blog/taxonomy.test.ts} (97%) rename apps/landing/src/{landing/blog/blog-taxonomy.ts => features/blog/taxonomy.ts} (100%) rename apps/landing/src/{landing/blog/blog-types.ts => features/blog/types.ts} (86%) rename apps/landing/src/{components/connectors/ConnectorBrowser.astro => features/connectors/Browser.astro} (96%) rename apps/landing/src/{data/connectors.test.ts => features/connectors/data.test.ts} (98%) rename apps/landing/src/{data/connectors.ts => features/connectors/data.ts} (100%) rename apps/landing/src/{styles/connectors.css => features/connectors/styles.css} (100%) rename apps/landing/src/{components/landing => features/home/components}/ControlPlaneDiagram.astro (96%) rename apps/landing/src/{components/landing => features/home/components}/DownloadCommand.astro (98%) rename apps/landing/src/{components/landing => features/home/components}/HeroProductSurface.astro (99%) rename apps/landing/src/{components/landing => features/home/components}/OpenClawSection.astro (99%) rename apps/landing/src/{components/landing => features/home/components}/ProductUpdatesSection.astro (100%) rename apps/landing/src/{components/landing => features/home/components}/RoadmapSection.astro (89%) rename apps/landing/src/{components/landing => features/home/components}/TerminalSurface.astro (92%) rename apps/landing/src/{data/home.ts => features/home/content.ts} (93%) rename apps/landing/src/{landing/openclaw-demo-video.ts => features/home/demo-video.ts} (77%) rename apps/landing/src/{landing/download-command/download-command.store.test.ts => features/home/download/store.test.ts} (96%) rename apps/landing/src/{landing/download-command/download-command.store.ts => features/home/download/store.ts} (99%) rename apps/landing/src/{landing/hero-product/hero-product.store.test.ts => features/home/hero/store.test.ts} (98%) rename apps/landing/src/{landing/hero-product/hero-product.store.ts => features/home/hero/store.ts} (100%) rename apps/landing/src/{data => features/home}/roadmap.ts (100%) rename apps/landing/src/{styles/landing.css => features/home/styles.css} (100%) rename apps/landing/src/{landing/terminal/terminal-types.ts => features/home/terminal/types.ts} (100%) delete mode 100644 apps/landing/src/landing/config/landing-api.ts rename apps/landing/src/layouts/{layout-types.ts => types.ts} (88%) rename apps/landing/src/server/{landing-api.ts => api.ts} (67%) rename apps/landing/src/server/{landing/landing-notifications.test.ts => notifications.test.ts} (90%) rename apps/landing/src/server/{landing/landing-notifications.ts => notifications.ts} (81%) rename apps/landing/src/server/{landing-schemas.ts => schemas.ts} (100%) rename apps/landing/src/{landing/analytics/landing-analytics.ts => shared/analytics/events.ts} (97%) rename apps/landing/src/{landing/analytics/google-tag-config.ts => shared/analytics/google-tag.ts} (100%) rename apps/landing/src/{landing/analytics/google-tag-script.ts => shared/analytics/script.ts} (95%) rename apps/landing/src/{components/landing/SiteFooter.astro => shared/components/Footer.astro} (88%) rename apps/landing/src/{components/landing => shared/components}/FooterContactButton.astro (100%) rename apps/landing/src/{components/landing => shared/components}/FooterContactButton.test.ts (100%) rename apps/landing/src/{components/landing => shared/components}/GoogleTag.astro (83%) rename apps/landing/src/{components/landing/SiteHeader.astro => shared/components/Header.astro} (89%) rename apps/landing/src/{components/landing => shared/components}/TrackedLink.astro (100%) create mode 100644 apps/landing/src/shared/config/api.ts rename apps/landing/src/{landing/config/landing-config.ts => shared/config/site.ts} (96%) rename apps/landing/src/{landing/content/brand-icons.test.tsx => shared/icons/brands.test.tsx} (88%) rename apps/landing/src/{landing/content/brand-icons.tsx => shared/icons/brands.tsx} (100%) rename apps/landing/src/{data => shared/navigation}/footer.ts (93%) rename apps/landing/src/{data/navigation.ts => shared/navigation/main.ts} (100%) rename apps/landing/src/{landing/seo/site-images.ts => shared/seo/images.ts} (86%) rename apps/landing/src/{landing/seo/structured-data.test.ts => shared/seo/schema.test.ts} (95%) rename apps/landing/src/{landing/seo/structured-data.ts => shared/seo/schema.ts} (96%) rename apps/landing/src/{ => shared}/styles/base.css (100%) rename apps/landing/src/{landing => shared}/transitions/use-text-swap-controller.ts (100%) rename apps/landing/src/{landing => shared}/transitions/use-transitioned-store-state.ts (100%) rename apps/landing/src/tooling/{install-script.test.ts => install.test.ts} (98%) rename apps/landing/src/tooling/{install-script.ts => install.ts} (100%) diff --git a/apps/landing/astro.config.ts b/apps/landing/astro.config.ts index 79974139..b9c97436 100644 --- a/apps/landing/astro.config.ts +++ b/apps/landing/astro.config.ts @@ -14,7 +14,7 @@ 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 REPOSITORY_ROOT = fileURLToPath(new URL("../../", import.meta.url)); @@ -174,6 +174,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/index.ts b/apps/landing/src/actions/index.ts index 6fcff8b9..ca6595fe 100644 --- a/apps/landing/src/actions/index.ts +++ b/apps/landing/src/actions/index.ts @@ -1,14 +1,15 @@ 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 { submitContactLead } from "@/server/api"; +import { NotificationConfigurationError } from "@/server/notifications"; +import type { NotificationError } from "@/server/notifications"; +import { ContactRequestSchema } from "@/server/schemas"; + import { SENT_CONTACT_ACTION_STATE } from "./contact-action-state"; import type { ContactActionState } from "./contact-action-state"; -function readLandingWorkerBindings() { +function readWorkerBindings() { return { LANDING_SLACK_WEBHOOK_URL: typeof env.LANDING_SLACK_WEBHOOK_URL === "string" @@ -17,8 +18,8 @@ function readLandingWorkerBindings() { }; } -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 +35,12 @@ export const server = { input: ContactRequestSchema, handler: async (input, { request }): Promise => { const result = await submitContactLead(input, { - bindings: readLandingWorkerBindings(), + bindings: readWorkerBindings(), request, }); if (result.isErr()) { - throw createLandingActionError(result.error); + throw createActionError(result.error); } return SENT_CONTACT_ACTION_STATE; diff --git a/apps/landing/src/content.config.ts b/apps/landing/src/content.config.ts index 7c373d30..773cc4e7 100644 --- a/apps/landing/src/content.config.ts +++ b/apps/landing/src/content.config.ts @@ -3,7 +3,7 @@ import { docsSchema } from "@astrojs/starlight/schema"; import { glob } from "astro/loaders"; import { defineCollection } from "astro:content"; -import { createBlogPostContentSchema } from "./landing/blog/blog-content-schema"; +import { createBlogPostContentSchema } from "@/features/blog/schema"; const blog = defineCollection({ loader: glob({ diff --git a/apps/landing/src/landing/blog/blog-collection.ts b/apps/landing/src/features/blog/collection.ts similarity index 99% rename from apps/landing/src/landing/blog/blog-collection.ts rename to apps/landing/src/features/blog/collection.ts index 13792192..55b4832f 100644 --- a/apps/landing/src/landing/blog/blog-collection.ts +++ b/apps/landing/src/features/blog/collection.ts @@ -1,7 +1,7 @@ import { getCollection } from "astro:content"; import type { CollectionEntry } from "astro:content"; -import type { BlogPost, BlogPostContent, BlogPostSummary } from "./blog-types"; +import type { BlogPost, BlogPostContent, BlogPostSummary } from "./types"; const blogDateFormatter = new Intl.DateTimeFormat("en-US", { day: "numeric", diff --git a/apps/landing/src/components/blog/OptimizedBlogImage.astro b/apps/landing/src/features/blog/components/Image.astro similarity index 100% rename from apps/landing/src/components/blog/OptimizedBlogImage.astro rename to apps/landing/src/features/blog/components/Image.astro diff --git a/apps/landing/src/components/blog/BlogIndex.astro b/apps/landing/src/features/blog/components/Index.astro similarity index 82% rename from apps/landing/src/components/blog/BlogIndex.astro rename to apps/landing/src/features/blog/components/Index.astro index 30ee465f..52fca47d 100644 --- a/apps/landing/src/components/blog/BlogIndex.astro +++ b/apps/landing/src/features/blog/components/Index.astro @@ -1,11 +1,11 @@ --- 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"; +} from "@/features/blog/taxonomy"; +import type { BlogCategoryFilter } from "@/features/blog/taxonomy"; +import type { BlogPostSummary } from "@/features/blog/types"; +import TrackedLink from "@/shared/components/TrackedLink.astro"; +import Thumbnail from "./Thumbnail.astro"; interface Props { activeCategory: BlogCategoryFilter; @@ -49,7 +49,7 @@ const { activeCategory, categories, posts } = Astro.props; posts.map((post, postIndex) => (
- +
{post.category} diff --git a/apps/landing/src/components/blog/LinkedText.astro b/apps/landing/src/features/blog/components/LinkedText.astro similarity index 100% rename from apps/landing/src/components/blog/LinkedText.astro rename to apps/landing/src/features/blog/components/LinkedText.astro diff --git a/apps/landing/src/components/blog/BlogPost.astro b/apps/landing/src/features/blog/components/Post.astro similarity index 95% rename from apps/landing/src/components/blog/BlogPost.astro rename to apps/landing/src/features/blog/components/Post.astro index 971b1c95..1be559c6 100644 --- a/apps/landing/src/components/blog/BlogPost.astro +++ b/apps/landing/src/features/blog/components/Post.astro @@ -1,8 +1,8 @@ --- import type { MarkdownHeading } from "astro"; import type { AstroComponentFactory } from "astro/runtime/server/index.js"; -import type { BlogPost, BlogPostSummary } from "../../landing/blog/blog-types"; -import TrackedLink from "../landing/TrackedLink.astro"; +import type { BlogPost, BlogPostSummary } from "@/features/blog/types"; +import TrackedLink from "@/shared/components/TrackedLink.astro"; interface Props { Content: AstroComponentFactory; diff --git a/apps/landing/src/components/blog/BlogThumbnail.astro b/apps/landing/src/features/blog/components/Thumbnail.astro similarity index 70% rename from apps/landing/src/components/blog/BlogThumbnail.astro rename to apps/landing/src/features/blog/components/Thumbnail.astro index 5ee46d2b..1261faa5 100644 --- a/apps/landing/src/components/blog/BlogThumbnail.astro +++ b/apps/landing/src/features/blog/components/Thumbnail.astro @@ -1,10 +1,10 @@ --- -import type { BlogPostSummary } from "../../landing/blog/blog-types"; +import type { BlogPostSummary } from "@/features/blog/types"; import { BLOG_THUMBNAIL_IMAGE_SIZES, BLOG_THUMBNAIL_IMAGE_WIDTHS, -} from "./blog-image-presets"; -import OptimizedBlogImage from "./OptimizedBlogImage.astro"; +} from "./image-presets"; +import Image from "./Image.astro"; interface Props { post: BlogPostSummary; @@ -15,7 +15,7 @@ const { post, priority = false } = Astro.props; ---
- { const posts = [ diff --git a/apps/landing/src/landing/blog/blog-taxonomy.ts b/apps/landing/src/features/blog/taxonomy.ts similarity index 100% rename from apps/landing/src/landing/blog/blog-taxonomy.ts rename to apps/landing/src/features/blog/taxonomy.ts diff --git a/apps/landing/src/landing/blog/blog-types.ts b/apps/landing/src/features/blog/types.ts similarity index 86% rename from apps/landing/src/landing/blog/blog-types.ts rename to apps/landing/src/features/blog/types.ts index 589585b7..cb3440b1 100644 --- a/apps/landing/src/landing/blog/blog-types.ts +++ b/apps/landing/src/features/blog/types.ts @@ -1,7 +1,7 @@ import type { MarkdownHeading } from "astro"; import type { z } from "astro/zod"; -import type { createBlogPostContentSchema } from "./blog-content-schema"; +import type { createBlogPostContentSchema } from "./schema"; export type BlogPostContent = z.infer< ReturnType diff --git a/apps/landing/src/components/connectors/ConnectorBrowser.astro b/apps/landing/src/features/connectors/Browser.astro similarity index 96% rename from apps/landing/src/components/connectors/ConnectorBrowser.astro rename to apps/landing/src/features/connectors/Browser.astro index 0051e71e..0fc7ba80 100644 --- a/apps/landing/src/components/connectors/ConnectorBrowser.astro +++ b/apps/landing/src/features/connectors/Browser.astro @@ -1,7 +1,7 @@ --- -import type { DataSourceConnector } from "../../data/connectors"; -import { getConnectorPath } from "../../data/connectors"; -import { BrandIcon, hasBrandIcon } from "../../landing/content/brand-icons"; +import type { DataSourceConnector } from "@/features/connectors/data"; +import { getConnectorPath } from "@/features/connectors/data"; +import { BrandIcon, hasBrandIcon } from "@/shared/icons/brands"; interface Props { connectors: readonly DataSourceConnector[]; diff --git a/apps/landing/src/data/connectors.test.ts b/apps/landing/src/features/connectors/data.test.ts similarity index 98% rename from apps/landing/src/data/connectors.test.ts rename to apps/landing/src/features/connectors/data.test.ts index d6f7d28d..f6292713 100644 --- a/apps/landing/src/data/connectors.test.ts +++ b/apps/landing/src/features/connectors/data.test.ts @@ -5,7 +5,7 @@ import { DATA_SOURCE_CONNECTORS, getConnectorFaqs, getConnectorPath, -} from "./connectors"; +} from "./data"; function getConnector(key: string) { const connector = DATA_SOURCE_CONNECTORS.find( diff --git a/apps/landing/src/data/connectors.ts b/apps/landing/src/features/connectors/data.ts similarity index 100% rename from apps/landing/src/data/connectors.ts rename to apps/landing/src/features/connectors/data.ts diff --git a/apps/landing/src/styles/connectors.css b/apps/landing/src/features/connectors/styles.css similarity index 100% rename from apps/landing/src/styles/connectors.css rename to apps/landing/src/features/connectors/styles.css diff --git a/apps/landing/src/components/landing/ControlPlaneDiagram.astro b/apps/landing/src/features/home/components/ControlPlaneDiagram.astro similarity index 96% rename from apps/landing/src/components/landing/ControlPlaneDiagram.astro rename to apps/landing/src/features/home/components/ControlPlaneDiagram.astro index 85149765..cfa63881 100644 --- a/apps/landing/src/components/landing/ControlPlaneDiagram.astro +++ b/apps/landing/src/features/home/components/ControlPlaneDiagram.astro @@ -1,7 +1,7 @@ --- -import OneQueryIcon from "../../assets/onequery-icon.svg"; -import { BrandIcon } from "../../landing/content/brand-icons"; -import type { BrandIconName } from "../../landing/content/brand-icons"; +import OneQueryIcon from "@/assets/onequery-icon.svg"; +import { BrandIcon } from "@/shared/icons/brands"; +import type { BrandIconName } from "@/shared/icons/brands"; type ControlPlaneInput = { key: string; diff --git a/apps/landing/src/components/landing/DownloadCommand.astro b/apps/landing/src/features/home/components/DownloadCommand.astro similarity index 98% rename from apps/landing/src/components/landing/DownloadCommand.astro rename to apps/landing/src/features/home/components/DownloadCommand.astro index 8abae9f3..c9a16679 100644 --- a/apps/landing/src/components/landing/DownloadCommand.astro +++ b/apps/landing/src/features/home/components/DownloadCommand.astro @@ -2,8 +2,8 @@ import { COPY_FEEDBACK_RESET_DELAY_MS, INSTALL_COMMANDS, -} from "../../landing/config/landing-config"; -import { BrandIcon } from "../../landing/content/brand-icons"; +} from "@/shared/config/site"; +import { BrandIcon } from "@/shared/icons/brands"; const defaultMethod = INSTALL_COMMANDS[0]; diff --git a/apps/landing/src/components/landing/HeroProductSurface.astro b/apps/landing/src/features/home/components/HeroProductSurface.astro similarity index 99% rename from apps/landing/src/components/landing/HeroProductSurface.astro rename to apps/landing/src/features/home/components/HeroProductSurface.astro index f424ae6a..cd4e1e85 100644 --- a/apps/landing/src/components/landing/HeroProductSurface.astro +++ b/apps/landing/src/features/home/components/HeroProductSurface.astro @@ -10,8 +10,8 @@ import { heroProductTabMeta, heroProductTabs, heroSafeQueryChecks, -} from "../../landing/hero-product/hero-product.store"; -import type { HeroProductTab } from "../../landing/hero-product/hero-product.store"; +} from "@/features/home/hero/store"; +import type { HeroProductTab } from "@/features/home/hero/store"; const initialTab: HeroProductTab = "integrations"; const initialTabMeta = heroProductTabMeta[initialTab]; diff --git a/apps/landing/src/components/landing/OpenClawSection.astro b/apps/landing/src/features/home/components/OpenClawSection.astro similarity index 99% rename from apps/landing/src/components/landing/OpenClawSection.astro rename to apps/landing/src/features/home/components/OpenClawSection.astro index 23c19613..11b6e534 100644 --- a/apps/landing/src/components/landing/OpenClawSection.astro +++ b/apps/landing/src/features/home/components/OpenClawSection.astro @@ -2,7 +2,7 @@ import { getOpenClawDemoPosterImage, OPEN_CLAW_DEMO_VIDEO, -} from "../../landing/openclaw-demo-video"; +} from "@/features/home/demo-video"; const openClawDemoPosterImage = await getOpenClawDemoPosterImage(); --- diff --git a/apps/landing/src/components/landing/ProductUpdatesSection.astro b/apps/landing/src/features/home/components/ProductUpdatesSection.astro similarity index 100% rename from apps/landing/src/components/landing/ProductUpdatesSection.astro rename to apps/landing/src/features/home/components/ProductUpdatesSection.astro diff --git a/apps/landing/src/components/landing/RoadmapSection.astro b/apps/landing/src/features/home/components/RoadmapSection.astro similarity index 89% rename from apps/landing/src/components/landing/RoadmapSection.astro rename to apps/landing/src/features/home/components/RoadmapSection.astro index ce908592..1443a748 100644 --- a/apps/landing/src/components/landing/RoadmapSection.astro +++ b/apps/landing/src/features/home/components/RoadmapSection.astro @@ -1,6 +1,6 @@ --- -import { SECTION_IDS } from "../../landing/config/landing-config"; -import { ROADMAP_LANES } from "../../data/roadmap"; +import { SECTION_IDS } from "@/shared/config/site"; +import { ROADMAP_LANES } from "@/features/home/roadmap"; ---
diff --git a/apps/landing/src/components/landing/TerminalSurface.astro b/apps/landing/src/features/home/components/TerminalSurface.astro similarity index 92% rename from apps/landing/src/components/landing/TerminalSurface.astro rename to apps/landing/src/features/home/components/TerminalSurface.astro index 3c14e457..b6124b4b 100644 --- a/apps/landing/src/components/landing/TerminalSurface.astro +++ b/apps/landing/src/features/home/components/TerminalSurface.astro @@ -1,5 +1,5 @@ --- -import type { TerminalLine } from "../../landing/terminal/terminal-types"; +import type { TerminalLine } from "@/features/home/terminal/types"; interface Props { footer: string; diff --git a/apps/landing/src/data/home.ts b/apps/landing/src/features/home/content.ts similarity index 93% rename from apps/landing/src/data/home.ts rename to apps/landing/src/features/home/content.ts index 37834a35..eaee2d35 100644 --- a/apps/landing/src/data/home.ts +++ b/apps/landing/src/features/home/content.ts @@ -1,5 +1,5 @@ -import { INSTALL_COMMANDS } from "../landing/config/landing-config"; -import type { TerminalLine } from "../landing/terminal/terminal-types"; +import type { TerminalLine } from "@/features/home/terminal/types"; +import { INSTALL_COMMANDS } from "@/shared/config/site"; const agentToolCommands = [ "onequery api --source github://demo-prod acme/web/pulls --paginate --max-pages 2 --jq '.[] | {number,title,user,head,base}' --json", diff --git a/apps/landing/src/landing/openclaw-demo-video.ts b/apps/landing/src/features/home/demo-video.ts similarity index 77% rename from apps/landing/src/landing/openclaw-demo-video.ts rename to apps/landing/src/features/home/demo-video.ts index e08bdb2d..2848a6c1 100644 --- a/apps/landing/src/landing/openclaw-demo-video.ts +++ b/apps/landing/src/features/home/demo-video.ts @@ -1,8 +1,8 @@ import { getImage } from "astro:assets"; -import openClawDemoPoster from "../assets/openclaw-demo-poster.png"; -import openClawDemoVideoMp4Src from "../assets/openclaw-demo-video.mp4?url"; -import openClawDemoVideoWebmSrc from "../assets/openclaw-demo-video.webm?url"; +import openClawDemoPoster from "@/assets/openclaw-demo-poster.png"; +import openClawDemoVideoMp4Src from "@/assets/openclaw-demo-video.mp4?url"; +import openClawDemoVideoWebmSrc from "@/assets/openclaw-demo-video.webm?url"; export const OPEN_CLAW_DEMO_VIDEO = { description: diff --git a/apps/landing/src/landing/download-command/download-command.store.test.ts b/apps/landing/src/features/home/download/store.test.ts similarity index 96% rename from apps/landing/src/landing/download-command/download-command.store.test.ts rename to apps/landing/src/features/home/download/store.test.ts index a2eafe83..ed032d09 100644 --- a/apps/landing/src/landing/download-command/download-command.store.test.ts +++ b/apps/landing/src/features/home/download/store.test.ts @@ -4,8 +4,8 @@ import { createDownloadCommandStore, getInstallMethod, readSelectedInstallMethod, -} from "./download-command.store"; -import type { DownloadCommandCopyInput } from "./download-command.store"; +} from "./store"; +import type { DownloadCommandCopyInput } from "./store"; const COPY_FEEDBACK_RESET_DELAY_MS = 100; diff --git a/apps/landing/src/landing/download-command/download-command.store.ts b/apps/landing/src/features/home/download/store.ts similarity index 99% rename from apps/landing/src/landing/download-command/download-command.store.ts rename to apps/landing/src/features/home/download/store.ts index 306b6db3..a62085ce 100644 --- a/apps/landing/src/landing/download-command/download-command.store.ts +++ b/apps/landing/src/features/home/download/store.ts @@ -3,7 +3,7 @@ import { atom } from "nanostores"; import { COPY_FEEDBACK_RESET_DELAY_MS, INSTALL_COMMANDS, -} from "../config/landing-config"; +} from "@/shared/config/site"; export type InstallMethod = (typeof INSTALL_COMMANDS)[number]; export type InstallMethodLabel = InstallMethod["label"]; diff --git a/apps/landing/src/landing/hero-product/hero-product.store.test.ts b/apps/landing/src/features/home/hero/store.test.ts similarity index 98% rename from apps/landing/src/landing/hero-product/hero-product.store.test.ts rename to apps/landing/src/features/home/hero/store.test.ts index 500914e0..186a5421 100644 --- a/apps/landing/src/landing/hero-product/hero-product.store.test.ts +++ b/apps/landing/src/features/home/hero/store.test.ts @@ -8,7 +8,7 @@ import { createHeroProductStore, readActiveHeroProductTab, readSafeQueryAnimationState, -} from "./hero-product.store"; +} from "./store"; async function advanceTimersByTime(ms: number) { vi.advanceTimersByTime(ms); diff --git a/apps/landing/src/landing/hero-product/hero-product.store.ts b/apps/landing/src/features/home/hero/store.ts similarity index 100% rename from apps/landing/src/landing/hero-product/hero-product.store.ts rename to apps/landing/src/features/home/hero/store.ts diff --git a/apps/landing/src/data/roadmap.ts b/apps/landing/src/features/home/roadmap.ts similarity index 100% rename from apps/landing/src/data/roadmap.ts rename to apps/landing/src/features/home/roadmap.ts diff --git a/apps/landing/src/styles/landing.css b/apps/landing/src/features/home/styles.css similarity index 100% rename from apps/landing/src/styles/landing.css rename to apps/landing/src/features/home/styles.css diff --git a/apps/landing/src/landing/terminal/terminal-types.ts b/apps/landing/src/features/home/terminal/types.ts similarity index 100% rename from apps/landing/src/landing/terminal/terminal-types.ts rename to apps/landing/src/features/home/terminal/types.ts diff --git a/apps/landing/src/landing/config/landing-api.ts b/apps/landing/src/landing/config/landing-api.ts deleted file mode 100644 index 404a08ee..00000000 --- a/apps/landing/src/landing/config/landing-api.ts +++ /dev/null @@ -1 +0,0 @@ -export const LANDING_API_PREFIX = "/api" as const; diff --git a/apps/landing/src/layouts/BaseLayout.astro b/apps/landing/src/layouts/BaseLayout.astro index 574cdc60..4b881782 100644 --- a/apps/landing/src/layouts/BaseLayout.astro +++ b/apps/landing/src/layouts/BaseLayout.astro @@ -1,19 +1,19 @@ --- -import "../styles/base.css"; +import "@/shared/styles/base.css"; import { Font } from "astro:assets"; -import GoogleTag from "../components/landing/GoogleTag.astro"; +import GoogleTag from "@/shared/components/GoogleTag.astro"; import { createCanonicalUrl, - createLandingPageStructuredData, + createHomePageStructuredData, normalizeSiteUrl, ONEQUERY_DEFAULT_DESCRIPTION, -} from "../landing/seo/structured-data"; +} from "@/shared/seo/schema"; import { getOneQueryPublicShareImageMetadata, getOneQueryStructuredShareImageMetadata, ONEQUERY_DEFAULT_SHARE_IMAGE_ALT, -} from "../landing/seo/site-images"; -import type { PageMetadata } from "./layout-types"; +} from "@/shared/seo/images"; +import type { PageMetadata } from "./types"; const DEFAULT_TITLE = "OneQuery | Governed Data Access for AI Agents"; const DEFAULT_DESCRIPTION = ONEQUERY_DEFAULT_DESCRIPTION; @@ -26,7 +26,7 @@ const defaultShareImage = getOneQueryPublicShareImageMetadata(siteUrl); const defaultStructuredShareImage = getOneQueryStructuredShareImageMetadata(siteUrl); const DEFAULT_IMAGE_URL = defaultShareImage.url; -const DEFAULT_STRUCTURED_DATA = createLandingPageStructuredData({ +const DEFAULT_STRUCTURED_DATA = createHomePageStructuredData({ description: DEFAULT_DESCRIPTION, imageAlt: DEFAULT_IMAGE_ALT, imageHeight: defaultStructuredShareImage.height, 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/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"; --- ; diff --git a/apps/landing/src/pages/blog/category/[categorySlug]/index.astro b/apps/landing/src/pages/blog/category/[categorySlug]/index.astro index 243a7912..465cca96 100644 --- a/apps/landing/src/pages/blog/category/[categorySlug]/index.astro +++ b/apps/landing/src/pages/blog/category/[categorySlug]/index.astro @@ -1,13 +1,13 @@ --- -import BlogIndex from "../../../../components/blog/BlogIndex.astro"; -import BlogLayout from "../../../../layouts/BlogLayout.astro"; -import { getBlogPostSummaries } from "../../../../landing/blog/blog-collection"; -import { getBlogIndexPage } from "../../../../landing/blog/blog-index-page"; +import BlogIndex from "@/features/blog/components/Index.astro"; +import BlogLayout from "@/layouts/BlogLayout.astro"; +import { getBlogPostSummaries } from "@/features/blog/collection"; +import { getBlogIndexPage } from "@/features/blog/index-page"; import { getBlogCategorySlug, getPopulatedBlogPostCategories, -} from "../../../../landing/blog/blog-taxonomy"; -import type { BlogPostCategory } from "../../../../landing/blog/blog-taxonomy"; +} from "@/features/blog/taxonomy"; +import type { BlogPostCategory } from "@/features/blog/taxonomy"; interface Props { category: BlogPostCategory; diff --git a/apps/landing/src/pages/blog/index.astro b/apps/landing/src/pages/blog/index.astro index 160429b0..d6d0fefa 100644 --- a/apps/landing/src/pages/blog/index.astro +++ b/apps/landing/src/pages/blog/index.astro @@ -1,7 +1,7 @@ --- -import BlogIndex from "../../components/blog/BlogIndex.astro"; -import BlogLayout from "../../layouts/BlogLayout.astro"; -import { getBlogIndexPage } from "../../landing/blog/blog-index-page"; +import BlogIndex from "@/features/blog/components/Index.astro"; +import BlogLayout from "@/layouts/BlogLayout.astro"; +import { getBlogIndexPage } from "@/features/blog/index-page"; const page = await getBlogIndexPage({ category: "All", diff --git a/apps/landing/src/pages/connectors.astro b/apps/landing/src/pages/connectors.astro index 07d0a7f2..7f9c9967 100644 --- a/apps/landing/src/pages/connectors.astro +++ b/apps/landing/src/pages/connectors.astro @@ -1,12 +1,12 @@ --- -import ConnectorBrowser from "../components/connectors/ConnectorBrowser.astro"; -import { DATA_SOURCE_CONNECTORS } from "../data/connectors"; -import MarketingLayout from "../layouts/MarketingLayout.astro"; +import ConnectorBrowser from "@/features/connectors/Browser.astro"; +import { DATA_SOURCE_CONNECTORS } from "@/features/connectors/data"; +import MarketingLayout from "@/layouts/MarketingLayout.astro"; import { createCanonicalUrl, createConnectorIndexStructuredData, -} from "../landing/seo/structured-data"; -import "../styles/connectors.css"; +} from "@/shared/seo/schema"; +import "@/features/connectors/styles.css"; const title = "OneQuery Connectors | Supported Data Sources"; const description = diff --git a/apps/landing/src/pages/connectors/[connectorSlug].astro b/apps/landing/src/pages/connectors/[connectorSlug].astro index c5928267..d630fcc7 100644 --- a/apps/landing/src/pages/connectors/[connectorSlug].astro +++ b/apps/landing/src/pages/connectors/[connectorSlug].astro @@ -1,5 +1,5 @@ --- -import type { DataSourceConnector } from "../../data/connectors"; +import type { DataSourceConnector } from "@/features/connectors/data"; import { DATA_SOURCE_CONNECTORS, getConnectorFaqs, @@ -9,14 +9,14 @@ import { getConnectorMetaDescription, getConnectorPath, getRelatedConnectors, -} from "../../data/connectors"; -import MarketingLayout from "../../layouts/MarketingLayout.astro"; -import { BrandIcon, hasBrandIcon } from "../../landing/content/brand-icons"; +} from "@/features/connectors/data"; +import MarketingLayout from "@/layouts/MarketingLayout.astro"; +import { BrandIcon, hasBrandIcon } from "@/shared/icons/brands"; import { createCanonicalUrl, createConnectorPageStructuredData, -} from "../../landing/seo/structured-data"; -import "../../styles/connectors.css"; +} from "@/shared/seo/schema"; +import "@/features/connectors/styles.css"; export function getStaticPaths() { return DATA_SOURCE_CONNECTORS.map((connector) => ({ diff --git a/apps/landing/src/pages/index.astro b/apps/landing/src/pages/index.astro index f7e600b6..8b40f55a 100644 --- a/apps/landing/src/pages/index.astro +++ b/apps/landing/src/pages/index.astro @@ -1,76 +1,76 @@ --- -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"; +} from "@/shared/seo/schema"; import { getOneQueryPublicShareImageMetadata, getOneQueryStructuredShareImageMetadata, ONEQUERY_DEFAULT_SHARE_IMAGE_ALT, -} from "../landing/seo/site-images"; +} 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 | Governed Data Access for AI Agents"; +const imageAlt = ONEQUERY_DEFAULT_SHARE_IMAGE_ALT; const siteUrl = normalizeSiteUrl(Astro.site); -const landingCanonicalUrl = createCanonicalUrl("/", siteUrl); -const landingShareImage = getOneQueryPublicShareImageMetadata(siteUrl); -const landingStructuredShareImage = +const canonicalUrl = createCanonicalUrl("/", siteUrl); +const shareImage = getOneQueryPublicShareImageMetadata(siteUrl); +const structuredShareImage = getOneQueryStructuredShareImageMetadata(siteUrl); -const landingImageUrl = landingShareImage.url; +const imageUrl = shareImage.url; const openClawDemoPosterImage = await getOpenClawDemoPosterImage(); -const landingStructuredData = createLandingPageStructuredData({ +const structuredData = createHomePageStructuredData({ description: ONEQUERY_DEFAULT_DESCRIPTION, - imageAlt: landingImageAlt, - imageHeight: landingStructuredShareImage.height, - imageUrl: landingStructuredShareImage.url, - imageWidth: landingStructuredShareImage.width, + imageAlt, + imageHeight: structuredShareImage.height, + imageUrl: structuredShareImage.url, + imageWidth: structuredShareImage.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`, + pageUrl: `${canonicalUrl}#demo`, thumbnailHeight: OPEN_CLAW_DEMO_VIDEO.posterHeight, thumbnailUrl: openClawDemoPosterImage.src, thumbnailWidth: 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 +93,7 @@ const landingMetaTags = [ }, ]; -type LandingCtaAction = { +type CtaAction = { className: string; href: string; label: string; @@ -103,7 +103,7 @@ type LandingCtaAction = { trackingSection: string; }; -const heroActions: ReadonlyArray = [ +const heroActions: ReadonlyArray = [ { className: "button button-primary", href: `#${SECTION_IDS.install}`, @@ -120,7 +120,7 @@ const heroActions: ReadonlyArray = [ }, ] as const; -const finalCtaActions: ReadonlyArray = [ +const finalCtaActions: ReadonlyArray = [ { className: "button button-primary", href: NPM_PACKAGE_URL, @@ -143,16 +143,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..6aabe1aa 100644 --- a/apps/landing/src/server/api-catalog.ts +++ b/apps/landing/src/server/api-catalog.ts @@ -1,4 +1,4 @@ -import { LANDING_API_PREFIX } from "../landing/config/landing-api"; +import { API_PREFIX } from "@/shared/config/api"; const API_CATALOG_PATH = "/.well-known/api-catalog"; const API_CATALOG_CONTENT_TYPE = @@ -20,8 +20,8 @@ export const AGENT_DISCOVERY_LINK_HEADER = [ 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: [ diff --git a/apps/landing/src/server/landing-api.ts b/apps/landing/src/server/api.ts similarity index 67% rename from apps/landing/src/server/landing-api.ts rename to apps/landing/src/server/api.ts index 2c168ffd..c1572a06 100644 --- a/apps/landing/src/server/landing-api.ts +++ b/apps/landing/src/server/api.ts @@ -1,62 +1,52 @@ 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 ContactResponse = Record; -export type LandingProductUpdatesInput = z.infer< - typeof ProductUpdatesRequestSchema ->; +export type ProductUpdatesInput = z.infer; -export type LandingContactInput = z.infer; +export type ContactInput = z.infer; -export type LandingApiErrorResponse = - | LandingInternalErrorResponse - | LandingServiceUnavailableErrorResponse - | LandingValidationErrorResponse; +export type ApiErrorResponse = + | InternalErrorResponse + | ServiceUnavailableErrorResponse + | ValidationErrorResponse; -export interface LandingWorkerBindings { +export interface WorkerBindings { // 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; } -type LandingRequestContext = { - bindings: LandingWorkerBindings; +type RequestContext = { + bindings: WorkerBindings; request: Request; }; @@ -68,10 +58,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 +81,26 @@ function resolveLandingNotificationDelivery(input: { }; } -function resolveLandingNotificationDeliveryFromRequest({ +function resolveNotificationDeliveryFromRequest({ bindings, request, -}: LandingRequestContext) { - return resolveLandingNotificationDelivery({ +}: RequestContext) { + return resolveNotificationDelivery({ hostname: new URL(request.url).hostname, slackWebhookUrl: bindings.LANDING_SLACK_WEBHOOK_URL, }); } -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 +108,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 +147,7 @@ function createJsonResponse( } function createValidationErrorResponse( - body: LandingValidationErrorResponse, + body: ValidationErrorResponse, requestId: string ) { return createJsonResponse(body, { @@ -167,7 +157,7 @@ function createValidationErrorResponse( } function createInternalErrorResponse(requestId: string) { - return createJsonResponse( + return createJsonResponse( { code: "internal_error", message: "Internal server error", @@ -180,14 +170,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 +189,7 @@ function createServiceUnavailableResponse( ); } -function createBodyValidationError( - message: string -): LandingValidationErrorResponse { +function createBodyValidationError(message: string): ValidationErrorResponse { return { code: "validation_error", fieldErrors: { @@ -243,7 +231,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 +239,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 +247,7 @@ async function readValidatedRequestBody( function isValidationErrorResponse( input: unknown -): input is LandingValidationErrorResponse { +): input is ValidationErrorResponse { return ( typeof input === "object" && input !== null && @@ -273,12 +261,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 +274,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,13 +297,13 @@ export async function submitContactLead( return Result.err(result.error); } - return Result.ok({}); + return Result.ok({}); } export async function handleProductUpdatesRequest({ bindings, request, -}: LandingRequestContext) { +}: RequestContext) { const requestId = createRequestId(); try { @@ -335,7 +323,7 @@ export async function handleProductUpdatesRequest({ return createServiceUnavailableResponse(result.error, requestId); } - return createJsonResponse(result.value, { + return createJsonResponse(result.value, { requestId, status: 200, }); @@ -356,7 +344,7 @@ export async function handleProductUpdatesRequest({ export async function handleContactRequest({ bindings, request, -}: LandingRequestContext) { +}: RequestContext) { const requestId = createRequestId(); try { @@ -373,7 +361,7 @@ export async function handleContactRequest({ return createServiceUnavailableResponse(result.error, requestId); } - return createJsonResponse(result.value, { + return createJsonResponse(result.value, { requestId, status: 200, }); diff --git a/apps/landing/src/server/app.test.ts b/apps/landing/src/server/app.test.ts index 334d8007..21faf218 100644 --- a/apps/landing/src/server/app.test.ts +++ b/apps/landing/src/server/app.test.ts @@ -1,18 +1,15 @@ 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, + WorkerBindings, +} from "./api"; import { createContactNotification, createProductUpdatesNotification, -} from "./landing/landing-notifications"; +} from "./notifications"; const originalFetch = globalThis.fetch; @@ -34,7 +31,7 @@ describe("landing API handlers", () => { const response = await handleProductUpdatesRequest({ bindings: { LANDING_SLACK_WEBHOOK_URL: "https://example.com/hooks/landing", - } satisfies LandingWorkerBindings, + } satisfies WorkerBindings, request: new Request("https://landing.onequery.dev/api/product-updates", { body: JSON.stringify({ email: "team@example.com" }), headers: { "content-type": "application/json" }, @@ -160,7 +157,7 @@ describe("landing API handlers", () => { }); expect(response.status).toBe(400); - const body = (await response.json()) as LandingValidationErrorResponse; + const body = (await response.json()) as ValidationErrorResponse; expect(body).toEqual({ code: "validation_error", fieldErrors: { @@ -186,8 +183,7 @@ describe("landing API handlers", () => { }); expect(response.status).toBe(503); - const body = - (await response.json()) as LandingServiceUnavailableErrorResponse; + const body = (await response.json()) as ServiceUnavailableErrorResponse; 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 97% rename from apps/landing/src/landing/analytics/landing-analytics.ts rename to apps/landing/src/shared/analytics/events.ts index da6fe26a..c903c73d 100644 --- a/apps/landing/src/landing/analytics/landing-analytics.ts +++ b/apps/landing/src/shared/analytics/events.ts @@ -36,7 +36,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/GoogleTag.astro b/apps/landing/src/shared/components/GoogleTag.astro similarity index 83% rename from apps/landing/src/components/landing/GoogleTag.astro rename to apps/landing/src/shared/components/GoogleTag.astro index a692e47b..3c8de1d7 100644 --- a/apps/landing/src/components/landing/GoogleTag.astro +++ b/apps/landing/src/shared/components/GoogleTag.astro @@ -1,11 +1,11 @@ --- -import { googleTagConfig } from "../../landing/analytics/google-tag-config"; +import { googleTagConfig } from "@/shared/analytics/google-tag"; import { createGoogleTagAfterSwapScript, createGoogleTagBootstrapScript, createGoogleTagEventBridgeScript, createGoogleTagScriptSrc, -} from "../../landing/analytics/google-tag-script"; +} from "@/shared/analytics/script"; const scriptSrc = createGoogleTagScriptSrc(googleTagConfig); const eventBridgeScript = createGoogleTagEventBridgeScript(); diff --git a/apps/landing/src/components/landing/SiteHeader.astro b/apps/landing/src/shared/components/Header.astro similarity index 89% rename from apps/landing/src/components/landing/SiteHeader.astro rename to apps/landing/src/shared/components/Header.astro index 9c6a07eb..574a5be5 100644 --- a/apps/landing/src/components/landing/SiteHeader.astro +++ b/apps/landing/src/shared/components/Header.astro @@ -1,8 +1,8 @@ --- -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 { NAVIGATION_ITEMS } from "@/shared/navigation/main"; +import { REPOSITORY_URL } from "@/shared/config/site"; +import { BrandIcon } from "@/shared/icons/brands"; import TrackedLink from "./TrackedLink.astro"; type NavigationItem = (typeof NAVIGATION_ITEMS)[number]; 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/shared/config/api.ts b/apps/landing/src/shared/config/api.ts new file mode 100644 index 00000000..c31d5fb2 --- /dev/null +++ b/apps/landing/src/shared/config/api.ts @@ -0,0 +1 @@ +export const API_PREFIX = "/api" as const; 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/data/navigation.ts b/apps/landing/src/shared/navigation/main.ts similarity index 100% rename from apps/landing/src/data/navigation.ts rename to apps/landing/src/shared/navigation/main.ts diff --git a/apps/landing/src/landing/seo/site-images.ts b/apps/landing/src/shared/seo/images.ts similarity index 86% rename from apps/landing/src/landing/seo/site-images.ts rename to apps/landing/src/shared/seo/images.ts index 79677e67..18577555 100644 --- a/apps/landing/src/landing/seo/site-images.ts +++ b/apps/landing/src/shared/seo/images.ts @@ -1,5 +1,5 @@ -import { toAbsoluteSiteUrl } from "./structured-data"; -import type { StructuredImageMetadata } from "./structured-data"; +import { toAbsoluteSiteUrl } from "./schema"; +import type { StructuredImageMetadata } from "./schema"; type SiteInput = string | URL | null | undefined; type TypedStructuredImageMetadata = StructuredImageMetadata & { diff --git a/apps/landing/src/landing/seo/structured-data.test.ts b/apps/landing/src/shared/seo/schema.test.ts similarity index 95% rename from apps/landing/src/landing/seo/structured-data.test.ts rename to apps/landing/src/shared/seo/schema.test.ts index d10d2418..55c6a869 100644 --- a/apps/landing/src/landing/seo/structured-data.test.ts +++ b/apps/landing/src/shared/seo/schema.test.ts @@ -1,20 +1,21 @@ 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 "../../data/connectors"; -import type { BlogPost } from "../blog/blog-types"; -import { NPM_PACKAGE_URL } from "../config/landing-config"; +} from "@/features/connectors/data"; +import { NPM_PACKAGE_URL } from "@/shared/config/site"; + import { createBlogPostStructuredData, createCanonicalUrl, createConnectorIndexStructuredData, createConnectorPageStructuredData, - createLandingPageStructuredData, -} from "./structured-data"; + createHomePageStructuredData, +} from "./schema"; function getConnector(key: string) { const connector = DATA_SOURCE_CONNECTORS.find( @@ -37,9 +38,9 @@ describe("createCanonicalUrl", () => { }); }); -describe("createLandingPageStructuredData", () => { +describe("createHomePageStructuredData", () => { it("emits landing-page schema with npm as the install target", () => { - const schema = createLandingPageStructuredData({ + const schema = createHomePageStructuredData({ description: "Landing description", imageAlt: "OneQuery share image", imageUrl: "/og.png", diff --git a/apps/landing/src/landing/seo/structured-data.ts b/apps/landing/src/shared/seo/schema.ts similarity index 96% rename from apps/landing/src/landing/seo/structured-data.ts rename to apps/landing/src/shared/seo/schema.ts index ec7d8580..bc2858a6 100644 --- a/apps/landing/src/landing/seo/structured-data.ts +++ b/apps/landing/src/shared/seo/schema.ts @@ -1,14 +1,17 @@ -import type { ConnectorFaq, DataSourceConnector } from "../../data/connectors"; +import type { BlogPost, BlogPostSummary } from "@/features/blog/types"; +import type { + ConnectorFaq, + DataSourceConnector, +} from "@/features/connectors/data"; import { getConnectorInterfaceDescription, getConnectorPath, -} from "../../data/connectors"; -import type { BlogPost, BlogPostSummary } from "../blog/blog-types"; +} from "@/features/connectors/data"; import { NPM_PACKAGE_URL, REPOSITORY_URL, SELF_HOST_DOCS_URL, -} from "../config/landing-config"; +} from "@/shared/config/site"; export type StructuredData = Record; export type StructuredImageMetadata = { @@ -37,7 +40,7 @@ const CORE_TOPICS = [ type SiteInput = string | URL | null | undefined; -type LandingPageStructuredDataInput = { +type HomePageStructuredDataInput = { description: string; imageAlt: string; imageHeight?: number; @@ -45,10 +48,10 @@ type LandingPageStructuredDataInput = { imageWidth?: number; site?: SiteInput; title: string; - video?: LandingVideoStructuredDataInput; + video?: DemoVideoStructuredDataInput; }; -type LandingVideoStructuredDataInput = { +type DemoVideoStructuredDataInput = { contentUrl: string; description: string; duration?: string; @@ -162,7 +165,7 @@ function getSoftwareApplicationId(site: SiteInput) { return `${normalizeSiteUrl(site)}/#software`; } -function getLandingDemoVideoId(site: SiteInput) { +function getDemoVideoId(site: SiteInput) { return `${normalizeSiteUrl(site)}/#demo-video`; } @@ -255,8 +258,8 @@ function createOneQuerySoftwareApplication(input: { }; } -function createLandingDemoVideoStructuredData( - input: LandingVideoStructuredDataInput & { site?: SiteInput } +function createDemoVideoStructuredData( + input: DemoVideoStructuredDataInput & { site?: SiteInput } ): StructuredData { const siteUrl = normalizeSiteUrl(input.site); const thumbnailUrl = toAbsoluteSiteUrl(input.thumbnailUrl, siteUrl); @@ -264,7 +267,7 @@ function createLandingDemoVideoStructuredData( return { "@type": "VideoObject", - "@id": getLandingDemoVideoId(siteUrl), + "@id": getDemoVideoId(siteUrl), name: input.name, description: input.description, thumbnailUrl: [thumbnailUrl], @@ -336,13 +339,13 @@ function createConnectorSoftwareApplication( }; } -export function createLandingPageStructuredData( - input: LandingPageStructuredDataInput +export function createHomePageStructuredData( + input: HomePageStructuredDataInput ): StructuredData { const siteUrl = normalizeSiteUrl(input.site); const videoReference = input.video ? { - "@id": getLandingDemoVideoId(siteUrl), + "@id": getDemoVideoId(siteUrl), } : undefined; @@ -354,7 +357,7 @@ export function createLandingPageStructuredData( }), ...(input.video ? [ - createLandingDemoVideoStructuredData({ + createDemoVideoStructuredData({ ...input.video, site: siteUrl, }), 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/landing/transitions/use-text-swap-controller.ts b/apps/landing/src/shared/transitions/use-text-swap-controller.ts similarity index 100% rename from apps/landing/src/landing/transitions/use-text-swap-controller.ts rename to apps/landing/src/shared/transitions/use-text-swap-controller.ts diff --git a/apps/landing/src/landing/transitions/use-transitioned-store-state.ts b/apps/landing/src/shared/transitions/use-transitioned-store-state.ts similarity index 100% rename from apps/landing/src/landing/transitions/use-transitioned-store-state.ts rename to apps/landing/src/shared/transitions/use-transitioned-store-state.ts 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", From f59249eb1c31e68e5467b4db966fb288ffc3c90f Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 15:15:12 +0900 Subject: [PATCH 3/6] refactor(landing): remove redundant wrappers --- .../src/actions/contact-action-state.ts | 11 -- apps/landing/src/actions/index.ts | 18 +-- .../src/features/blog/components/Image.astro | 41 ------ .../src/features/blog/components/Index.astro | 124 ++++++++++-------- .../features/blog/components/LinkedText.astro | 24 ---- .../features/blog/components/Thumbnail.astro | 26 ---- .../features/blog/components/image-presets.ts | 7 - apps/landing/src/index.test.ts | 10 +- apps/landing/src/layouts/BaseLayout.astro | 23 +++- .../src/pages/.well-known/api-catalog.ts | 2 +- apps/landing/src/pages/api/contact.ts | 9 +- apps/landing/src/pages/api/product-updates.ts | 9 +- apps/landing/src/server/api-catalog.ts | 5 +- apps/landing/src/server/api.ts | 80 +++++------ apps/landing/src/server/bindings.ts | 12 ++ .../src/shared/components/GoogleTag.astro | 19 --- .../src/shared/components/Header.astro | 15 ++- apps/landing/src/shared/config/api.ts | 1 - apps/landing/src/shared/navigation/main.ts | 13 -- .../transitions/use-text-swap-controller.ts | 89 ------------- .../use-transitioned-store-state.ts | 25 ---- 21 files changed, 166 insertions(+), 397 deletions(-) delete mode 100644 apps/landing/src/actions/contact-action-state.ts delete mode 100644 apps/landing/src/features/blog/components/Image.astro delete mode 100644 apps/landing/src/features/blog/components/LinkedText.astro delete mode 100644 apps/landing/src/features/blog/components/Thumbnail.astro delete mode 100644 apps/landing/src/features/blog/components/image-presets.ts create mode 100644 apps/landing/src/server/bindings.ts delete mode 100644 apps/landing/src/shared/components/GoogleTag.astro delete mode 100644 apps/landing/src/shared/config/api.ts delete mode 100644 apps/landing/src/shared/navigation/main.ts delete mode 100644 apps/landing/src/shared/transitions/use-text-swap-controller.ts delete mode 100644 apps/landing/src/shared/transitions/use-transitioned-store-state.ts 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 ca6595fe..203ca359 100644 --- a/apps/landing/src/actions/index.ts +++ b/apps/landing/src/actions/index.ts @@ -1,22 +1,18 @@ import { ActionError, defineAction } from "astro:actions"; -import { env } from "cloudflare:workers"; import { submitContactLead } from "@/server/api"; +import { readWorkerBindings } from "@/server/bindings"; import { NotificationConfigurationError } from "@/server/notifications"; import type { NotificationError } from "@/server/notifications"; import { ContactRequestSchema } from "@/server/schemas"; -import { SENT_CONTACT_ACTION_STATE } from "./contact-action-state"; -import type { ContactActionState } from "./contact-action-state"; +type ContactActionState = { + status: "sent"; +}; -function readWorkerBindings() { - return { - LANDING_SLACK_WEBHOOK_URL: - typeof env.LANDING_SLACK_WEBHOOK_URL === "string" - ? env.LANDING_SLACK_WEBHOOK_URL - : undefined, - }; -} +const SENT_CONTACT_ACTION_STATE = { + status: "sent", +} satisfies ContactActionState; function createActionError(error: NotificationError) { const message = NotificationConfigurationError.is(error) diff --git a/apps/landing/src/features/blog/components/Image.astro b/apps/landing/src/features/blog/components/Image.astro deleted file mode 100644 index 1bb3f3e9..00000000 --- a/apps/landing/src/features/blog/components/Image.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/features/blog/components/Index.astro b/apps/landing/src/features/blog/components/Index.astro index 52fca47d..2b0cf892 100644 --- a/apps/landing/src/features/blog/components/Index.astro +++ b/apps/landing/src/features/blog/components/Index.astro @@ -1,11 +1,17 @@ --- -import { - getBlogIndexPath, -} from "@/features/blog/taxonomy"; +import { Picture } from "astro:assets"; +import { getBlogIndexPath } from "@/features/blog/taxonomy"; import type { BlogCategoryFilter } from "@/features/blog/taxonomy"; import type { BlogPostSummary } from "@/features/blog/types"; import TrackedLink from "@/shared/components/TrackedLink.astro"; -import Thumbnail from "./Thumbnail.astro"; + +const THUMBNAIL_SIZES = + "(min-width: 1120px) 33vw, (min-width: 720px) 50vw, 100vw"; +const THUMBNAIL_WIDTHS = [320, 480, 640, 900, 1254]; +const LAZY_IMAGE_ATTRIBUTES = { + decoding: "async" as const, + loading: "lazy" as const, +}; interface Props { activeCategory: BlogCategoryFilter; @@ -17,54 +23,66 @@ const { activeCategory, categories, posts } = Astro.props; ---
-
-

OneQuery

-

Blog

- -
+
+

OneQuery

+

Blog

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

{post.title}

-

{post.description}

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

{post.title}

+

{post.description}

+ + {post.readTime} + + +
+
+ )) + } +
+
+
diff --git a/apps/landing/src/features/blog/components/LinkedText.astro b/apps/landing/src/features/blog/components/LinkedText.astro deleted file mode 100644 index 85de7644..00000000 --- a/apps/landing/src/features/blog/components/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/features/blog/components/Thumbnail.astro b/apps/landing/src/features/blog/components/Thumbnail.astro deleted file mode 100644 index 1261faa5..00000000 --- a/apps/landing/src/features/blog/components/Thumbnail.astro +++ /dev/null @@ -1,26 +0,0 @@ ---- -import type { BlogPostSummary } from "@/features/blog/types"; -import { - BLOG_THUMBNAIL_IMAGE_SIZES, - BLOG_THUMBNAIL_IMAGE_WIDTHS, -} from "./image-presets"; -import Image from "./Image.astro"; - -interface Props { - post: BlogPostSummary; - priority?: boolean; -} - -const { post, priority = false } = Astro.props; ---- - -
- {post.coverImage.alt} -
diff --git a/apps/landing/src/features/blog/components/image-presets.ts b/apps/landing/src/features/blog/components/image-presets.ts deleted file mode 100644 index 5dbe5ce8..00000000 --- a/apps/landing/src/features/blog/components/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/index.test.ts b/apps/landing/src/index.test.ts index c4dc5f18..9763f034 100644 --- a/apps/landing/src/index.test.ts +++ b/apps/landing/src/index.test.ts @@ -9,7 +9,7 @@ import { describe("landing discovery resources", () => { it("serves the API catalog well-known resource", async () => { const response = createApiCatalogResponse( - new Request("https://onequery.dev/.well-known/api-catalog") + new Request("https://onequery.dev/.well-known/api-catalog/") ); const body = (await response.json()) as { linkset: ReadonlyArray>; @@ -26,14 +26,14 @@ describe("landing discovery resources", () => { expect.objectContaining({ "api-catalog": [ { - href: "https://onequery.dev/.well-known/api-catalog", + href: "https://onequery.dev/.well-known/api-catalog/", type: "application/linkset+json", }, ], anchor: "https://onequery.dev/", }), expect.objectContaining({ - anchor: "https://onequery.dev/.well-known/api-catalog", + anchor: "https://onequery.dev/.well-known/api-catalog/", item: [ { href: "https://onequery.dev/api/product-updates/", @@ -51,7 +51,7 @@ describe("landing discovery resources", () => { it("omits a response body for HEAD API catalog requests", async () => { const response = createApiCatalogResponse( - new Request("https://onequery.dev/.well-known/api-catalog", { + new Request("https://onequery.dev/.well-known/api-catalog/", { method: "HEAD", }) ); @@ -65,7 +65,7 @@ describe("landing discovery resources", () => { expect.objectContaining({ linkset: expect.arrayContaining([ expect.objectContaining({ - anchor: "https://preview.onequery.dev/.well-known/api-catalog", + anchor: "https://preview.onequery.dev/.well-known/api-catalog/", item: [ { href: "https://preview.onequery.dev/api/product-updates/", diff --git a/apps/landing/src/layouts/BaseLayout.astro b/apps/landing/src/layouts/BaseLayout.astro index 4b881782..f5ec5591 100644 --- a/apps/landing/src/layouts/BaseLayout.astro +++ b/apps/landing/src/layouts/BaseLayout.astro @@ -1,7 +1,13 @@ --- import "@/shared/styles/base.css"; import { Font } from "astro:assets"; -import GoogleTag from "@/shared/components/GoogleTag.astro"; +import { googleTagConfig } from "@/shared/analytics/google-tag"; +import { + createGoogleTagAfterSwapScript, + createGoogleTagBootstrapScript, + createGoogleTagEventBridgeScript, + createGoogleTagScriptSrc, +} from "@/shared/analytics/script"; import { createCanonicalUrl, createHomePageStructuredData, @@ -25,6 +31,12 @@ const DEFAULT_CANONICAL_URL = createCanonicalUrl("/", siteUrl); const defaultShareImage = getOneQueryPublicShareImageMetadata(siteUrl); const defaultStructuredShareImage = getOneQueryStructuredShareImageMetadata(siteUrl); +const googleTagScriptSrc = createGoogleTagScriptSrc(googleTagConfig); +const googleTagEventBridgeScript = createGoogleTagEventBridgeScript(); +const googleTagBootstrapScript = + createGoogleTagBootstrapScript(googleTagConfig); +const googleTagAfterSwapScript = + createGoogleTagAfterSwapScript(googleTagConfig); const DEFAULT_IMAGE_URL = defaultShareImage.url; const DEFAULT_STRUCTURED_DATA = createHomePageStructuredData({ description: DEFAULT_DESCRIPTION, @@ -68,7 +80,14 @@ const structuredDataItems = - + + - - - - + + + - + diff --git a/apps/landing/src/pages/blog/[postSlug].astro b/apps/landing/src/pages/blog/[postSlug].astro index d1f2b90a..ed36c2e5 100644 --- a/apps/landing/src/pages/blog/[postSlug].astro +++ b/apps/landing/src/pages/blog/[postSlug].astro @@ -8,11 +8,16 @@ import { getBlogPostSummaries, toBlogPost, } from "@/features/blog/collection"; -import { getBlogPostShareImageMetadata } from "@/features/blog/images"; -import { getBlogPostShareMetadata } from "@/features/blog/share"; +import { + getBlogShareImage, + getPreferredBlogShareImage, +} from "@/features/blog/images"; +import { ONEQUERY } from "@/shared/seo/constants"; import { createBlogPostStructuredData, + createCanonicalUrl, getBlogPostKeywords, + toIsoDateTime, } from "@/shared/seo/schema"; interface Props { @@ -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", }, ]; --- = [ ; 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) { @@ -34,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", }, @@ -68,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/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 index 18577555..1f237c81 100644 --- a/apps/landing/src/shared/seo/images.ts +++ b/apps/landing/src/shared/seo/images.ts @@ -1,32 +1,12 @@ +import { ONEQUERY } from "./constants"; +import type { ShareImage } from "./constants"; import { toAbsoluteSiteUrl } from "./schema"; -import type { StructuredImageMetadata } from "./schema"; type SiteInput = string | URL | null | undefined; -type TypedStructuredImageMetadata = StructuredImageMetadata & { - type: string; -}; -export const ONEQUERY_DEFAULT_SHARE_IMAGE_ALT = - "OneQuery - Governed Data Access for AI Agents"; - -export const ONEQUERY_PUBLIC_SHARE_IMAGE = { - height: 630, - type: "image/png", - url: "/og.png", - width: 1200, -} as const satisfies TypedStructuredImageMetadata; - -export function getOneQueryPublicShareImageMetadata( - site?: SiteInput -): TypedStructuredImageMetadata { +export function getOneQueryShareImage(site?: SiteInput): ShareImage { return { - ...ONEQUERY_PUBLIC_SHARE_IMAGE, - url: toAbsoluteSiteUrl(ONEQUERY_PUBLIC_SHARE_IMAGE.url, site), + ...ONEQUERY.IMAGES.SHARE, + url: toAbsoluteSiteUrl(ONEQUERY.IMAGES.SHARE.url, site), }; } - -export function getOneQueryStructuredShareImageMetadata( - site?: SiteInput -): TypedStructuredImageMetadata { - return getOneQueryPublicShareImageMetadata(site); -} diff --git a/apps/landing/src/shared/seo/schema.test.ts b/apps/landing/src/shared/seo/schema.test.ts index cbb0f3de..490f3676 100644 --- a/apps/landing/src/shared/seo/schema.test.ts +++ b/apps/landing/src/shared/seo/schema.test.ts @@ -9,6 +9,7 @@ import { } from "@/features/connectors/data"; import { NPM_PACKAGE_URL } from "@/shared/config/site"; +import { ONEQUERY } from "./constants"; import { createBlogPostStructuredData, createCanonicalUrl, @@ -42,8 +43,10 @@ describe("createHomePageStructuredData", () => { it("emits landing-page schema with npm as the install target", () => { const schema = createHomePageStructuredData({ description: "Landing description", - imageAlt: "OneQuery share image", - imageUrl: "/og.png", + image: { + ...ONEQUERY.IMAGES.SHARE, + alt: "OneQuery share image", + }, title: "OneQuery", video: { contentUrl: "/_astro/openclaw-demo-video.hash.mp4", @@ -51,9 +54,11 @@ describe("createHomePageStructuredData", () => { duration: "PT20S", name: "OneQuery OpenClaw agent access demo", pageUrl: "https://onequery.dev/#demo", - thumbnailHeight: 900, - thumbnailUrl: "/_astro/openclaw-demo-poster.hash.avif", - thumbnailWidth: 1400, + thumbnail: { + height: 900, + url: "/_astro/openclaw-demo-poster.hash.avif", + width: 1400, + }, uploadDate: "2026-05-22T00:00:00.000Z", }, }); @@ -200,7 +205,14 @@ describe("createBlogPostStructuredData", () => { slug: "debug-production-agent-runs-with-onequery", title: "Debugging production on Cloudflare with Codex.", }; - const schema = createBlogPostStructuredData(post); + const schema = createBlogPostStructuredData({ + image: { + height: coverImage.height, + url: coverImage.src, + width: coverImage.width, + }, + post, + }); const graph = schema["@graph"]; expect(Array.isArray(graph)).toBe(true); diff --git a/apps/landing/src/shared/seo/schema.ts b/apps/landing/src/shared/seo/schema.ts index bc2858a6..1389dd60 100644 --- a/apps/landing/src/shared/seo/schema.ts +++ b/apps/landing/src/shared/seo/schema.ts @@ -13,20 +13,16 @@ import { SELF_HOST_DOCS_URL, } from "@/shared/config/site"; -export type StructuredData = Record; -export type StructuredImageMetadata = { - height: number; - url: string; - width: number; -}; +import { + ONEQUERY, + SCHEMA_FRAGMENTS, + SCHEMA_URLS, + SEO_PATHS, +} from "./constants"; +import type { SeoImage } from "./constants"; -export const ONEQUERY_SITE_NAME = "OneQuery"; -export const ONEQUERY_SITE_URL = "https://onequery.dev"; -export const ONEQUERY_DEFAULT_DESCRIPTION = - "OneQuery gives AI agents production context without production keys, using approved sources, centralized credentials, enforced limits, and full audit logs."; +export type StructuredData = Record; -const DEFAULT_IMAGE_WIDTH = 1200; -const DEFAULT_IMAGE_HEIGHT = 630; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; const READ_TIME_MINUTES_PATTERN = /\d+/u; const CORE_TOPICS = [ @@ -42,10 +38,7 @@ type SiteInput = string | URL | null | undefined; type HomePageStructuredDataInput = { description: string; - imageAlt: string; - imageHeight?: number; - imageUrl: string; - imageWidth?: number; + image: SeoImage & { alt: string }; site?: SiteInput; title: string; video?: DemoVideoStructuredDataInput; @@ -54,27 +47,37 @@ type HomePageStructuredDataInput = { type DemoVideoStructuredDataInput = { contentUrl: string; description: string; - duration?: string; - embedUrl?: string; + duration: string; name: string; pageUrl: string; - thumbnailHeight?: number; - thumbnailUrl: string; - thumbnailWidth?: number; + thumbnail: SeoImage; uploadDate: string; }; type BlogIndexStructuredDataInput = { - breadcrumbName?: string; + breadcrumbName: string; description: string; - itemListName?: string; - pathname?: string; - postImages?: Partial>; + 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; @@ -91,9 +94,9 @@ type ConnectorPageStructuredDataInput = { title: string; }; -export function normalizeSiteUrl(site: SiteInput = ONEQUERY_SITE_URL) { +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; + const siteUrl = rawSite && rawSite.length > 0 ? rawSite : ONEQUERY.SITE_URL; return siteUrl.replace(/\/+$/u, ""); } @@ -137,7 +140,7 @@ export function toIsoDateTime(date: string) { export function getBlogPostKeywords(post: Pick) { return [ - "OneQuery", + ONEQUERY.NAME, "AI agents", "production data access", "governed data access", @@ -153,35 +156,60 @@ function createGraph(graph: StructuredData[]): StructuredData { }; } +function getNodeId(baseUrl: string, fragment: string) { + return `${baseUrl}#${fragment}`; +} + function getOrganizationId(site: SiteInput) { - return `${normalizeSiteUrl(site)}/#organization`; + return getNodeId(`${normalizeSiteUrl(site)}/`, SCHEMA_FRAGMENTS.ORGANIZATION); } function getWebsiteId(site: SiteInput) { - return `${normalizeSiteUrl(site)}/#website`; + return getNodeId(`${normalizeSiteUrl(site)}/`, SCHEMA_FRAGMENTS.WEBSITE); } function getSoftwareApplicationId(site: SiteInput) { - return `${normalizeSiteUrl(site)}/#software`; + return getNodeId(`${normalizeSiteUrl(site)}/`, SCHEMA_FRAGMENTS.SOFTWARE); } function getDemoVideoId(site: SiteInput) { - return `${normalizeSiteUrl(site)}/#demo-video`; + 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; + height: number; + site: SiteInput; url: string; - width?: number; + width: number; }) { return { "@type": "ImageObject", url: toAbsoluteSiteUrl(input.url, input.site), ...(input.alt ? { caption: input.alt } : {}), - ...(input.width ? { width: input.width } : {}), - ...(input.height ? { height: input.height } : {}), + width: input.width, + height: input.height, }; } @@ -191,14 +219,12 @@ function createOneQueryOrganization(site: SiteInput): StructuredData { return { "@type": "Organization", "@id": getOrganizationId(siteUrl), - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, url: `${siteUrl}/`, logo: createImageObject({ - alt: "OneQuery icon", - height: 512, + ...ONEQUERY.IMAGES.ICON, + alt: ONEQUERY.ICON_IMAGE_ALT, site: siteUrl, - url: "/onequery-icon.png", - width: 512, }), sameAs: [REPOSITORY_URL], knowsAbout: [...CORE_TOPICS], @@ -211,9 +237,9 @@ function createOneQueryWebsite(site: SiteInput): StructuredData { return { "@type": "WebSite", "@id": getWebsiteId(siteUrl), - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, url: `${siteUrl}/`, - description: ONEQUERY_DEFAULT_DESCRIPTION, + description: ONEQUERY.SITE_DESCRIPTION, inLanguage: "en", publisher: { "@id": getOrganizationId(siteUrl), @@ -223,24 +249,22 @@ function createOneQueryWebsite(site: SiteInput): StructuredData { function createOneQuerySoftwareApplication(input: { description: string; - site?: SiteInput; + site: SiteInput; }): StructuredData { const siteUrl = normalizeSiteUrl(input.site); return { "@type": "SoftwareApplication", "@id": getSoftwareApplicationId(siteUrl), - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, url: `${siteUrl}/`, applicationCategory: "DeveloperApplication", operatingSystem: "Web, CLI, Self-hosted gateway", description: input.description, image: createImageObject({ - alt: "OneQuery icon", - height: 512, + ...ONEQUERY.IMAGES.ICON, + alt: ONEQUERY.ICON_IMAGE_ALT, site: siteUrl, - url: "/onequery-icon.png", - width: 512, }), publisher: { "@id": getOrganizationId(siteUrl), @@ -259,10 +283,10 @@ function createOneQuerySoftwareApplication(input: { } function createDemoVideoStructuredData( - input: DemoVideoStructuredDataInput & { site?: SiteInput } + input: DemoVideoStructuredDataInput & { site: SiteInput } ): StructuredData { const siteUrl = normalizeSiteUrl(input.site); - const thumbnailUrl = toAbsoluteSiteUrl(input.thumbnailUrl, siteUrl); + const thumbnailUrl = toAbsoluteSiteUrl(input.thumbnail.url, siteUrl); const pageUrl = toAbsoluteSiteUrl(input.pageUrl, siteUrl); return { @@ -272,27 +296,24 @@ function createDemoVideoStructuredData( description: input.description, thumbnailUrl: [thumbnailUrl], uploadDate: input.uploadDate, - ...(input.duration ? { duration: input.duration } : {}), + duration: input.duration, contentUrl: toAbsoluteSiteUrl(input.contentUrl, siteUrl), - ...(input.embedUrl - ? { embedUrl: toAbsoluteSiteUrl(input.embedUrl, siteUrl) } - : {}), url: pageUrl, inLanguage: "en", publisher: { "@id": getOrganizationId(siteUrl), }, isPartOf: { - "@id": `${siteUrl}/#webpage`, + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.WEBPAGE), }, mainEntityOfPage: { - "@id": `${siteUrl}/#webpage`, + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.WEBPAGE), }, thumbnail: createImageObject({ - height: input.thumbnailHeight, + height: input.thumbnail.height, site: siteUrl, url: thumbnailUrl, - width: input.thumbnailWidth, + width: input.thumbnail.width, }), }; } @@ -306,7 +327,7 @@ function createConnectorFeatureList(connector: DataSourceConnector) { `${connector.label} ${getConnectorInterfaceDescription(connector)}`, `${connector.availability} setup for approved source access`, "Centralized credentials for AI agent workflows", - "Audit-ready source access through OneQuery", + `Audit-ready source access through ${ONEQUERY.NAME}`, ]; } @@ -316,11 +337,12 @@ function createConnectorSoftwareApplication( ): StructuredData { const siteUrl = normalizeSiteUrl(site); const connectorUrl = createCanonicalUrl(getConnectorPath(connector), siteUrl); + const connectorId = getNodeId(connectorUrl, SCHEMA_FRAGMENTS.CONNECTOR); return { "@type": "SoftwareApplication", - "@id": `${connectorUrl}#connector`, - name: `OneQuery ${connector.label} connector`, + "@id": connectorId, + name: `${ONEQUERY.NAME} ${connector.label} connector`, url: connectorUrl, applicationCategory: "DeveloperApplication", applicationSubCategory: connector.category, @@ -365,7 +387,7 @@ export function createHomePageStructuredData( : []), { "@type": "WebPage", - "@id": `${siteUrl}/#webpage`, + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.WEBPAGE), url: `${siteUrl}/`, name: input.title, description: input.description, @@ -383,29 +405,29 @@ export function createHomePageStructuredData( } : {}), primaryImageOfPage: createImageObject({ - alt: input.imageAlt, - height: input.imageHeight ?? DEFAULT_IMAGE_HEIGHT, + alt: input.image.alt, + height: input.image.height, site: siteUrl, - url: input.imageUrl, - width: input.imageWidth ?? DEFAULT_IMAGE_WIDTH, + url: input.image.url, + width: input.image.width, }), breadcrumb: { - "@id": `${siteUrl}/#breadcrumb`, + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.BREADCRUMB), }, significantLink: [ - createCanonicalUrl("/blog", siteUrl), + createCanonicalUrl(SEO_PATHS.BLOG, siteUrl), NPM_PACKAGE_URL, SELF_HOST_DOCS_URL, ], }, { "@type": "BreadcrumbList", - "@id": `${siteUrl}/#breadcrumb`, + "@id": getNodeId(`${siteUrl}/`, SCHEMA_FRAGMENTS.BREADCRUMB), itemListElement: [ { "@type": "ListItem", position: 1, - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, item: `${siteUrl}/`, }, ], @@ -417,19 +439,19 @@ export function createBlogIndexStructuredData( input: BlogIndexStructuredDataInput ): StructuredData { const siteUrl = normalizeSiteUrl(input.site); - const blogUrl = createCanonicalUrl("/blog", siteUrl); - const pageUrl = createCanonicalUrl(input.pathname ?? "/blog", siteUrl); - const breadcrumbItems = [ + const blogUrl = createCanonicalUrl(SEO_PATHS.BLOG, siteUrl); + const pageUrl = createCanonicalUrl(input.pathname, siteUrl); + const breadcrumbItems: BreadcrumbListItem[] = [ { "@type": "ListItem", position: 1, - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, item: `${siteUrl}/`, }, { "@type": "ListItem", position: 2, - name: "Blog", + name: ONEQUERY.BLOG_NAME, item: blogUrl, }, ]; @@ -438,7 +460,7 @@ export function createBlogIndexStructuredData( breadcrumbItems.push({ "@type": "ListItem", position: 3, - name: input.breadcrumbName ?? input.title, + name: input.breadcrumbName, item: pageUrl, }); } @@ -447,8 +469,8 @@ export function createBlogIndexStructuredData( ...createSiteGraph(siteUrl), { "@type": "Blog", - "@id": `${blogUrl}#blog`, - name: "OneQuery Blog", + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), + name: ONEQUERY.BLOG_NAME, url: blogUrl, description: input.description, inLanguage: "en", @@ -456,12 +478,12 @@ export function createBlogIndexStructuredData( "@id": getOrganizationId(siteUrl), }, blogPost: input.posts.map((post) => ({ - "@id": `${createCanonicalUrl(`/blog/${post.slug}`, siteUrl)}#article`, + "@id": getBlogPostId(getBlogPostUrl(post.slug, siteUrl)), })), }, { "@type": "CollectionPage", - "@id": `${pageUrl}#webpage`, + "@id": getNodeId(pageUrl, SCHEMA_FRAGMENTS.WEBPAGE), url: pageUrl, name: input.title, description: input.description, @@ -470,17 +492,17 @@ export function createBlogIndexStructuredData( "@id": getWebsiteId(siteUrl), }, mainEntity: { - "@id": `${blogUrl}#blog`, + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), }, breadcrumb: { - "@id": `${blogUrl}#breadcrumb`, + "@id": getNodeId(pageUrl, SCHEMA_FRAGMENTS.BREADCRUMB), }, }, { "@type": "ItemList", - "@id": `${pageUrl}#posts`, - name: input.itemListName ?? "OneQuery Blog posts", - itemListOrder: "https://schema.org/ItemListOrderDescending", + "@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", @@ -488,13 +510,13 @@ export function createBlogIndexStructuredData( item: createBlogPostSummaryStructuredData( post, siteUrl, - input.postImages?.[post.slug] + getBlogPostImage(input.postImages, post.slug) ), })), }, { "@type": "BreadcrumbList", - "@id": `${pageUrl}#breadcrumb`, + "@id": getNodeId(pageUrl, SCHEMA_FRAGMENTS.BREADCRUMB), itemListElement: breadcrumbItems, }, ]); @@ -504,17 +526,17 @@ export function createConnectorIndexStructuredData( input: ConnectorIndexStructuredDataInput ): StructuredData { const siteUrl = normalizeSiteUrl(input.site); - const connectorsUrl = createCanonicalUrl("/connectors", siteUrl); + const connectorsUrl = createCanonicalUrl(SEO_PATHS.CONNECTORS, siteUrl); return createGraph([ ...createSiteGraph(siteUrl), createOneQuerySoftwareApplication({ - description: ONEQUERY_DEFAULT_DESCRIPTION, + description: ONEQUERY.SITE_DESCRIPTION, site: siteUrl, }), { "@type": "CollectionPage", - "@id": `${connectorsUrl}#webpage`, + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.WEBPAGE), url: connectorsUrl, name: input.title, description: input.description, @@ -526,16 +548,16 @@ export function createConnectorIndexStructuredData( "@id": getSoftwareApplicationId(siteUrl), }, mainEntity: { - "@id": `${connectorsUrl}#connectors`, + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.CONNECTORS), }, breadcrumb: { - "@id": `${connectorsUrl}#breadcrumb`, + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.BREADCRUMB), }, }, { "@type": "ItemList", - "@id": `${connectorsUrl}#connectors`, - name: "OneQuery supported data source connectors", + "@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", @@ -545,12 +567,12 @@ export function createConnectorIndexStructuredData( }, { "@type": "BreadcrumbList", - "@id": `${connectorsUrl}#breadcrumb`, + "@id": getNodeId(connectorsUrl, SCHEMA_FRAGMENTS.BREADCRUMB), itemListElement: [ { "@type": "ListItem", position: 1, - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, item: `${siteUrl}/`, }, { @@ -568,22 +590,23 @@ export function createConnectorPageStructuredData( input: ConnectorPageStructuredDataInput ): StructuredData { const siteUrl = normalizeSiteUrl(input.site); - const connectorsUrl = createCanonicalUrl("/connectors", siteUrl); + 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_DEFAULT_DESCRIPTION, + description: ONEQUERY.SITE_DESCRIPTION, site: siteUrl, }), createConnectorSoftwareApplication(input.connector, siteUrl), { "@type": "WebPage", - "@id": `${connectorUrl}#webpage`, + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.WEBPAGE), url: connectorUrl, name: input.title, description: input.description, @@ -592,29 +615,29 @@ export function createConnectorPageStructuredData( "@id": getWebsiteId(siteUrl), }, about: { - "@id": `${connectorUrl}#connector`, + "@id": connectorId, }, mainEntity: { - "@id": `${connectorUrl}#connector`, + "@id": connectorId, }, hasPart: [ { - "@id": `${connectorUrl}#faq`, + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.FAQ), }, { - "@id": `${connectorUrl}#setup-checklist`, + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.SETUP_CHECKLIST), }, ], relatedLink: input.relatedConnectors.map((connector) => createCanonicalUrl(getConnectorPath(connector), siteUrl) ), breadcrumb: { - "@id": `${connectorUrl}#breadcrumb`, + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.BREADCRUMB), }, }, { "@type": "FAQPage", - "@id": `${connectorUrl}#faq`, + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.FAQ), mainEntity: input.faqs.map((faq) => ({ "@type": "Question", name: faq.question, @@ -626,7 +649,7 @@ export function createConnectorPageStructuredData( }, { "@type": "ItemList", - "@id": `${connectorUrl}#setup-checklist`, + "@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) => ({ @@ -637,12 +660,12 @@ export function createConnectorPageStructuredData( }, { "@type": "BreadcrumbList", - "@id": `${connectorUrl}#breadcrumb`, + "@id": getNodeId(connectorUrl, SCHEMA_FRAGMENTS.BREADCRUMB), itemListElement: [ { "@type": "ListItem", position: 1, - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, item: `${siteUrl}/`, }, { @@ -663,17 +686,12 @@ export function createConnectorPageStructuredData( } export function createBlogPostStructuredData( - post: BlogPost, - site?: SiteInput, - image: StructuredImageMetadata = { - height: DEFAULT_IMAGE_HEIGHT, - url: "/og.png", - width: DEFAULT_IMAGE_WIDTH, - } + input: BlogPostStructuredDataInput ): StructuredData { - const siteUrl = normalizeSiteUrl(site); - const blogUrl = createCanonicalUrl("/blog", siteUrl); - const postUrl = createCanonicalUrl(`/blog/${post.slug}`, siteUrl); + 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); @@ -681,8 +699,8 @@ export function createBlogPostStructuredData( ...createSiteGraph(siteUrl), { "@type": "Blog", - "@id": `${blogUrl}#blog`, - name: "OneQuery Blog", + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), + name: ONEQUERY.BLOG_NAME, url: blogUrl, publisher: { "@id": getOrganizationId(siteUrl), @@ -690,14 +708,14 @@ export function createBlogPostStructuredData( }, { "@type": "BlogPosting", - "@id": `${postUrl}#article`, + "@id": getBlogPostId(postUrl), mainEntityOfPage: { - "@id": `${postUrl}#webpage`, + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.WEBPAGE), }, headline: post.title, description: post.description, image: createImageObject({ - alt: `${post.title} - OneQuery Blog`, + alt: `${post.title} - ${ONEQUERY.BLOG_NAME}`, height: image.height, site: siteUrl, url: image.url, @@ -711,13 +729,13 @@ export function createBlogPostStructuredData( : {}), author: { "@id": getOrganizationId(siteUrl), - name: "OneQuery Maintainers", + name: ONEQUERY.AUTHOR_NAME, }, publisher: { "@id": getOrganizationId(siteUrl), }, isPartOf: { - "@id": `${blogUrl}#blog`, + "@id": getNodeId(blogUrl, SCHEMA_FRAGMENTS.BLOG), }, articleSection: post.category, keywords: getBlogPostKeywords(post), @@ -735,35 +753,35 @@ export function createBlogPostStructuredData( }, { "@type": "WebPage", - "@id": `${postUrl}#webpage`, + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.WEBPAGE), url: postUrl, - name: `${post.title} | OneQuery Blog`, + name: `${post.title} | ${ONEQUERY.BLOG_NAME}`, description: post.description, inLanguage: "en", isPartOf: { "@id": getWebsiteId(siteUrl), }, mainEntity: { - "@id": `${postUrl}#article`, + "@id": getBlogPostId(postUrl), }, breadcrumb: { - "@id": `${postUrl}#breadcrumb`, + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.BREADCRUMB), }, }, { "@type": "BreadcrumbList", - "@id": `${postUrl}#breadcrumb`, + "@id": getNodeId(postUrl, SCHEMA_FRAGMENTS.BREADCRUMB), itemListElement: [ { "@type": "ListItem", position: 1, - name: ONEQUERY_SITE_NAME, + name: ONEQUERY.NAME, item: `${siteUrl}/`, }, { "@type": "ListItem", position: 2, - name: "Blog", + name: ONEQUERY.BLOG_NAME, item: blogUrl, }, { @@ -780,24 +798,20 @@ export function createBlogPostStructuredData( function createBlogPostSummaryStructuredData( post: BlogPostSummary, site: SiteInput, - image: StructuredImageMetadata = { - height: DEFAULT_IMAGE_HEIGHT, - url: "/og.png", - width: DEFAULT_IMAGE_WIDTH, - } + image: SeoImage ): StructuredData { const siteUrl = normalizeSiteUrl(site); - const postUrl = createCanonicalUrl(`/blog/${post.slug}`, siteUrl); + const postUrl = getBlogPostUrl(post.slug, siteUrl); const publishedTime = toIsoDateTime(post.publishedAt); return { "@type": "BlogPosting", - "@id": `${postUrl}#article`, + "@id": getBlogPostId(postUrl), url: postUrl, headline: post.title, description: post.description, image: createImageObject({ - alt: `${post.title} - OneQuery Blog`, + alt: `${post.title} - ${ONEQUERY.BLOG_NAME}`, height: image.height, site: siteUrl, url: image.url, @@ -806,7 +820,7 @@ function createBlogPostSummaryStructuredData( ...(publishedTime ? { datePublished: publishedTime } : {}), author: { "@id": getOrganizationId(siteUrl), - name: "OneQuery Maintainers", + name: ONEQUERY.AUTHOR_NAME, }, publisher: { "@id": getOrganizationId(siteUrl), From 9849e8fe09a2a7f81d25bf311a51db523439a0ee Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 16:01:23 +0900 Subject: [PATCH 6/6] fix(landing): satisfy api catalog route lint --- apps/landing/src/pages/.well-known/api-catalog.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/landing/src/pages/.well-known/api-catalog.ts b/apps/landing/src/pages/.well-known/api-catalog.ts index f3b418c8..9666c7f7 100644 --- a/apps/landing/src/pages/.well-known/api-catalog.ts +++ b/apps/landing/src/pages/.well-known/api-catalog.ts @@ -1,6 +1,7 @@ import type { APIRoute } from "astro"; -import { createApiCatalogResponse } from "@/server/api-catalog"; +// oxlint's type-aware resolver misses "@/..." imports inside hidden route directories. +import { createApiCatalogResponse } from "../../server/api-catalog"; export const prerender = false;