diff --git a/package-lock.json b/package-lock.json index 01fe025..c892275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-astro": "^3.1.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", "pagefind": "^1.5.2", "prettier": "^3.9.6", "prettier-plugin-astro": "^0.14.1", @@ -40,7 +42,12 @@ "remark-link-card": "^1.3.1", "remark-math": "^6.0.0", "remark-sectionize": "^2.1.0", - "typescript-eslint": "^8.67.0" + "typescript-eslint": "^8.67.0", + "unist-util-remove": "^4.0.0" + }, + "devDependencies": { + "@types/hast": "^3.0.5", + "@types/unist": "^3.0.3" } }, "node_modules/@antfu/install-pkg": { @@ -10476,6 +10483,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-4.0.0.tgz", + "integrity": "sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-remove-position": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", diff --git a/package.json b/package.json index cc97693..2d20723 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "eslint": "^10.8.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-astro": "^3.1.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", "pagefind": "^1.5.2", "prettier": "^3.9.6", "prettier-plugin-astro": "^0.14.1", @@ -43,10 +45,15 @@ "remark-link-card": "^1.3.1", "remark-math": "^6.0.0", "remark-sectionize": "^2.1.0", - "typescript-eslint": "^8.67.0" + "typescript-eslint": "^8.67.0", + "unist-util-remove": "^4.0.0" }, "allowScripts": { "esbuild@0.28.2": true, "esbuild@0.25.4": true + }, + "devDependencies": { + "@types/hast": "^3.0.5", + "@types/unist": "^3.0.3" } } diff --git a/src/lib/excerpt.ts b/src/lib/excerpt.ts new file mode 100644 index 0000000..ffdde6c --- /dev/null +++ b/src/lib/excerpt.ts @@ -0,0 +1,31 @@ +import { fromHtml } from "hast-util-from-html"; +import { toText } from "hast-util-to-text"; +import { remove } from "unist-util-remove"; +import type { Node } from "unist"; +import type { Element } from "hast"; + +function isHeadingAnchor(node: Node): node is Element { + const element = node as Element; + return ( + node.type === "element" && + element.tagName === "a" && + Array.isArray(element.properties.className) && + element.properties.className.includes("anchor") + ); +} + +/** Builds a short plain-text excerpt from an article's rendered HTML. */ +export function excerptFromHtml(html: string | undefined, maxLength = 120): string | undefined { + if (!html) return undefined; + + const tree = fromHtml(html, { fragment: true }); + + // Drop the "#" anchor links rehype-autolink-headings appends to headings; + // they're navigation, not article content. + remove(tree, isHeadingAnchor); + + const text = toText(tree).replace(/\s+/g, " ").trim(); + if (!text) return undefined; + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trimEnd()}…`; +} diff --git a/src/pages/articles/[...slug].astro b/src/pages/articles/[...slug].astro index b853659..6c90a86 100644 --- a/src/pages/articles/[...slug].astro +++ b/src/pages/articles/[...slug].astro @@ -6,6 +6,9 @@ import Loading from "../../components/Loading.astro"; import { getCollection, render } from "astro:content"; import type { CollectionEntry } from "astro:content"; +import { excerptFromHtml } from "../../lib/excerpt"; +import { SITE_DESCRIPTION } from "../../consts"; + export async function getStaticPaths() { const posts = await getCollection("articles"); return posts.map((post) => ({ @@ -19,9 +22,12 @@ type Props = CollectionEntry<"articles">; const article = Astro.props; const { Content } = await render(article); -const { title, description, pubDate, updatedDate, draft } = article.data; +const { title, pubDate, updatedDate, draft } = article.data; const { url } = article.data; +const description = + article.data.description ?? excerptFromHtml(article.rendered?.html) ?? SITE_DESCRIPTION; + const ogImage = `${import.meta.env.BASE_URL}og/${article.id}.png`; --- diff --git a/src/pages/og/[...slug].ts b/src/pages/og/[...slug].ts index 7e6a196..de640b5 100644 --- a/src/pages/og/[...slug].ts +++ b/src/pages/og/[...slug].ts @@ -2,6 +2,7 @@ import { getCollection } from "astro:content"; import { OGImageRoute } from "astro-og-canvas"; import { SITE_NAME, SITE_DESCRIPTION } from "../../consts"; +import { excerptFromHtml } from "../../lib/excerpt"; const articles = await getCollection("articles", ({ data }) => !data.draft); @@ -10,7 +11,8 @@ const pages = Object.fromEntries( article.id, { title: article.data.title, - description: article.data.description ?? SITE_DESCRIPTION + description: + article.data.description ?? excerptFromHtml(article.rendered?.html) ?? SITE_DESCRIPTION } ]) );