feat: replace RSS handling

This replaces the RSS hack with a custom helper that replaces MDX components manually.
This commit is contained in:
Stefan Imhoff
2026-01-25 17:18:36 +01:00
committed by Stefan Imhoff
parent f4e41cb807
commit ceadc30005
17 changed files with 714 additions and 152 deletions

View File

@@ -63,6 +63,7 @@
"jimp": "^0.22.12", "jimp": "^0.22.12",
"lint-staged": "^15.2.4", "lint-staged": "^15.2.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-it": "^14.1.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"pagefind": "^1.1.0", "pagefind": "^1.1.0",
"plop": "^4.0.1", "plop": "^4.0.1",
@@ -74,6 +75,7 @@
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"rollup": "^4.18.0", "rollup": "^4.18.0",
"sanitize-html": "^2.14.0",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"svgo": "^3.3.2", "svgo": "^3.3.2",
"tailwindcss-logical": "^3.0.1", "tailwindcss-logical": "^3.0.1",

69
pnpm-lock.yaml generated
View File

@@ -151,6 +151,9 @@ importers:
lodash: lodash:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
markdown-it:
specifier: ^14.1.0
version: 14.1.0
npm-run-all: npm-run-all:
specifier: ^4.1.5 specifier: ^4.1.5
version: 4.1.5 version: 4.1.5
@@ -184,6 +187,9 @@ importers:
rollup: rollup:
specifier: npm:@rollup/wasm-node specifier: npm:@rollup/wasm-node
version: '@rollup/wasm-node@4.55.2' version: '@rollup/wasm-node@4.55.2'
sanitize-html:
specifier: ^2.14.0
version: 2.17.0
sharp: sharp:
specifier: 0.33.4 specifier: 0.33.4
version: 0.33.4 version: 0.33.4
@@ -3194,6 +3200,9 @@ packages:
html-void-elements@3.0.0: html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
http-cache-semantics@4.1.1: http-cache-semantics@4.1.1:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
@@ -3661,6 +3670,9 @@ packages:
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
lint-staged@15.2.9: lint-staged@15.2.9:
resolution: {integrity: sha512-BZAt8Lk3sEnxw7tfxM7jeZlPRuT4M68O0/CwZhhaw6eeWu0Lz5eERE3m386InivXB64fp/mDID452h48tvKlRQ==} resolution: {integrity: sha512-BZAt8Lk3sEnxw7tfxM7jeZlPRuT4M68O0/CwZhhaw6eeWu0Lz5eERE3m386InivXB64fp/mDID452h48tvKlRQ==}
engines: {node: '>=18.12.0'} engines: {node: '>=18.12.0'}
@@ -3767,6 +3779,10 @@ packages:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'} engines: {node: '>=16'}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
markdown-table@3.0.3: markdown-table@3.0.3:
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
@@ -3827,6 +3843,9 @@ packages:
mdn-data@2.0.30: mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
memorystream@0.3.1: memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
engines: {node: '>= 0.10.0'} engines: {node: '>= 0.10.0'}
@@ -4254,6 +4273,9 @@ packages:
resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parse5@7.1.2: parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
@@ -4773,6 +4795,10 @@ packages:
property-information@6.5.0: property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -4990,6 +5016,9 @@ packages:
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sanitize-html@2.17.0:
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
sass-formatter@0.7.9: sass-formatter@0.7.9:
resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==} resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==}
@@ -5442,6 +5471,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
uglify-js@3.19.2: uglify-js@3.19.2:
resolution: {integrity: sha512-S8KA6DDI47nQXJSi2ctQ629YzwOVs+bQML6DAtvy0wgNdpi+0ySpQK0g2pxBq2xfF2z3YCscu7NNA8nXT9PlIQ==} resolution: {integrity: sha512-S8KA6DDI47nQXJSi2ctQ629YzwOVs+bQML6DAtvy0wgNdpi+0ySpQK0g2pxBq2xfF2z3YCscu7NNA8nXT9PlIQ==}
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
@@ -9723,6 +9755,13 @@ snapshots:
html-void-elements@3.0.0: {} html-void-elements@3.0.0: {}
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
http-cache-semantics@4.1.1: {} http-cache-semantics@4.1.1: {}
human-signals@5.0.0: {} human-signals@5.0.0: {}
@@ -10139,6 +10178,10 @@ snapshots:
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
lint-staged@15.2.9: lint-staged@15.2.9:
dependencies: dependencies:
chalk: 5.3.0 chalk: 5.3.0
@@ -10270,6 +10313,15 @@ snapshots:
markdown-extensions@2.0.0: {} markdown-extensions@2.0.0: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
markdown-table@3.0.3: {} markdown-table@3.0.3: {}
mdast-util-definitions@6.0.0: mdast-util-definitions@6.0.0:
@@ -10445,6 +10497,8 @@ snapshots:
mdn-data@2.0.30: {} mdn-data@2.0.30: {}
mdurl@2.0.0: {}
memorystream@0.3.1: {} memorystream@0.3.1: {}
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
@@ -11061,6 +11115,8 @@ snapshots:
parse-passwd@1.0.0: {} parse-passwd@1.0.0: {}
parse-srcset@1.0.2: {}
parse5@7.1.2: parse5@7.1.2:
dependencies: dependencies:
entities: 4.5.0 entities: 4.5.0
@@ -11462,6 +11518,8 @@ snapshots:
property-information@6.5.0: {} property-information@6.5.0: {}
punycode.js@2.3.1: {}
punycode@2.3.1: {} punycode@2.3.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
@@ -11742,6 +11800,15 @@ snapshots:
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
sanitize-html@2.17.0:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.4.41
sass-formatter@0.7.9: sass-formatter@0.7.9:
dependencies: dependencies:
suf-log: 2.5.3 suf-log: 2.5.3
@@ -12289,6 +12356,8 @@ snapshots:
typescript@5.5.4: {} typescript@5.5.4: {}
uc.micro@2.1.0: {}
uglify-js@3.19.2: uglify-js@3.19.2:
optional: true optional: true

File diff suppressed because one or more lines are too long

View File

@@ -16,10 +16,10 @@ const { class: className, id, ...props } = Astro.props;
className, className,
]} ]}
data-umami-event={`Apple TV+: ${id}`} data-umami-event={`Apple TV+: ${id}`}
href={`https://tv.apple.com/show/foundation/umc.cmc.${id}`} href={`https://tv.apple.com/show/umc.cmc.${id}`}
title="Apple TV+" title="Apple TV+"
{...props} {...props}
><span class="hidden" aria-hidden="true">[</span><span class="hidden" aria-hidden="true" ><span class="hidden" aria-hidden="true">[</span><span class="hidden" aria-hidden="true"
>etflix]</span >]</span
></Link ></Link
> >

View File

@@ -10,7 +10,7 @@ const { class: className, noMargin, src, ...props } = Astro.props;
<div <div
class:list={[ class:list={[
'image-shadow block h-auto w-full rounded-1 border-1 border-solid border-black/[0.1] bg-black/[0.1] shadow shadow-black/10 mbe-10 mbs-0 dark:border-white/[0.1] dark:opacity-[0.87] dark:shadow-white/10', 'block h-auto w-full rounded-2 mbe-10 mbs-0 dark:opacity-[0.87]',
{ 'mbe-0': noMargin }, { 'mbe-0': noMargin },
className, className,
]} ]}

View File

@@ -1,18 +0,0 @@
---
export interface Props {
class?: string;
id: string;
[key: string]: any;
}
const { class: className, id, ...props } = Astro.props;
---
<div class:list={['relative aspect-video mbe-10', className]} {...props}>
<iframe
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="absolute h-full w-full"
frameborder="0"
src={`https://odysee.com/$/embed/${id}`}></iframe>
</div>

View File

@@ -1,55 +0,0 @@
---
import { site } from '../data/site';
import { dateToISO } from '../utils';
import { rssMapping } from '../mdx-components';
const { allPosts } = Astro.props;
const rssHeaderXml = `<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/rss.xsl" type="text/xsl"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${site.title}</title>
<description><![CDATA[ ${site.description} ]]></description>
<link>${site.url}</link>`;
const rssFooterXml = ` </channel>
</rss>`;
---
<Fragment set:html={rssHeaderXml} />
{
allPosts
.filter((post: any) => !post.frontmatter.draft)
.map((post: any) => (
<>
<Fragment
set:html={` <item>
<title>${post.frontmatter.title.replace('&', '&amp;')}</title>
<link>${`${site.url}/${post.frontmatter.slug}/`}</link>
<guid>${`${site.url}/${post.frontmatter.slug}/`}</guid>
<description><![CDATA[${post.frontmatter.description}]]></description>
<pubDate>${dateToISO(post.frontmatter.date)}</pubDate>
<enclosure url="${
site.url +
(post.frontmatter.cover
? post.frontmatter.cover.startsWith('/assets/images/cover/') &&
post.frontmatter.cover.endsWith('.webp')
? post.frontmatter.cover
.replace('/assets/images/cover/', '/assets/images/og/')
.replace(/\.webp$/, '.jpg')
: post.frontmatter.cover
: '/assets/images/og/bonsai.jpg')
}" length="0" type="image/jpeg" />
<content:encoded><![CDATA[`}
/>
<post.Content components={rssMapping} />
<Fragment
set:html={`]]></content:encoded>
</item>
`}
/>
</>
))
}
<Fragment set:html={rssFooterXml} />

View File

@@ -1,13 +0,0 @@
---
import path from 'node:path';
import fs from 'node:fs/promises';
const distFolder = './dist';
const filename = 'rss.xml';
const fileUrl = path.join(distFolder, filename);
if (Astro.slots.has('rss-writer')) {
const html = await Astro.slots.render('rss-writer');
await fs.writeFile(fileUrl, html);
}
---

View File

@@ -12,7 +12,7 @@ tags: ["productivity", "self-improvement", "book", "minimalism"]
For one and a half years I use now the principles of _GTD_ (<em>Getting Things Done</em>®), from the book by _David Allen_, to organize my tasks. For one and a half years I use now the principles of _GTD_ (<em>Getting Things Done</em>®), from the book by _David Allen_, to organize my tasks.
In his book _<ProductLink asin="0143126563" text="Getting Things Done: The Art of Stress-Free Productivity" />_, David Allen introduces an interesting system that allows you to do your tasks efficiently. In his book <ProductLink asin="0143126563" text="Getting Things Done: The Art of Stress-Free Productivity" />, David Allen introduces an interesting system that allows you to do your tasks efficiently.
<Bookshelf> <Bookshelf>
<AmazonBook asin="0143126563" alt="Getting Things Done: The Art of Stress-Free Productivity" /> <AmazonBook asin="0143126563" alt="Getting Things Done: The Art of Stress-Free Productivity" />

View File

@@ -10,7 +10,7 @@ tags: ["design", "download", "book", "japan", "recommendation"]
During a visit to the art exhibition _Japan and the West_ in the [Art Museum Wolfsburg](https://www.kunstmuseum-wolfsburg.de/), I was able to get my hands on a wonderful book about the traditional, Japanese colors. During a visit to the art exhibition _Japan and the West_ in the [Art Museum Wolfsburg](https://www.kunstmuseum-wolfsburg.de/), I was able to get my hands on a wonderful book about the traditional, Japanese colors.
In the museum shop, I bought the book _<ProductLink asin="4894445786" text="The Traditional Colors of Japan" />_ which is in Japanese. In addition to a full-color palette of 250 colors, it contains an exact indication of RGB, CMYK, and other information. In the museum shop, I bought the book <ProductLink asin="4894445786" text="The Traditional Colors of Japan" /> which is in Japanese. In addition to a full-color palette of 250 colors, it contains an exact indication of RGB, CMYK, and other information.
<Bookshelf> <Bookshelf>
<AmazonBook asin="4894445786" alt="The Traditional Colors of Japan" /> <AmazonBook asin="4894445786" alt="The Traditional Colors of Japan" />
@@ -19,7 +19,7 @@ In the museum shop, I bought the book _<ProductLink asin="4894445786" text="The
Each page of the book shows three colors that are always juxtaposed with one or more stunning photos. The photos show the traditional use of colors in kimonos, samurai armor, monk clothing, or nature. Each page of the book shows three colors that are always juxtaposed with one or more stunning photos. The photos show the traditional use of colors in kimonos, samurai armor, monk clothing, or nature.
In 2011 the second book, _<ProductLink asin="475624114X" text="Traditional Japanese Color Palette" />_ was released which showcases a huge amount of Japanese color palattes with examples from culture, tradition, and art. In 2011 the second book, <ProductLink asin="475624114X" text="Traditional Japanese Color Palette" /> was released which showcases a huge amount of Japanese color palattes with examples from culture, tradition, and art.
## Color Palette for Graphic Software ## Color Palette for Graphic Software

View File

@@ -21,10 +21,7 @@ These works of art are painful (dozens of bamboo needles are [stung several time
One of the best living artists of the Irezumi is Horiyoshi III, whose works can be seen in many illustrated books: <ProductLink asin="9074822452" text="Tattoos from the Floating World: Ukiyo-e Motifs in Japanese Tattoo" /> and <ProductLink asin="0764312014" text="Bushido: Legacies of the Japanese Tattoo" />. One of the best living artists of the Irezumi is Horiyoshi III, whose works can be seen in many illustrated books: <ProductLink asin="9074822452" text="Tattoos from the Floating World: Ukiyo-e Motifs in Japanese Tattoo" /> and <ProductLink asin="0764312014" text="Bushido: Legacies of the Japanese Tattoo" />.
<Bookshelf> <Bookshelf>
<AmazonBook <AmazonBook asin="9074822452" alt="Tattoos from the Floating World: Ukiyo-e Motifs in the Japanese Tattoo" />
asin="9074822452"
alt="Tattoos from the Floating World: Ukiyo-e Motifs in the Japanese Tattoo"
/>
<AmazonBook asin="0764312014" alt="Bushido" /> <AmazonBook asin="0764312014" alt="Bushido" />
</Bookshelf> </Bookshelf>
@@ -38,7 +35,7 @@ The links to the Japanese art of [ukiyo-e](https://grokipedia.com/page/Ukiyo-e)
## Motives ## Motives
In addition to gods, mythical creatures, and demons, the most important source of motifs is the ancient Chinese novella _[Shuǐhǔ Zhuàn](https://grokipedia.com/page/Water_Margin)_ (Water Margin), known in Japan as _Suikoden_. It belongs to one of the four classical books of Chinese literature. The German translation of this entertaining book is called _<ProductLink asin="3458318917" text="Die Räuber vom Liang Schan Moor" />_. In addition to gods, mythical creatures, and demons, the most important source of motifs is the ancient Chinese novella _[Shuǐhǔ Zhuàn](https://grokipedia.com/page/Water_Margin)_ (Water Margin), known in Japan as _Suikoden_. It belongs to one of the four classical books of Chinese literature. The German translation of this entertaining book is called <ProductLink asin="3458318917" text="Die Räuber vom Liang Schan Moor" />.
## Water Margin ## Water Margin

View File

@@ -20,7 +20,6 @@ import ListItem from './components/ListItem.astro';
import MarkdownImage from './components/MarkdownImage.astro'; import MarkdownImage from './components/MarkdownImage.astro';
import MoreLink from './components/MoreLink.astro'; import MoreLink from './components/MoreLink.astro';
import NetflixFlag from './components/NetflixFlag.astro'; import NetflixFlag from './components/NetflixFlag.astro';
import OdyseeVideo from './components/OdyseeVideo.astro';
import OrderedList from './components/OrderedList.astro'; import OrderedList from './components/OrderedList.astro';
import PrimeVideoFlag from './components/PrimeVideoFlag.astro'; import PrimeVideoFlag from './components/PrimeVideoFlag.astro';
import ProductLink from './components/ProductLink.astro'; import ProductLink from './components/ProductLink.astro';
@@ -55,7 +54,6 @@ export const mapping = {
MarkdownImage, MarkdownImage,
MoreLink, MoreLink,
NetflixFlag, NetflixFlag,
OdyseeVideo,
PrimeVideoFlag, PrimeVideoFlag,
ProductLink, ProductLink,
ProjectIntro, ProjectIntro,
@@ -80,34 +78,3 @@ export const mapping = {
p: Text, p: Text,
ul: UnorderedList, ul: UnorderedList,
}; };
// Mapping for RSS feed to reduce the size of the feed
export const rssMapping = {
AmazonBook,
AppleTVFlag,
Banner,
Blockquote,
Book,
Bookshelf,
ColorStack,
ColorSwatch,
DisplayBox,
DownloadLink,
EmailLink,
Figure,
Flag,
Image,
MarkdownImage,
MoreLink,
NetflixFlag,
OdyseeVideo,
PrimeVideoFlag,
ProductLink,
ProjectIntro,
Pullquote,
Ruby,
Spotify,
ThemeBox,
Verse,
YouTube,
};

View File

@@ -13,13 +13,6 @@ import PageTitle from '../components/PageTitle.astro';
import Image from '../components/Image.astro'; import Image from '../components/Image.astro';
import JournalList from '../components/JournalList.astro'; import JournalList from '../components/JournalList.astro';
/* FIXME: Remove hack as soon as this issue is resolved:
* Issue: https://github.com/withastro/roadmap/issues/533
* Proposal: https://github.com/withastro/roadmap/discussions/419
*/
import WriteFile from '../components/WriteFile.astro';
import RssXml from '../components/RssXml.astro';
import Headline from '../components/Headline.astro'; import Headline from '../components/Headline.astro';
import MoreLink from '../components/MoreLink.astro'; import MoreLink from '../components/MoreLink.astro';
@@ -92,7 +85,3 @@ rssPosts.sort(sortMarkdownByDate);
<JournalList entries={formattedLatest} /> <JournalList entries={formattedLatest} />
</article> </article>
</GridLayout> </GridLayout>
<WriteFile slot="rss-writer">
<RssXml allPosts={isProduction ? rssPosts : []} slot="rss-writer" />
</WriteFile>

View File

@@ -4,7 +4,7 @@ import { getCollection } from 'astro:content';
import { site } from '../data/site'; import { site } from '../data/site';
import { sortByDate } from '../utils'; import { sortByDate } from '../utils';
export async function get(context) { export async function GET(context) {
const haiku = await getCollection('haiku'); const haiku = await getCollection('haiku');
haiku.sort(sortByDate); haiku.sort(sortByDate);
@@ -18,7 +18,7 @@ export async function get(context) {
pubDate: item.data.date, pubDate: item.data.date,
customData: '<language>en-us</language>', customData: '<language>en-us</language>',
link: `/haiku/${item.slug}/`, link: `/haiku/${item.slug}/`,
content: `<div><p>${item.data.de}</p><hr /><p>${item.data.en}</p></div>`, content: `<blockquote><p>${item.data.de}</p><hr /><p>${item.data.en}</p></blockquote>`,
})), })),
customData: `<language>en-us</language>`, customData: `<language>en-us</language>`,
}); });

77
src/pages/rss.xml.js Normal file
View File

@@ -0,0 +1,77 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import MarkdownIt from 'markdown-it';
import sanitizeHtml from 'sanitize-html';
import { site } from '../data/site';
import { sortByDate } from '../utils';
const parser = new MarkdownIt({ html: true });
import { stripMDXComponents } from '../utils';
export async function GET(context) {
const journal = await getCollection('journal', ({ data }) => !data.draft);
const haiku = await getCollection('haiku');
journal.sort(sortByDate);
haiku.sort(sortByDate);
return rss({
stylesheet: '/rss.xsl',
title: site.title,
description: site.description,
site: context.site,
items: [
...journal.map((post) => {
// Filter out import statements from content
const contentWithoutImports = post.body
.split('\n')
.filter((line) => !line.startsWith('import'))
.join('\n');
// First strip MDX components, then render markdown
const processedContent = stripMDXComponents(contentWithoutImports, context.site);
const renderedContent = parser.render(processedContent);
const sanitizedContent = sanitizeHtml(renderedContent, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
'img',
'figure',
'figcaption',
'details',
'summary',
'div',
]),
allowedAttributes: {
div: ['style'],
a: ['href'],
img: ['src', 'alt', 'width', 'height'],
},
});
return {
title: post.data.title,
pubDate: post.data.date,
description: `<![CDATA[${post.data.description}]]>`,
link: `/${post.slug}/`,
content: `<![CDATA[${sanitizedContent}]]>`,
enclosure: {
url: `${site.url}${post.data.cover.startsWith('/assets/images/cover/') && post.data.cover.endsWith('.webp') ? post.data.cover.replace('/assets/images/cover/', '/assets/images/og/').replace(/\.webp$/, '.jpg') : '/assets/images/og/bonsai.jpg'}`,
length: 0,
type: 'image/jpeg',
},
customData: '<language>en-us</language>',
};
}),
...haiku.map((item) => {
return {
title: `Haiku ${item.slug}`,
pubDate: item.data.date,
customData: '<language>en-us</language>',
link: `/haiku/${item.slug}/`,
content: `<blockquote><p>${item.data.de}</p><hr /><p>${item.data.en}</p></blockquote>`,
};
}),
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf()),
customData: `<language>en-us</language>`,
});
}

View File

@@ -4,6 +4,7 @@ export * from './is-production';
export * from './pick-two-random-colors'; export * from './pick-two-random-colors';
export * from './remark-reading-time'; export * from './remark-reading-time';
export * from './remark-widont'; export * from './remark-widont';
export * from './rss-parser';
export * from './sort-by-alphabet'; export * from './sort-by-alphabet';
export * from './sort-by-date'; export * from './sort-by-date';
export * from './sort-by-sortkey'; export * from './sort-by-sortkey';

510
src/utils/rss-parser.ts Normal file
View File

@@ -0,0 +1,510 @@
/**
* Helper utilities for RSS parsing and content normalization.
*
* Extracted from src/pages/rss-test.xml.js to centralize helpers.
*
* Functions:
* - stripMarkdown(text): Remove simple markdown formatting.
* - makeAbsolute(url, siteUrl): Convert relative URLs to absolute using siteUrl.
* - fixImagePaths(html, siteUrl): Replace <img src="..."> with absolute src.
* - replaceImageComponent(attributes, siteUrl): Convert an MDX `<Image ... />` component into HTML.
* - replaceAmazonBookComponent(attributes): Convert an MDX `<AmazonBook ... />` component into HTML.
* - stripMDXComponents(text, siteUrl): Replace `<Image />`, `<AmazonBook />` and strip other MDX component tags.
*
* These are implemented in TypeScript with minimal dependencies so they can be
* used from RSS builder code or other places.
*/
/* eslint-disable @typescript-eslint/no-non-null-assertion */
export function stripMarkdown(text: string): string {
// Remove markdown links: [text](url) => text
// Remove basic markdown formatting characters: *, _, `, ~
return text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*_`~]/g, '');
}
/**
* Convert a possibly-relative url to an absolute URL using siteUrl as the base.
* If the url is already absolute (http/https) it is returned unchanged.
*/
export function makeAbsolute(url: string, siteUrl: string): string {
if (!url) return url;
if (/^https?:\/\//i.test(url)) {
return url;
}
try {
// The URL constructor will resolve relative URLs against the base.
return new URL(url, siteUrl).toString();
} catch (e) {
// Fallback: simple concatenation with a single slash between.
const base = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl;
const path = url.startsWith('/') ? url : `/${url}`;
return `${base}${path}`;
}
}
/**
* Replace img tags in HTML that have relative src attributes with absolute URLs.
* This is useful after rendering markdown/html that contains <img src="...">.
*/
export function fixImagePaths(html: string, siteUrl: string): string {
if (!html) return html;
return html.replace(
/<img([^>]*)\s+src=(?:"|')([^"']*)(?:"|')([^>]*)>/g,
(_match, beforeSrc: string, src: string, afterSrc: string) => {
const absoluteSrc = makeAbsolute(src, siteUrl);
return `<img${beforeSrc} src="${absoluteSrc}"${afterSrc}>`;
}
);
}
/**
* Extract attribute value from a string like `src="value"` or `src='value'`.
* Returns null if attribute is not found.
*/
function getAttr(str: string, name: string): string | null {
const regex = new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i');
const match = str.match(regex);
return match ? match[1] || match[2] : null;
}
// Convert an MDX <Image ... />
export function replaceImageComponent(attributes: string, siteUrl: string): string {
// Extract possible props we care about
const src = getAttr(attributes, 'src');
const alt = getAttr(attributes, 'alt') || '';
const caption = getAttr(attributes, 'caption');
const source = getAttr(attributes, 'source');
const sourceUrl = getAttr(attributes, 'sourceUrl');
const href = getAttr(attributes, 'href');
const width = getAttr(attributes, 'width');
const height = getAttr(attributes, 'height');
if (!src) return '';
const absoluteSrc = makeAbsolute(src, siteUrl);
// Build <img> tag
let imgHtml = `<img src="${absoluteSrc}" alt="${escapeHtmlAttr(alt)}"`;
if (width) imgHtml += ` width="${escapeHtmlAttr(width)}"`;
if (height) imgHtml += ` height="${escapeHtmlAttr(height)}"`;
imgHtml += ` />`;
// Optionally wrap in <a>
if (href) {
const safeHref = escapeHtmlAttr(href);
imgHtml = `<a href="${safeHref}">${imgHtml}</a>`;
}
// Build figcaption if needed
if (caption || source) {
let captionContent = '';
if (caption) captionContent += escapeHtml(caption);
if (caption && source) captionContent += ' ';
if (source) {
if (sourceUrl) {
captionContent += `<cite><a href="${escapeHtmlAttr(sourceUrl)}">${escapeHtml(source)}</a></cite>`;
} else {
captionContent += `<cite>${escapeHtml(source)}</cite>`;
}
}
return `<figure>${imgHtml}<figcaption>${captionContent}</figcaption></figure>`;
}
return `<figure>${imgHtml}</figure>`;
}
// Convert an MDX <AmazonBook ... />
export function replaceAmazonBookComponent(attributes: string): string {
const asin = getAttr(attributes, 'asin');
const alt = getAttr(attributes, 'alt') || '';
if (!asin) return '';
// Construct Amazon Image URL
const amazonImageUrl = `https://images-na.ssl-images-amazon.com/images/P/${asin}.01.LZZZZZZZ.jpg`;
const amazonProductUrl = `https://www.amazon.de/gp/product/${asin}`;
// We simplify the output to a standard anchor + img tag
return `<a href="${amazonProductUrl}" aria-label="${escapeHtmlAttr(alt)}">
<img src="${amazonImageUrl}" alt="${escapeHtmlAttr(alt)}" />
</a>`;
}
// Convert an MDX <AppleTV ... />
export function replaceAppleTvComponent(attributes: string): string {
const id = getAttr(attributes, 'id');
if (!id) return '';
// The URL pattern from the component
const url = `https://tv.apple.com/show/umc.cmc.${id}`;
// We preserve the inner HTML structure (spans and Apple logo)
// but strip the complex Tailwind classes for the "pure HTML" output.
return `<a href="${url}" title="Apple TV+">[]</a>`;
}
// Convert an MDX <NetflixFlag ... />
export function replaceNetflixComponent(attributes: string): string {
const id = getAttr(attributes, 'id');
if (!id) return '';
// The URL pattern from the component
const url = `https://www.netflix.com/title/${id}`;
// We preserve the inner HTML structure (spans and Apple logo)
// but strip the complex Tailwind classes for the "pure HTML" output.
return `<a href="${url}" title="Netflix">[Netflix]</a>`;
}
// Convert an MDX <PrimeVideoFlag ... />
export function replacePrimeVideoComponent(attributes: string): string {
const id = getAttr(attributes, 'id');
if (!id) return '';
// The URL pattern from the component
const url = `https://www.amazon.de/gp/video/detail/${id}`;
// We preserve the inner HTML structure (spans and Apple logo)
// but strip the complex Tailwind classes for the "pure HTML" output.
return `<a href="${url}" title="Prime Video">[Prime Video]</a>`;
}
// Convert an MDX <Flag ... />
export function replaceFlagComponent(attributes: string, siteUrl: string): string {
const label = getAttr(attributes, 'label');
const href = getAttr(attributes, 'href');
if (!label) return '';
// Inner content with decorative brackets, mimicking the original component
const innerHtml = `[${escapeHtml(label)}]`;
if (href) {
const absoluteHref = makeAbsolute(href, siteUrl);
return `<a href="${escapeHtmlAttr(absoluteHref)}" title="${escapeHtmlAttr(label)}">${innerHtml}</a>`;
}
return `<span title="${escapeHtmlAttr(label)}">${innerHtml}</span>`;
}
export function replaceBlockquoteComponent(
attributes: string,
content: string,
siteUrl: string
): string {
const author = getAttr(attributes, 'author');
const source = getAttr(attributes, 'source');
const sourceUrl = getAttr(attributes, 'sourceUrl');
const lang = getAttr(attributes, 'lang') || 'en';
let footerHtml = '';
// Build the footer if we have an author or source
if (author || source) {
footerHtml += '<footer>—';
if (author) {
footerHtml += ` <b>${escapeHtml(author)}</b>`;
}
if (author && source) {
footerHtml += ',';
}
if (source) {
const safeSource = escapeHtml(source);
// Add space before source
footerHtml += ' ';
if (sourceUrl) {
const absoluteUrl = makeAbsolute(sourceUrl, siteUrl);
footerHtml += `<cite><a href="${escapeHtmlAttr(absoluteUrl)}">${safeSource}</a></cite>`;
} else {
footerHtml += `<cite>${safeSource}</cite>`;
}
}
footerHtml += '</footer>';
}
return `<blockquote lang="${escapeHtmlAttr(lang)}">${content}${footerHtml}</blockquote>`;
}
// Convert an MDX <Pullquote ... />
export function replacePullquoteComponent(attributes: string, siteUrl: string): string {
const text = getAttr(attributes, 'text');
if (!text) return '';
const author = getAttr(attributes, 'author');
const source = getAttr(attributes, 'source');
const sourceUrl = getAttr(attributes, 'sourceUrl');
const lang = getAttr(attributes, 'lang') || 'en';
const alignment = getAttr(attributes, 'alignment') || 'center';
// Map alignment to inline styles for RSS compatibility
const style = alignment === 'left' ? 'text-align: left;' : 'text-align: center;';
let footerHtml = '';
if (author || source) {
footerHtml += '<footer>';
if (author) {
footerHtml += `<b>${escapeHtml(author)}</b>`;
}
if (author && source) {
footerHtml += ', ';
}
if (source) {
const safeSource = escapeHtml(source);
if (sourceUrl) {
const absoluteUrl = makeAbsolute(sourceUrl, siteUrl);
footerHtml += `<cite><a href="${escapeHtmlAttr(absoluteUrl)}">${safeSource}</a></cite>`;
} else {
footerHtml += `<cite>${safeSource}</cite>`;
}
}
footerHtml += '</footer>';
}
return `<blockquote lang="${escapeHtmlAttr(lang)}" style="${style}"><p>${text}</p>${footerHtml}</blockquote>`;
}
// Convert an MDX <ProductLink ... />
export function replaceProductLinkComponent(attributes: string): string {
const asin = getAttr(attributes, 'asin');
const text = getAttr(attributes, 'text');
if (!asin || !text) return '';
const url = `https://www.amazon.de/gp/product/${asin}`;
return `<a href="${url}">${escapeHtml(text)}</a>`;
}
// Convert an MDX <DownloadLink ... />
export function replaceDownloadLinkComponent(attributes: string, siteUrl: string): string {
const href = getAttr(attributes, 'href');
const text = getAttr(attributes, 'text');
if (!href || !text) return '';
const absoluteHref = makeAbsolute(href, siteUrl);
return `<a href="${escapeHtmlAttr(absoluteHref)}">${escapeHtml(text)} &#8595;</a>`;
}
// Convert an MDX <MoreLink ... />
export function replaceMoreLinkComponent(attributes: string, siteUrl: string): string {
const href = getAttr(attributes, 'href');
const text = getAttr(attributes, 'text');
if (!href || !text) return '';
const absoluteHref = makeAbsolute(href, siteUrl);
return `<a href="${escapeHtmlAttr(absoluteHref)}">${escapeHtml(text)} &#8594;</a>`;
}
// Convert an MDX <Ruby ... />
export function replaceRubyComponent(attributes: string): string {
const base = getAttr(attributes, 'base');
const text = getAttr(attributes, 'text');
if (!base || !text) return '';
return `<ruby>${escapeHtml(base)}<rp></rp><rt>${escapeHtml(text)}</rt><rp></rp></ruby>`;
}
// Convert an MDX <Spotify ... />
export function replaceSpotifyComponent(attributes: string): string {
const id = getAttr(attributes, 'id');
if (!id) return '';
// Construct the Spotify embed URL
const src = `https://open.spotify.com/embed/show/${id}?utm_source=generator&theme=0`;
return `<iframe src="${src}" width="100%" height="352" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>`;
}
// Convert an MDX <Figure ...>...</Figure>
export function replaceFigureComponent(attributes: string, content: string): string {
const caption = getAttr(attributes, 'caption');
let html = `<figure>${content}`;
if (caption) {
html += `<figcaption>${escapeHtml(caption)}</figcaption>`;
}
html += `</figure>`;
return html;
}
// Convert an MDX <Banner ...>...</Banner>
export function replaceBannerComponent(attributes: string, content: string): string {
const summary = getAttr(attributes, 'summary');
// Check for the presence of the 'open' attribute (boolean or explicitly set)
const isOpen = /\bopen\b/i.test(attributes);
let html = '<aside>';
if (summary) {
html += `<details${isOpen ? ' open' : ''}><summary><strong>${escapeHtml(summary)}</strong></summary>${content}</details>`;
} else {
html += content;
}
html += '</aside>';
return html;
}
// Convert an MDX <ColorSwatch ... />
export function replaceColorSwatchComponent(attributes: string): string {
const color = getAttr(attributes, 'color');
if (!color) return '';
return `<div style="width: 100px; height: 100px; background-color: ${escapeHtmlAttr(color)};"></div>`;
}
// Convert wrapper components (like <ColorStack> and <BookShelf>)
export function replaceWrapperComponent(content: string): string {
return `<div>${content}</div>`;
}
/**
* Strip MDX/JSX-like components from text but preserve/convert specific components.
*/
export function stripMDXComponents(text: string, siteUrl: string): string {
if (!text) return text;
// <Image ... />
let processed = text.replace(/<Image([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceImageComponent(attributes, siteUrl)
);
// AmazonBook ... />
processed = processed.replace(/<AmazonBook([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceAmazonBookComponent(attributes)
);
// AppleTV ... />
processed = processed.replace(
/<Apple(Tv|TV)([\s\S]*?)\/>/g,
(_match, _tagSuffix, attributes: string) => replaceAppleTvComponent(attributes)
);
// <Netflix ... />
processed = processed.replace(/<Netflix([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceNetflixComponent(attributes)
);
// <PrimeVideo ... />
processed = processed.replace(/<PrimeVideo([\s\S]*?)\/>/g, (_match, attributes: string) =>
replacePrimeVideoComponent(attributes)
);
// <Flag ... />
processed = processed.replace(/<Flag([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceFlagComponent(attributes, siteUrl)
);
// <Blockquote ...>...</Blockquote>
processed = processed.replace(
/<Blockquote\b([^>]*)>([\s\S]*?)<\/Blockquote>/g,
(_match, attributes: string, content: string) =>
replaceBlockquoteComponent(attributes, content, siteUrl)
);
// <Pullquote ... />
processed = processed.replace(/<Pullquote([\s\S]*?)\/>/g, (_match, attributes: string) =>
replacePullquoteComponent(attributes, siteUrl)
);
// <Figure ...>...</Figure>
processed = processed.replace(
/<Figure\b([^>]*)>([\s\S]*?)<\/Figure>/g,
(_match, attributes: string, content: string) => replaceFigureComponent(attributes, content)
);
// <Banner ...>...</Banner>
processed = processed.replace(
/<Banner\b([^>]*)>([\s\S]*?)<\/Banner>/g,
(_match, attributes: string, content: string) => replaceBannerComponent(attributes, content)
);
// <ProductLink ... />
processed = processed.replace(/<ProductLink([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceProductLinkComponent(attributes)
);
// <DownloadLink ... />
processed = processed.replace(/<DownloadLink([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceDownloadLinkComponent(attributes, siteUrl)
);
// <MoreLink ... />
processed = processed.replace(/<MoreLink([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceMoreLinkComponent(attributes, siteUrl)
);
// <Ruby ... />
processed = processed.replace(/<Ruby([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceRubyComponent(attributes)
);
// <Spotify ... />
processed = processed.replace(/<Spotify([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceSpotifyComponent(attributes)
);
// <ColorSwatch ... />
processed = processed.replace(/<ColorSwatch([\s\S]*?)\/>/g, (_match, attributes: string) =>
replaceColorSwatchComponent(attributes)
);
// <ColorStack>...</ColorStack> and <BookShelf>...</BookShelf>
processed = processed.replace(
/<(ColorStack|Bookshelf)\b[^>]*>([\s\S]*?)<\/\1>/g,
(_match, _tag, content: string) => replaceWrapperComponent(content)
);
// Remove any other self-closing components e.g. <Foo bar="baz" />
const removedSelfClosing = processed.replace(/<([A-Z][\w\d]*)\b[^>]*?\/>/g, '');
// Remove paired component tags, including their content, e.g. <Foo>...</Foo>
const removedPaired = removedSelfClosing.replace(
/<([A-Z][\w\d]*)\b[^>]*?>([\s\S]*?)<\/\1>/g,
''
);
return removedPaired;
}
/**
* Simple helper to escape text for inclusion inside HTML text nodes.
*/
function escapeHtml(str: string | null | undefined): string {
if (str == null) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* Escape for attribute values (double-quoted).
*/
function escapeHtmlAttr(str: string | null | undefined): string {
if (str == null) return '';
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}