From ceadc300057375179b8deb70bfbd8911206c1bca Mon Sep 17 00:00:00 2001 From: Stefan Imhoff Date: Sun, 25 Jan 2026 17:18:36 +0100 Subject: [PATCH] feat: replace RSS handling This replaces the RSS hack with a custom helper that replaces MDX components manually. --- package.json | 2 + pnpm-lock.yaml | 69 +++ public/rss.xsl | 54 +- src/components/AppleTVFlag.astro | 4 +- src/components/MarkdownImage.astro | 2 +- src/components/OdyseeVideo.astro | 18 - src/components/RssXml.astro | 55 -- src/components/WriteFile.astro | 13 - src/content/journal/2007/gtd.mdx | 2 +- src/content/journal/2007/japanese-colors.mdx | 4 +- src/content/journal/2007/koi-design.mdx | 7 +- src/mdx-components.ts | 33 -- src/pages/index.astro | 11 - src/pages/rss-haiku.xml.js | 4 +- src/pages/rss.xml.js | 77 +++ src/utils/index.ts | 1 + src/utils/rss-parser.ts | 510 +++++++++++++++++++ 17 files changed, 714 insertions(+), 152 deletions(-) delete mode 100644 src/components/OdyseeVideo.astro delete mode 100644 src/components/RssXml.astro delete mode 100644 src/components/WriteFile.astro create mode 100644 src/pages/rss.xml.js create mode 100644 src/utils/rss-parser.ts 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 @@ <xsl:value-of select="/rss/channel/title"/> Web Feed - + -

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} >] 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 = `${escapeHtmlAttr(alt)}`; + + // 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 ` + ${escapeHtmlAttr(alt)} + `; +} + +// 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 = ''; + 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(/