Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/layouts/ArticleLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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 <meta name="description">, 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(" ");
Expand Down
42 changes: 34 additions & 8 deletions src/utils/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/$/, "");

Expand All @@ -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 <meta name="description">. 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<Homepage>} */
export async function getHomepageArticles() {
//const url = 'https://cms.thetriangle.org/wp-json/triangle/v1/homepage';
Expand All @@ -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<ClassifiedPost[]>} */
Expand Down Expand Up @@ -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) {
Expand All @@ -89,7 +115,7 @@ export async function getSubsectionArticles(subsection, page) {
});

if (!res.ok) return;
return res.json();
return sanitizeExcerpts(await res.json());
}

/**
Expand All @@ -109,7 +135,7 @@ export async function getAuthorArticles(author, page) {
});

if (!res.ok) return;
return res.json();
return sanitizeExcerpts(await res.json());
}

/**
Expand All @@ -126,7 +152,7 @@ export async function getArticle(article) {
});

if (!res.ok) return;
return normalizeArticle(await res.json());
return sanitizeExcerpts(normalizeArticle(await res.json()));
}

/**
Expand Down Expand Up @@ -160,7 +186,7 @@ export async function getRandomArticle() {
});

if (!res.ok) return;
return res.json();
return sanitizeExcerpts(await res.json());
}

/**
Expand All @@ -177,7 +203,7 @@ export async function search(search) {
});

if (!res.ok) return;
return res.json();
return sanitizeExcerpts(await res.json());
}
/** @returns {Promise<GalleryImage[]>} */
export async function gallery() {
Expand Down Expand Up @@ -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) : [];
}

/**
Expand Down
25 changes: 25 additions & 0 deletions src/utils/shortcodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <meta
* name="description">. 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, " ");
}
Loading