From 515a1f065c445e988edb29055eca28f1dc7077ea Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Wed, 26 Aug 2026 16:07:51 +0200 Subject: [PATCH] feat(seo): add configurable identity schema to JSON-LD --- docs/app/app.config.ts | 32 +++++++++ layer/app/composables/useSeo.ts | 124 +++++++++++++++++++++++++++++++- layer/app/types/index.d.ts | 56 +++++++++++++++ 3 files changed, 209 insertions(+), 3 deletions(-) diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts index dddf34414..892fbb7c6 100644 --- a/docs/app/app.config.ts +++ b/docs/app/app.config.ts @@ -2,6 +2,38 @@ export default defineAppConfig({ search: { fts: true, }, + seo: { + schema: { + type: 'SoftwareApplication', + applicationCategory: 'DeveloperApplication', + operatingSystem: 'Web', + price: 0, + priceCurrency: 'USD', + sameAs: [ + 'https://github.com/nuxt-content/docus', + 'https://www.npmjs.com/package/docus', + ], + organization: { + name: 'Nuxt', + url: 'https://nuxt.com', + logo: '/logo/logo-dark.svg', + sameAs: [ + 'https://github.com/nuxt', + 'https://x.com/nuxt_js', + 'https://discord.com/invite/ps2h6QT', + ], + parentOrganization: { + name: 'Vercel', + url: 'https://vercel.com', + sameAs: [ + 'https://github.com/vercel', + 'https://x.com/vercel', + 'https://www.linkedin.com/company/vercel', + ], + }, + }, + }, + }, header: { title: 'Docus', logo: { diff --git a/layer/app/composables/useSeo.ts b/layer/app/composables/useSeo.ts index c2b099e92..6ac2f16a2 100644 --- a/layer/app/composables/useSeo.ts +++ b/layer/app/composables/useSeo.ts @@ -1,4 +1,5 @@ import type { MaybeRefOrGetter } from 'vue' +import type { AppConfig } from 'nuxt/schema' import type { BreadcrumbItem } from '../utils/navigation' import { joinURL, withoutTrailingSlash } from 'ufo' @@ -33,6 +34,98 @@ export interface UseSeoOptions { breadcrumbs?: MaybeRefOrGetter } +type SeoSchemaConfig = NonNullable + +type SeoOrganizationConfig = NonNullable + +/** + * `contactPoint` and `address` are intentionally not derived: they can only come + * from real business data, so a site has to provide them itself. + */ +function buildOrganizationNode(organization: SeoOrganizationConfig, id: string, baseUrl: string) { + const node: Record = { + '@type': 'Organization', + '@id': id, + 'name': organization.name, + 'url': organization.url || baseUrl, + } + + if (organization.logo) { + node.logo = organization.logo.startsWith('http') ? organization.logo : joinURL(baseUrl, organization.logo) + } + + if (organization.sameAs?.length) { + node.sameAs = organization.sameAs + } + + return node +} + +/** + * `Organization` nodes for the site publisher and, when the publisher belongs to + * a larger company, its parent. Both are linked so the graph has no orphan node, + * and `publisher` keeps pointing at a single entity. + */ +function buildOrganizationSchemas(schema: SeoSchemaConfig | undefined, baseUrl: string) { + const organization = schema?.organization + if (!organization?.name) return [] + + const publisher = buildOrganizationNode(organization, `${baseUrl}/#organization`, baseUrl) + const nodes = [publisher] + + const parent = organization.parentOrganization + if (parent?.name) { + const parentId = `${baseUrl}/#parent-organization` + publisher.parentOrganization = { '@id': parentId } + nodes.push(buildOrganizationNode(parent, parentId, baseUrl)) + } + + return nodes +} + +/** The node that answers "what is this site?": a product, a company, a person. */ +function buildIdentitySchema( + schema: SeoSchemaConfig | undefined, + context: { baseUrl: string, name: string | undefined, description: string | undefined, organizationId?: string }, +) { + const type = schema?.type + if (!type || !context.name) return undefined + + // The publisher Organization is already emitted as its own node. + if (type === 'Organization' && schema?.organization?.name) return undefined + + const node: Record = { + '@type': type, + '@id': `${context.baseUrl}/#identity`, + 'name': context.name, + 'description': context.description, + 'url': context.baseUrl, + } + + if (schema?.sameAs?.length) { + node.sameAs = schema.sameAs + } + + if (type === 'SoftwareApplication') { + node.applicationCategory = schema?.applicationCategory || 'DeveloperApplication' + node.operatingSystem = schema?.operatingSystem || 'Web' + } + + if (typeof schema?.price === 'number' && (type === 'SoftwareApplication' || type === 'Product')) { + node.offers = { + '@type': 'Offer', + 'price': schema.price, + 'priceCurrency': schema.priceCurrency || 'USD', + } + } + + if (context.organizationId && type !== 'Person') { + node.publisher = { '@id': context.organizationId } + } + + return node +} + /** * Composable for comprehensive SEO setup including: * - Meta tags (title, description, og:*, twitter:*) @@ -43,6 +136,7 @@ export interface UseSeoOptions { export function useSeo(options: UseSeoOptions) { const route = useRoute() const site = useSiteConfig() + const seoSchema = useAppConfig().seo?.schema const { locale, locales, isEnabled: isI18nEnabled, switchLocalePath } = useDocusI18n() const title = computed(() => toValue(options.title)) @@ -166,19 +260,43 @@ export function useSeo(options: UseSeoOptions) { }) } - // WebSite schema for landing pages + // WebSite schema for landing pages, plus the site identity when configured if (type.value === 'website') { const websiteSchema: Record = { - '@context': 'https://schema.org', '@type': 'WebSite', + '@id': `${baseUrl.value}/#website`, 'name': site.name || title.value, 'description': description.value, 'url': baseUrl.value, } + const graph: Record[] = [websiteSchema] + + // The first node is the publisher; any other is a company it belongs to. + const organizationSchemas = buildOrganizationSchemas(seoSchema, baseUrl.value) + const publisherId = organizationSchemas[0]?.['@id'] as string | undefined + if (organizationSchemas.length) { + graph.push(...organizationSchemas) + websiteSchema.publisher = { '@id': publisherId } + } + + const identitySchema = buildIdentitySchema(seoSchema, { + baseUrl: baseUrl.value, + name: site.name || title.value, + description: description.value, + organizationId: publisherId, + }) + if (identitySchema) { + graph.push(identitySchema) + websiteSchema.about = { '@id': identitySchema['@id'] } + } + scripts.push({ type: 'application/ld+json', - innerHTML: JSON.stringify(websiteSchema), + innerHTML: JSON.stringify({ + '@context': 'https://schema.org', + '@graph': graph, + }), }) } diff --git a/layer/app/types/index.d.ts b/layer/app/types/index.d.ts index b66beacab..d50b130e0 100644 --- a/layer/app/types/index.d.ts +++ b/layer/app/types/index.d.ts @@ -2,6 +2,17 @@ import type { FaqQuestions, LocalizedFaqQuestions } from '../../modules/assistan export type { FaqCategory, FaqQuestions, LocalizedFaqQuestions } from '../../modules/assistant/runtime/types' +/** + * An organization behind the site, emitted as a JSON-LD `Organization` node. + */ +export interface DocusSeoOrganization { + name: string + url?: string + logo?: string + /** Profile URLs (GitHub, X, LinkedIn…). */ + sameAs?: string[] +} + declare module 'nuxt/schema' { interface AppConfig { docus: { @@ -25,6 +36,51 @@ declare module 'nuxt/schema' { titleTemplate: string title: string description: string + /** + * JSON-LD identity of the site + * Allows agents and search engines to understand what the site is about + */ + schema?: { + /** + * Schema.org type describing the site. + * @default undefined (only `WebSite` is emitted) + */ + type?: 'SoftwareApplication' | 'Product' | 'Organization' | 'Person' + /** + * Canonical profile URLs (GitHub, X, LinkedIn…), used as `sameAs`. + */ + sameAs?: string[] + /** + * Category of the application, e.g. `DeveloperApplication`. + * Only used with `SoftwareApplication`. + */ + applicationCategory?: string + /** + * Platforms the application runs on, e.g. `Web`. + * Only used with `SoftwareApplication`. + */ + operatingSystem?: string + /** + * Price of the application. Set to `0` to advertise it as free. + * Only used with `SoftwareApplication` and `Product`. + */ + price?: number + /** + * Currency for `price`, as an ISO 4217 code. + * @default 'USD' + */ + priceCurrency?: string + /** + * Publisher of the site, emitted as a linked `Organization`. + * + * Set `parentOrganization` when the publisher belongs to a larger + * company: both are emitted and linked, so `publisher` keeps pointing + * at a single entity. + */ + organization?: DocusSeoOrganization & { + parentOrganization?: DocusSeoOrganization + } + } } header: { title: string