From 492a11783d0f4ac8031f196be0fbec35c6ef69e8 Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 16:15:30 +0900 Subject: [PATCH 1/6] feat(landing): add shared Astro SEO package --- apps/landing/package.json | 1 + apps/landing/src/layouts/BaseLayout.astro | 81 ++++++--------- apps/landing/src/layouts/types.ts | 12 +-- apps/landing/src/shared/seo/schema.ts | 4 +- bun.lock | 14 +++ packages/astro-seo/package.json | 26 +++++ packages/astro-seo/src/JsonLd.astro | 17 ++++ packages/astro-seo/src/SeoHead.astro | 116 ++++++++++++++++++++++ packages/astro-seo/src/env.d.ts | 1 + packages/astro-seo/src/index.ts | 19 ++++ packages/astro-seo/src/json-ld.test.ts | 53 ++++++++++ packages/astro-seo/src/json-ld.ts | 75 ++++++++++++++ packages/astro-seo/src/metadata.ts | 94 ++++++++++++++++++ packages/astro-seo/tsconfig.json | 8 ++ packages/astro-seo/vitest.config.ts | 9 ++ 15 files changed, 468 insertions(+), 62 deletions(-) create mode 100644 packages/astro-seo/package.json create mode 100644 packages/astro-seo/src/JsonLd.astro create mode 100644 packages/astro-seo/src/SeoHead.astro create mode 100644 packages/astro-seo/src/env.d.ts create mode 100644 packages/astro-seo/src/index.ts create mode 100644 packages/astro-seo/src/json-ld.test.ts create mode 100644 packages/astro-seo/src/json-ld.ts create mode 100644 packages/astro-seo/src/metadata.ts create mode 100644 packages/astro-seo/tsconfig.json create mode 100644 packages/astro-seo/vitest.config.ts diff --git a/apps/landing/package.json b/apps/landing/package.json index a5886d99..34f5a3e6 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -27,6 +27,7 @@ "@astrojs/starlight": "catalog:", "@nanostores/react": "catalog:", "@onequery/astro-agent-markdown": "workspace:*", + "@onequery/astro-seo": "workspace:*", "@onequery/db": "workspace:*", "@onequery/ui": "workspace:*", "astro": "catalog:", diff --git a/apps/landing/src/layouts/BaseLayout.astro b/apps/landing/src/layouts/BaseLayout.astro index 032eafbe..880e0136 100644 --- a/apps/landing/src/layouts/BaseLayout.astro +++ b/apps/landing/src/layouts/BaseLayout.astro @@ -1,5 +1,6 @@ --- import "@/shared/styles/base.css"; +import SeoHead from "@onequery/astro-seo/SeoHead.astro"; import { Font } from "astro:assets"; import { googleTagConfig } from "@/shared/analytics/google-tag"; import { @@ -61,12 +62,6 @@ const { title = DEFAULT_TITLE, twitterTitle = title, } = Astro.props; -const structuredDataItems = - structuredData === null - ? [] - : Array.isArray(structuredData) - ? structuredData - : [structuredData]; --- @@ -74,7 +69,35 @@ const structuredDataItems = - + & line\u2028break', + }; + const json = safeJsonLdStringify(item); + + expect(json).not.toContain(""); + expect(json).toContain("\\u003c/script\\u003e"); + expect(json).toContain("\\u0026"); + expect(json).toContain("\\u2028"); + expect(JSON.parse(json)).toEqual(item); + }); + + it("omits null object properties from JSON-LD output", () => { + const json = safeJsonLdStringify({ + "@context": "https://schema.org", + "@type": "Thing", + empty: null, + nested: { + keep: "value", + omit: null, + }, + }); + + expect(JSON.parse(json)).toEqual({ + "@context": "https://schema.org", + "@type": "Thing", + nested: { + keep: "value", + }, + }); + }); +}); + +describe("toStructuredDataItems", () => { + it("normalizes absent, single, and array structured data inputs", () => { + const item = { + "@context": "https://schema.org", + "@type": "Thing", + }; + + expect(toStructuredDataItems(null)).toEqual([]); + expect(toStructuredDataItems(item)).toEqual([item]); + expect(toStructuredDataItems([item])).toEqual([item]); + }); +}); diff --git a/packages/astro-seo/src/json-ld.ts b/packages/astro-seo/src/json-ld.ts new file mode 100644 index 00000000..50a8dd8c --- /dev/null +++ b/packages/astro-seo/src/json-ld.ts @@ -0,0 +1,75 @@ +export type JsonLdScalar = boolean | number | string; + +export type JsonLdArray = readonly JsonLdValue[]; + +export type JsonLdObject = { + readonly [key: string]: JsonLdValue | undefined; +}; + +export type JsonLdValue = JsonLdArray | JsonLdObject | JsonLdScalar | null; + +export type StructuredData = JsonLdObject; + +export type StructuredDataInput = + | readonly StructuredData[] + | StructuredData + | null + | undefined; + +const JSON_LD_SCRIPT_ESCAPE_PATTERN = /[<>&\u2028\u2029]/gu; + +const JSON_LD_SCRIPT_ESCAPES = { + "&": "\\u0026", + "<": "\\u003c", + ">": "\\u003e", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +} as const; + +function escapeJsonLdScriptCharacter(character: string) { + return JSON_LD_SCRIPT_ESCAPES[ + character as keyof typeof JSON_LD_SCRIPT_ESCAPES + ]; +} + +function omitNullValues(_key: string, value: unknown) { + if (value === null) { + return undefined; + } + + if (typeof value === "bigint") { + throw new TypeError("JSON-LD data cannot contain bigint values."); + } + + return value; +} + +function isStructuredDataArray( + structuredData: StructuredDataInput +): structuredData is readonly StructuredData[] { + return Array.isArray(structuredData); +} + +export function safeJsonLdStringify( + item: readonly StructuredData[] | StructuredData, + space?: number | string +) { + return JSON.stringify(item, omitNullValues, space).replace( + JSON_LD_SCRIPT_ESCAPE_PATTERN, + escapeJsonLdScriptCharacter + ); +} + +export function toStructuredDataItems( + structuredData: StructuredDataInput +): readonly StructuredData[] { + if (!structuredData) { + return []; + } + + if (isStructuredDataArray(structuredData)) { + return structuredData; + } + + return [structuredData]; +} diff --git a/packages/astro-seo/src/metadata.ts b/packages/astro-seo/src/metadata.ts new file mode 100644 index 00000000..be24d6cb --- /dev/null +++ b/packages/astro-seo/src/metadata.ts @@ -0,0 +1,94 @@ +import type { StructuredDataInput } from "./json-ld"; + +export type MetaTag = + | { + content: string; + name: string; + } + | { + content: string; + property: string; + }; + +export type OpenGraphType = + | "article" + | "book" + | "profile" + | "website" + | "music.album" + | "music.playlist" + | "music.radio_station" + | "music.song" + | "video.episode" + | "video.movie" + | "video.other" + | "video.tv_show"; + +export type TwitterCardType = + | "app" + | "player" + | "summary" + | "summary_large_image"; + +export type SeoImageMetadata = { + alt?: string; + height?: number; + secureUrl?: string; + type?: string; + url: string; + width?: number; +}; + +export type OpenGraphMetadata = { + description?: string; + image?: SeoImageMetadata; + locale?: string; + siteName?: string; + title?: string; + type?: OpenGraphType; + url?: string; +}; + +export type TwitterMetadata = { + card?: TwitterCardType; + description?: string; + image?: SeoImageMetadata; + site?: string; + title?: string; +}; + +export type SeoHeadMetadata = { + applicationName?: string; + appleMobileWebAppTitle?: string; + author?: string; + canonicalUrl: string; + description: string; + image?: SeoImageMetadata; + keywords?: readonly string[] | string | null; + metaTags?: readonly MetaTag[]; + openGraph?: OpenGraphMetadata; + robots?: string; + sitemapUrl?: string | null; + structuredData?: StructuredDataInput; + themeColor?: string | null; + title: string; + twitter?: TwitterMetadata; +}; + +export function formatKeywords( + keywords: readonly string[] | string | null | undefined +) { + if (!keywords) { + return undefined; + } + + const content = + typeof keywords === "string" + ? keywords.trim() + : keywords + .map((keyword) => keyword.trim()) + .filter(Boolean) + .join(", "); + + return content.length > 0 ? content : undefined; +} diff --git a/packages/astro-seo/tsconfig.json b/packages/astro-seo/tsconfig.json new file mode 100644 index 00000000..ef4055b9 --- /dev/null +++ b/packages/astro-seo/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@onequery/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/astro-seo/vitest.config.ts b/packages/astro-seo/vitest.config.ts new file mode 100644 index 00000000..f3b2a2c2 --- /dev/null +++ b/packages/astro-seo/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + hideSkippedTests: true, + silent: "passed-only", + }, +}); From fc34a24f9059d288d57447a963db5257213766f2 Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 16:27:48 +0900 Subject: [PATCH 2/6] feat(landing): centralize docs SEO head --- apps/landing/astro.config.ts | 1 + apps/landing/src/content/docs/docs/index.mdx | 159 ------------ apps/landing/src/shared/seo/constants.ts | 1 + apps/landing/src/shared/seo/docs.test.ts | 38 +++ apps/landing/src/shared/seo/docs.ts | 68 ++++++ apps/landing/src/shared/seo/schema.test.ts | 40 ++++ apps/landing/src/shared/seo/schema.ts | 59 ++++- apps/landing/src/starlightRouteData.ts | 100 ++++++++ packages/astro-seo/src/SeoHead.astro | 112 +-------- packages/astro-seo/src/index.ts | 4 +- packages/astro-seo/src/metadata.test.ts | 57 +++++ packages/astro-seo/src/metadata.ts | 239 +++++++++++++++++++ 12 files changed, 615 insertions(+), 263 deletions(-) create mode 100644 apps/landing/src/shared/seo/docs.test.ts create mode 100644 apps/landing/src/shared/seo/docs.ts create mode 100644 apps/landing/src/starlightRouteData.ts create mode 100644 packages/astro-seo/src/metadata.test.ts diff --git a/apps/landing/astro.config.ts b/apps/landing/astro.config.ts index e02d1de6..3df9638e 100644 --- a/apps/landing/astro.config.ts +++ b/apps/landing/astro.config.ts @@ -112,6 +112,7 @@ export default defineConfig({ alt: "OneQuery", src: "/src/assets/onequery-icon.svg", }, + routeMiddleware: "./src/starlightRouteData.ts", sidebar: [ { items: ["docs", "docs/getting-started"], diff --git a/apps/landing/src/content/docs/docs/index.mdx b/apps/landing/src/content/docs/docs/index.mdx index ef77dc79..e91db2d6 100644 --- a/apps/landing/src/content/docs/docs/index.mdx +++ b/apps/landing/src/content/docs/docs/index.mdx @@ -1,165 +1,6 @@ --- title: OneQuery Docs description: OneQuery documentation for installing the CLI, connecting governed sources, running read-only queries, and giving agents safe production context. -head: - - tag: title - content: OneQuery Documentation | Governed Agent Data Access - - tag: meta - attrs: - name: keywords - content: OneQuery documentation, AI agent data access, governed data access, read-only queries, Source API, source identifiers, production context, audit history, CLI install - - tag: meta - attrs: - name: robots - content: index, follow, max-image-preview:large - - tag: meta - attrs: - property: og:title - content: OneQuery Documentation | Governed Agent Data Access - - tag: meta - attrs: - property: og:type - content: website - - tag: meta - attrs: - property: og:site_name - content: OneQuery - - tag: meta - attrs: - property: og:locale - content: en_US - - tag: meta - attrs: - property: og:image - content: https://onequery.dev/og.png - - tag: meta - attrs: - property: og:image:secure_url - content: https://onequery.dev/og.png - - tag: meta - attrs: - property: og:image:type - content: image/png - - tag: meta - attrs: - property: og:image:width - content: "1200" - - tag: meta - attrs: - property: og:image:height - content: "630" - - tag: meta - attrs: - property: og:image:alt - content: OneQuery - Governed Data Access for AI Agents - - tag: meta - attrs: - name: twitter:title - content: OneQuery Documentation | Governed Agent Data Access - - tag: meta - attrs: - name: twitter:description - content: OneQuery documentation for installing the CLI, connecting governed sources, running read-only queries, and giving agents safe production context. - - tag: meta - attrs: - name: twitter:image - content: https://onequery.dev/og.png - - tag: meta - attrs: - name: twitter:image:alt - content: OneQuery - Governed Data Access for AI Agents - - tag: script - attrs: - type: application/ld+json - content: | - { - "@context": "https://schema.org", - "@graph": [ - { - "@type": "Organization", - "@id": "https://onequery.dev/#organization", - "name": "OneQuery", - "url": "https://onequery.dev/", - "logo": { - "@type": "ImageObject", - "url": "https://onequery.dev/onequery-icon.png", - "width": 512, - "height": 512, - "caption": "OneQuery icon" - }, - "sameAs": ["https://github.com/wordbricks/onequery"] - }, - { - "@type": "WebSite", - "@id": "https://onequery.dev/#website", - "name": "OneQuery", - "url": "https://onequery.dev/", - "description": "OneQuery gives AI agents production context without production keys, using approved sources, centralized credentials, enforced limits, and full audit logs.", - "inLanguage": "en", - "publisher": { - "@id": "https://onequery.dev/#organization" - } - }, - { - "@type": "SoftwareApplication", - "@id": "https://onequery.dev/#software", - "name": "OneQuery", - "url": "https://onequery.dev/", - "applicationCategory": "DeveloperApplication", - "operatingSystem": "Web, CLI, Self-hosted gateway", - "description": "OneQuery gives AI agents production context without production keys, using approved sources, centralized credentials, enforced limits, and full audit logs.", - "image": { - "@type": "ImageObject", - "url": "https://onequery.dev/onequery-icon.png", - "width": 512, - "height": 512, - "caption": "OneQuery icon" - }, - "publisher": { - "@id": "https://onequery.dev/#organization" - }, - "sameAs": ["https://github.com/wordbricks/onequery"], - "codeRepository": "https://github.com/wordbricks/onequery", - "installUrl": "https://www.npmjs.com/package/@onequery/cli", - "softwareHelp": "https://onequery.dev/docs/operations/self-host/" - }, - { - "@type": "WebPage", - "@id": "https://onequery.dev/docs/#webpage", - "url": "https://onequery.dev/docs/", - "name": "OneQuery Documentation", - "headline": "OneQuery Documentation", - "description": "OneQuery documentation for installing the CLI, connecting governed sources, running read-only queries, and giving agents safe production context.", - "isPartOf": { - "@id": "https://onequery.dev/#website" - }, - "about": { - "@id": "https://onequery.dev/#software" - }, - "breadcrumb": { - "@id": "https://onequery.dev/docs/#breadcrumb" - } - }, - { - "@type": "BreadcrumbList", - "@id": "https://onequery.dev/docs/#breadcrumb", - "itemListElement": [ - { - "@type": "ListItem", - "position": 1, - "name": "OneQuery", - "item": "https://onequery.dev/" - }, - { - "@type": "ListItem", - "position": 2, - "name": "Documentation", - "item": "https://onequery.dev/docs/" - } - ] - } - ] - } sidebar: label: Overview order: 1 diff --git a/apps/landing/src/shared/seo/constants.ts b/apps/landing/src/shared/seo/constants.ts index 2ca0a994..3c28a4a5 100644 --- a/apps/landing/src/shared/seo/constants.ts +++ b/apps/landing/src/shared/seo/constants.ts @@ -56,6 +56,7 @@ export const ONEQUERY = { export const SEO_PATHS = { BLOG: "/blog", CONNECTORS: "/connectors", + DOCS: "/docs", } as const; export const SCHEMA_FRAGMENTS = { diff --git a/apps/landing/src/shared/seo/docs.test.ts b/apps/landing/src/shared/seo/docs.test.ts new file mode 100644 index 00000000..4eb6c1ea --- /dev/null +++ b/apps/landing/src/shared/seo/docs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { + DOCS_INDEX_DESCRIPTION, + DOCS_INDEX_TITLE, + createDocsIndexHeadEntries, +} from "./docs"; + +describe("createDocsIndexHeadEntries", () => { + it("creates reusable docs index SEO head entries", () => { + const entries = createDocsIndexHeadEntries(); + + expect(entries).toContainEqual({ + tag: "title", + content: DOCS_INDEX_TITLE, + }); + expect(entries).toContainEqual({ + tag: "meta", + attrs: { + name: "description", + content: DOCS_INDEX_DESCRIPTION, + }, + }); + expect(entries).toContainEqual({ + tag: "meta", + attrs: { + property: "og:image:width", + content: "1200", + }, + }); + + const jsonLd = entries.find((entry) => entry.tag === "script"); + + expect(jsonLd?.content).toContain( + '"@id":"https://onequery.dev/docs/#webpage"' + ); + }); +}); diff --git a/apps/landing/src/shared/seo/docs.ts b/apps/landing/src/shared/seo/docs.ts new file mode 100644 index 00000000..8deec269 --- /dev/null +++ b/apps/landing/src/shared/seo/docs.ts @@ -0,0 +1,68 @@ +import { createSeoHeadEntries } from "@onequery/astro-seo"; +import type { SeoHeadEntry, SeoHeadMetadata } from "@onequery/astro-seo"; + +import { ONEQUERY, SEO_PATHS } from "./constants"; +import { + createCanonicalUrl, + createDocsIndexStructuredData, + toAbsoluteSiteUrl, +} from "./schema"; +import type { SiteInput } from "./schema"; + +export const DOCS_INDEX_TITLE = + "OneQuery Documentation | Governed Agent Data Access"; +export const DOCS_INDEX_HEADLINE = "OneQuery Documentation"; +export const DOCS_INDEX_DESCRIPTION = + "OneQuery documentation for installing the CLI, connecting governed sources, running read-only queries, and giving agents safe production context."; +export const DOCS_INDEX_KEYWORDS = [ + "OneQuery documentation", + "AI agent data access", + "governed data access", + "read-only queries", + "Source API", + "source identifiers", + "production context", + "audit history", + "CLI install", +] as const; + +export function createDocsIndexSeoMetadata(site?: SiteInput): SeoHeadMetadata { + const canonicalUrl = createCanonicalUrl(SEO_PATHS.DOCS, site); + const image = { + alt: ONEQUERY.SHARE_IMAGE_ALT, + height: ONEQUERY.IMAGES.SHARE.height, + type: ONEQUERY.IMAGES.SHARE.type, + url: toAbsoluteSiteUrl(ONEQUERY.IMAGES.SHARE.url, site), + width: ONEQUERY.IMAGES.SHARE.width, + }; + + return { + canonicalUrl, + description: DOCS_INDEX_DESCRIPTION, + image, + keywords: DOCS_INDEX_KEYWORDS, + openGraph: { + locale: "en_US", + siteName: ONEQUERY.NAME, + type: "website", + url: canonicalUrl, + }, + robots: "index, follow, max-image-preview:large", + sitemapUrl: "/sitemap-index.xml", + structuredData: createDocsIndexStructuredData({ + description: DOCS_INDEX_DESCRIPTION, + site, + title: DOCS_INDEX_HEADLINE, + }), + title: DOCS_INDEX_TITLE, + twitter: { + description: DOCS_INDEX_DESCRIPTION, + image, + title: DOCS_INDEX_TITLE, + }, + }; +} + +export function createDocsIndexHeadEntries(site?: SiteInput): SeoHeadEntry[] { + return createSeoHeadEntries(createDocsIndexSeoMetadata(site)); +} diff --git a/apps/landing/src/shared/seo/schema.test.ts b/apps/landing/src/shared/seo/schema.test.ts index 490f3676..97860767 100644 --- a/apps/landing/src/shared/seo/schema.test.ts +++ b/apps/landing/src/shared/seo/schema.test.ts @@ -15,6 +15,7 @@ import { createCanonicalUrl, createConnectorIndexStructuredData, createConnectorPageStructuredData, + createDocsIndexStructuredData, createHomePageStructuredData, } from "./schema"; @@ -92,6 +93,45 @@ describe("createHomePageStructuredData", () => { }); }); +describe("createDocsIndexStructuredData", () => { + it("emits WebPage and breadcrumb schema for the docs index", () => { + const schema = createDocsIndexStructuredData({ + description: "OneQuery documentation.", + title: "OneQuery Documentation", + }); + const graph = schema["@graph"]; + + expect(Array.isArray(graph)).toBe(true); + expect(graph).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + "@type": "SoftwareApplication", + "@id": "https://onequery.dev/#software", + softwareHelp: "https://onequery.dev/docs/operations/self-host/", + }), + expect.objectContaining({ + "@type": "WebPage", + "@id": "https://onequery.dev/docs/#webpage", + headline: "OneQuery Documentation", + about: { + "@id": "https://onequery.dev/#software", + }, + }), + expect.objectContaining({ + "@type": "BreadcrumbList", + "@id": "https://onequery.dev/docs/#breadcrumb", + itemListElement: expect.arrayContaining([ + expect.objectContaining({ + name: "Documentation", + item: "https://onequery.dev/docs/", + }), + ]), + }), + ]) + ); + }); +}); + describe("createConnectorIndexStructuredData", () => { it("emits CollectionPage and ItemList schema for connector discovery", () => { const connectors = DATA_SOURCE_CONNECTORS.slice(0, 2); diff --git a/apps/landing/src/shared/seo/schema.ts b/apps/landing/src/shared/seo/schema.ts index 6aeeec86..97f2350f 100644 --- a/apps/landing/src/shared/seo/schema.ts +++ b/apps/landing/src/shared/seo/schema.ts @@ -36,7 +36,7 @@ const CORE_TOPICS = [ "safe production debugging", ] as const; -type SiteInput = string | URL | null | undefined; +export type SiteInput = string | URL | null | undefined; type HomePageStructuredDataInput = { description: string; @@ -96,6 +96,12 @@ type ConnectorPageStructuredDataInput = { title: string; }; +type DocsIndexStructuredDataInput = { + description: string; + 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; @@ -437,6 +443,57 @@ export function createHomePageStructuredData( ]); } +export function createDocsIndexStructuredData( + input: DocsIndexStructuredDataInput +): StructuredData { + const siteUrl = normalizeSiteUrl(input.site); + const docsUrl = createCanonicalUrl(SEO_PATHS.DOCS, siteUrl); + + return createGraph([ + ...createSiteGraph(siteUrl), + createOneQuerySoftwareApplication({ + description: ONEQUERY.SITE_DESCRIPTION, + site: siteUrl, + }), + { + "@type": "WebPage", + "@id": getNodeId(docsUrl, SCHEMA_FRAGMENTS.WEBPAGE), + url: docsUrl, + name: input.title, + headline: input.title, + description: input.description, + inLanguage: "en", + isPartOf: { + "@id": getWebsiteId(siteUrl), + }, + about: { + "@id": getSoftwareApplicationId(siteUrl), + }, + breadcrumb: { + "@id": getNodeId(docsUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + }, + }, + { + "@type": "BreadcrumbList", + "@id": getNodeId(docsUrl, SCHEMA_FRAGMENTS.BREADCRUMB), + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: ONEQUERY.NAME, + item: `${siteUrl}/`, + }, + { + "@type": "ListItem", + position: 2, + name: "Documentation", + item: docsUrl, + }, + ], + }, + ]); +} + export function createBlogIndexStructuredData( input: BlogIndexStructuredDataInput ): StructuredData { diff --git a/apps/landing/src/starlightRouteData.ts b/apps/landing/src/starlightRouteData.ts new file mode 100644 index 00000000..8bab3579 --- /dev/null +++ b/apps/landing/src/starlightRouteData.ts @@ -0,0 +1,100 @@ +import { defineRouteMiddleware } from "@astrojs/starlight/route-data"; +import type { StarlightRouteData } from "@astrojs/starlight/route-data"; +import type { SeoHeadEntry } from "@onequery/astro-seo"; + +import { createDocsIndexHeadEntries } from "@/shared/seo/docs"; + +type StarlightHead = StarlightRouteData["head"]; +type StarlightHeadEntry = StarlightHead[number]; +type HeadIdentity = string | undefined; + +const DOCS_INDEX_PATHNAME = "/docs/"; +const UNIQUE_LINK_RELS = new Set(["canonical", "sitemap"]); +const META_IDENTITY_KEYS = ["name", "property", "http-equiv"] as const; + +export const onRequest = defineRouteMiddleware((context) => { + if (context.url.pathname !== DOCS_INDEX_PATHNAME) { + return; + } + + const route = context.locals.starlightRoute; + + route.head = mergeHeadEntries( + route.head, + createDocsIndexHeadEntries(context.site) + ); +}); + +function toStarlightHeadEntry(entry: SeoHeadEntry): StarlightHeadEntry { + return entry; +} + +function mergeHeadEntries( + current: StarlightHead, + next: readonly SeoHeadEntry[] +): StarlightHead { + const nextEntries = next.map(toStarlightHeadEntry); + const nextIdentities = new Set( + nextEntries + .map(getHeadIdentity) + .filter((identity): identity is string => identity !== undefined) + ); + + return [ + ...current.filter((entry) => { + const identity = getHeadIdentity(entry); + + return identity === undefined || !nextIdentities.has(identity); + }), + ...nextEntries, + ]; +} + +function getHeadIdentity(entry: StarlightHeadEntry): HeadIdentity { + switch (entry.tag) { + case "title": + return "title"; + case "meta": + return getMetaIdentity(entry); + case "link": + return getLinkIdentity(entry); + case "script": + return getScriptIdentity(entry); + default: + return undefined; + } +} + +function getMetaIdentity(entry: StarlightHeadEntry): HeadIdentity { + for (const key of META_IDENTITY_KEYS) { + const value = getAttributeValue(entry, key); + + if (value) { + return `meta:${key}:${value}`; + } + } + + return undefined; +} + +function getLinkIdentity(entry: StarlightHeadEntry): HeadIdentity { + const rel = getAttributeValue(entry, "rel"); + + if (rel && UNIQUE_LINK_RELS.has(rel)) { + return `link:${rel}`; + } + + return undefined; +} + +function getScriptIdentity(entry: StarlightHeadEntry): HeadIdentity { + const type = getAttributeValue(entry, "type"); + + return type === "application/ld+json" ? `script:${type}` : undefined; +} + +function getAttributeValue(entry: StarlightHeadEntry, key: string) { + const value = entry.attrs?.[key]; + + return typeof value === "string" ? value : undefined; +} diff --git a/packages/astro-seo/src/SeoHead.astro b/packages/astro-seo/src/SeoHead.astro index 125d9fe4..9e0ece72 100644 --- a/packages/astro-seo/src/SeoHead.astro +++ b/packages/astro-seo/src/SeoHead.astro @@ -1,116 +1,24 @@ --- -import JsonLd from "./JsonLd.astro"; -import { formatKeywords } from "./metadata"; -import { toStructuredDataItems } from "./json-ld"; +import { createSeoHeadEntries } from "./metadata"; import type { SeoHeadMetadata } from "./metadata"; type Props = SeoHeadMetadata; -const { - applicationName, - appleMobileWebAppTitle, - author, - canonicalUrl, - description, - image, - keywords, - metaTags = [], - openGraph = {}, - robots, - sitemapUrl, - structuredData, - themeColor, - title, - twitter = {}, -} = Astro.props; - -const keywordContent = formatKeywords(keywords); -const ogTitle = openGraph.title ?? title; -const ogDescription = openGraph.description ?? description; -const ogUrl = openGraph.url ?? canonicalUrl; -const ogImage = openGraph.image ?? image; -const twitterTitle = twitter.title ?? title; -const twitterDescription = twitter.description ?? description; -const twitterImage = twitter.image ?? image; -const structuredDataItems = toStructuredDataItems(structuredData); +const headEntries = createSeoHeadEntries(Astro.props); --- - {title} - {themeColor ? : null} - {robots ? : null} - {author ? : null} - { - applicationName ? ( - - ) : null - } - { - appleMobileWebAppTitle ? ( - - ) : null - } - - {sitemapUrl ? : null} - - {keywordContent ? : null} - - - {openGraph.type ? : null} - - { - openGraph.siteName ? ( - - ) : null - } - { - openGraph.locale ? ( - - ) : null - } - {ogImage ? : null} - { - ogImage?.secureUrl || ogImage?.url ? ( - - ) : null - } - {ogImage?.type ? : null} - { - ogImage?.width ? ( - - ) : null - } - { - ogImage?.height ? ( - - ) : null - } - {ogImage?.alt ? : null} - - {twitter.site ? : null} - - - { - twitterImage ? ( - - ) : null - } - { - twitterImage?.alt ? ( - - ) : null - } { - metaTags.map((tag) => - "name" in tag ? ( - + headEntries.map((entry) => + entry.tag === "title" ? ( + {entry.content} + ) : entry.tag === "meta" ? ( + + ) : entry.tag === "link" ? ( + ) : ( - + ", + }, + title: "OneQuery Documentation", + }); + + expect(entries).toContainEqual({ + tag: "meta", + attrs: { name: "keywords", content: "OneQuery docs, agent data access" }, + }); + expect(entries).toContainEqual({ + tag: "meta", + attrs: { property: "og:image:width", content: "1200" }, + }); + expect(entries).toContainEqual({ + tag: "meta", + attrs: { name: "twitter:image:alt", content: "OneQuery share image" }, + }); + + const script = entries.find((entry) => entry.tag === "script"); + + expect(script?.content).toContain("\\u003c/script\\u003e"); + }); +}); diff --git a/packages/astro-seo/src/metadata.ts b/packages/astro-seo/src/metadata.ts index be24d6cb..86d20571 100644 --- a/packages/astro-seo/src/metadata.ts +++ b/packages/astro-seo/src/metadata.ts @@ -1,3 +1,4 @@ +import { safeJsonLdStringify, toStructuredDataItems } from "./json-ld"; import type { StructuredDataInput } from "./json-ld"; export type MetaTag = @@ -75,6 +76,25 @@ export type SeoHeadMetadata = { twitter?: TwitterMetadata; }; +export type SeoHeadAttrs = Record; + +export type SeoHeadEntry = + | { + attrs?: never; + content: string; + tag: "title"; + } + | { + attrs: SeoHeadAttrs; + content?: never; + tag: "link" | "meta"; + } + | { + attrs: SeoHeadAttrs; + content: string; + tag: "script"; + }; + export function formatKeywords( keywords: readonly string[] | string | null | undefined ) { @@ -92,3 +112,222 @@ export function formatKeywords( return content.length > 0 ? content : undefined; } + +export function createSeoHeadEntries( + metadata: SeoHeadMetadata +): SeoHeadEntry[] { + const { + applicationName, + appleMobileWebAppTitle, + author, + canonicalUrl, + description, + image, + keywords, + metaTags = [], + openGraph = {}, + robots, + sitemapUrl, + structuredData, + themeColor, + title, + twitter = {}, + } = metadata; + const keywordContent = formatKeywords(keywords); + const ogTitle = openGraph.title ?? title; + const ogDescription = openGraph.description ?? description; + const ogUrl = openGraph.url ?? canonicalUrl; + const ogImage = openGraph.image ?? image; + const twitterTitle = twitter.title ?? title; + const twitterDescription = twitter.description ?? description; + const twitterImage = twitter.image ?? image; + const entries: SeoHeadEntry[] = [{ tag: "title", content: title }]; + + if (themeColor) { + entries.push({ + tag: "meta", + attrs: { name: "theme-color", content: themeColor }, + }); + } + + if (robots) { + entries.push({ + tag: "meta", + attrs: { name: "robots", content: robots }, + }); + } + + if (author) { + entries.push({ tag: "meta", attrs: { name: "author", content: author } }); + } + + if (applicationName) { + entries.push({ + tag: "meta", + attrs: { name: "application-name", content: applicationName }, + }); + } + + if (appleMobileWebAppTitle) { + entries.push({ + tag: "meta", + attrs: { + name: "apple-mobile-web-app-title", + content: appleMobileWebAppTitle, + }, + }); + } + + entries.push({ + tag: "link", + attrs: { rel: "canonical", href: canonicalUrl }, + }); + + if (sitemapUrl) { + entries.push({ tag: "link", attrs: { rel: "sitemap", href: sitemapUrl } }); + } + + entries.push({ + tag: "meta", + attrs: { name: "description", content: description }, + }); + + if (keywordContent) { + entries.push({ + tag: "meta", + attrs: { name: "keywords", content: keywordContent }, + }); + } + + entries.push( + { tag: "meta", attrs: { property: "og:title", content: ogTitle } }, + { + tag: "meta", + attrs: { property: "og:description", content: ogDescription }, + } + ); + + if (openGraph.type) { + entries.push({ + tag: "meta", + attrs: { property: "og:type", content: openGraph.type }, + }); + } + + entries.push({ + tag: "meta", + attrs: { property: "og:url", content: ogUrl }, + }); + + if (openGraph.siteName) { + entries.push({ + tag: "meta", + attrs: { property: "og:site_name", content: openGraph.siteName }, + }); + } + + if (openGraph.locale) { + entries.push({ + tag: "meta", + attrs: { property: "og:locale", content: openGraph.locale }, + }); + } + + if (ogImage) { + entries.push( + { tag: "meta", attrs: { property: "og:image", content: ogImage.url } }, + { + tag: "meta", + attrs: { + property: "og:image:secure_url", + content: ogImage.secureUrl ?? ogImage.url, + }, + } + ); + } + + if (ogImage?.type) { + entries.push({ + tag: "meta", + attrs: { property: "og:image:type", content: ogImage.type }, + }); + } + + if (ogImage?.width) { + entries.push({ + tag: "meta", + attrs: { property: "og:image:width", content: String(ogImage.width) }, + }); + } + + if (ogImage?.height) { + entries.push({ + tag: "meta", + attrs: { property: "og:image:height", content: String(ogImage.height) }, + }); + } + + if (ogImage?.alt) { + entries.push({ + tag: "meta", + attrs: { property: "og:image:alt", content: ogImage.alt }, + }); + } + + entries.push({ + tag: "meta", + attrs: { + name: "twitter:card", + content: twitter.card ?? "summary_large_image", + }, + }); + + if (twitter.site) { + entries.push({ + tag: "meta", + attrs: { name: "twitter:site", content: twitter.site }, + }); + } + + entries.push( + { tag: "meta", attrs: { name: "twitter:title", content: twitterTitle } }, + { + tag: "meta", + attrs: { name: "twitter:description", content: twitterDescription }, + } + ); + + if (twitterImage) { + entries.push({ + tag: "meta", + attrs: { name: "twitter:image", content: twitterImage.url }, + }); + } + + if (twitterImage?.alt) { + entries.push({ + tag: "meta", + attrs: { name: "twitter:image:alt", content: twitterImage.alt }, + }); + } + + for (const tag of metaTags) { + entries.push({ + tag: "meta", + attrs: + "name" in tag + ? { name: tag.name, content: tag.content } + : { property: tag.property, content: tag.content }, + }); + } + + for (const item of toStructuredDataItems(structuredData)) { + entries.push({ + tag: "script", + attrs: { type: "application/ld+json" }, + content: safeJsonLdStringify(item), + }); + } + + return entries; +} From 95c5d2f14ce0efa1f0eb8cc1c573e3b1b562f7e7 Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 16:43:31 +0900 Subject: [PATCH 3/6] chore(landing): strengthen SEO schema typing --- apps/landing/src/shared/seo/schema.ts | 38 +++++++++++++---------- bun.lock | 7 +++++ package.json | 1 + packages/astro-seo/package.json | 4 ++- packages/astro-seo/src/index.ts | 3 ++ packages/astro-seo/src/json-ld.test.ts | 12 +++++++ packages/astro-seo/src/json-ld.ts | 43 +++++++++++++++++++------- 7 files changed, 79 insertions(+), 29 deletions(-) diff --git a/apps/landing/src/shared/seo/schema.ts b/apps/landing/src/shared/seo/schema.ts index 97f2350f..c0c18d3e 100644 --- a/apps/landing/src/shared/seo/schema.ts +++ b/apps/landing/src/shared/seo/schema.ts @@ -1,4 +1,8 @@ -import type { StructuredData } from "@onequery/astro-seo"; +import type { + JsonLdObject, + StructuredData, + StructuredDataGraph, +} from "@onequery/astro-seo"; import type { BlogPost, BlogPostSummary } from "@/features/blog/types"; import type { @@ -25,6 +29,8 @@ import type { SeoImage } from "./constants"; export type { StructuredData }; +type StructuredDataNode = JsonLdObject; + const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; const READ_TIME_MINUTES_PATTERN = /\d+/u; const CORE_TOPICS = [ @@ -157,7 +163,7 @@ export function getBlogPostKeywords(post: Pick) { ].join(", "); } -function createGraph(graph: StructuredData[]): StructuredData { +function createGraph(graph: StructuredDataNode[]): StructuredDataGraph { return { "@context": "https://schema.org", "@graph": graph, @@ -211,7 +217,7 @@ function createImageObject(input: { site: SiteInput; url: string; width: number; -}) { +}): StructuredDataNode { return { "@type": "ImageObject", url: toAbsoluteSiteUrl(input.url, input.site), @@ -221,7 +227,7 @@ function createImageObject(input: { }; } -function createOneQueryOrganization(site: SiteInput): StructuredData { +function createOneQueryOrganization(site: SiteInput): StructuredDataNode { const siteUrl = normalizeSiteUrl(site); return { @@ -239,7 +245,7 @@ function createOneQueryOrganization(site: SiteInput): StructuredData { }; } -function createOneQueryWebsite(site: SiteInput): StructuredData { +function createOneQueryWebsite(site: SiteInput): StructuredDataNode { const siteUrl = normalizeSiteUrl(site); return { @@ -258,7 +264,7 @@ function createOneQueryWebsite(site: SiteInput): StructuredData { function createOneQuerySoftwareApplication(input: { description: string; site: SiteInput; -}): StructuredData { +}): StructuredDataNode { const siteUrl = normalizeSiteUrl(input.site); return { @@ -292,7 +298,7 @@ function createOneQuerySoftwareApplication(input: { function createDemoVideoStructuredData( input: DemoVideoStructuredDataInput & { site: SiteInput } -): StructuredData { +): StructuredDataNode { const siteUrl = normalizeSiteUrl(input.site); const thumbnailUrl = toAbsoluteSiteUrl(input.thumbnail.url, siteUrl); const pageUrl = toAbsoluteSiteUrl(input.pageUrl, siteUrl); @@ -326,7 +332,7 @@ function createDemoVideoStructuredData( }; } -function createSiteGraph(site: SiteInput): StructuredData[] { +function createSiteGraph(site: SiteInput): StructuredDataNode[] { return [createOneQueryOrganization(site), createOneQueryWebsite(site)]; } @@ -342,7 +348,7 @@ function createConnectorFeatureList(connector: DataSourceConnector) { function createConnectorSoftwareApplication( connector: DataSourceConnector, site: SiteInput -): StructuredData { +): StructuredDataNode { const siteUrl = normalizeSiteUrl(site); const connectorUrl = createCanonicalUrl(getConnectorPath(connector), siteUrl); const connectorId = getNodeId(connectorUrl, SCHEMA_FRAGMENTS.CONNECTOR); @@ -371,7 +377,7 @@ function createConnectorSoftwareApplication( export function createHomePageStructuredData( input: HomePageStructuredDataInput -): StructuredData { +): StructuredDataGraph { const siteUrl = normalizeSiteUrl(input.site); const videoReference = input.video ? { @@ -445,7 +451,7 @@ export function createHomePageStructuredData( export function createDocsIndexStructuredData( input: DocsIndexStructuredDataInput -): StructuredData { +): StructuredDataGraph { const siteUrl = normalizeSiteUrl(input.site); const docsUrl = createCanonicalUrl(SEO_PATHS.DOCS, siteUrl); @@ -496,7 +502,7 @@ export function createDocsIndexStructuredData( export function createBlogIndexStructuredData( input: BlogIndexStructuredDataInput -): StructuredData { +): StructuredDataGraph { const siteUrl = normalizeSiteUrl(input.site); const blogUrl = createCanonicalUrl(SEO_PATHS.BLOG, siteUrl); const pageUrl = createCanonicalUrl(input.pathname, siteUrl); @@ -583,7 +589,7 @@ export function createBlogIndexStructuredData( export function createConnectorIndexStructuredData( input: ConnectorIndexStructuredDataInput -): StructuredData { +): StructuredDataGraph { const siteUrl = normalizeSiteUrl(input.site); const connectorsUrl = createCanonicalUrl(SEO_PATHS.CONNECTORS, siteUrl); @@ -647,7 +653,7 @@ export function createConnectorIndexStructuredData( export function createConnectorPageStructuredData( input: ConnectorPageStructuredDataInput -): StructuredData { +): StructuredDataGraph { const siteUrl = normalizeSiteUrl(input.site); const connectorsUrl = createCanonicalUrl(SEO_PATHS.CONNECTORS, siteUrl); const connectorUrl = createCanonicalUrl( @@ -746,7 +752,7 @@ export function createConnectorPageStructuredData( export function createBlogPostStructuredData( input: BlogPostStructuredDataInput -): StructuredData { +): StructuredDataGraph { const { image, post } = input; const siteUrl = normalizeSiteUrl(input.site); const blogUrl = createCanonicalUrl(SEO_PATHS.BLOG, siteUrl); @@ -858,7 +864,7 @@ function createBlogPostSummaryStructuredData( post: BlogPostSummary, site: SiteInput, image: SeoImage -): StructuredData { +): StructuredDataNode { const siteUrl = normalizeSiteUrl(site); const postUrl = getBlogPostUrl(post.slug, siteUrl); const publishedTime = toIsoDateTime(post.publishedAt); diff --git a/bun.lock b/bun.lock index 4e99fe0d..a09bc33b 100644 --- a/bun.lock +++ b/bun.lock @@ -191,10 +191,12 @@ "version": "0.0.0", "devDependencies": { "astro": "catalog:", + "schema-dts": "catalog:", "vitest": "catalog:testing", }, "peerDependencies": { "astro": "^6.3.0", + "schema-dts": "^2.0.0", }, }, "packages/audit-contracts": { @@ -486,6 +488,7 @@ "remark-gfm": "4.0.1", "remark-stringify": "11.0.0", "remotion": "4.0.448", + "schema-dts": "2.0.0", "simple-icons": "16.20.0", "smol-toml": "1.6.1", "sonner": "2.0.7", @@ -3900,6 +3903,10 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "schema-dts": ["schema-dts@2.0.0", "", { "dependencies": { "schema-dts-lib": "^1.0.0" } }, "sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw=="], + + "schema-dts-lib": ["schema-dts-lib@1.0.0", "", { "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-9MEO5vpQH9JdBioUupqluzxSYxPLjhmqRUudk15adUl/ypnRsM2/M1kN3AmVJQeG7nZqcL68H8JlGqQQT6vy9A=="], + "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], "sdp-transform": ["sdp-transform@3.0.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ=="], diff --git a/package.json b/package.json index d22d19c4..71534c1e 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "remotion": "4.0.448", "remark-gfm": "4.0.1", "remark-stringify": "11.0.0", + "schema-dts": "2.0.0", "simple-icons": "16.20.0", "smol-toml": "1.6.1", "sonner": "2.0.7", diff --git a/packages/astro-seo/package.json b/packages/astro-seo/package.json index 8d171868..0cec97cf 100644 --- a/packages/astro-seo/package.json +++ b/packages/astro-seo/package.json @@ -18,9 +18,11 @@ }, "devDependencies": { "astro": "catalog:", + "schema-dts": "catalog:", "vitest": "catalog:testing" }, "peerDependencies": { - "astro": "^6.3.0" + "astro": "^6.3.0", + "schema-dts": "^2.0.0" } } diff --git a/packages/astro-seo/src/index.ts b/packages/astro-seo/src/index.ts index 5a6a9405..1359d982 100644 --- a/packages/astro-seo/src/index.ts +++ b/packages/astro-seo/src/index.ts @@ -4,9 +4,12 @@ export type { JsonLdObject, JsonLdScalar, JsonLdValue, + SchemaOrgStructuredData, StructuredData, + StructuredDataGraph, StructuredDataInput, } from "./json-ld"; +export type { Graph, Thing, WithContext } from "schema-dts"; export { createSeoHeadEntries, formatKeywords } from "./metadata"; export type { MetaTag, diff --git a/packages/astro-seo/src/json-ld.test.ts b/packages/astro-seo/src/json-ld.test.ts index bb73ea69..62c71388 100644 --- a/packages/astro-seo/src/json-ld.test.ts +++ b/packages/astro-seo/src/json-ld.test.ts @@ -1,3 +1,4 @@ +import type { WebPage, WithContext } from "schema-dts"; import { describe, expect, it } from "vitest"; import { safeJsonLdStringify, toStructuredDataItems } from "./json-ld"; @@ -37,6 +38,17 @@ describe("safeJsonLdStringify", () => { }, }); }); + + it("accepts schema-dts typed schema.org objects", () => { + const item = { + "@context": "https://schema.org", + "@type": "WebPage", + name: "OneQuery Documentation", + url: "https://onequery.dev/docs/", + } satisfies WithContext; + + expect(JSON.parse(safeJsonLdStringify(item))).toEqual(item); + }); }); describe("toStructuredDataItems", () => { diff --git a/packages/astro-seo/src/json-ld.ts b/packages/astro-seo/src/json-ld.ts index 50a8dd8c..b2310ac8 100644 --- a/packages/astro-seo/src/json-ld.ts +++ b/packages/astro-seo/src/json-ld.ts @@ -1,5 +1,21 @@ +import type { + Graph, + JsonLdObject as SchemaDtsJsonLdObject, + Thing, + WithContext, +} from "schema-dts"; + export type JsonLdScalar = boolean | number | string; +export type SchemaOrgStructuredData< + T extends SchemaDtsJsonLdObject | string = Thing, +> = Graph | WithContext; + +export type StructuredDataGraph = { + readonly "@context": "https://schema.org"; + readonly "@graph": readonly StructuredData[]; +}; + export type JsonLdArray = readonly JsonLdValue[]; export type JsonLdObject = { @@ -8,13 +24,14 @@ export type JsonLdObject = { export type JsonLdValue = JsonLdArray | JsonLdObject | JsonLdScalar | null; -export type StructuredData = JsonLdObject; +export type StructuredData = + | JsonLdObject + | StructuredDataGraph + | SchemaOrgStructuredData; -export type StructuredDataInput = - | readonly StructuredData[] - | StructuredData - | null - | undefined; +export type StructuredDataInput< + T extends SchemaDtsJsonLdObject | string = Thing, +> = readonly StructuredData[] | StructuredData | null | undefined; const JSON_LD_SCRIPT_ESCAPE_PATTERN = /[<>&\u2028\u2029]/gu; @@ -44,9 +61,11 @@ function omitNullValues(_key: string, value: unknown) { return value; } -function isStructuredDataArray( - structuredData: StructuredDataInput -): structuredData is readonly StructuredData[] { +function isStructuredDataArray< + T extends SchemaDtsJsonLdObject | string = Thing, +>( + structuredData: StructuredDataInput +): structuredData is readonly StructuredData[] { return Array.isArray(structuredData); } @@ -60,9 +79,9 @@ export function safeJsonLdStringify( ); } -export function toStructuredDataItems( - structuredData: StructuredDataInput -): readonly StructuredData[] { +export function toStructuredDataItems< + T extends SchemaDtsJsonLdObject | string = Thing, +>(structuredData: StructuredDataInput): readonly StructuredData[] { if (!structuredData) { return []; } From 06d65dbd92ebc9d05a678cc69e8bca0e3e627bd0 Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 16:52:36 +0900 Subject: [PATCH 4/6] chore(landing): refine astro seo dependency metadata --- packages/astro-seo/package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/astro-seo/package.json b/packages/astro-seo/package.json index 0cec97cf..fd046303 100644 --- a/packages/astro-seo/package.json +++ b/packages/astro-seo/package.json @@ -16,13 +16,14 @@ "test:watch": "vitest", "typecheck": "tsgo --noEmit" }, + "dependencies": { + "schema-dts": "catalog:" + }, "devDependencies": { "astro": "catalog:", - "schema-dts": "catalog:", "vitest": "catalog:testing" }, "peerDependencies": { - "astro": "^6.3.0", - "schema-dts": "^2.0.0" + "astro": "^6.3.0" } } From d4f427cfff9041487d97074164edcac9b28d685e Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 16:55:06 +0900 Subject: [PATCH 5/6] chore(landing): trim low-value tests and re-exports --- .../src/features/connectors/data.test.ts | 17 --------- apps/landing/src/layouts/types.ts | 2 - apps/landing/src/shared/seo/docs.test.ts | 38 ------------------- apps/landing/src/shared/seo/schema.ts | 8 +--- packages/astro-seo/src/index.ts | 1 - 5 files changed, 1 insertion(+), 65 deletions(-) delete mode 100644 apps/landing/src/shared/seo/docs.test.ts diff --git a/apps/landing/src/features/connectors/data.test.ts b/apps/landing/src/features/connectors/data.test.ts index f6292713..06fab4a9 100644 --- a/apps/landing/src/features/connectors/data.test.ts +++ b/apps/landing/src/features/connectors/data.test.ts @@ -26,23 +26,6 @@ describe("DATA_SOURCE_CONNECTORS", () => { ); }); - it("includes recently added source providers", () => { - expect(DATA_SOURCE_CONNECTORS).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - category: "Productivity", - key: "cal", - label: "Cal.com", - }), - expect.objectContaining({ - category: "Productivity", - key: "granola", - label: "Granola", - }), - ]) - ); - }); - it("derives unique SEO slugs for connector landing pages", () => { const slugs = DATA_SOURCE_CONNECTORS.map((connector) => connector.slug); diff --git a/apps/landing/src/layouts/types.ts b/apps/landing/src/layouts/types.ts index 503d3832..6214667c 100644 --- a/apps/landing/src/layouts/types.ts +++ b/apps/landing/src/layouts/types.ts @@ -1,7 +1,5 @@ import type { MetaTag, StructuredData } from "@onequery/astro-seo"; -export type { MetaTag }; - export interface PageMetadata { canonicalUrl?: string; description?: string; diff --git a/apps/landing/src/shared/seo/docs.test.ts b/apps/landing/src/shared/seo/docs.test.ts deleted file mode 100644 index 4eb6c1ea..00000000 --- a/apps/landing/src/shared/seo/docs.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - DOCS_INDEX_DESCRIPTION, - DOCS_INDEX_TITLE, - createDocsIndexHeadEntries, -} from "./docs"; - -describe("createDocsIndexHeadEntries", () => { - it("creates reusable docs index SEO head entries", () => { - const entries = createDocsIndexHeadEntries(); - - expect(entries).toContainEqual({ - tag: "title", - content: DOCS_INDEX_TITLE, - }); - expect(entries).toContainEqual({ - tag: "meta", - attrs: { - name: "description", - content: DOCS_INDEX_DESCRIPTION, - }, - }); - expect(entries).toContainEqual({ - tag: "meta", - attrs: { - property: "og:image:width", - content: "1200", - }, - }); - - const jsonLd = entries.find((entry) => entry.tag === "script"); - - expect(jsonLd?.content).toContain( - '"@id":"https://onequery.dev/docs/#webpage"' - ); - }); -}); diff --git a/apps/landing/src/shared/seo/schema.ts b/apps/landing/src/shared/seo/schema.ts index c0c18d3e..353fb164 100644 --- a/apps/landing/src/shared/seo/schema.ts +++ b/apps/landing/src/shared/seo/schema.ts @@ -1,8 +1,4 @@ -import type { - JsonLdObject, - StructuredData, - StructuredDataGraph, -} from "@onequery/astro-seo"; +import type { JsonLdObject, StructuredDataGraph } from "@onequery/astro-seo"; import type { BlogPost, BlogPostSummary } from "@/features/blog/types"; import type { @@ -27,8 +23,6 @@ import { } from "./constants"; import type { SeoImage } from "./constants"; -export type { StructuredData }; - type StructuredDataNode = JsonLdObject; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; diff --git a/packages/astro-seo/src/index.ts b/packages/astro-seo/src/index.ts index 1359d982..7a547d79 100644 --- a/packages/astro-seo/src/index.ts +++ b/packages/astro-seo/src/index.ts @@ -9,7 +9,6 @@ export type { StructuredDataGraph, StructuredDataInput, } from "./json-ld"; -export type { Graph, Thing, WithContext } from "schema-dts"; export { createSeoHeadEntries, formatKeywords } from "./metadata"; export type { MetaTag, From ee717d866853f2d854976a899835f01b3f57569a Mon Sep 17 00:00:00 2001 From: lentil32 Date: Mon, 1 Jun 2026 17:02:12 +0900 Subject: [PATCH 6/6] Use remark plugin for blog reading time --- apps/landing/astro.config.ts | 4 ++ apps/landing/package.json | 2 + .../blog/context-enrichment-with-onequery.mdx | 1 - ...ug-production-agent-runs-with-onequery.mdx | 1 - .../do-not-give-agents-production-keys.mdx | 1 - ...rtups-can-build-an-in-house-data-agent.mdx | 1 - .../blog/llm-safe-data-access-layer.mdx | 1 - .../blog/making-data-source-setup-boring.mdx | 1 - ...telemetry-to-improve-prompts-with-gepa.mdx | 1 - apps/landing/src/features/blog/collection.ts | 46 +++++++++++++++++-- .../src/features/blog/remark-reading-time.ts | 21 +++++++++ apps/landing/src/features/blog/schema.ts | 1 - apps/landing/src/features/blog/types.ts | 1 + apps/landing/src/pages/blog/[postSlug].astro | 4 +- bun.lock | 9 +++- 15 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 apps/landing/src/features/blog/remark-reading-time.ts diff --git a/apps/landing/astro.config.ts b/apps/landing/astro.config.ts index 3df9638e..a982e16c 100644 --- a/apps/landing/astro.config.ts +++ b/apps/landing/astro.config.ts @@ -10,6 +10,7 @@ import { agentMarkdown } from "@onequery/astro-agent-markdown/astro"; import { defineConfig, envField, fontProviders } from "astro/config"; import { visualizer } from "rollup-plugin-visualizer"; +import { remarkReadingTime } from "./src/features/blog/remark-reading-time"; import { DEFAULT_DEV_PORT, DEV_SERVER_HOST, @@ -171,6 +172,9 @@ export default defineConfig({ ], }), ], + markdown: { + remarkPlugins: [remarkReadingTime], + }, server: { host: DEV_SERVER_HOST, port: DEFAULT_DEV_PORT, diff --git a/apps/landing/package.json b/apps/landing/package.json index 34f5a3e6..045154ac 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -32,9 +32,11 @@ "@onequery/ui": "workspace:*", "astro": "catalog:", "better-result": "catalog:", + "mdast-util-to-string": "4.0.0", "nanostores": "catalog:", "react": "19.3.0-canary-d5736f09-20260507", "react-dom": "19.3.0-canary-d5736f09-20260507", + "reading-time": "1.5.0", "simple-icons": "catalog:", "zod": "catalog:" }, diff --git a/apps/landing/src/content/blog/context-enrichment-with-onequery.mdx b/apps/landing/src/content/blog/context-enrichment-with-onequery.mdx index e9770dd9..608286ec 100644 --- a/apps/landing/src/content/blog/context-enrichment-with-onequery.mdx +++ b/apps/landing/src/content/blog/context-enrichment-with-onequery.mdx @@ -6,7 +6,6 @@ coverImage: src: "../../assets/blog/context-enrichment-with-onequery-icon.png" alt: "Context Enrichment with OneQuery - OneQuery blog cover image." publishedAt: "2026-05-01" -readTime: "10 min read" --- ## From Context Enrichment to Implementation diff --git a/apps/landing/src/content/blog/debug-production-agent-runs-with-onequery.mdx b/apps/landing/src/content/blog/debug-production-agent-runs-with-onequery.mdx index 976bd321..e5417670 100644 --- a/apps/landing/src/content/blog/debug-production-agent-runs-with-onequery.mdx +++ b/apps/landing/src/content/blog/debug-production-agent-runs-with-onequery.mdx @@ -6,7 +6,6 @@ coverImage: src: "../../assets/blog/debug-production-agent-runs-with-onequery-icon.png" alt: "Debugging production on Cloudflare with Codex - OneQuery blog cover image." publishedAt: "2026-05-06" -readTime: "7 min read" --- ## The Concept: Connect Cloudflare Logs to Codex diff --git a/apps/landing/src/content/blog/do-not-give-agents-production-keys.mdx b/apps/landing/src/content/blog/do-not-give-agents-production-keys.mdx index 5c40afcb..e8147664 100644 --- a/apps/landing/src/content/blog/do-not-give-agents-production-keys.mdx +++ b/apps/landing/src/content/blog/do-not-give-agents-production-keys.mdx @@ -6,7 +6,6 @@ coverImage: src: "../../assets/blog/do-not-give-agents-production-keys-icon.png" alt: "Do not give agents the keys to production - OneQuery blog cover image." publishedAt: "2026-04-29" -readTime: "9 min read" --- ## Agents are not operators diff --git a/apps/landing/src/content/blog/how-startups-can-build-an-in-house-data-agent.mdx b/apps/landing/src/content/blog/how-startups-can-build-an-in-house-data-agent.mdx index b2308114..6e2ac847 100644 --- a/apps/landing/src/content/blog/how-startups-can-build-an-in-house-data-agent.mdx +++ b/apps/landing/src/content/blog/how-startups-can-build-an-in-house-data-agent.mdx @@ -6,7 +6,6 @@ coverImage: src: "../../assets/blog/how-startups-can-build-an-in-house-data-agent-icon.png" alt: "How startups can build an in-house data agent - OneQuery blog cover image." publishedAt: "2026-04-28" -readTime: "10 min read" --- ## What OpenAI actually built diff --git a/apps/landing/src/content/blog/llm-safe-data-access-layer.mdx b/apps/landing/src/content/blog/llm-safe-data-access-layer.mdx index 610b24f7..348a8faa 100644 --- a/apps/landing/src/content/blog/llm-safe-data-access-layer.mdx +++ b/apps/landing/src/content/blog/llm-safe-data-access-layer.mdx @@ -6,7 +6,6 @@ coverImage: src: "../../assets/blog/llm-safe-data-access-layer-icon.png" alt: "A Safe Data Access Layer for LLMs - OneQuery blog cover image." publishedAt: "2026-04-30" -readTime: "8 min read" --- ## LLM Risk Is Authority Risk diff --git a/apps/landing/src/content/blog/making-data-source-setup-boring.mdx b/apps/landing/src/content/blog/making-data-source-setup-boring.mdx index 63ae08e6..0e929c1c 100644 --- a/apps/landing/src/content/blog/making-data-source-setup-boring.mdx +++ b/apps/landing/src/content/blog/making-data-source-setup-boring.mdx @@ -6,7 +6,6 @@ coverImage: src: "../../assets/blog/making-data-source-setup-boring-icon.png" alt: "Making data source setup boring - OneQuery blog cover image." publishedAt: "2026-04-21" -readTime: "7 min read" --- ## Why source setup stays messy diff --git a/apps/landing/src/content/blog/using-llm-telemetry-to-improve-prompts-with-gepa.mdx b/apps/landing/src/content/blog/using-llm-telemetry-to-improve-prompts-with-gepa.mdx index 5926b247..c1743606 100644 --- a/apps/landing/src/content/blog/using-llm-telemetry-to-improve-prompts-with-gepa.mdx +++ b/apps/landing/src/content/blog/using-llm-telemetry-to-improve-prompts-with-gepa.mdx @@ -6,7 +6,6 @@ coverImage: src: "../../assets/blog/using-llm-telemetry-to-improve-prompts-with-gepa-icon.png" alt: "Using LLM telemetry to improve prompts with GEPA - OneQuery blog cover image." publishedAt: "2026-04-30" -readTime: "9 min read" --- ## What is GEPA? diff --git a/apps/landing/src/features/blog/collection.ts b/apps/landing/src/features/blog/collection.ts index 55b4832f..208a7898 100644 --- a/apps/landing/src/features/blog/collection.ts +++ b/apps/landing/src/features/blog/collection.ts @@ -1,8 +1,9 @@ -import { getCollection } from "astro:content"; import type { CollectionEntry } from "astro:content"; +import { getCollection, render } from "astro:content"; import type { BlogPost, BlogPostContent, BlogPostSummary } from "./types"; +const READ_TIME_PATTERN = /^\d+ min read$/u; const blogDateFormatter = new Intl.DateTimeFormat("en-US", { day: "numeric", month: "short", @@ -10,10 +11,40 @@ const blogDateFormatter = new Intl.DateTimeFormat("en-US", { year: "numeric", }); +type BlogPostRenderData = { + headings?: BlogPost["headings"]; + remarkPluginFrontmatter: unknown; +}; + function formatBlogPostDate(publishedAt: string) { return blogDateFormatter.format(new Date(`${publishedAt}T00:00:00.000Z`)); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getBlogPostReadTime( + entry: CollectionEntry<"blog">, + remarkPluginFrontmatter: unknown +) { + if (!isRecord(remarkPluginFrontmatter)) { + throw new Error( + `Blog post "${entry.id}" is missing remark plugin frontmatter.` + ); + } + + const { readTime } = remarkPluginFrontmatter; + + if (typeof readTime === "string" && READ_TIME_PATTERN.test(readTime)) { + return readTime; + } + + throw new Error( + `Blog post "${entry.id}" is missing a valid remark-generated readTime.` + ); +} + export function comparePostDates( left: Pick, right: Pick @@ -36,7 +67,7 @@ function toBlogPostSummary(post: BlogPost): BlogPostSummary { export function toBlogPost( entry: CollectionEntry<"blog">, - headings: BlogPost["headings"] = [] + { headings = [], remarkPluginFrontmatter }: BlogPostRenderData ): BlogPost { const data = entry.data as BlogPostContent; @@ -45,6 +76,7 @@ export function toBlogPost( body: entry.body ?? "", date: formatBlogPostDate(data.publishedAt), headings, + readTime: getBlogPostReadTime(entry, remarkPluginFrontmatter), slug: entry.id, }; } @@ -56,7 +88,15 @@ export async function getBlogPostEntries(): Promise[]> { } export async function getBlogPosts(): Promise { - return (await getBlogPostEntries()).map((entry) => toBlogPost(entry)); + const entries = await getBlogPostEntries(); + + return Promise.all( + entries.map(async (entry) => { + const { headings, remarkPluginFrontmatter } = await render(entry); + + return toBlogPost(entry, { headings, remarkPluginFrontmatter }); + }) + ); } export async function getBlogPostSummaries(): Promise { diff --git a/apps/landing/src/features/blog/remark-reading-time.ts b/apps/landing/src/features/blog/remark-reading-time.ts new file mode 100644 index 00000000..c5245d28 --- /dev/null +++ b/apps/landing/src/features/blog/remark-reading-time.ts @@ -0,0 +1,21 @@ +import { toString } from "mdast-util-to-string"; +import getReadingTime from "reading-time"; + +type RemarkVFile = { + data: { + astro?: { + frontmatter?: Record; + }; + }; +}; + +export function remarkReadingTime() { + return function transform(tree: unknown, file: RemarkVFile) { + const textOnPage = toString(tree); + const readingTime = getReadingTime(textOnPage); + + file.data.astro ??= {}; + file.data.astro.frontmatter ??= {}; + file.data.astro.frontmatter.readTime = readingTime.text; + }; +} diff --git a/apps/landing/src/features/blog/schema.ts b/apps/landing/src/features/blog/schema.ts index ff67d8fa..815e9b40 100644 --- a/apps/landing/src/features/blog/schema.ts +++ b/apps/landing/src/features/blog/schema.ts @@ -18,7 +18,6 @@ export function createBlogPostContentSchema(context: SchemaContext) { coverImage: blogImageSchema, description: z.string().min(1), publishedAt: z.iso.date(), - readTime: z.string().regex(/^\d+ min read$/u), title: z.string().min(1), }); } diff --git a/apps/landing/src/features/blog/types.ts b/apps/landing/src/features/blog/types.ts index cb3440b1..d83240b3 100644 --- a/apps/landing/src/features/blog/types.ts +++ b/apps/landing/src/features/blog/types.ts @@ -11,6 +11,7 @@ export type BlogPost = BlogPostContent & { body: string; date: string; headings: readonly MarkdownHeading[]; + readTime: string; slug: string; }; diff --git a/apps/landing/src/pages/blog/[postSlug].astro b/apps/landing/src/pages/blog/[postSlug].astro index ed36c2e5..ea490e8c 100644 --- a/apps/landing/src/pages/blog/[postSlug].astro +++ b/apps/landing/src/pages/blog/[postSlug].astro @@ -32,8 +32,8 @@ export async function getStaticPaths() { } const { entry } = Astro.props; -const { Content, headings } = await render(entry); -const post = toBlogPost(entry, headings); +const { Content, headings, remarkPluginFrontmatter } = await render(entry); +const post = toBlogPost(entry, { headings, remarkPluginFrontmatter }); const postSummaries = await getBlogPostSummaries(); const shareImage = await getBlogShareImage( getPreferredBlogShareImage(post), diff --git a/bun.lock b/bun.lock index a09bc33b..b0d3df76 100644 --- a/bun.lock +++ b/bun.lock @@ -130,9 +130,11 @@ "@onequery/ui": "workspace:*", "astro": "catalog:", "better-result": "catalog:", + "mdast-util-to-string": "4.0.0", "nanostores": "catalog:", "react": "19.3.0-canary-d5736f09-20260507", "react-dom": "19.3.0-canary-d5736f09-20260507", + "reading-time": "1.5.0", "simple-icons": "catalog:", "zod": "catalog:", }, @@ -189,14 +191,15 @@ "packages/astro-seo": { "name": "@onequery/astro-seo", "version": "0.0.0", + "dependencies": { + "schema-dts": "catalog:", + }, "devDependencies": { "astro": "catalog:", - "schema-dts": "catalog:", "vitest": "catalog:testing", }, "peerDependencies": { "astro": "^6.3.0", - "schema-dts": "^2.0.0", }, }, "packages/audit-contracts": { @@ -3787,6 +3790,8 @@ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],