Skip to content

Commit 85a0a02

Browse files
authored
Merge pull request #5 from compstatgenlab/copilot/add-figure-labels-to-articles
Add semantic figure/figcaption support to blog articles
2 parents dbe540e + 55eac0a commit 85a0a02

4 files changed

Lines changed: 110 additions & 6 deletions

File tree

astro.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { defineConfig } from 'astro/config';
22
import tailwind from '@astrojs/tailwind';
33
import sitemap from '@astrojs/sitemap';
4+
import { rehypeFigureCaption } from './src/plugins/rehype-figure-caption.mjs';
45

56
export default defineConfig({
67
site: 'https://compstatgenlab.github.io/',
@@ -12,5 +13,6 @@ export default defineConfig({
1213
shikiConfig: {
1314
theme: 'github-light',
1415
},
16+
rehypePlugins: [rehypeFigureCaption],
1517
},
1618
});

src/content/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const articles = defineCollection({
99
author: z.string().optional().default('Simone Rubinacci'),
1010
tags: z.array(z.string()).optional().default([]),
1111
heroImage: z.string().optional(),
12+
heroImageCaption: z.string().optional(),
1213
draft: z.boolean().optional().default(false),
1314
}),
1415
});

src/layouts/Article.astro

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ export interface Props {
88
author?: string;
99
tags?: string[];
1010
heroImage?: string;
11+
heroImageCaption?: string;
1112
}
12-
const { title, description, date, author = 'Simone Rubinacci', tags = [], heroImage } = Astro.props;
13+
const { title, description, date, author = 'Simone Rubinacci', tags = [], heroImage, heroImageCaption } = Astro.props;
1314
1415
const formatted = date.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
1516
---
@@ -48,11 +49,18 @@ const formatted = date.toLocaleDateString('en-GB', { day: 'numeric', month: 'lon
4849
</header>
4950

5051
{heroImage && (
51-
<img
52-
src={heroImage}
53-
alt={title}
54-
class="w-full rounded-xl mb-12 object-cover max-h-80 border border-stone-100 shadow-sm"
55-
/>
52+
<figure class="mb-12">
53+
<img
54+
src={heroImage}
55+
alt={title}
56+
class="w-full rounded-xl object-cover max-h-80 border border-stone-100 shadow-sm"
57+
/>
58+
{heroImageCaption && (
59+
<figcaption class="mt-2 text-center font-mono text-[0.72rem] text-stone-400 leading-snug">
60+
{heroImageCaption}
61+
</figcaption>
62+
)}
63+
</figure>
5664
)}
5765

5866
<!-- Content -->
@@ -66,6 +74,9 @@ const formatted = date.toLocaleDateString('en-GB', { day: 'numeric', month: 'lon
6674
[&_blockquote]:border-l-2 [&_blockquote]:border-blue-200 [&_blockquote]:pl-5 [&_blockquote]:text-stone-500 [&_blockquote]:italic
6775
[&_hr]:border-stone-100 [&_hr]:my-10
6876
[&_img]:rounded-lg [&_img]:shadow-sm [&_img]:border [&_img]:border-stone-100
77+
[&_figure]:my-8 [&_figure]:text-center
78+
[&_figure_img]:rounded-lg [&_figure_img]:shadow-sm [&_figure_img]:border [&_figure_img]:border-stone-100 [&_figure_img]:mx-auto [&_figure_img]:mb-0
79+
[&_figcaption]:mt-2 [&_figcaption_p]:font-mono [&_figcaption_p]:text-[0.72rem] [&_figcaption_p]:text-stone-400 [&_figcaption_p]:leading-snug [&_figcaption_p]:mb-0
6980
[&_ul]:pl-5 [&_ul>li]:mt-1.5 [&_ul>li]:text-stone-700
7081
[&_ol>li]:mt-1.5 [&_ol>li]:text-stone-700
7182
[&_strong]:text-stone-900 [&_strong]:font-semibold">
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Rehype plugin that converts a markdown image followed by a blockquote caption
3+
* into a semantic <figure> / <figcaption> element pair.
4+
*
5+
* Markdown pattern (existing articles):
6+
* ![Alt text](/path/to/image.png)
7+
* > **Figure N:** *Caption text*
8+
*
9+
* Produces:
10+
* <figure>
11+
* <img src="/path/to/image.png" alt="Alt text" />
12+
* <figcaption><p><strong>Figure N:</strong> <em>Caption text</em></p></figcaption>
13+
* </figure>
14+
*/
15+
16+
/** Returns the index of the next non-whitespace sibling starting from `from`. */
17+
function nextNonWhitespace(children, from) {
18+
for (let childIndex = from; childIndex < children.length; childIndex++) {
19+
const node = children[childIndex];
20+
if (node.type === 'text' && node.value.trim() === '') continue;
21+
return childIndex;
22+
}
23+
return -1;
24+
}
25+
26+
function transformChildren(node) {
27+
if (!node.children) return;
28+
29+
const newChildren = [];
30+
// Track indices that have already been consumed (e.g. a blockquote paired
31+
// with the preceding image paragraph).
32+
const consumed = new Set();
33+
34+
for (let i = 0; i < node.children.length; i++) {
35+
if (consumed.has(i)) continue;
36+
37+
const child = node.children[i];
38+
39+
// Recurse first so inner nodes are already transformed
40+
transformChildren(child);
41+
42+
// Look for a <p> containing only a single <img>
43+
if (child.type === 'element' && child.tagName === 'p') {
44+
const realChildren = child.children.filter(
45+
(c) => !(c.type === 'text' && c.value.trim() === '')
46+
);
47+
48+
if (
49+
realChildren.length === 1 &&
50+
realChildren[0].type === 'element' &&
51+
realChildren[0].tagName === 'img'
52+
) {
53+
// Find the next meaningful sibling (skip whitespace text nodes)
54+
const nextIdx = nextNonWhitespace(node.children, i + 1);
55+
const next = nextIdx !== -1 ? node.children[nextIdx] : null;
56+
57+
if (next && next.type === 'element' && next.tagName === 'blockquote') {
58+
const figure = {
59+
type: 'element',
60+
tagName: 'figure',
61+
properties: {},
62+
children: [
63+
realChildren[0],
64+
{
65+
type: 'element',
66+
tagName: 'figcaption',
67+
properties: {},
68+
children: next.children,
69+
},
70+
],
71+
};
72+
newChildren.push(figure);
73+
// Mark all nodes between i+1 and nextIdx (inclusive) as consumed
74+
for (let skipIndex = i + 1; skipIndex <= nextIdx; skipIndex++) consumed.add(skipIndex);
75+
continue;
76+
}
77+
}
78+
}
79+
80+
newChildren.push(child);
81+
}
82+
83+
node.children = newChildren;
84+
}
85+
86+
export function rehypeFigureCaption() {
87+
return (tree) => {
88+
transformChildren(tree);
89+
};
90+
}

0 commit comments

Comments
 (0)