diff --git a/src/layouts/ArticleLayout.astro b/src/layouts/ArticleLayout.astro index 52cf08d..dbc464a 100644 --- a/src/layouts/ArticleLayout.astro +++ b/src/layouts/ArticleLayout.astro @@ -4,12 +4,17 @@ import Footer from "../components/Footer.astro"; import Splash from "../components/Splash.astro"; import { getSiteTitle } from "../utils/siteConfig"; import { normalizeText } from "../utils/data"; +import { stripShortcodes } from "../utils/shortcodes"; const siteTitle = await getSiteTitle(); const siteUrl = "https://www.thetriangle.org"; const cleanText = (value: unknown): string => normalizeText(String(value ?? "")); +// Shortcodes are stripped rather than expanded here: the body is the last +// fallback for , and a crossword post whose whole body +// is [puzzleme ...] would otherwise describe itself to search engines and +// social cards with its embed ids. const truncateDescription = (value: unknown): string => { - const text = cleanText(value); + const text = cleanText(stripShortcodes(String(value ?? ""))); if (text.length <= 160) return text; const clipped = text.slice(0, 157); const lastSpace = clipped.lastIndexOf(" "); diff --git a/src/utils/db.js b/src/utils/db.js index e9ae0cb..f4ae3ea 100644 --- a/src/utils/db.js +++ b/src/utils/db.js @@ -11,6 +11,8 @@ * @typedef {import('./types').ArticleComments} ArticleComments */ +import { stripShortcodes } from "./shortcodes"; + const cmsBaseUrl = import.meta.env.CMS_API_BASE_URL ?? "https://localhost:8080/v1"; const normalizedCmsBaseUrl = String(cmsBaseUrl).replace(/\/$/, ""); @@ -21,6 +23,30 @@ function normalizeArticle(post) { return post; } +/** + * Strip WordPress shortcodes out of every excerpt in a CMS payload. + * + * Done here rather than at the ~10 places an excerpt is rendered: excerpts ride + * along inside a different shape on every endpoint (homepage sections, section + * and author listings, search) and reach the page through cards, the RSS feed, + * the infinite-scroll route and . Walking the parsed + * body once covers all of them, and the payloads are a page of articles. + * @template T + * @param {T} value + * @returns {T} + */ +function sanitizeExcerpts(value) { + if (Array.isArray(value)) { + value.forEach(sanitizeExcerpts); + } else if (value && typeof value === 'object') { + if (typeof value.excerpt === 'string') { + value.excerpt = stripShortcodes(value.excerpt).replace(/\s+/g, ' ').trim(); + } + Object.values(value).forEach(sanitizeExcerpts); + } + return value; +} + /** @returns {Promise} */ export async function getHomepageArticles() { //const url = 'https://cms.thetriangle.org/wp-json/triangle/v1/homepage'; @@ -37,7 +63,7 @@ export async function getHomepageArticles() { }); if (!res.ok) throw new Error(String(res.status)); - return res.json(); + return sanitizeExcerpts(await res.json()); } /** @returns {Promise} */ @@ -75,7 +101,7 @@ export async function getSectionArticles(section, page) { }); if (!res.ok) return; - return res.json(); + return sanitizeExcerpts(await res.json()); } export async function getSubsectionArticles(subsection, page) { @@ -89,7 +115,7 @@ export async function getSubsectionArticles(subsection, page) { }); if (!res.ok) return; - return res.json(); + return sanitizeExcerpts(await res.json()); } /** @@ -109,7 +135,7 @@ export async function getAuthorArticles(author, page) { }); if (!res.ok) return; - return res.json(); + return sanitizeExcerpts(await res.json()); } /** @@ -126,7 +152,7 @@ export async function getArticle(article) { }); if (!res.ok) return; - return normalizeArticle(await res.json()); + return sanitizeExcerpts(normalizeArticle(await res.json())); } /** @@ -160,7 +186,7 @@ export async function getRandomArticle() { }); if (!res.ok) return; - return res.json(); + return sanitizeExcerpts(await res.json()); } /** @@ -177,7 +203,7 @@ export async function search(search) { }); if (!res.ok) return; - return res.json(); + return sanitizeExcerpts(await res.json()); } /** @returns {Promise} */ export async function gallery() { @@ -209,7 +235,7 @@ export async function getRecentArticles(limit = 20) { if (!res.ok) return []; const body = await res.json(); - return Array.isArray(body?.articles) ? body.articles : []; + return Array.isArray(body?.articles) ? sanitizeExcerpts(body.articles) : []; } /** diff --git a/src/utils/shortcodes.ts b/src/utils/shortcodes.ts index 3d26082..83a4b88 100644 --- a/src/utils/shortcodes.ts +++ b/src/utils/shortcodes.ts @@ -70,3 +70,28 @@ export function expandShortcodes(content: string): string { puzzlemeEmbed(raw), ); } + +/** + * Two shapes, both narrow enough to leave editorial brackets ("[sic]", + * "[Editor's note]") alone: the shortcodes this corpus actually carries, named + * explicitly; and any bracketed token carrying attributes, which is what makes + * it a shortcode rather than prose. Mirrors shortcodePattern in the CMS's + * database/http_models.go, which keeps them out of newly derived excerpts. + */ +const shortcodePattern = + /\[\/?(?:puzzleme|caption|gallery|embed|playlist|audio|video)\b[^\]]*\]|\[[a-z][a-z0-9_-]*\s+[^\]]*=[^\]]*\]/gis; + +/** + * Remove shortcodes rather than expand them, for the places that show article + * text as plain prose: excerpts and meta descriptions. Only the body has room + * for an embed, and an excerpt that is a shortcode is worse than a short one -- + * a crossword post whose whole body is [puzzleme ...] printed its embed ids + * under the headline on section listings, in the RSS feed and in . Excerpts stored before the CMS learned to skip + * shortcodes still carry them, so this is what actually clears the page. + */ +export function stripShortcodes(content: string): string { + if (!content || !content.includes("[")) return content; + + return content.replace(shortcodePattern, " "); +}