Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
124 changes: 121 additions & 3 deletions layer/app/composables/useSeo.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -33,6 +34,98 @@ export interface UseSeoOptions {
breadcrumbs?: MaybeRefOrGetter<BreadcrumbItem[] | undefined>
}

type SeoSchemaConfig = NonNullable<AppConfig['seo']['schema']>

type SeoOrganizationConfig = NonNullable<SeoSchemaConfig['organization']>

/**
* `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<string, unknown> = {
'@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<string, unknown> = {
'@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:*)
Expand All @@ -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))
Expand Down Expand Up @@ -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<string, unknown> = {
'@context': 'https://schema.org',
'@type': 'WebSite',
'@id': `${baseUrl.value}/#website`,
'name': site.name || title.value,
'description': description.value,
'url': baseUrl.value,
}

const graph: Record<string, unknown>[] = [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,
}),
})
}

Expand Down
56 changes: 56 additions & 0 deletions layer/app/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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
Expand Down
Loading