From 1c8b65e84f428363686a3b4b87670fa8fd10b476 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Fri, 7 Aug 2026 16:43:45 -0400 Subject: [PATCH] Strip shortcodes out of excerpts on the way in Article bodies still hold unexpanded WordPress shortcodes. The body expands them into embeds, but the excerpt has no room for one: a crossword post whose whole body is [puzzleme ...] derived an excerpt that was the shortcode source, and it reached the Comics & Puzzles listing, the RSS feed and as literal text. The CMS no longer derives excerpts like this, but excerpts stored before that still carry shortcodes, so strip on the way in as well. Excerpts ride inside a different shape on every endpoint and reach the page through cards, the feed, the infinite-scroll route and the layout head, so this walks each parsed payload once at the data boundary rather than touching ~10 render sites. The meta description also falls back to the raw body, which would otherwise be where the shortcode resurfaced. Co-Authored-By: Claude Opus 5 --- src/layouts/ArticleLayout.astro | 7 +++++- src/utils/db.js | 42 ++++++++++++++++++++++++++------- src/utils/shortcodes.ts | 25 ++++++++++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-) 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, " "); +}