From bc3cf9691f990010fb7cb96f80418f3ff7d26a47 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 1 May 2026 15:02:07 +0100 Subject: [PATCH] Tighten social metadata generation --- quartz/components/Head.tsx | 10 ++++++---- quartz/plugins/emitters/ogImage.tsx | 8 +++++--- quartz/plugins/transformers/description.ts | 19 +++++++++++++++++++ quartz/plugins/transformers/frontmatter.ts | 6 +++++- quartz/util/social.ts | 22 ++++++++++++++++++++++ 5 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 quartz/util/social.ts diff --git a/quartz/components/Head.tsx b/quartz/components/Head.tsx index 23183ca..63d537e 100644 --- a/quartz/components/Head.tsx +++ b/quartz/components/Head.tsx @@ -5,6 +5,7 @@ import { googleFontHref, googleFontSubsetHref } from "../util/theme" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" import { unescapeHTML } from "../util/escape" import { CustomOgImagesEmitterName } from "../plugins/emitters/ogImage" +import { canonicalSocialUrl, normalizeSocialDescription } from "../util/social" export default (() => { const Head: QuartzComponent = ({ cfg, @@ -15,10 +16,11 @@ export default (() => { const titleSuffix = cfg.pageTitleSuffix ?? "" const title = (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix - const description = + const description = normalizeSocialDescription( fileData.frontmatter?.socialDescription ?? - fileData.frontmatter?.description ?? - unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) + fileData.frontmatter?.description ?? + unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description), + ) const { css, js, additionalHead } = externalResources @@ -29,7 +31,7 @@ export default (() => { // Url of current page const socialUrl = - fileData.slug === "404" ? url.toString() : joinSegments(url.toString(), fileData.slug!) + fileData.slug === "404" ? url.toString() : canonicalSocialUrl(cfg.baseUrl!, fileData.slug!) const usesCustomOgImage = ctx.cfg.plugins.emitters.some( (e) => e.name === CustomOgImagesEmitterName, diff --git a/quartz/plugins/emitters/ogImage.tsx b/quartz/plugins/emitters/ogImage.tsx index 813d934..ab2206b 100644 --- a/quartz/plugins/emitters/ogImage.tsx +++ b/quartz/plugins/emitters/ogImage.tsx @@ -12,6 +12,7 @@ import { BuildCtx } from "../../util/ctx" import { QuartzPluginData } from "../vfile" import fs from "node:fs/promises" import { styleText } from "util" +import { normalizeSocialDescription } from "../../util/social" const defaultOptions: SocialImageOptions = { colorScheme: "lightMode", @@ -76,10 +77,11 @@ async function processOgImage( const titleSuffix = cfg.pageTitleSuffix ?? "" const title = (fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix - const description = + const description = normalizeSocialDescription( fileData.frontmatter?.socialDescription ?? - fileData.frontmatter?.description ?? - unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description) + fileData.frontmatter?.description ?? + unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description), + ) const stream = await generateSocialImage( { diff --git a/quartz/plugins/transformers/description.ts b/quartz/plugins/transformers/description.ts index 763a8f8..dc2bfdf 100644 --- a/quartz/plugins/transformers/description.ts +++ b/quartz/plugins/transformers/description.ts @@ -53,6 +53,17 @@ function isStandalonePreviewTagline(node: RootContent): boolean { return isElementNode(onlyChild) && ["em", "strong"].includes(onlyChild.tagName) } +function isStructuredMetadataParagraph(node: RootContent): boolean { + if (!isElementNode(node) || node.tagName !== "p") { + return false + } + + const text = normalizeWhitespace(toString(node)).toLowerCase() + const labels = ["date:", "location:", "primary actors:", "authors:"] + const labelMatches = labels.filter((label) => text.includes(label)).length + return labelMatches >= 2 +} + export function extractDescriptionText(tree: HTMLRoot, pageTitle?: string): string { const children = [...tree.children] let startIdx = 0 @@ -77,6 +88,14 @@ export function extractDescriptionText(tree: HTMLRoot, pageTitle?: string): stri startIdx++ } + if (startIdx < children.length && isStructuredMetadataParagraph(children[startIdx])) { + startIdx++ + } + + while (startIdx < children.length && !isMeaningfulNode(children[startIdx])) { + startIdx++ + } + return escapeHTML(toString({ ...tree, children: children.slice(startIdx) })) } diff --git a/quartz/plugins/transformers/frontmatter.ts b/quartz/plugins/transformers/frontmatter.ts index 7deb619..e4537f2 100644 --- a/quartz/plugins/transformers/frontmatter.ts +++ b/quartz/plugins/transformers/frontmatter.ts @@ -63,7 +63,11 @@ function getFirstMarkdownHeading(root: MdRoot): string | undefined { return toString(node).trim().length > 0 }) - if (!firstMeaningfulNode || firstMeaningfulNode.type !== "heading") { + if ( + !firstMeaningfulNode || + firstMeaningfulNode.type !== "heading" || + firstMeaningfulNode.depth !== 1 + ) { return undefined } diff --git a/quartz/util/social.ts b/quartz/util/social.ts new file mode 100644 index 0000000..2618807 --- /dev/null +++ b/quartz/util/social.ts @@ -0,0 +1,22 @@ +import { FullSlug, joinSegments, simplifySlug } from "./path" + +export function canonicalSocialUrl(baseUrl: string, slug?: FullSlug): string { + const rootUrl = `https://${baseUrl}` + return slug ? joinSegments(rootUrl, simplifySlug(slug)) : rootUrl +} + +export function normalizeSocialDescription(description: string, maxLength = 220): string { + const normalized = description.replace(/\s+/g, " ").trim() + if (normalized.length <= maxLength) { + return normalized + } + + const softCut = normalized.slice(0, maxLength + 1) + const boundary = softCut.lastIndexOf(" ") + const truncated = + boundary > Math.floor(maxLength * 0.6) + ? softCut.slice(0, boundary) + : normalized.slice(0, maxLength) + + return truncated.replace(/[.,;:\s]+$/g, "") + "..." +}