A Quarto feed that R-bloggers can republish
R-bloggers copies the full HTML of each post from your feed onto its own site. Three parts of my Quarto posts were not fit for that trip. A short post-render script now fixes the feed, and leaves the pages alone.
This blog is now on its way to R-bloggers. Before I filled in their form, I read their rules. Most of them are about the content: posts about R, in English, with real substance. A few of them are about the feed itself:
- The feed must hold the full text of each post, not a summary.
- The HTML must be well formatted.
- Images should be PNG or a similar format.
- The blog must link back to R-bloggers.
The full text was easy. In Quarto, feed: true on a listing gives the full text of each post. The link back is one line in the footer.
The other two made me look at my feed in a new way. R-bloggers does not link to a post. It copies the HTML of the post out of the feed and shows it on its own site, with none of my stylesheets or scripts around it. So the question was not “does my blog look right?” It was “does this HTML still make sense on someone else’s page?”
What I found
I read the feed with a short script and listed every link, image and video in each post. For three posts, it found three kinds of problem:
- One relative link. The rewind 0.3.0 post links to my first post as
../../posts/2026-08-27-undo-for-shiny/index.html. On my blog, that works. On R-bloggers, it points at a page of R-bloggers that does not exist. - Three SVG pictures. Each post opens with a small animated illustration, an SVG with CSS animation. R-bloggers asks for PNG or a similar format.
- Three videos. Each post has a short demo video. On the page, a small script starts it, or shows the controls if the browser refuses. In another site’s page, or in a feed reader, I cannot count on the video element or the script.
The page version of each post was right. Only the copy in the feed needed to change.
A script after the render
Quarto writes the feed, index.xml, as part of the render. It also runs scripts that a project names before and after the render. A post-render script runs once the whole site, the feed included, is in _site:
project:
type: website
pre-render: scripts/node-step.ts covers.mjs
post-render: scripts/node-step.ts feed.mjsfeed.mjs reads the feed, finds each <item>, and changes the HTML inside its <description>. It does not touch the pages. The node-step.ts in front of it comes later in this post.
The relative link
Each item in the feed has a <link>: the address of the post. A relative URL inside the post is relative to that address. JavaScript’s URL does the rest:
const absolute = (url, base) => {
if (/^([a-z][a-z0-9+.-]*:|#|\/\/)/i.test(url)) return url;
return new URL(url, base).href;
};
html = html.replace(/<[a-z][^>]*>/gi, (tag) =>
tag.replace(/\b(href|src|poster)="([^"]*)"/g,
(_, name, url) => `${name}="${absolute(url, link)}"`));A URL that already has a scheme, such as https: or mailto:, stays as it is. So does a link to a heading on the same page, such as #try-it. The outer replace() limits the change to HTML tags. The reason for that comes later in this post.
The pictures and videos never had this problem. A Lua filter writes them into the page, and it gives them absolute URLs from the start. It reads site-url from _quarto.yml, because Quarto does not pass that value to filters.
The SVG picture
The page keeps its animated SVG. The feed gets a PNG of its first frame.
The PNG comes from the step before the render. A Node script already draws a share image for each post with resvg, so it draws the still of the hero at the same time, at twice the width of the page. A little simplified:
function render(svg, width) {
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: width },
font: { fontFiles: Object.values(fontFiles), loadSystemFonts: false },
});
return resvg.render().asPng();
}
writeFileSync(join(postsDir, slug, "hero.png"), render(heroSvg, 1600));resvg does not run CSS animations. It draws the picture as it is before any animation starts. So each hero needs a first frame that makes sense on its own. For one hero, it did not: the knob of a slider sat at one end and its coloured bar was full. I drew that one again, with the knob where the bar ends.
resvg also needs real font files, and it does not read a web font from Google. The fonts of the blog are in the repository for that reason.
feed.mjs then swaps the file name in the feed, but only when the PNG is really there:
html = html.replace(
/(<figure class="post-hero[^"]*">\s*<img [^>]*src=")([^"]+)\.svg"/g,
(whole, head, stem) => {
const file = localFile(`${stem}.png`, siteUrl);
return file && existsSync(file) ? `${head}${stem}.png"` : whole;
});The video
The feed gets the poster of the video, the still that shows before it plays. The poster is a link to the post, and the caption ends with “Watch the video in the post.” The script after the video goes too.
A reader on R-bloggers thus sees a picture of the demo, and one click takes them to the real thing.
A second run does nothing
Each change leaves a fixed feed as it is. An absolute link stays absolute. A PNG is not an SVG. A poster with no video around it does not match the pattern for a video. So the script can run on every render, and on a feed that it already fixed, with no harm.
The bug this post found
The first version of the script changed href=", src=" and poster=" anywhere in the HTML of a post. For six posts, that was fine.
This post was the first to show the script’s own code. The code block for the picture, with src=" in its regular expression, is HTML text too. The script found src=" in it and made it absolute. In the feed, the code block came out as:
/(<figure class="post-hero[^"]*">\s*<img [^>]*src="https://tenmeh.github.io/posts/...
The page was fine. Only the copy in the feed was broken, and that is the copy that R-bloggers shows.
The fix is the outer replace() in the code for links. It finds each HTML tag, and changes attributes only inside one. In a highlighted code block, the text src=" sits between tags, never inside one, so it stays as written.
I found it by comparing the text of each code block in the feed with the same block on the page. That comparison is now part of the check below.
How I checked it
A small Python script reads the rendered feed and reports, for each post, the number of relative links, SVG images and video elements. All three are zero. It also compares the text of each code block in the feed with the same block on the page, and they match for all seven posts. It parses the feed as XML too, so a broken tag would stop it.
Then I did what R-bloggers asks for, and loaded the feed in the SimplePie demo. It read the feed, and each post showed in full, with “Watch the video in the post.” where each video was.
A second problem: the build that only works with Node
The share images and the PNG stills need Node, and three npm packages. The GitHub workflow that publishes this blog installs them before the render.
Posit Connect Cloud also publishes this repository on each push. After I added the share images, every publish there failed. The email said only: “Unable to render”.
The reason was the pre-render step. Connect Cloud renders with Quarto, but it does not install npm packages. So node scripts/covers.mjs stopped at its first import, and Quarto stopped the whole render. I could make the same failure on my own machine: a fresh clone, with no node_modules, gave exit status 1 and no site at all.
A missing share image should not stop a blog from rendering. So each Node step now runs through a small wrapper. Quarto runs a .ts script with its own copy of Deno, so the wrapper needs no Node to start. The core of it:
const skip = (why: string) => {
console.warn(`WARNING: skipped scripts/${script}: ${why}.`);
Deno.exit(0);
};
try {
Deno.statSync("node_modules");
} catch {
skip("node_modules is missing");
}
let status;
try {
status = await new Deno.Command("node", { args: [`scripts/${script}`] }).output();
} catch {
skip("Node is not installed");
}
// A step that starts and then fails is a real error: stop the render.
Deno.exit(status!.code);With Node and its packages, the step runs as before. Without them, it is skipped with a warning, and the site renders without the generated pictures. A fresh clone with no node_modules now gives exit status 0 and two warnings.
The last line matters. A script that starts and then fails is a real bug, and it should still stop the render.
The limits
- The script edits HTML with regular expressions. That works because the HTML comes from my own Lua filter, in a shape that I know. It is not a general tool for any feed.
- The feed covers the whole site. R-bloggers wants posts about R only. This post and every other post here is about R. Before I write one that is not, the blog needs a second feed for the R category alone.
- The PNG is one frame. An animation that tells a story over time loses it in the feed. For a hero picture, that is a fair trade.
Try it
The scripts are in the repository of this blog:
scripts/feed.mjsfixes the feed.scripts/node-step.tsruns a Node step only where Node is there.scripts/covers.mjsdraws the share images and the PNG stills.
If your Quarto blog goes to R-bloggers too, read your own feed once, as HTML with nothing around it. That is how it will arrive.