From f25cd82024dd681e0a60f4edfa27f8927812b5d5 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Fri, 14 Nov 2025 13:52:58 +0000 Subject: [PATCH 1/3] Add automatic JSON-LD schema generation for docs --- src/components/Head.tsx | 15 +++ src/components/Layout/Layout.tsx | 6 ++ src/components/Layout/MDXWrapper.tsx | 40 +++++++- src/utilities/json-ld.ts | 135 +++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/utilities/json-ld.ts diff --git a/src/components/Head.tsx b/src/components/Head.tsx index 6789294f81..7cb5b059b8 100644 --- a/src/components/Head.tsx +++ b/src/components/Head.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Helmet } from 'react-helmet'; +import { JsonLdSchema, serializeJsonLd } from '../utilities/json-ld'; export const Head = ({ title, @@ -7,12 +8,14 @@ export const Head = ({ description, metaTitle, keywords, + jsonLd, }: { title: string; canonical: string; description: string; metaTitle?: string; keywords?: string; + jsonLd?: JsonLdSchema | JsonLdSchema[]; }) => ( {metaTitle || title} @@ -24,6 +27,18 @@ export const Head = ({ {keywords && } + {/* JSON-LD Structured Data */} + {jsonLd && + (Array.isArray(jsonLd) ? ( + jsonLd.map((schema, index) => ( + + )) + ) : ( + + ))} + ; @@ -184,6 +185,36 @@ const MDXWrapper: React.FC = ({ children, pageContext, location const { canonicalUrl } = useSiteMetadata(); const canonical = canonicalUrl(location.pathname); + // Generate JSON-LD schema for the page + const jsonLd = useMemo(() => { + // Extract custom JSON-LD fields from frontmatter + const customFields: Record = {}; + + // Collect any frontmatter fields that start with 'jsonld_custom_' + Object.entries(frontmatter || {}).forEach(([key, value]) => { + if (key.startsWith('jsonld_custom_')) { + const schemaKey = key.replace('jsonld_custom_', ''); + customFields[schemaKey] = value; + } + }); + + // Infer schema type from path if not explicitly set in frontmatter + const schemaType = frontmatter?.jsonld_type || inferSchemaTypeFromPath(location.pathname); + + return generateArticleSchema({ + title, + description, + url: canonical, + keywords, + schemaType, + datePublished: frontmatter?.jsonld_date_published, + dateModified: frontmatter?.jsonld_date_modified, + authorName: frontmatter?.jsonld_author_name, + authorType: frontmatter?.jsonld_author_type, + customFields, + }); + }, [title, description, canonical, keywords, frontmatter, location.pathname]); + // Use the copyable headers hook useCopyableHeaders(); @@ -206,7 +237,14 @@ const MDXWrapper: React.FC = ({ children, pageContext, location return ( - +
; +} + +/** + * Generates a JSON-LD schema for documentation pages. + * Supports customization through frontmatter fields. + * + * @param params - The parameters for generating the schema + * @returns A JSON-LD schema object + */ +export const generateArticleSchema = ({ + title, + description, + url, + dateModified, + datePublished, + keywords, + schemaType = 'TechArticle', + authorName = 'Ably', + authorType = 'Organization', + customFields = {}, +}: GenerateArticleSchemaParams): JsonLdSchema => { + const schema: JsonLdSchema = { + '@context': 'https://schema.org', + '@type': schemaType, + headline: title, + description: description, + url: url, + publisher: { + '@type': 'Organization', + name: 'Ably', + url: 'https://ably.com', + }, + author: { + '@type': authorType, + name: authorName, + ...(authorType === 'Organization' ? { url: 'https://ably.com' } : {}), + }, + }; + + // Add optional fields if provided + if (dateModified) { + schema.dateModified = dateModified; + } + + if (datePublished) { + schema.datePublished = datePublished; + } + + if (keywords) { + schema.keywords = keywords.split(',').map((k) => k.trim()); + } + + // Merge any custom fields from frontmatter + Object.entries(customFields).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + schema[key] = value; + } + }); + + return schema; +}; + +/** + * Generates a BreadcrumbList JSON-LD schema for navigation breadcrumbs. + * + * @param breadcrumbs - Array of breadcrumb items with name and url + * @returns A JSON-LD schema object + */ +export const generateBreadcrumbSchema = (breadcrumbs: Array<{ name: string; url: string }>): JsonLdSchema => { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: breadcrumbs.map((crumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: crumb.name, + item: crumb.url, + })), + }; +}; + +/** + * Infers the appropriate schema type based on the page URL path. + * + * @param pathname - The URL pathname of the page + * @returns The appropriate schema.org type + */ +export const inferSchemaTypeFromPath = (pathname: string): string => { + // API documentation and reference pages + if (pathname.includes('/api/')) { + return 'APIReference'; + } + + // Tutorial and guide pages + if (pathname.includes('/guides/') || pathname.includes('/quickstart') || pathname.includes('/getting-started')) { + return 'HowTo'; + } + + // Default to TechArticle for technical documentation + return 'TechArticle'; +}; + +/** + * Serializes a JSON-LD schema object to a JSON string for use in script tags. + * + * @param schema - The JSON-LD schema object + * @returns A JSON string representation + */ +export const serializeJsonLd = (schema: JsonLdSchema): string => { + return JSON.stringify(schema); +}; From 3e2f77338b8f22b60aa574ae2c5947db276b1e77 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 19 Nov 2025 09:38:55 +0000 Subject: [PATCH 2/3] fixup! Add automatic JSON-LD schema generation for docs --- src/components/Layout/MDXWrapper.tsx | 10 +- src/utilities/json-ld.ts | 227 ++++++++++++++++++++------- 2 files changed, 172 insertions(+), 65 deletions(-) diff --git a/src/components/Layout/MDXWrapper.tsx b/src/components/Layout/MDXWrapper.tsx index 5d5eaf82b5..16a4fdead6 100644 --- a/src/components/Layout/MDXWrapper.tsx +++ b/src/components/Layout/MDXWrapper.tsx @@ -33,7 +33,7 @@ import { useSiteMetadata } from 'src/hooks/use-site-metadata'; import { ProductName } from 'src/templates/template-data'; import { getMetaTitle } from '../common/meta-title'; import UserContext from 'src/contexts/user-context'; -import { generateArticleSchema, inferSchemaTypeFromPath } from 'src/utilities/json-ld'; +import { generateCompleteSchema } from 'src/utilities/json-ld'; type MDXWrapperProps = PageProps; @@ -198,15 +198,13 @@ const MDXWrapper: React.FC = ({ children, pageContext, location } }); - // Infer schema type from path if not explicitly set in frontmatter - const schemaType = frontmatter?.jsonld_type || inferSchemaTypeFromPath(location.pathname); - - return generateArticleSchema({ + return generateCompleteSchema({ title, description, url: canonical, + pathname: location.pathname, keywords, - schemaType, + schemaType: frontmatter?.jsonld_type, datePublished: frontmatter?.jsonld_date_published, dateModified: frontmatter?.jsonld_date_modified, authorName: frontmatter?.jsonld_author_name, diff --git a/src/utilities/json-ld.ts b/src/utilities/json-ld.ts index 0aba6ea9b0..4898854147 100644 --- a/src/utilities/json-ld.ts +++ b/src/utilities/json-ld.ts @@ -1,17 +1,27 @@ /** * JSON-LD Schema Generator for Ably Documentation * - * Generates structured data (JSON-LD) for documentation pages to improve SEO - * and provide rich snippets in search results. + * Generates comprehensive structured data (JSON-LD) using @graph for documentation pages + * to improve discoverability by search engines and AI (LLMs). + * + * Based on Ably's JSON-LD Schema Prompt requirements. */ export type JsonLdSchema = { - '@context': string; - '@type': string; + '@context'?: string; + '@type'?: string; + '@id'?: string; + '@graph'?: JsonLdNode[]; + [key: string]: unknown; +}; + +export type JsonLdNode = { + '@type': string | string[]; + '@id'?: string; [key: string]: unknown; }; -export interface GenerateArticleSchemaParams { +export interface GenerateSchemaParams { title: string; description: string; url: string; @@ -21,17 +31,117 @@ export interface GenerateArticleSchemaParams { schemaType?: string; authorName?: string; authorType?: string; + pathname?: string; customFields?: Record; } /** - * Generates a JSON-LD schema for documentation pages. - * Supports customization through frontmatter fields. - * - * @param params - The parameters for generating the schema - * @returns A JSON-LD schema object + * Generates the Ably Organization entity (always included once in @graph). + */ +export const generateAblyOrganization = (): JsonLdNode => { + return { + '@type': 'Organization', + '@id': 'https://ably.com#organization', + name: 'Ably', + url: 'https://ably.com', + logo: { + '@type': 'ImageObject', + url: 'https://ably.com/favicon-512x512.png', + }, + sameAs: [ + 'https://www.linkedin.com/company/ably-realtime/', + 'https://twitter.com/ablyrealtime', + 'https://github.com/ably', + 'https://www.g2.com/products/ably', + ], + }; +}; + +/** + * Generates a WebSite node for the Ably documentation site. + */ +export const generateWebSiteNode = (): JsonLdNode => { + return { + '@type': 'WebSite', + '@id': 'https://ably.com#website', + name: 'Ably Documentation', + url: 'https://ably.com', + publisher: { + '@id': 'https://ably.com#organization', + }, + }; +}; + +/** + * Generates a BreadcrumbList node for navigation. + */ +export const generateBreadcrumbNode = (pathname: string, url: string): JsonLdNode | null => { + // Parse pathname to create breadcrumbs + const segments = pathname.split('/').filter(Boolean); + + if (segments.length === 0) { + return null; + } + + const breadcrumbs: Array<{ name: string; url: string }> = []; + let currentPath = ''; + + // Add home + breadcrumbs.push({ name: 'Home', url: 'https://ably.com' }); + + // Add each segment + segments.forEach((segment) => { + currentPath += `/${segment}`; + const name = segment + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + breadcrumbs.push({ + name, + url: `https://ably.com${currentPath}`, + }); + }); + + return { + '@type': 'BreadcrumbList', + '@id': `${url}#breadcrumb`, + itemListElement: breadcrumbs.map((crumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: crumb.name, + item: crumb.url, + })), + }; +}; + +/** + * Infers the appropriate schema type based on the page URL path. */ -export const generateArticleSchema = ({ +export const inferSchemaTypeFromPath = (pathname: string): string => { + // API documentation and reference pages + if (pathname.includes('/api/') || pathname.includes('/reference/')) { + return 'APIReference'; + } + + // Tutorial and guide pages + if (pathname.includes('/guides/') || pathname.includes('/quickstart') || pathname.includes('/getting-started')) { + return 'HowTo'; + } + + // Conceptual/learning pages + if (pathname.includes('/concepts/') || pathname.includes('/learn/')) { + return 'Article'; + } + + // Default to TechArticle for technical documentation + return 'TechArticle'; +}; + +/** + * Generates the main content node (TechArticle, APIReference, HowTo, etc.) + */ +export const generateMainContentNode = ({ title, description, url, @@ -42,93 +152,92 @@ export const generateArticleSchema = ({ authorName = 'Ably', authorType = 'Organization', customFields = {}, -}: GenerateArticleSchemaParams): JsonLdSchema => { - const schema: JsonLdSchema = { - '@context': 'https://schema.org', +}: GenerateSchemaParams): JsonLdNode => { + const entityId = `${url}#${schemaType.toLowerCase()}`; + + const node: JsonLdNode = { '@type': schemaType, + '@id': entityId, headline: title, + name: title, description: description, url: url, + inLanguage: 'en', + mainEntityOfPage: url, publisher: { - '@type': 'Organization', - name: 'Ably', - url: 'https://ably.com', + '@id': 'https://ably.com#organization', }, author: { '@type': authorType, name: authorName, - ...(authorType === 'Organization' ? { url: 'https://ably.com' } : {}), + ...(authorType === 'Organization' ? { '@id': 'https://ably.com#organization' } : {}), }, }; - // Add optional fields if provided + // Add optional fields if (dateModified) { - schema.dateModified = dateModified; + node.dateModified = dateModified; } if (datePublished) { - schema.datePublished = datePublished; + node.datePublished = datePublished; } if (keywords) { - schema.keywords = keywords.split(',').map((k) => k.trim()); + node.keywords = keywords.split(',').map((k) => k.trim()); } - // Merge any custom fields from frontmatter + // Merge custom fields Object.entries(customFields).forEach(([key, value]) => { if (value !== undefined && value !== null) { - schema[key] = value; + node[key] = value; } }); - return schema; + return node; }; /** - * Generates a BreadcrumbList JSON-LD schema for navigation breadcrumbs. + * Generates a complete JSON-LD schema with @graph structure. * - * @param breadcrumbs - Array of breadcrumb items with name and url - * @returns A JSON-LD schema object + * This follows the Ably JSON-LD Schema Prompt requirements for comprehensive, + * truthful structured data that improves discoverability. */ -export const generateBreadcrumbSchema = (breadcrumbs: Array<{ name: string; url: string }>): JsonLdSchema => { - return { - '@context': 'https://schema.org', - '@type': 'BreadcrumbList', - itemListElement: breadcrumbs.map((crumb, index) => ({ - '@type': 'ListItem', - position: index + 1, - name: crumb.name, - item: crumb.url, - })), - }; -}; +export const generateCompleteSchema = (params: GenerateSchemaParams): JsonLdSchema => { + const { url, pathname = '', schemaType: explicitSchemaType } = params; -/** - * Infers the appropriate schema type based on the page URL path. - * - * @param pathname - The URL pathname of the page - * @returns The appropriate schema.org type - */ -export const inferSchemaTypeFromPath = (pathname: string): string => { - // API documentation and reference pages - if (pathname.includes('/api/')) { - return 'APIReference'; - } + // Infer schema type if not explicitly provided + const schemaType = explicitSchemaType || inferSchemaTypeFromPath(pathname); - // Tutorial and guide pages - if (pathname.includes('/guides/') || pathname.includes('/quickstart') || pathname.includes('/getting-started')) { - return 'HowTo'; + // Build the @graph array + const graph: JsonLdNode[] = []; + + // 1. Always include Ably Organization + graph.push(generateAblyOrganization()); + + // 2. Include WebSite node + graph.push(generateWebSiteNode()); + + // 3. Include BreadcrumbList if we have a pathname + if (pathname) { + const breadcrumb = generateBreadcrumbNode(pathname, url); + if (breadcrumb) { + graph.push(breadcrumb); + } } - // Default to TechArticle for technical documentation - return 'TechArticle'; + // 4. Include main content node + graph.push(generateMainContentNode({ ...params, schemaType })); + + // Return complete schema with @graph + return { + '@context': 'https://schema.org', + '@graph': graph, + }; }; /** * Serializes a JSON-LD schema object to a JSON string for use in script tags. - * - * @param schema - The JSON-LD schema object - * @returns A JSON string representation */ export const serializeJsonLd = (schema: JsonLdSchema): string => { return JSON.stringify(schema); From 8e2d1823678e35ecf71a1f60bb2ae79d2f75a37c Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Nov 2025 15:30:56 +0000 Subject: [PATCH 3/3] fixup! Add automatic JSON-LD schema generation for docs --- src/components/Head.tsx | 11 +- src/components/Layout/Layout.tsx | 7 +- src/components/Layout/MDXWrapper.tsx | 26 +++-- src/pages/docs/auth/basic.mdx | 35 +++++++ src/utilities/json-ld.ts | 149 ++++++++++++++++++++++++--- 5 files changed, 201 insertions(+), 27 deletions(-) diff --git a/src/components/Head.tsx b/src/components/Head.tsx index 7cb5b059b8..edf13b8f8e 100644 --- a/src/components/Head.tsx +++ b/src/components/Head.tsx @@ -28,16 +28,19 @@ export const Head = ({ {keywords && } {/* JSON-LD Structured Data */} - {jsonLd && - (Array.isArray(jsonLd) ? ( + {jsonLd && ( + Array.isArray(jsonLd) ? ( jsonLd.map((schema, index) => ( )) ) : ( - - ))} + + ) + )} diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index 0d1241ee1d..2a37777034 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -24,7 +24,12 @@ export type Frontmatter = { jsonld_date_modified?: string; jsonld_author_name?: string; jsonld_author_type?: string; - [key: string]: unknown; // Allow additional custom JSON-LD fields + jsonld_image?: string; + jsonld_image_description?: string; + jsonld_sdks?: string[]; + jsonld_faqs?: Array<{ question: string; answer: string }>; + jsonld_howto_steps?: Array<{ name: string; text: string }>; + [key: string]: unknown; }; export type PageContextType = { diff --git a/src/components/Layout/MDXWrapper.tsx b/src/components/Layout/MDXWrapper.tsx index 16a4fdead6..6a92b26931 100644 --- a/src/components/Layout/MDXWrapper.tsx +++ b/src/components/Layout/MDXWrapper.tsx @@ -190,6 +190,23 @@ const MDXWrapper: React.FC = ({ children, pageContext, location // Extract custom JSON-LD fields from frontmatter const customFields: Record = {}; + // Handle special structured fields + if (frontmatter?.jsonld_image) { + customFields.image = frontmatter.jsonld_image; + } + if (frontmatter?.jsonld_image_description) { + customFields.imageDescription = frontmatter.jsonld_image_description; + } + if (frontmatter?.jsonld_sdks) { + customFields.sdks = frontmatter.jsonld_sdks; + } + if (frontmatter?.jsonld_faqs) { + customFields.faqs = frontmatter.jsonld_faqs; + } + if (frontmatter?.jsonld_howto_steps) { + customFields.howToSteps = frontmatter.jsonld_howto_steps; + } + // Collect any frontmatter fields that start with 'jsonld_custom_' Object.entries(frontmatter || {}).forEach(([key, value]) => { if (key.startsWith('jsonld_custom_')) { @@ -235,14 +252,7 @@ const MDXWrapper: React.FC = ({ children, pageContext, location return ( - +
{ * Generates a BreadcrumbList node for navigation. */ export const generateBreadcrumbNode = (pathname: string, url: string): JsonLdNode | null => { - // Parse pathname to create breadcrumbs const segments = pathname.split('/').filter(Boolean); if (segments.length === 0) { @@ -86,10 +86,8 @@ export const generateBreadcrumbNode = (pathname: string, url: string): JsonLdNod const breadcrumbs: Array<{ name: string; url: string }> = []; let currentPath = ''; - // Add home breadcrumbs.push({ name: 'Home', url: 'https://ably.com' }); - // Add each segment segments.forEach((segment) => { currentPath += `/${segment}`; const name = segment @@ -119,25 +117,113 @@ export const generateBreadcrumbNode = (pathname: string, url: string): JsonLdNod * Infers the appropriate schema type based on the page URL path. */ export const inferSchemaTypeFromPath = (pathname: string): string => { - // API documentation and reference pages if (pathname.includes('/api/') || pathname.includes('/reference/')) { return 'APIReference'; } - // Tutorial and guide pages if (pathname.includes('/guides/') || pathname.includes('/quickstart') || pathname.includes('/getting-started')) { return 'HowTo'; } - // Conceptual/learning pages if (pathname.includes('/concepts/') || pathname.includes('/learn/')) { return 'Article'; } - // Default to TechArticle for technical documentation return 'TechArticle'; }; +/** + * Generates SDK list from frontmatter if provided. + */ +export const generateSDKList = (sdks: string[] | undefined, url: string): JsonLdNode | null => { + if (!sdks || sdks.length === 0) { + return null; + } + + const sdkRepos: Record = { + javascript: { name: 'JavaScript SDK', repo: 'https://github.com/ably/ably-js' }, + nodejs: { name: 'Node.js SDK', repo: 'https://github.com/ably/ably-js' }, + ruby: { name: 'Ruby SDK', repo: 'https://github.com/ably/ably-ruby' }, + python: { name: 'Python SDK', repo: 'https://github.com/ably/ably-python' }, + java: { name: 'Java SDK', repo: 'https://github.com/ably/ably-java' }, + swift: { name: 'Swift SDK', repo: 'https://github.com/ably/ably-cocoa' }, + objc: { name: 'Objective-C SDK', repo: 'https://github.com/ably/ably-cocoa' }, + csharp: { name: 'C# SDK', repo: 'https://github.com/ably/ably-dotnet' }, + go: { name: 'Go SDK', repo: 'https://github.com/ably/ably-go' }, + flutter: { name: 'Flutter SDK', repo: 'https://github.com/ably/ably-flutter' }, + php: { name: 'PHP SDK', repo: 'https://github.com/ably/ably-php' }, + }; + + return { + '@type': 'ItemList', + '@id': `${url}#sdks`, + name: 'Ably SDKs', + itemListElement: sdks.map((sdk, index) => { + const sdkInfo = sdkRepos[sdk.toLowerCase()] || { name: `${sdk} SDK`, repo: '' }; + return { + '@type': 'ListItem', + position: index + 1, + item: { + '@type': 'SoftwareSourceCode', + name: sdkInfo.name, + programmingLanguage: sdk, + ...(sdkInfo.repo ? { codeRepository: sdkInfo.repo } : {}), + }, + }; + }), + }; +}; + +/** + * Generates FAQ entities from frontmatter if provided. + */ +export const generateFAQPage = ( + faqs: Array<{ question: string; answer: string }> | undefined, + url: string, +): JsonLdNode | null => { + if (!faqs || faqs.length === 0) { + return null; + } + + return { + '@type': 'FAQPage', + '@id': `${url}#faq`, + mainEntity: faqs.map((faq) => ({ + '@type': 'Question', + name: faq.question, + acceptedAnswer: { + '@type': 'Answer', + text: faq.answer, + }, + })), + }; +}; + +/** + * Generates HowTo steps from frontmatter if provided. + */ +export const generateHowToSteps = ( + steps: Array<{ name: string; text: string }> | undefined, + url: string, + title: string, +): JsonLdNode | null => { + if (!steps || steps.length === 0) { + return null; + } + + return { + '@type': 'HowTo', + '@id': `${url}#howto`, + name: title, + step: steps.map((step, index) => ({ + '@type': 'HowToStep', + position: index + 1, + name: step.name, + text: step.text, + })), + }; +}; + /** * Generates the main content node (TechArticle, APIReference, HowTo, etc.) */ @@ -174,7 +260,6 @@ export const generateMainContentNode = ({ }, }; - // Add optional fields if (dateModified) { node.dateModified = dateModified; } @@ -187,9 +272,26 @@ export const generateMainContentNode = ({ node.keywords = keywords.split(',').map((k) => k.trim()); } - // Merge custom fields + // Add image if provided in custom fields + if (customFields.image) { + node.image = { + '@type': 'ImageObject', + url: customFields.image, + ...(customFields.imageDescription ? { description: customFields.imageDescription } : {}), + }; + } + + // Add other custom fields Object.entries(customFields).forEach(([key, value]) => { - if (value !== undefined && value !== null) { + if ( + value !== undefined && + value !== null && + key !== 'image' && + key !== 'imageDescription' && + key !== 'sdks' && + key !== 'faqs' && + key !== 'howToSteps' + ) { node[key] = value; } }); @@ -204,12 +306,10 @@ export const generateMainContentNode = ({ * truthful structured data that improves discoverability. */ export const generateCompleteSchema = (params: GenerateSchemaParams): JsonLdSchema => { - const { url, pathname = '', schemaType: explicitSchemaType } = params; + const { url, pathname = '', schemaType: explicitSchemaType, customFields = {} } = params; - // Infer schema type if not explicitly provided const schemaType = explicitSchemaType || inferSchemaTypeFromPath(pathname); - // Build the @graph array const graph: JsonLdNode[] = []; // 1. Always include Ably Organization @@ -229,7 +329,28 @@ export const generateCompleteSchema = (params: GenerateSchemaParams): JsonLdSche // 4. Include main content node graph.push(generateMainContentNode({ ...params, schemaType })); - // Return complete schema with @graph + // 5. Include SDK list if provided + const sdkList = generateSDKList(customFields.sdks as string[] | undefined, url); + if (sdkList) { + graph.push(sdkList); + } + + // 6. Include HowTo steps if provided + const howToSteps = generateHowToSteps( + customFields.howToSteps as Array<{ name: string; text: string }> | undefined, + url, + params.title, + ); + if (howToSteps) { + graph.push(howToSteps); + } + + // 7. Include FAQPage if provided + const faqPage = generateFAQPage(customFields.faqs as Array<{ question: string; answer: string }> | undefined, url); + if (faqPage) { + graph.push(faqPage); + } + return { '@context': 'https://schema.org', '@graph': graph,