diff --git a/package.json b/package.json
index ac70561..6c01f17 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"jimp": "^0.22.12",
"lint-staged": "^15.2.4",
"lodash": "^4.17.21",
+ "markdown-it": "^14.1.0",
"npm-run-all": "^4.1.5",
"pagefind": "^1.1.0",
"plop": "^4.0.1",
@@ -74,6 +75,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"prettier-plugin-tailwindcss": "^0.6.5",
"rollup": "^4.18.0",
+ "sanitize-html": "^2.14.0",
"sharp": "^0.33.4",
"svgo": "^3.3.2",
"tailwindcss-logical": "^3.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7f89ccd..91c385f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -151,6 +151,9 @@ importers:
lodash:
specifier: ^4.17.21
version: 4.17.21
+ markdown-it:
+ specifier: ^14.1.0
+ version: 14.1.0
npm-run-all:
specifier: ^4.1.5
version: 4.1.5
@@ -184,6 +187,9 @@ importers:
rollup:
specifier: npm:@rollup/wasm-node
version: '@rollup/wasm-node@4.55.2'
+ sanitize-html:
+ specifier: ^2.14.0
+ version: 2.17.0
sharp:
specifier: 0.33.4
version: 0.33.4
@@ -3194,6 +3200,9 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
+ htmlparser2@8.0.2:
+ resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+
http-cache-semantics@4.1.1:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
@@ -3661,6 +3670,9 @@ packages:
lines-and-columns@1.2.4:
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:
resolution: {integrity: sha512-BZAt8Lk3sEnxw7tfxM7jeZlPRuT4M68O0/CwZhhaw6eeWu0Lz5eERE3m386InivXB64fp/mDID452h48tvKlRQ==}
engines: {node: '>=18.12.0'}
@@ -3767,6 +3779,10 @@ packages:
resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==}
engines: {node: '>=16'}
+ markdown-it@14.1.0:
+ resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
+ hasBin: true
+
markdown-table@3.0.3:
resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==}
@@ -3827,6 +3843,9 @@ packages:
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
+ mdurl@2.0.0:
+ resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+
memorystream@0.3.1:
resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
engines: {node: '>= 0.10.0'}
@@ -4254,6 +4273,9 @@ packages:
resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==}
engines: {node: '>=0.10.0'}
+ parse-srcset@1.0.2:
+ resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+
parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
@@ -4773,6 +4795,10 @@ packages:
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
+ punycode.js@2.3.1:
+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
+ engines: {node: '>=6'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -4990,6 +5016,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ sanitize-html@2.17.0:
+ resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
+
sass-formatter@0.7.9:
resolution: {integrity: sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==}
@@ -5442,6 +5471,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ uc.micro@2.1.0:
+ resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+
uglify-js@3.19.2:
resolution: {integrity: sha512-S8KA6DDI47nQXJSi2ctQ629YzwOVs+bQML6DAtvy0wgNdpi+0ySpQK0g2pxBq2xfF2z3YCscu7NNA8nXT9PlIQ==}
engines: {node: '>=0.8.0'}
@@ -9723,6 +9755,13 @@ snapshots:
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: {}
human-signals@5.0.0: {}
@@ -10139,6 +10178,10 @@ snapshots:
lines-and-columns@1.2.4: {}
+ linkify-it@5.0.0:
+ dependencies:
+ uc.micro: 2.1.0
+
lint-staged@15.2.9:
dependencies:
chalk: 5.3.0
@@ -10270,6 +10313,15 @@ snapshots:
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: {}
mdast-util-definitions@6.0.0:
@@ -10445,6 +10497,8 @@ snapshots:
mdn-data@2.0.30: {}
+ mdurl@2.0.0: {}
+
memorystream@0.3.1: {}
merge-stream@2.0.0: {}
@@ -11061,6 +11115,8 @@ snapshots:
parse-passwd@1.0.0: {}
+ parse-srcset@1.0.2: {}
+
parse5@7.1.2:
dependencies:
entities: 4.5.0
@@ -11462,6 +11518,8 @@ snapshots:
property-information@6.5.0: {}
+ punycode.js@2.3.1: {}
+
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
@@ -11742,6 +11800,15 @@ snapshots:
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:
dependencies:
suf-log: 2.5.3
@@ -12289,6 +12356,8 @@ snapshots:
typescript@5.5.4: {}
+ uc.micro@2.1.0: {}
+
uglify-js@3.19.2:
optional: true
diff --git a/public/rss.xsl b/public/rss.xsl
index f408bf8..64c14ae 100644
--- a/public/rss.xsl
+++ b/public/rss.xsl
@@ -9,17 +9,53 @@
Web Feed
-
+
-
-
- This is a web feed, also known as an RSS feed. Subscribe by copying the URL from the address bar into your newsreader.
-
-
- Visit About Feeds to get started with newsreaders and subscribing. It’s free.
-
-
diff --git a/src/components/AppleTVFlag.astro b/src/components/AppleTVFlag.astro
index 1098c49..31c2ab1 100644
--- a/src/components/AppleTVFlag.astro
+++ b/src/components/AppleTVFlag.astro
@@ -16,10 +16,10 @@ const { class: className, id, ...props } = Astro.props;
className,
]}
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+"
{...props}
>[ etflix] ]
diff --git a/src/components/MarkdownImage.astro b/src/components/MarkdownImage.astro
index fee7733..ea13ab7 100644
--- a/src/components/MarkdownImage.astro
+++ b/src/components/MarkdownImage.astro
@@ -10,7 +10,7 @@ const { class: className, noMargin, src, ...props } = Astro.props;
-
-
diff --git a/src/components/RssXml.astro b/src/components/RssXml.astro
deleted file mode 100644
index 624faca..0000000
--- a/src/components/RssXml.astro
+++ /dev/null
@@ -1,55 +0,0 @@
----
-import { site } from '../data/site';
-import { dateToISO } from '../utils';
-
-import { rssMapping } from '../mdx-components';
-
-const { allPosts } = Astro.props;
-const rssHeaderXml = `
-
-
-
- ${site.title}
-
- ${site.url}`;
-
-const rssFooterXml = `
- `;
----
-
-
-{
- allPosts
- .filter((post: any) => !post.frontmatter.draft)
- .map((post: any) => (
- <>
-
- ${post.frontmatter.title.replace('&', '&')}
- ${`${site.url}/${post.frontmatter.slug}/`}
- ${`${site.url}/${post.frontmatter.slug}/`}
-
- ${dateToISO(post.frontmatter.date)}
-
-
-
-
-
-`}
- />
- >
- ))
-}
-
diff --git a/src/components/WriteFile.astro b/src/components/WriteFile.astro
deleted file mode 100644
index 6fdf974..0000000
--- a/src/components/WriteFile.astro
+++ /dev/null
@@ -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);
-}
----
diff --git a/src/content/journal/2007/gtd.mdx b/src/content/journal/2007/gtd.mdx
index f505c86..fa99e42 100644
--- a/src/content/journal/2007/gtd.mdx
+++ b/src/content/journal/2007/gtd.mdx
@@ -12,7 +12,7 @@ tags: ["productivity", "self-improvement", "book", "minimalism"]
For one and a half years I use now the principles of _GTD_ (Getting Things Done ®), from the book by _David Allen_, to organize my tasks.
-In his book _ _, David Allen introduces an interesting system that allows you to do your tasks efficiently.
+In his book , David Allen introduces an interesting system that allows you to do your tasks efficiently.
diff --git a/src/content/journal/2007/japanese-colors.mdx b/src/content/journal/2007/japanese-colors.mdx
index 44dc53f..25d440d 100644
--- a/src/content/journal/2007/japanese-colors.mdx
+++ b/src/content/journal/2007/japanese-colors.mdx
@@ -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.
-In the museum shop, I bought the book _ _ 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 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.
@@ -19,7 +19,7 @@ In the museum shop, I bought the book _ _ was released which showcases a huge amount of Japanese color palattes with examples from culture, tradition, and art.
+In 2011 the second book, was released which showcases a huge amount of Japanese color palattes with examples from culture, tradition, and art.
## Color Palette for Graphic Software
diff --git a/src/content/journal/2007/koi-design.mdx b/src/content/journal/2007/koi-design.mdx
index aa0ac42..96d6679 100644
--- a/src/content/journal/2007/koi-design.mdx
+++ b/src/content/journal/2007/koi-design.mdx
@@ -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: and .
-
+
@@ -38,7 +35,7 @@ The links to the Japanese art of [ukiyo-e](https://grokipedia.com/page/Ukiyo-e)
## 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 _ _.
+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 .
## Water Margin
diff --git a/src/mdx-components.ts b/src/mdx-components.ts
index 1f34c55..b09ac7c 100644
--- a/src/mdx-components.ts
+++ b/src/mdx-components.ts
@@ -20,7 +20,6 @@ import ListItem from './components/ListItem.astro';
import MarkdownImage from './components/MarkdownImage.astro';
import MoreLink from './components/MoreLink.astro';
import NetflixFlag from './components/NetflixFlag.astro';
-import OdyseeVideo from './components/OdyseeVideo.astro';
import OrderedList from './components/OrderedList.astro';
import PrimeVideoFlag from './components/PrimeVideoFlag.astro';
import ProductLink from './components/ProductLink.astro';
@@ -55,7 +54,6 @@ export const mapping = {
MarkdownImage,
MoreLink,
NetflixFlag,
- OdyseeVideo,
PrimeVideoFlag,
ProductLink,
ProjectIntro,
@@ -80,34 +78,3 @@ export const mapping = {
p: Text,
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,
-};
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 1c45e02..a290a2c 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -13,13 +13,6 @@ import PageTitle from '../components/PageTitle.astro';
import Image from '../components/Image.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 MoreLink from '../components/MoreLink.astro';
@@ -92,7 +85,3 @@ rssPosts.sort(sortMarkdownByDate);
-
-
-
-
diff --git a/src/pages/rss-haiku.xml.js b/src/pages/rss-haiku.xml.js
index a85a650..fa7afd1 100644
--- a/src/pages/rss-haiku.xml.js
+++ b/src/pages/rss-haiku.xml.js
@@ -4,7 +4,7 @@ import { getCollection } from 'astro:content';
import { site } from '../data/site';
import { sortByDate } from '../utils';
-export async function get(context) {
+export async function GET(context) {
const haiku = await getCollection('haiku');
haiku.sort(sortByDate);
@@ -18,7 +18,7 @@ export async function get(context) {
pubDate: item.data.date,
customData: 'en-us ',
link: `/haiku/${item.slug}/`,
- content: `${item.data.de}
${item.data.en}
`,
+ content: `${item.data.de}
${item.data.en}
`,
})),
customData: `en-us `,
});
diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js
new file mode 100644
index 0000000..ce8f50a
--- /dev/null
+++ b/src/pages/rss.xml.js
@@ -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: ``,
+ link: `/${post.slug}/`,
+ content: ``,
+ 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: 'en-us ',
+ };
+ }),
+ ...haiku.map((item) => {
+ return {
+ title: `Haiku ${item.slug}`,
+ pubDate: item.data.date,
+ customData: 'en-us ',
+ link: `/haiku/${item.slug}/`,
+ content: `${item.data.de}
${item.data.en}
`,
+ };
+ }),
+ ].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf()),
+ customData: `en-us `,
+ });
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 7c8b0c0..9eaacdb 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -4,6 +4,7 @@ export * from './is-production';
export * from './pick-two-random-colors';
export * from './remark-reading-time';
export * from './remark-widont';
+export * from './rss-parser';
export * from './sort-by-alphabet';
export * from './sort-by-date';
export * from './sort-by-sortkey';
diff --git a/src/utils/rss-parser.ts b/src/utils/rss-parser.ts
new file mode 100644
index 0000000..8e3901f
--- /dev/null
+++ b/src/utils/rss-parser.ts
@@ -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 with absolute src.
+ * - replaceImageComponent(attributes, siteUrl): Convert an MDX ` ` component into HTML.
+ * - replaceAmazonBookComponent(attributes): Convert an MDX ` ` component into HTML.
+ * - stripMDXComponents(text, siteUrl): Replace ` `, ` ` 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 .
+ */
+export function fixImagePaths(html: string, siteUrl: string): string {
+ if (!html) return html;
+ return html.replace(
+ / ]*)\s+src=(?:"|')([^"']*)(?:"|')([^>]*)>/g,
+ (_match, beforeSrc: string, src: string, afterSrc: string) => {
+ const absoluteSrc = makeAbsolute(src, siteUrl);
+ return ` `;
+ }
+ );
+}
+
+/**
+ * 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
+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 tag
+ let imgHtml = ` `;
+
+ // Optionally wrap in
+ if (href) {
+ const safeHref = escapeHtmlAttr(href);
+ imgHtml = ` ${imgHtml} `;
+ }
+
+ // Build figcaption if needed
+ if (caption || source) {
+ let captionContent = '';
+
+ if (caption) captionContent += escapeHtml(caption);
+
+ if (caption && source) captionContent += ' – ';
+
+ if (source) {
+ if (sourceUrl) {
+ captionContent += `${escapeHtml(source)} `;
+ } else {
+ captionContent += `${escapeHtml(source)} `;
+ }
+ }
+
+ return `${imgHtml}${captionContent} `;
+ }
+
+ return `${imgHtml} `;
+}
+
+// Convert an MDX
+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 `
+
+ `;
+}
+
+// Convert an MDX
+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 `[] `;
+}
+
+// Convert an MDX
+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 `[Netflix] `;
+}
+
+// Convert an MDX
+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 `[Prime Video] `;
+}
+
+// Convert an MDX
+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 `${innerHtml} `;
+ }
+
+ return `${innerHtml} `;
+}
+
+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 += '—';
+
+ if (author) {
+ footerHtml += ` ${escapeHtml(author)} `;
+ }
+
+ if (author && source) {
+ footerHtml += ',';
+ }
+
+ if (source) {
+ const safeSource = escapeHtml(source);
+ // Add space before source
+ footerHtml += ' ';
+
+ if (sourceUrl) {
+ const absoluteUrl = makeAbsolute(sourceUrl, siteUrl);
+ footerHtml += `${safeSource} `;
+ } else {
+ footerHtml += `${safeSource} `;
+ }
+ }
+
+ footerHtml += ' ';
+ }
+
+ return `${content}${footerHtml} `;
+}
+
+// Convert an MDX
+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 += '';
+
+ if (author) {
+ footerHtml += `${escapeHtml(author)} `;
+ }
+
+ if (author && source) {
+ footerHtml += ', ';
+ }
+
+ if (source) {
+ const safeSource = escapeHtml(source);
+ if (sourceUrl) {
+ const absoluteUrl = makeAbsolute(sourceUrl, siteUrl);
+ footerHtml += `${safeSource} `;
+ } else {
+ footerHtml += `${safeSource} `;
+ }
+ }
+
+ footerHtml += ' ';
+ }
+
+ return `${text}
${footerHtml} `;
+}
+
+// Convert an MDX
+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 `${escapeHtml(text)} `;
+}
+
+// Convert an MDX
+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 `${escapeHtml(text)} ↓ `;
+}
+
+// Convert an MDX
+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 `${escapeHtml(text)} → `;
+}
+
+// Convert an MDX
+export function replaceRubyComponent(attributes: string): string {
+ const base = getAttr(attributes, 'base');
+ const text = getAttr(attributes, 'text');
+
+ if (!base || !text) return '';
+
+ return `${escapeHtml(base)}( ${escapeHtml(text)} ) `;
+}
+
+// Convert an MDX
+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 ``;
+}
+
+// Convert an MDX ...
+export function replaceFigureComponent(attributes: string, content: string): string {
+ const caption = getAttr(attributes, 'caption');
+
+ let html = `${content}`;
+
+ if (caption) {
+ html += `${escapeHtml(caption)} `;
+ }
+
+ html += ` `;
+ return html;
+}
+
+// Convert an MDX ...
+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 = '';
+
+ if (summary) {
+ html += `${escapeHtml(summary)} ${content} `;
+ } else {
+ html += content;
+ }
+
+ html += ' ';
+ return html;
+}
+
+// Convert an MDX
+export function replaceColorSwatchComponent(attributes: string): string {
+ const color = getAttr(attributes, 'color');
+
+ if (!color) return '';
+
+ return `
`;
+}
+
+// Convert wrapper components (like and )
+export function replaceWrapperComponent(content: string): string {
+ return `${content}
`;
+}
+
+/**
+ * Strip MDX/JSX-like components from text but preserve/convert specific components.
+ */
+export function stripMDXComponents(text: string, siteUrl: string): string {
+ if (!text) return text;
+
+ //
+ let processed = text.replace(/ /g, (_match, attributes: string) =>
+ replaceImageComponent(attributes, siteUrl)
+ );
+
+ // AmazonBook ... />
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceAmazonBookComponent(attributes)
+ );
+
+ // AppleTV ... />
+ processed = processed.replace(
+ / /g,
+ (_match, _tagSuffix, attributes: string) => replaceAppleTvComponent(attributes)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceNetflixComponent(attributes)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replacePrimeVideoComponent(attributes)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceFlagComponent(attributes, siteUrl)
+ );
+
+ // ...
+ processed = processed.replace(
+ /]*)>([\s\S]*?)<\/Blockquote>/g,
+ (_match, attributes: string, content: string) =>
+ replaceBlockquoteComponent(attributes, content, siteUrl)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replacePullquoteComponent(attributes, siteUrl)
+ );
+
+ // ...
+ processed = processed.replace(
+ /]*)>([\s\S]*?)<\/Figure>/g,
+ (_match, attributes: string, content: string) => replaceFigureComponent(attributes, content)
+ );
+
+ // ...
+ processed = processed.replace(
+ /]*)>([\s\S]*?)<\/Banner>/g,
+ (_match, attributes: string, content: string) => replaceBannerComponent(attributes, content)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceProductLinkComponent(attributes)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceDownloadLinkComponent(attributes, siteUrl)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceMoreLinkComponent(attributes, siteUrl)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceRubyComponent(attributes)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceSpotifyComponent(attributes)
+ );
+
+ //
+ processed = processed.replace(/ /g, (_match, attributes: string) =>
+ replaceColorSwatchComponent(attributes)
+ );
+
+ // ... and ...
+ processed = processed.replace(
+ /<(ColorStack|Bookshelf)\b[^>]*>([\s\S]*?)<\/\1>/g,
+ (_match, _tag, content: string) => replaceWrapperComponent(content)
+ );
+
+ // Remove any other self-closing components e.g.
+ const removedSelfClosing = processed.replace(/<([A-Z][\w\d]*)\b[^>]*?\/>/g, '');
+
+ // Remove paired component tags, including their content, e.g. ...
+ 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, '&').replace(//g, '>');
+}
+
+/**
+ * Escape for attribute values (double-quoted).
+ */
+function escapeHtmlAttr(str: string | null | undefined): string {
+ if (str == null) return '';
+ return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/