Expand [caption] shortcodes instead of emitting them as text

WordPress expands shortcodes at render time via do_shortcode(), so the
raw post_content the export reads still contains them literally. All ten
[caption] instances were being written straight into the Markdown and
rendered as visible markup -- the Hosting page led with
[caption id="attachment_205" align="aligncenter" width="300"].

They now become the same div.wp-caption / p.wp-caption-text structure
WordPress produces, which the ported theme CSS already styles. The
shortcode is swapped for a placeholder before the HTML->Markdown pass and
rebuilt after, so the image inside stays Markdown and still goes through
Astro's image pipeline rather than being frozen as raw HTML pointing into
src/assets, which would not resolve at runtime.

[caption] is the only shortcode in use -- no gallery/embed/video/audio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:39:12 -04:00
co-authored by Claude Opus 5
parent 73399911cc
commit 873dcc1892
11 changed files with 100 additions and 12 deletions
+40 -2
View File
@@ -100,6 +100,35 @@ function rewriteInternalLinks(html, linkMap, unresolved) {
);
}
// WordPress expands [caption] at render time via do_shortcode(); the raw post_content keeps it
// literal, so it has to be expanded here or it shows up as visible markup on the page.
// The shortcode is swapped for a placeholder before the HTML->Markdown conversion and rebuilt
// afterwards, so the <img> inside still goes through Turndown (and therefore still gets picked
// up by Astro's image pipeline) rather than being frozen as raw HTML pointing into src/assets.
const CAPTION_PLACEHOLDER = (i) => `CAPTIONPLACEHOLDER${i}END`;
function extractCaptions(html, captions) {
return html.replace(/\[caption([^\]]*)\]([\s\S]*?)\[\/caption\]/gi, (_match, attrs, inner) => {
const lastTag = inner.lastIndexOf('>');
captions.push({
align: (attrs.match(/align=["']?(align\w+)["']?/i) || [, 'aligncenter'])[1],
width: (attrs.match(/width=["']?(\d+)["']?/i) || [, ''])[1],
mediaHtml: inner.slice(0, lastTag + 1),
text: inner.slice(lastTag + 1).trim(),
});
return CAPTION_PLACEHOLDER(captions.length - 1);
});
}
// Blank lines around the image are load-bearing: they close the surrounding raw HTML block so
// the image is parsed as Markdown rather than swallowed as literal HTML.
function buildCaption({ align, width, mediaHtml, text }) {
const media = turndown.turndown(mediaHtml).trim();
const style = width ? ` style="width: ${Number(width) + 10}px"` : '';
const caption = text ? `\n<p class="wp-caption-text">${text}</p>` : '';
return `<div class="wp-caption ${align}"${style}>\n\n${media}\n${caption}\n</div>`;
}
async function buildLinkMap(conn) {
const [rows] = await conn.execute(
`SELECT ID, post_type, post_name FROM ${TABLE_PREFIX}posts
@@ -134,11 +163,20 @@ async function exportPostType(conn, postType, destDir, { attachmentPaths, thumbn
}
html = rewriteImageUrls(html, destDir);
html = rewriteInternalLinks(html, linkMap, unresolved);
const markdown = turndown.turndown(html);
const captions = [];
html = extractCaptions(html, captions);
const markdown = turndown
.turndown(html)
.replace(/CAPTIONPLACEHOLDER(\d+)END/g, (_m, i) => buildCaption(captions[Number(i)]));
let excerpt = fixLiteralNewlines(row.post_excerpt || '');
if (!excerpt) {
const plain = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const plain = html
.replace(/CAPTIONPLACEHOLDER\d+END/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// 60 words: what the theme's front-page loop sets ($sbExcerptLength = 60).
// The slideshow truncates this further to 30 at render, as the theme does.
excerpt = plain.split(' ').slice(0, 60).join(' ');