From 4488888c638f64e6e347ae1a3d4c1f4db4b16ad2 Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 08:52:33 +0200 Subject: [PATCH 1/9] Adds a basic robots.txt file This was created with the Cloudflare AI skill for robots.txt files. See this AI readiness evaluation of the guide for more: https://isitagentready.com/bitcoin.design?profile=content --- robots.txt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 robots.txt diff --git a/robots.txt b/robots.txt new file mode 100644 index 000000000..76a247535 --- /dev/null +++ b/robots.txt @@ -0,0 +1,19 @@ +# robots.txt for bitcoin.design +# RFC 9309 compliant groups and rules + +User-agent: * +Allow: /guide/ +Allow: /projects/ +Allow: /community/ +Allow: /join/ +Allow: /assets/ +Allow: / +Disallow: /404.html +Disallow: /search.html +Disallow: /search-data.json +Disallow: /script/ +Disallow: /docker-compose.yml +Disallow: /Gemfile +Disallow: /RAKEFILE + +Sitemap: https://bitcoin.design/sitemap.xml \ No newline at end of file From b5d965f85262dcf206f5ced75d683c7df947c32e Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 08:55:56 +0200 Subject: [PATCH 2/9] Link response headers for agents Per the Cloudflare AI readiness evaluation: https://isitagentready.com/bitcoin.design?profile=content It took the search.json file that indexes the site content as an API catalog to serve to robots. Let's try this out. My suspicions is that the file may be too big to be ingested easily. --- .well-known/api-catalog | 25 +++++++++++++++++++++++++ .well-known/service-desc.json | 10 ++++++++++ netlify.toml | 15 +++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 .well-known/api-catalog create mode 100644 .well-known/service-desc.json diff --git a/.well-known/api-catalog b/.well-known/api-catalog new file mode 100644 index 000000000..3222a49c2 --- /dev/null +++ b/.well-known/api-catalog @@ -0,0 +1,25 @@ +{ + "linkset": [ + { + "anchor": "https://bitcoin.design/.well-known/api-catalog", + "item": [ + { + "href": "https://bitcoin.design/search-data.json", + "type": "application/json" + } + ], + "service-doc": [ + { + "href": "https://bitcoin.design/guide/", + "type": "text/html" + } + ], + "describedby": [ + { + "href": "https://bitcoin.design/search-data.json", + "type": "application/json" + } + ] + } + ] +} diff --git a/.well-known/service-desc.json b/.well-known/service-desc.json new file mode 100644 index 000000000..db99f9269 --- /dev/null +++ b/.well-known/service-desc.json @@ -0,0 +1,10 @@ +{ + "name": "Bitcoin Design website service descriptor", + "description": "Discovery metadata for machine clients.", + "homepage": "https://bitcoin.design/", + "resources": { + "apiCatalog": "https://bitcoin.design/.well-known/api-catalog", + "serviceDoc": "https://bitcoin.design/guide/", + "describedBy": "https://bitcoin.design/search-data.json" + } +} diff --git a/netlify.toml b/netlify.toml index 9e6b33c4f..7f125da3a 100644 --- a/netlify.toml +++ b/netlify.toml @@ -2,3 +2,18 @@ for = "/.well-known/nostr.json" [headers.values] Access-Control-Allow-Origin = "*" + +[[headers]] + for = "/" + [headers.values] + Link = "; rel=\"api-catalog\", ; rel=\"service-desc\"; type=\"application/json\", ; rel=\"service-doc\"; type=\"text/html\", ; rel=\"describedby\"; type=\"application/json\"" + +[[headers]] + for = "/index.html" + [headers.values] + Link = "; rel=\"api-catalog\", ; rel=\"service-desc\"; type=\"application/json\", ; rel=\"service-doc\"; type=\"text/html\", ; rel=\"describedby\"; type=\"application/json\"" + +[[headers]] + for = "/.well-known/api-catalog" + [headers.values] + Content-Type = "application/linkset+json; profile=\"https://www.rfc-editor.org/info/rfc9727\"" From a2e34095e583713b0dbc298a12dd91ad878d9560 Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 09:00:10 +0200 Subject: [PATCH 3/9] Enable markdown content requests Allows agents to request markdown instead of HTML. --- netlify.toml | 4 + .../edge-functions/markdown-negotiation.ts | 285 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 netlify/edge-functions/markdown-negotiation.ts diff --git a/netlify.toml b/netlify.toml index 7f125da3a..e0b25f3e7 100644 --- a/netlify.toml +++ b/netlify.toml @@ -17,3 +17,7 @@ for = "/.well-known/api-catalog" [headers.values] Content-Type = "application/linkset+json; profile=\"https://www.rfc-editor.org/info/rfc9727\"" + +[[edge_functions]] + path = "/*" + function = "markdown-negotiation" diff --git a/netlify/edge-functions/markdown-negotiation.ts b/netlify/edge-functions/markdown-negotiation.ts new file mode 100644 index 000000000..4d3c38826 --- /dev/null +++ b/netlify/edge-functions/markdown-negotiation.ts @@ -0,0 +1,285 @@ +const MARKDOWN_CONTENT_TYPE = "text/markdown; charset=utf-8"; + +export default async (request: Request, context: { next: () => Promise }) => { + const response = await context.next(); + + if (!wantsMarkdown(request)) { + return response; + } + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.toLowerCase().includes("text/html")) { + return response; + } + + const html = await response.text(); + const markdown = htmlToMarkdown(html, new URL(request.url)); + + const headers = new Headers(response.headers); + headers.set("content-type", MARKDOWN_CONTENT_TYPE); + headers.set("x-markdown-tokens", estimateTokenCount(markdown).toString()); + setVaryAccept(headers); + + return new Response(markdown, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +function wantsMarkdown(request: Request): boolean { + const accept = request.headers.get("accept"); + if (!accept) { + return false; + } + + return accept.toLowerCase().includes("text/markdown"); +} + +function setVaryAccept(headers: Headers): void { + const vary = headers.get("vary"); + if (!vary) { + headers.set("vary", "Accept"); + return; + } + + const items = vary + .split(",") + .map((v) => v.trim().toLowerCase()) + .filter(Boolean); + + if (!items.includes("accept")) { + headers.set("vary", `${vary}, Accept`); + } +} + +function estimateTokenCount(markdown: string): number { + // Heuristic estimate commonly used for GPT-style token budgeting. + return Math.max(1, Math.ceil(markdown.length / 4)); +} + +function htmlToMarkdown(html: string, baseUrl: URL): string { + const doc = new DOMParser().parseFromString(html, "text/html"); + if (!doc) { + return ""; + } + + for (const selector of ["script", "style", "noscript"]) { + doc.querySelectorAll(selector).forEach((node) => node.remove()); + } + + const title = normalizeWhitespace(doc.querySelector("title")?.textContent || ""); + const body = doc.body; + const bodyMarkdown = body ? renderChildren(body, baseUrl) : ""; + + const parts: string[] = []; + if (title) { + parts.push(`---\ntitle: ${title}\n---`); + parts.push(`# ${title}`); + } + + if (bodyMarkdown) { + parts.push(bodyMarkdown.trim()); + } + + return parts.join("\n\n").trim() + "\n"; +} + +function renderChildren(parent: Element, baseUrl: URL): string { + const chunks: string[] = []; + for (const child of Array.from(parent.childNodes)) { + const rendered = renderNode(child, baseUrl, 0); + if (rendered) { + chunks.push(rendered.trim()); + } + } + return chunks.join("\n\n"); +} + +function renderNode(node: Node, baseUrl: URL, listDepth: number): string { + if (node.nodeType === Node.TEXT_NODE) { + return normalizeWhitespace(node.textContent || ""); + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const el = node as Element; + const tag = el.tagName.toLowerCase(); + + if (/^h[1-6]$/.test(tag)) { + const level = Number(tag[1]); + const text = renderInlineChildren(el, baseUrl); + return text ? `${"#".repeat(level)} ${text}` : ""; + } + + if (tag === "p") { + return renderInlineChildren(el, baseUrl); + } + + if (tag === "a") { + const text = renderInlineChildren(el, baseUrl) || normalizeWhitespace(el.getAttribute("href") || ""); + const href = toAbsoluteUrl(el.getAttribute("href"), baseUrl); + return href ? `[${text}](${href})` : text; + } + + if (tag === "img") { + const alt = normalizeWhitespace(el.getAttribute("alt") || "image"); + const src = toAbsoluteUrl(el.getAttribute("src"), baseUrl); + return src ? `![${alt}](${src})` : ""; + } + + if (tag === "pre") { + const code = el.textContent?.trim() || ""; + return code ? `\`\`\`\n${code}\n\`\`\`` : ""; + } + + if (tag === "code") { + const code = normalizeWhitespace(el.textContent || ""); + return code ? `\`${code}\`` : ""; + } + + if (tag === "blockquote") { + const text = renderChildren(el, baseUrl) + .split("\n") + .map((line) => (line ? `> ${line}` : ">")) + .join("\n"); + return text; + } + + if (tag === "ul" || tag === "ol") { + return renderList(el, baseUrl, tag === "ol", listDepth); + } + + if (tag === "li") { + return renderInlineChildren(el, baseUrl); + } + + if (tag === "br") { + return "\n"; + } + + if (tag === "hr") { + return "---"; + } + + return renderChildren(el, baseUrl); +} + +function renderList(list: Element, baseUrl: URL, ordered: boolean, listDepth: number): string { + const lines: string[] = []; + const indent = " ".repeat(listDepth); + + let index = 1; + for (const child of Array.from(list.children)) { + if (child.tagName.toLowerCase() !== "li") { + continue; + } + + const content = renderChildren(child, baseUrl) || renderInlineChildren(child, baseUrl); + if (!content) { + continue; + } + + const prefix = ordered ? `${index}. ` : "- "; + const normalized = content + .split("\n") + .map((line, i) => (i === 0 ? `${indent}${prefix}${line}` : `${indent} ${line}`)) + .join("\n"); + + lines.push(normalized); + index += 1; + + for (const nested of Array.from(child.children)) { + const nestedTag = nested.tagName.toLowerCase(); + if (nestedTag === "ul" || nestedTag === "ol") { + const nestedList = renderList(nested, baseUrl, nestedTag === "ol", listDepth + 1); + if (nestedList) { + lines.push(nestedList); + } + } + } + } + + return lines.join("\n"); +} + +function renderInlineChildren(parent: Element, baseUrl: URL): string { + const out: string[] = []; + for (const child of Array.from(parent.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = normalizeWhitespace(child.textContent || ""); + if (text) { + out.push(text); + } + continue; + } + + if (child.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + const el = child as Element; + const tag = el.tagName.toLowerCase(); + + if (tag === "a") { + const text = renderInlineChildren(el, baseUrl) || normalizeWhitespace(el.getAttribute("href") || ""); + const href = toAbsoluteUrl(el.getAttribute("href"), baseUrl); + out.push(href ? `[${text}](${href})` : text); + continue; + } + + if (tag === "code") { + const text = normalizeWhitespace(el.textContent || ""); + if (text) { + out.push(`\`${text}\``); + } + continue; + } + + if (tag === "strong" || tag === "b") { + const text = renderInlineChildren(el, baseUrl); + if (text) { + out.push(`**${text}**`); + } + continue; + } + + if (tag === "em" || tag === "i") { + const text = renderInlineChildren(el, baseUrl); + if (text) { + out.push(`*${text}*`); + } + continue; + } + + if (tag === "br") { + out.push("\n"); + continue; + } + + const text = renderInlineChildren(el, baseUrl); + if (text) { + out.push(text); + } + } + + return normalizeWhitespace(out.join(" ")).replace(/ \n /g, "\n").trim(); +} + +function toAbsoluteUrl(href: string | null, baseUrl: URL): string { + if (!href) { + return ""; + } + + try { + return new URL(href, baseUrl).toString(); + } catch { + return href; + } +} + +function normalizeWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} From 2341048340b662efcd590a34e9e753a3fe4eb48e Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 09:00:58 +0200 Subject: [PATCH 4/9] Add agent specific rules --- robots.txt | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/robots.txt b/robots.txt index 76a247535..90d5428b6 100644 --- a/robots.txt +++ b/robots.txt @@ -1,6 +1,30 @@ # robots.txt for bitcoin.design # RFC 9309 compliant groups and rules +# AI crawlers — explicit entries required per RFC 9309 +User-agent: GPTBot +User-agent: OAI-SearchBot +User-agent: Claude-Web +User-agent: anthropic-ai +User-agent: Google-Extended +User-agent: Amazonbot +User-agent: Applebot-Extended +User-agent: Bytespider +User-agent: CCBot +Allow: /guide/ +Allow: /projects/ +Allow: /community/ +Allow: /join/ +Allow: /assets/ +Allow: / +Disallow: /404.html +Disallow: /search.html +Disallow: /search-data.json +Disallow: /script/ +Disallow: /docker-compose.yml +Disallow: /Gemfile +Disallow: /RAKEFILE + User-agent: * Allow: /guide/ Allow: /projects/ From ecce4e9fc6699e1857eea3f593ae2a6e6ed096ed Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 09:05:42 +0200 Subject: [PATCH 5/9] Add AI content usage preferences All set to yes, so AI can freely use the content for training, search, and input. --- robots.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/robots.txt b/robots.txt index 90d5428b6..c3efc16b4 100644 --- a/robots.txt +++ b/robots.txt @@ -40,4 +40,6 @@ Disallow: /docker-compose.yml Disallow: /Gemfile Disallow: /RAKEFILE +Content-Signal: ai-train=yes, search=yes, ai-input=yes + Sitemap: https://bitcoin.design/sitemap.xml \ No newline at end of file From 82bc8e2fc4fff1c2470d70d1f8b20adb14712b31 Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 09:06:28 +0200 Subject: [PATCH 6/9] Update netlify.toml Added content signal to netlify. --- netlify.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netlify.toml b/netlify.toml index e0b25f3e7..9f27d64d4 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,3 +1,8 @@ +[[headers]] + for = "/*" + [headers.values] + Content-Signal = "ai-train=yes, search=yes, ai-input=yes" + [[headers]] for = "/.well-known/nostr.json" [headers.values] From 69cb5e72854364f2957895c394dbc0ba24456161 Mon Sep 17 00:00:00 2001 From: Christoph Ono Date: Mon, 20 Apr 2026 09:31:35 +0200 Subject: [PATCH 7/9] Update robots.txt Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- robots.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robots.txt b/robots.txt index c3efc16b4..3df0f5e03 100644 --- a/robots.txt +++ b/robots.txt @@ -1,7 +1,7 @@ # robots.txt for bitcoin.design # RFC 9309 compliant groups and rules -# AI crawlers — explicit entries required per RFC 9309 +# AI crawlers — explicit entries for selected agents User-agent: GPTBot User-agent: OAI-SearchBot User-agent: Claude-Web From 4dc5bb58296b1166aa357d26fc9ab941446fa2e9 Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 09:45:31 +0200 Subject: [PATCH 8/9] Fix Netlify function markdown handling When run on Netlify preview, the function to serve the markdown content threw an error because it couldn't find a DOM parsing library it expected to exist. --- .../edge-functions/markdown-negotiation.ts | 169 ++++++++++++++++-- 1 file changed, 152 insertions(+), 17 deletions(-) diff --git a/netlify/edge-functions/markdown-negotiation.ts b/netlify/edge-functions/markdown-negotiation.ts index 4d3c38826..61272ca8f 100644 --- a/netlify/edge-functions/markdown-negotiation.ts +++ b/netlify/edge-functions/markdown-negotiation.ts @@ -1,4 +1,9 @@ +// @ts-ignore: Deno URL import is resolved by Netlify Edge at runtime. +import { DOMParser as DenoDOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts"; + const MARKDOWN_CONTENT_TYPE = "text/markdown; charset=utf-8"; +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; export default async (request: Request, context: { next: () => Promise }) => { const response = await context.next(); @@ -59,13 +64,30 @@ function estimateTokenCount(markdown: string): number { } function htmlToMarkdown(html: string, baseUrl: URL): string { - const doc = new DOMParser().parseFromString(html, "text/html"); + let doc: { querySelector: (selector: string) => any; querySelectorAll: (selector: string) => any; body?: any } | null = null; + + // Prefer a parser that is available in Netlify Edge runtimes. + try { + doc = new DenoDOMParser().parseFromString(html, "text/html") as any; + } catch { + doc = null; + } + + // Fall back to native DOMParser if available. + if (!doc && typeof DOMParser !== "undefined") { + try { + doc = new DOMParser().parseFromString(html, "text/html") as any; + } catch { + doc = null; + } + } + if (!doc) { - return ""; + return htmlToMarkdownFallback(html, baseUrl); } for (const selector of ["script", "style", "noscript"]) { - doc.querySelectorAll(selector).forEach((node) => node.remove()); + doc.querySelectorAll(selector).forEach((node: any) => node.remove()); } const title = normalizeWhitespace(doc.querySelector("title")?.textContent || ""); @@ -85,9 +107,122 @@ function htmlToMarkdown(html: string, baseUrl: URL): string { return parts.join("\n\n").trim() + "\n"; } -function renderChildren(parent: Element, baseUrl: URL): string { +function htmlToMarkdownFallback(html: string, baseUrl: URL): string { + const sanitized = html + .replace(//g, "") + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/noscript>/gi, ""); + + const titleMatch = sanitized.match(/]*>([\s\S]*?)<\/title>/i); + const title = titleMatch ? normalizeWhitespace(decodeHtmlEntities(stripTags(titleMatch[1]))) : ""; + + const bodyMatch = sanitized.match(/]*>([\s\S]*?)<\/body>/i); + const bodyHtml = bodyMatch ? bodyMatch[1] : sanitized; + const bodyMarkdown = renderHtmlFragmentToMarkdown(bodyHtml, baseUrl); + + const parts: string[] = []; + if (title) { + parts.push(`---\ntitle: ${title}\n---`); + parts.push(`# ${title}`); + } + + if (bodyMarkdown) { + parts.push(bodyMarkdown.trim()); + } + + return parts.join("\n\n").trim() + "\n"; +} + +function renderHtmlFragmentToMarkdown(html: string, baseUrl: URL): string { + const protectedBlocks: string[] = []; + let content = html; + + // Protect code blocks so later tag cleanup does not alter their content. + content = content.replace(/]*>([\s\S]*?)<\/pre>/gi, (_match, preContent: string) => { + const code = decodeHtmlEntities(stripTags(preContent)).trim(); + const markdown = code ? `\n\n\`\`\`\n${code}\n\`\`\`\n\n` : ""; + const index = protectedBlocks.push(markdown) - 1; + return `__MD_PROTECTED_BLOCK_${index}__`; + }); + + content = content.replace(/]*>([\s\S]*?)<\/h\1>/gi, (_match, level: string, text: string) => { + const clean = decodeHtmlEntities(stripTags(text)).trim(); + return clean ? `\n\n${"#".repeat(Number(level))} ${clean}\n\n` : ""; + }); + + content = content.replace(/]*)>([\s\S]*?)<\/a>/gi, (_match, attrs: string, text: string) => { + const hrefMatch = attrs.match(/href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + const rawHref = hrefMatch ? hrefMatch[1] || hrefMatch[2] || hrefMatch[3] || "" : ""; + const href = toAbsoluteUrl(rawHref, baseUrl); + const label = normalizeWhitespace(decodeHtmlEntities(stripTags(text))) || href; + return href ? `[${label}](${href})` : label; + }); + + content = content.replace(/]*)>/gi, (_match, attrs: string) => { + const altMatch = attrs.match(/alt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + const srcMatch = attrs.match(/src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); + const altRaw = altMatch ? altMatch[1] || altMatch[2] || altMatch[3] || "image" : "image"; + const srcRaw = srcMatch ? srcMatch[1] || srcMatch[2] || srcMatch[3] || "" : ""; + const src = toAbsoluteUrl(srcRaw, baseUrl); + const alt = normalizeWhitespace(decodeHtmlEntities(altRaw)) || "image"; + return src ? `![${alt}](${src})` : ""; + }); + + content = content + .replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_m, _tag: string, text: string) => { + const clean = normalizeWhitespace(decodeHtmlEntities(stripTags(text))); + return clean ? `**${clean}**` : ""; + }) + .replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_m, _tag: string, text: string) => { + const clean = normalizeWhitespace(decodeHtmlEntities(stripTags(text))); + return clean ? `*${clean}*` : ""; + }) + .replace(/]*>([\s\S]*?)<\/code>/gi, (_match, text: string) => { + const clean = normalizeWhitespace(decodeHtmlEntities(stripTags(text))); + return clean ? `\`${clean}\`` : ""; + }); + + content = content + .replace(/]*>([\s\S]*?)<\/li>/gi, (_match, text: string) => { + const clean = normalizeWhitespace(decodeHtmlEntities(stripTags(text))); + return clean ? `\n- ${clean}` : ""; + }) + .replace(/<\/p>|<\/div>|<\/section>|<\/article>|<\/blockquote>/gi, "\n\n") + .replace(//gi, "\n") + .replace(//gi, "\n\n---\n\n"); + + content = decodeHtmlEntities(stripTags(content)) + .replace(/\n{3,}/g, "\n\n") + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trim(); + + for (let i = 0; i < protectedBlocks.length; i += 1) { + content = content.replace(`__MD_PROTECTED_BLOCK_${i}__`, protectedBlocks[i]); + } + + return content; +} + +function stripTags(value: string): string { + return value.replace(/<[^>]*>/g, " "); +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'|'/gi, "'"); +} + +function renderChildren(parent: { childNodes: ArrayLike }, baseUrl: URL): string { const chunks: string[] = []; - for (const child of Array.from(parent.childNodes)) { + for (const child of Array.from(parent.childNodes as ArrayLike) as any[]) { const rendered = renderNode(child, baseUrl, 0); if (rendered) { chunks.push(rendered.trim()); @@ -96,16 +231,16 @@ function renderChildren(parent: Element, baseUrl: URL): string { return chunks.join("\n\n"); } -function renderNode(node: Node, baseUrl: URL, listDepth: number): string { - if (node.nodeType === Node.TEXT_NODE) { +function renderNode(node: any, baseUrl: URL, listDepth: number): string { + if (node.nodeType === TEXT_NODE) { return normalizeWhitespace(node.textContent || ""); } - if (node.nodeType !== Node.ELEMENT_NODE) { + if (node.nodeType !== ELEMENT_NODE) { return ""; } - const el = node as Element; + const el = node; const tag = el.tagName.toLowerCase(); if (/^h[1-6]$/.test(tag)) { @@ -167,12 +302,12 @@ function renderNode(node: Node, baseUrl: URL, listDepth: number): string { return renderChildren(el, baseUrl); } -function renderList(list: Element, baseUrl: URL, ordered: boolean, listDepth: number): string { +function renderList(list: any, baseUrl: URL, ordered: boolean, listDepth: number): string { const lines: string[] = []; const indent = " ".repeat(listDepth); let index = 1; - for (const child of Array.from(list.children)) { + for (const child of Array.from(list.children as ArrayLike) as any[]) { if (child.tagName.toLowerCase() !== "li") { continue; } @@ -191,7 +326,7 @@ function renderList(list: Element, baseUrl: URL, ordered: boolean, listDepth: nu lines.push(normalized); index += 1; - for (const nested of Array.from(child.children)) { + for (const nested of Array.from(child.children as ArrayLike) as any[]) { const nestedTag = nested.tagName.toLowerCase(); if (nestedTag === "ul" || nestedTag === "ol") { const nestedList = renderList(nested, baseUrl, nestedTag === "ol", listDepth + 1); @@ -205,10 +340,10 @@ function renderList(list: Element, baseUrl: URL, ordered: boolean, listDepth: nu return lines.join("\n"); } -function renderInlineChildren(parent: Element, baseUrl: URL): string { +function renderInlineChildren(parent: any, baseUrl: URL): string { const out: string[] = []; - for (const child of Array.from(parent.childNodes)) { - if (child.nodeType === Node.TEXT_NODE) { + for (const child of Array.from(parent.childNodes as ArrayLike) as any[]) { + if (child.nodeType === TEXT_NODE) { const text = normalizeWhitespace(child.textContent || ""); if (text) { out.push(text); @@ -216,11 +351,11 @@ function renderInlineChildren(parent: Element, baseUrl: URL): string { continue; } - if (child.nodeType !== Node.ELEMENT_NODE) { + if (child.nodeType !== ELEMENT_NODE) { continue; } - const el = child as Element; + const el = child; const tag = el.tagName.toLowerCase(); if (tag === "a") { From 7aeedd6d7999d28220fc6dcff334a410ace4f19b Mon Sep 17 00:00:00 2001 From: GBKS Date: Mon, 20 Apr 2026 09:53:26 +0200 Subject: [PATCH 9/9] Remove netlify markdown handler dependency The deployed Edge function still crashes. Probably related to this dependency. --- netlify/edge-functions/markdown-negotiation.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/netlify/edge-functions/markdown-negotiation.ts b/netlify/edge-functions/markdown-negotiation.ts index 61272ca8f..3d7c59344 100644 --- a/netlify/edge-functions/markdown-negotiation.ts +++ b/netlify/edge-functions/markdown-negotiation.ts @@ -1,6 +1,3 @@ -// @ts-ignore: Deno URL import is resolved by Netlify Edge at runtime. -import { DOMParser as DenoDOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts"; - const MARKDOWN_CONTENT_TYPE = "text/markdown; charset=utf-8"; const TEXT_NODE = 3; const ELEMENT_NODE = 1; @@ -66,13 +63,6 @@ function estimateTokenCount(markdown: string): number { function htmlToMarkdown(html: string, baseUrl: URL): string { let doc: { querySelector: (selector: string) => any; querySelectorAll: (selector: string) => any; body?: any } | null = null; - // Prefer a parser that is available in Netlify Edge runtimes. - try { - doc = new DenoDOMParser().parseFromString(html, "text/html") as any; - } catch { - doc = null; - } - // Fall back to native DOMParser if available. if (!doc && typeof DOMParser !== "undefined") { try {