feat(seo): add validated article and FAQ structured data

This commit is contained in:
DeCentN2Madness committed 2026-10-08 17:38:06 -07:00
1 parent 4ed01d7d28
commit 4079156776
9 files changed
+773 -29

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import type { CollectionEntry } from 'astro:content';
import { load } from 'cheerio';
import type { Publication } from './publication-policy';
import { socialImageUrl } from './seo.ts';
/** JSON embedded in HTML must not be able to close its script element. */
export function serializeJsonLd(value: unknown): string {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
export function blogPostingSchema(
entry: CollectionEntry<'blog'>,
publication: Publication,
authors: CollectionEntry<'authors'>[],
site: string
) {
// Reuse record eligibility, independent of staging's global robots override.
// Syndicated records must not acquire an invented original author/publisher.
if (!publication.sitemapEligible) return undefined;
const data = entry.data;
const matches = authors.filter(({ data: author }) => author.slug === data.author && author.lang === data.lang);
if (matches.length !== 1 || !matches[0].data.name.trim()) {
throw new Error(`Missing or ambiguous article author: ${entry.id} (${data.lang}/${data.author})`);
}
const image = socialImageUrl(data.featuredImage, site);
// Current content records establish calendar days, not publication times.
const datePublished = data.date.toISOString().slice(0, 10);
const dateModified = data.updatedDate?.toISOString().slice(0, 10);
if (dateModified && dateModified < datePublished) {
throw new Error(`Article modification precedes publication: ${entry.id}`);
}
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': `${publication.canonical}#article`,
url: publication.canonical,
mainEntityOfPage: publication.canonical,
headline: data.title,
...(data.description?.trim() ? { description: data.description } : {}),
inLanguage: data.lang,
datePublished,
...(dateModified ? { dateModified } : {}),
...(image ? { image } : {}),
author: { '@type': 'Person', name: matches[0].data.name }
};
}
export interface FAQItem {
question: string;
answer: string;
answerHtml?: string;
}
function htmlText(html: string): string {
const $ = load(html, {}, false);
$('script, style, template, [hidden], [aria-hidden="true"]').remove();
$('br').replaceWith(' ');
$('p, div, li, h1, h2, h3, h4, h5, h6, ul, ol, blockquote').append(' ');
return $.root().text().replace(/\s+/g, ' ').trim();
}
/** Match FAQAccordion's runtime grouping of direct H3s and following siblings. */
export function faqItemsFromSourceHtml(html: string): FAQItem[] {
const $ = load(html, {}, false);
const items: FAQItem[] = [];
let question: string | undefined;
let answerHtml = '';
const finish = () => {
if (question !== undefined) items.push({ question, answer: htmlText(answerHtml), answerHtml });
};
for (const node of $.root().contents().toArray()) {
if (node.type === 'tag' && node.name === 'h3') {
finish();
question = $(node).text().trim();
answerHtml = '';
} else if (question !== undefined) {
answerHtml += $.html(node);
}
}
finish();
return items;
}
export function faqPageSchema(items: FAQItem[], publication: Publication) {
if (!publication.sitemapEligible || !items.length) return undefined;
const questions = new Set<string>();
const mainEntity = items.map((item) => {
const name = item.question.replace(/\s+/g, ' ').trim();
// The visible answer is authoritative; the legacy plain-text copy can drift.
const text = htmlText(item.answerHtml || `<p>${item.answer}</p>`);
if (!name || !text) throw new Error(`Empty FAQ question/answer: ${publication.route}`);
const key = name.toLowerCase();
if (questions.has(key)) throw new Error(`Duplicate FAQ question: ${publication.route}: ${name}`);
questions.add(key);
return { '@type': 'Question', name, acceptedAnswer: { '@type': 'Answer', text } };
});
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'@id': `${publication.canonical}#faq`,
mainEntity
};
}