返回资讯中心
外部精选
软件工程
#开发者实时流

Making documentation pages agent friendly

* Cloudflare Worker: Markdown Mirror for I'd Rather Be Writing

Hacker News 最新taubek2026/10/27 · 今天30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

**

  • Cloudflare Worker: Markdown Mirror for I'd Rather Be Writing
  • Serves a clean Markdown version of any page when the URL ends in .md
  • or when the request's Accept header asks for text/markdown. */

const SITE_ORIGIN = 'https://idratherbewriting.com'; const LLMS_TXT_URL = ${SITE_ORIGIN}/llms.txt; const MIRROR_USER_AGENT = 'Cloudflare-Markdown-Mirror/1.1'; const CACHE_SECONDS = 3600;

export default { async fetch(request, env, ctx) { const method = request.method.toUpperCase(); const url = new URL(request.url);

const isMdExtension = url.pathname.endsWith('.md'); const acceptsMarkdown = (request.headers.get('Accept') || '').toLowerCase().includes('text/markdown');

// Only GET and HEAD requests that ask for Markdown are handled here if ((method !== 'GET' && method !== 'HEAD') || (!isMdExtension && !acceptsMarkdown)) { return fetch(request); }

try { return await serveMarkdown(url, method, isMdExtension, ctx); } catch (err) { // Never break the site. If anything goes wrong, fall back to the normal response. console.error('Markdown mirror error:', err && err.stack ? err.stack : err); return fetch(request); } } };

async function serveMarkdown(url, method, isMdExtension, ctx) { const originBase = url.origin.includes('workers.dev') ? SITE_ORIGIN : url.origin;

// 1. Resolve the path of the HTML page this request maps to let targetPath = url.pathname; if (isMdExtension) { targetPath = targetPath.slice(0, -3); // Strip .md if (targetPath === '' || targetPath.endsWith('/')) { targetPath += 'index.html'; } }

// 2. Check the edge cache. The cache key is separate from the HTML page's URL, // so a cached Markdown response can never be served to a browser. const cache = caches.default; const cacheKey = new Request(${originBase}${targetPath}?markdown-mirror=1, { method: 'GET' }); const cached = await cache.match(cacheKey); if (cached) { return method === 'HEAD' ? new Response(null, { status: cached.status, headers: cached.headers }) : cached; }

// 3. Fetch the HTML page from the origin let originRes = await fetchOrigin(new URL(targetPath, originBase));

// Extensionless .md requests (e.g. /ai/product-skills-docs-first.md) map to .html pages if (originRes.status === 404 && isMdExtension && !/.html?$/i.test(targetPath)) { const htmlRes = await fetchOrigin(new URL(${targetPath}.html, originBase)); if (htmlRes.ok) { originRes = htmlRes; } }

if (!originRes.ok) { if (!isMdExtension) { return originRes; // Let the origin's own error page through } return textResponse(Page not found: ${url.pathname}, originRes.status, method); }

// Images, PDFs, feeds, llms.txt, and other non-HTML files pass through unchanged const contentType = (originRes.headers.get('Content-Type') || '').toLowerCase(); if (!contentType.includes('text/html')) { return originRes; }

// 4. Convert the HTML to Markdown const canonicalUrl = originRes.url || new URL(targetPath, originBase).toString(); const html = await originRes.text(); const markdown = convertHtmlToMarkdown(html, canonicalUrl);

// 5. Return the Markdown and cache it at the edge const headers = { 'Content-Type': 'text/markdown; charset=utf-8', 'Cache-Control': public, max-age=${CACHE_SECONDS}, 'Vary': 'Accept', 'Link': <${canonicalUrl}>; rel="canonical", 'Access-Control-Allow-Origin': '*' }; const mdResponse = new Response(markdown, { status: 200, headers }); ctx.waitUntil(cache.put(cacheKey, mdResponse.clone()));

return method === 'HEAD' ? new Response(null, { status: 200, headers }) : mdResponse; }

function fetchOrigin(originUrl) { return fetch(originUrl.toString(), { headers: { 'User-Agent': MIRROR_USER_AGENT, 'Accept': 'text/html' } }); }

function textResponse(body, status, method) { return new Response(method === 'HEAD' ? null : body, { status, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); }

// --------------------------------------------------------------------------- // HTML to Markdown conversion // ---------------------------------------------------------------------------

const PH = String.fromCharCode(0xE000); // Wraps code placeholders (a private-use character) const INDENT = String.fromCharCode(0xE001); // Marks intentional indentation that cleanup must keep const BLOCK_TOKEN = (n) => ${PH}B${n}${PH}; // Placeholder for a fenced code block const INLINE_TOKEN = (n) => ${PH}I${n}${PH}; // Placeholder for an inline code span

function convertHtmlToMarkdown(html, canonicalUrl) { // Extract metadata const title = extractTitle(html); const description = getMetaContent(html, 'name', 'description').replace(/\s+/g, ' '); const dateMatch = html.match(/"datePublished":\s*"([^"]+)"/i); const date = (dateMatch ? dateMatch[1] : getMetaContent(html, 'property', 'article:published_time')).split('T')[0];

const blocks = []; const inlines = [];

let content = extractMainContent(html);

// Remove scripts, styles, comments, and form widgets first, so that markup // inside script strings (like the ads' document.write calls) can't confuse // any of the steps that follow content = content .replace(/<!--[\s\S]*?-->/g, '') .replace(/<(script|style|noscript|template|svg|form|button|select|textarea|nav)\b[\s\S]?</\1>/gi, '') .replace(/<input\b[^>]>/gi, '');

// Remove page chrome and in-post ads. Ads (div.comicBlock) appear in the middle // of pages, so they're removed here instead of being treated as the end of the page. content = removeElements(content, 'div', classPattern('comicBlock', 'courseProgress', 'lastModifiedDate', 'subheading')); content = removeElements(content, 'ol', classPattern('breadcrumb')); content = removeElements(content, 'ul', /\bid\s*=\s*["']markdown-toc["']/i); content = removeElements(content, 'p', classPattern('page-meta', 'sponsoredLabel')); content = removeElements(content, 'span', classPattern('pageCategories'));

// Remove the draft notice but keep the other callouts (Note, Tip, Important, Warning) content = content .replace(/<div\b[^>]\bclass\s=\s*"'?alert(?:\s[^"'])?["'][^>]>\s*<i\b[^>]\bfa-flask\b[^>]>[\s\S]?</div>/gi, '') .replace(/<i\b[^>]\bclass\s*=\s*"'?fa(?:\s[^"'])?["'][^>]>\s*</i>/gi, '') .replace(/<div\b[^>]\bclass\s=\s*"'?(?:alert|summary)(?:\s[^"'])?["'][^>]>([\s\S]*?)</div>/gi, '<blockquote>$1</blockquote>');

// Protect code from the cleanup steps below. Code blocks keep their exact // whitespace and get a fence that's longer than any backtick run inside them. content = content.replace(/<pre\b([^>])>\s(?:<code\b([^>])>)?([\s\S]?)(?:</code>\s*)?</pre>/gi, (match, preAttrs, codeAttrs, code, offset, whole) => { const lang = detectLanguage(${codeAttrs || ''} ${preAttrs}, whole.slice(Math.max(0, offset - 300), offset)); const text = decodeHtml(code.replace(/<[^>]+>/g, '')).replace(/^\n+|\s+$/g, ''); const fence = ''.repeat(Math.max(3, longestRun(text, '') + 1)); return <div>${BLOCK_TOKEN(blocks.push(${fence}${lang}\n${text}\n${fence}) - 1)}</div>; });

content = content.replace(/<(code|kbd|samp)\b[^>]>([\s\S]?)</\1>/gi, (match, tag, code) => { const text = decodeHtml(code.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' '); if (!text.trim()) return ''; const ticks = ''.repeat(longestRun(text, '') + 1); const pad = text.startsWith('') || text.endsWith('') ? ' ' : ''; return INLINE_TOKEN(inlines.push(${ticks}${pad}${text}${pad}${ticks}) - 1); });

// Whitespace in HTML isn't significant, so collapse it. Line breaks are // re-created from the block-level tags below. content = content.replace(/\s+/g, ' ');

// Embedded media becomes links content = content.replace(/<iframe\b([^>])>(?:[\s\S]?</iframe>)?/gi, (match, attrs) => { const src = getAttr(attrs, 'src'); if (!src || !/(?:youtube.com|youtu.be|vimeo.com|loom.com)/i.test(src)) return ''; const label = escapeBrackets(getAttr(attrs, 'title') || 'Video'); return <p>[Video: ${label}](${absoluteUrl(src, canonicalUrl)})</p>; }); content = content.replace(/<(audio|video)\b([^>])>([\s\S]?)</\1>/gi, (match, tag, attrs, inner) => { const source = inner.match(/<source\b([^>]*)>/i); const src = getAttr(attrs, 'src') || (source ? getAttr(source[1], 'src') : ''); if (!src) return ''; const label = tag.toLowerCase() === 'audio' ? 'Audio' : 'Video'; return <p>[${label}](${absoluteUrl(src, canonicalUrl)})</p>; });

// Figures and images, with absolute URLs content = content.replace(/<figure\b[^>]>([\s\S]?)</figure>/gi, (match, inner) => { const img = inner.match(/<img\b([^>])>/i); if (!img) return <div>${inner}</div>; // Not an image figure, so keep its contents const src = getAttr(img[1], 'src'); if (!src) return ''; const alt = getAttr(img[1], 'alt'); const capMatch = inner.match(/<figcaption\b[^>]>([\s\S]?)</figcaption>/i); const caption = capMatch ? capMatch[1].trim() : ''; const captionText = stripTags(caption); let out = <p>![${escapeBrackets(alt || captionText || 'Image')}](${absoluteUrl(src, canonicalUrl)})</p>; if (alt && captionText && captionText !== alt) { out += <p><em>${caption}</em></p>; } return out; }); content = content.replace(/<img\b([^>])>/gi, (match, attrs) => { const src = getAttr(attrs, 'src'); if (!src) return ''; return ![${escapeBrackets(getAttr(attrs, 'alt'))}](${absoluteUrl(src, canonicalUrl)}); });

// Links, with absolute URLs (same-page #anchors are left as-is) content = content.replace(/<a\b([^>])>([\s\S]?)</a>/gi, (match, attrs, inner) => { const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); const href = getAttr(attrs, 'href'); if (//cdn-cgi/l/email-protection/i.test(href)) return '(email address hidden)'; // Cloudflare email obfuscation if (!href || /^javascript:/i.test(href)) return text; if (!text) return ''; return [${text}](${absoluteUrl(href, canonicalUrl)}); });

// Inline formatting content = content .replace(/<(strong|b)\b[^>]>([\s\S]?)</\1>/gi, (m, tag, inner) => (inner.trim() ? **${inner.trim()}** : '')) .replace(/<(em|i|cite)\b[^>]>([\s\S]?)</\1>/gi, (m, tag, inner) => (inner.trim() ? *${inner.trim()}* : '')) .replace(/<(del|s|strike)\b[^>]>([\s\S]?)</\1>/gi, (m, tag, inner) => (inner.trim() ? ~~${inner.trim()}~~ : '')) .replace(/<br\s*/?>/gi, '\n') .replace(/<hr\b[^>]*>/gi, '\n\n---\n\n');

// Tables become Markdown tables instead of runs of cell text content = content.replace(/<table\b[^>]>([\s\S]?)</table>/gi, (match, inner) => convertTable(inner));

// Headings (remove <h1> since the title is already emitted at the top) content = content .replace(/<h1\b[^>]>[\s\S]?</h1>/gi, '') .replace(/<h([2-6])\b[^>]>([\s\S]?)</h\1>/gi, (match, level, inner) => { const text = stripTags(inner); return text ? \n\n${'#'.repeat(Number(level))} ${text}\n\n : ''; });

// Definition lists and expandable sections content = content .replace(/<dt\b[^>]>([\s\S]?)</dt>/gi, (m, inner) => \n\n**${stripTags(inner)}**\n) .replace(/<dd\b[^>]>([\s\S]?)</dd>/gi, '$1\n\n') .replace(/</?dl\b[^>]>/gi, '\n\n') .replace(/<summary\b[^>]>([\s\S]?)</summary>/gi, (m, inner) => \n\n**${stripTags(inner)}**\n\n) .replace(/</?details\b[^>]>/gi, '\n\n');

// Lists (with numbering and nesting) and blockquotes, including callouts inside list steps content = convertListsAndQuotes(content);

// Paragraphs and other block elements become paragraph breaks content = content .replace(/</?p\b[^>]>/gi, '\n\n') .replace(/</?(?:div|section|article|header|footer|main|aside|figure|figcaption|center|address)\b[^>]>/gi, '\n\n') .replace(/<[^>]+>/g, '');

// Decode HTML entities, then clean up whitespace line by line content = decodeHtml(content) .replace(/[email\sprotected]/gi, '(email address hidden)') .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) .join('\n') .replace(new RegExp(INDENT, 'g'), ' ') .replace(/\n{3,}/g, '\n\n') .trim();

// Restore code blocks (

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →