feat(seo): add validated article and FAQ structured data
This commit is contained in:
9 files changed
+773
-29
No files matched your search
@@ -14,7 +14,7 @@ const readMore = { en: 'Read More', es: 'Leer Más', ar: 'اقرأ المزيد'
|
||||
<article class="card blog-card">
|
||||
{post.data.featuredImage && <a href={href}><img src={post.data.featuredImage} alt={post.data.alt || ''} loading="lazy" /></a>}
|
||||
<div class="card-body">
|
||||
<div class="blog-card-meta"><img src="/assets/images/rula-diab-avatar.jpg" alt="" /><p>Rula Diab, Clinical Director, M.Ed, BCBA, LBA<br /><time datetime={post.data.date.toISOString()}>{post.data.date.toLocaleDateString(post.data.lang, { day: 'numeric', month: 'long', year: 'numeric' })}</time></p></div>
|
||||
<div class="blog-card-meta"><img src="/assets/images/rula-diab-avatar.jpg" alt="" /><p>Rula Diab, Clinical Director, M.Ed, BCBA, LBA<br /><time datetime={post.data.date.toISOString()}>{post.data.date.toLocaleDateString(post.data.lang, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}</time></p></div>
|
||||
<h3><a href={href}>{post.data.title}</a></h3>
|
||||
<p>{post.data.description}</p>
|
||||
<div class="blog-card-footer" style="align-items:center;display:flex;justify-content:space-between;gap:var(--space-lg);margin-top:auto;">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
---
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
answerHtml?: string;
|
||||
}
|
||||
import { getPublication } from '../utils/publication';
|
||||
import { faqItemsFromSourceHtml, faqPageSchema, serializeJsonLd, type FAQItem } from '../utils/structured-data';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
@@ -12,6 +9,7 @@ interface Props {
|
||||
canonical?: string;
|
||||
variant?: 'blog' | 'source' | 'page';
|
||||
sourceSelector?: string;
|
||||
sourceHtml?: string;
|
||||
searchSelector?: string;
|
||||
faqIconColor?: string;
|
||||
}
|
||||
@@ -23,6 +21,7 @@ const {
|
||||
canonical,
|
||||
variant = 'blog',
|
||||
sourceSelector,
|
||||
sourceHtml,
|
||||
searchSelector,
|
||||
faqIconColor = '--color-accent-strong'
|
||||
} = Astro.props;
|
||||
@@ -34,19 +33,23 @@ const headingId = variant === 'page'
|
||||
: label
|
||||
? 'blog-faq-heading'
|
||||
: 'source-faq-heading';
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
...(canonical ? { '@id': `${canonical}#faq` } : {}),
|
||||
mainEntity: items.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer
|
||||
}
|
||||
}))
|
||||
};
|
||||
const publication = await getPublication(Astro.url.pathname);
|
||||
if (canonical && canonical.replace(/\/$/, '') !== publication.canonical.replace(/\/$/, '')) {
|
||||
throw new Error(`FAQ canonical disagrees with publication policy: ${publication.route}`);
|
||||
}
|
||||
if ((sourceSelector && items.length) || (sourceHtml !== undefined && !sourceSelector)) {
|
||||
throw new Error('FAQ items and runtime source content must not be mixed');
|
||||
}
|
||||
const schemaItems = sourceSelector
|
||||
? sourceHtml === undefined ? [] : faqItemsFromSourceHtml(sourceHtml)
|
||||
: items;
|
||||
const schema = faqPageSchema(schemaItems, publication);
|
||||
// Locals belong to this render request, not a module-level build-wide registry.
|
||||
const state = Astro.locals as { aiaFaqSchemaClaimed?: boolean };
|
||||
if (schema) {
|
||||
if (state.aiaFaqSchemaClaimed) throw new Error(`Multiple FAQ schemas: consolidate accordions on ${publication.route}`);
|
||||
state.aiaFaqSchemaClaimed = true;
|
||||
}
|
||||
---
|
||||
<section
|
||||
class:list={[
|
||||
@@ -83,7 +86,7 @@ const schema = {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{items.length > 0 && <script type="application/ld+json" is:inline set:html={JSON.stringify(schema)} />}
|
||||
{schema && <script type="application/ld+json" is:inline set:html={serializeJsonLd(schema)} />}
|
||||
|
||||
<script>
|
||||
const initFaqAccordions = () => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
---
|
||||
import Button from '../Button.astro';
|
||||
import FAQAccordion from '../FAQAccordion.astro';
|
||||
const sourceHtml = await Astro.slots.render('default');
|
||||
---
|
||||
|
||||
<article class="faq-page">
|
||||
<div id="faq-page-source" class="faq-source container source-page">
|
||||
<slot />
|
||||
</div>
|
||||
<div id="faq-page-source" class="faq-source container source-page" set:html={sourceHtml} />
|
||||
|
||||
<section class="faq-search container-narrow">
|
||||
<div class="hs-search-field hs-search-field--initialized">
|
||||
@@ -36,6 +35,7 @@ import FAQAccordion from '../FAQAccordion.astro';
|
||||
heading="Questions & Answers"
|
||||
variant="page"
|
||||
sourceSelector="#faq-page-source"
|
||||
sourceHtml={sourceHtml}
|
||||
searchSelector="#faq-search-input"
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
import BlogPostFooter from '../components/BlogPostFooter.astro';
|
||||
import LikeViewCounter from '../components/LikeViewCounter.astro';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getPublication } from '../utils/publication';
|
||||
import { blogPostingSchema, serializeJsonLd } from '../utils/structured-data';
|
||||
import site from '../data/site.json';
|
||||
interface Props { entry: CollectionEntry<'blog'> }
|
||||
const { entry } = Astro.props;
|
||||
const publication = await getPublication(Astro.url.pathname);
|
||||
const schema = blogPostingSchema(entry, publication, await getCollection('authors'), site.url);
|
||||
---
|
||||
<BaseLayout title={entry.data.title} description={entry.data.description} image={entry.data.featuredImage} lang={entry.data.lang} type="article">
|
||||
{schema && <script slot="head" type="application/ld+json" is:inline set:html={serializeJsonLd(schema)} />}
|
||||
<article class="blog-post">
|
||||
<header class="article-header container">
|
||||
<h1>{entry.data.displayH1 ?? entry.data.title}</h1>
|
||||
<div class="article-byline">
|
||||
<img src="/assets/images/rula-diab-avatar.jpg" alt="" />
|
||||
<p><strong>Rula Diab, Clinical Director, M.Ed, BCBA, LBA</strong><br /><time datetime={entry.data.date.toISOString()}>{entry.data.date.toLocaleDateString(entry.data.lang, { day: '2-digit', month: 'long', year: 'numeric' })}</time></p>
|
||||
<p><strong>Rula Diab, Clinical Director, M.Ed, BCBA, LBA</strong><br /><time datetime={entry.data.date.toISOString()}>{entry.data.date.toLocaleDateString(entry.data.lang, { day: '2-digit', month: 'long', year: 'numeric', timeZone: 'UTC' })}</time></p>
|
||||
</div>
|
||||
{entry.data.featuredImage && <div class="article-featured"><img src={entry.data.featuredImage} alt={entry.data.alt || ''} /></div>}
|
||||
</header>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user