diff --git a/apps/landing/astro.config.ts b/apps/landing/astro.config.ts index e02d1de6..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, @@ -112,6 +113,7 @@ export default defineConfig({ alt: "OneQuery", src: "/src/assets/onequery-icon.svg", }, + routeMiddleware: "./src/starlightRouteData.ts", sidebar: [ { items: ["docs", "docs/getting-started"], @@ -170,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 a5886d99..045154ac 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -27,13 +27,16 @@ "@astrojs/starlight": "catalog:", "@nanostores/react": "catalog:", "@onequery/astro-agent-markdown": "workspace:*", + "@onequery/astro-seo": "workspace:*", "@onequery/db": "workspace:*", "@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/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/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/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/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", + }, + }); + }); + + 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", () => { + 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..b2310ac8 --- /dev/null +++ b/packages/astro-seo/src/json-ld.ts @@ -0,0 +1,94 @@ +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 = { + readonly [key: string]: JsonLdValue | undefined; +}; + +export type JsonLdValue = JsonLdArray | JsonLdObject | JsonLdScalar | null; + +export type StructuredData = + | JsonLdObject + | StructuredDataGraph + | SchemaOrgStructuredData; + +export type StructuredDataInput< + T extends SchemaDtsJsonLdObject | string = Thing, +> = 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< + T extends SchemaDtsJsonLdObject | string = Thing, +>( + 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< + T extends SchemaDtsJsonLdObject | string = Thing, +>(structuredData: StructuredDataInput): readonly StructuredData[] { + if (!structuredData) { + return []; + } + + if (isStructuredDataArray(structuredData)) { + return structuredData; + } + + return [structuredData]; +} diff --git a/packages/astro-seo/src/metadata.test.ts b/packages/astro-seo/src/metadata.test.ts new file mode 100644 index 00000000..77d80ae0 --- /dev/null +++ b/packages/astro-seo/src/metadata.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { createSeoHeadEntries, formatKeywords } from "./metadata"; + +describe("formatKeywords", () => { + it("normalizes keyword arrays", () => { + expect(formatKeywords([" OneQuery ", "", "AI agents"])).toBe( + "OneQuery, AI agents" + ); + }); +}); + +describe("createSeoHeadEntries", () => { + it("creates social metadata and escaped JSON-LD script entries", () => { + const entries = createSeoHeadEntries({ + canonicalUrl: "https://onequery.dev/docs/", + description: "Docs for governed agent access.", + image: { + alt: "OneQuery share image", + height: 630, + type: "image/png", + url: "https://onequery.dev/og.png", + width: 1200, + }, + keywords: ["OneQuery docs", "agent data access"], + openGraph: { + locale: "en_US", + siteName: "OneQuery", + type: "website", + }, + robots: "index, follow", + structuredData: { + "@context": "https://schema.org", + "@type": "WebPage", + name: "", + }, + 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 new file mode 100644 index 00000000..86d20571 --- /dev/null +++ b/packages/astro-seo/src/metadata.ts @@ -0,0 +1,333 @@ +import { safeJsonLdStringify, toStructuredDataItems } from "./json-ld"; +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 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 +) { + 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; +} + +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; +} 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", + }, +});