@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
186 lines (156 loc) • 5.9 kB
JavaScript
/**
* Generates FAQPage JSON-LD structured data for SEO.
*
* USAGE:
* :page-faq-1-question: How do I install Redpanda?
* :page-faq-1-answer: Download from redpanda.com and run the installer. See our xref:get-started:intro.adoc[quickstart guide] for details.
* :page-faq-1-anchor: #installation (optional - links to section)
*
* :page-faq-2-question: What are the system requirements?
* :page-faq-2-answer: You need at least 2GB RAM and 2 CPU cores for production. For development, xref:deploy:docker-compose.adoc[use Docker Compose].
* :page-faq-2-anchor: #requirements
*
* The extension:
* - Generates schema.org FAQPage JSON-LD in <head>
* - Supports multiple FAQs numbered sequentially (1, 2, 3...)
* - Anchor is optional and adds URL to the FAQ question
* - Writers can use AsciiDoc xrefs in answers - they're resolved to full URLs
* - Xrefs are converted to "link text (URL)" format in JSON-LD
*/
/**
* Resolve xrefs in text to full URLs
* @param {string} text - Text containing xref macros
* @param {Object} currentPage - Current page context
* @param {Object} contentCatalog - Antora content catalog
* @param {string} siteUrl - Base site URL
* @param {Object} logger - Logger instance
* @returns {string} Text with xrefs resolved to plain text + URLs
*/
function resolveXrefs(text, currentPage, contentCatalog, siteUrl, logger) {
// Match xref:target[link text] pattern
const xrefPattern = /xref:([^\[]+)\[([^\]]+)\]/g
return text.replace(xrefPattern, (match, target, linkText) => {
try {
// Resolve the resource using Antora's content catalog
// This uses Antora's standard API for resolving page references
const resource = contentCatalog.resolveResource(target, currentPage.src, 'page')
if (resource && resource.pub && resource.pub.url) {
const fullUrl = siteUrl ? `${siteUrl}${resource.pub.url}` : `https://docs.redpanda.com${resource.pub.url}`
return `${linkText} (${fullUrl})`
} else {
// Xref couldn't be resolved (page doesn't exist or isn't loaded yet)
// Fall back to just the link text - this is expected for cross-component refs in local builds
return linkText
}
} catch (error) {
logger.warn(`FAQ xref resolution error for ${target}: ${error.message}`)
return linkText // Fallback to just the link text
}
})
}
/**
* Extract FAQ entries from page attributes
* @param {Object} attributes - Page attributes object
* @param {Object} page - Current page object
* @param {Object} contentCatalog - Antora content catalog
* @param {string} siteUrl - Base site URL
* @param {Object} logger - Logger instance
* @returns {Array<{question: string, answer: string, anchor?: string}>}
*/
function extractFaqs(attributes, page, contentCatalog, siteUrl, logger) {
const faqs = []
const faqNumbers = new Set()
// Find all FAQ numbers by scanning for -question attributes
Object.keys(attributes).forEach(key => {
const match = key.match(/^page-faq-(\d+)-question$/)
if (match) {
faqNumbers.add(parseInt(match[1], 10))
}
})
if (faqNumbers.size === 0) return faqs
// Extract FAQs in numerical order
const sortedNumbers = Array.from(faqNumbers).sort((a, b) => a - b)
sortedNumbers.forEach(num => {
const question = attributes[`page-faq-${num}-question`]
const answer = attributes[`page-faq-${num}-answer`]
const anchor = attributes[`page-faq-${num}-anchor`]
// Both question and answer are required
if (!question) {
logger.warn(`FAQ ${num}: question missing`)
return
}
if (!answer) {
logger.warn(`FAQ ${num}: answer missing`)
return
}
// Resolve any xrefs in the answer text
const resolvedAnswer = resolveXrefs(answer.trim(), page, contentCatalog, siteUrl, logger)
const faq = {
question: question.trim(),
answer: resolvedAnswer
}
if (anchor) {
faq.anchor = anchor.trim()
}
faqs.push(faq)
})
return faqs
}
/**
* Generate FAQPage JSON-LD structure
* @param {Array} faqs - Array of FAQ objects
* @param {string} baseUrl - Base URL for the page
* @returns {Object} FAQPage JSON-LD object
*/
function generateFaqJsonLd(faqs, baseUrl) {
const mainEntity = faqs.map(faq => {
const question = {
'@type': 'Question',
'name': faq.question,
'acceptedAnswer': {
'@type': 'Answer',
'text': faq.answer
}
}
// Add URL with anchor if provided
if (faq.anchor) {
const anchor = faq.anchor.startsWith('#') ? faq.anchor : `#${faq.anchor}`
question.url = `${baseUrl}${anchor}`
}
return question
})
return {
'@type': 'FAQPage',
'mainEntity': mainEntity
}
}
module.exports.register = function () {
const logger = this.getLogger('add-faq-structured-data-extension')
let playbook
this.once('playbookBuilt', ({ playbook: pb }) => {
playbook = pb
})
this.on('documentsConverted', ({ contentCatalog }) => {
const pages = contentCatalog.getPages()
const siteUrl = playbook?.site?.url || ''
let processedCount = 0
let totalFaqs = 0
pages.forEach(page => {
const attributes = page.asciidoc?.attributes
if (!attributes) return
// Extract FAQs from attributes and resolve any xrefs
const faqs = extractFaqs(attributes, page, contentCatalog, siteUrl, logger)
if (faqs.length === 0) return
// Store structured FAQ data as JSON for UI template to format
// Template will generate the JSON-LD structure using page.url
attributes['page-has-faqs'] = 'true'
attributes['page-faqs'] = JSON.stringify(faqs)
processedCount++
totalFaqs += faqs.length
})
if (processedCount > 0) {
logger.info(`Generated FAQ structured data for ${processedCount} pages (${totalFaqs} total FAQs)`)
}
})
}