Tighten social metadata generation

This commit is contained in:
Meta Vens 2026-05-01 15:02:07 +01:00
commit bc3cf9691f
5 changed files with 57 additions and 8 deletions

View file

@ -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,

View file

@ -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(
{

View file

@ -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) }))
}

View file

@ -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
}

22
quartz/util/social.ts Normal file
View file

@ -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, "") + "..."
}