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