diff --git a/config.toml b/config.toml index b169f8434..258f61702 100644 --- a/config.toml +++ b/config.toml @@ -5,6 +5,7 @@ title = "Kubermatic Docs" disableKinds = ["taxonomy"] ignoreErrors = ["error-disable-taxonomy"] enableRobotsTXT = true +enableGitInfo = true [outputFormats] [outputFormats.Search] @@ -17,10 +18,26 @@ enableRobotsTXT = true mediaType = "application/octet-stream" isPlainText = true notAlternative = true + [outputFormats.LLMsTxt] + baseName = "llms" + mediaType = "text/plain" + isPlainText = true + notAlternative = true + [outputFormats.LLMsFullTxt] + baseName = "llms-full" + mediaType = "text/plain" + isPlainText = true + notAlternative = true + [outputFormats.Markdown] + baseName = "index" + mediaType = "text/markdown" + isPlainText = true + notAlternative = true [outputs] -home = ["HTML", "Search", "Redirects"] -section = ["HTML"] +home = ["HTML", "Search", "Redirects", "LLMsTxt", "Markdown"] +section = ["HTML", "LLMsTxt", "LLMsFullTxt", "Markdown"] +page = ["HTML", "Markdown"] [markup.goldmark.renderer] unsafe = true diff --git a/content/developer-platform/_index.en.md b/content/developer-platform/_index.en.md index 72cf47939..b61b2d13a 100644 --- a/content/developer-platform/_index.en.md +++ b/content/developer-platform/_index.en.md @@ -1,6 +1,5 @@ +++ title = "Kubermatic Developer Platform" -sitemapexclude = true +++ KDP (Kubermatic Developer Platform) is a new Kubermatic product in development that targets the IDP diff --git a/layouts/_default/home.llmstxt.txt b/layouts/_default/home.llmstxt.txt new file mode 100644 index 000000000..2f24f2e2d --- /dev/null +++ b/layouts/_default/home.llmstxt.txt @@ -0,0 +1,29 @@ +# {{ .Site.Title }} + +> {{ .Site.Params.description }} + +> Last updated: {{ now.Format "2006-01-02" }} +> Products: {{ len (where (sort hugo.Data.products ".weight") "name" "!=" "") }} + +## Products +{{ range (sort hugo.Data.products ".weight") }} +{{- $key := urlize .name -}} +{{- $values := . -}} +{{- $url := "" -}} +{{- if .versions -}} + {{- $url = (cond (gt (len .versions) 1) (index .versions 1) (index .versions 0)).release -}} +{{- end -}} +{{- $pagePath := cond (ne $url "") (printf "/%s/%s" $key $url) (printf "/%s" $key) -}} +{{- with $.Site.GetPage $pagePath -}} + {{- if not (partial "llms/is-excluded.txt" .) -}} + {{- $llmsURL := "" -}} + {{- with .OutputFormats.Get "LLMsTxt" -}} + {{- $llmsURL = .Permalink -}} + {{- end -}} + {{- if $llmsURL -}} + {{- $title := $values.title | default $values.name }} +- [{{ $title }}]({{ $llmsURL }}): {{ $values.description }} +{{ end -}} +{{- end -}} +{{- end -}} +{{- end }} diff --git a/layouts/_default/home.markdown.md b/layouts/_default/home.markdown.md new file mode 100644 index 000000000..61e2adece --- /dev/null +++ b/layouts/_default/home.markdown.md @@ -0,0 +1,28 @@ +{{- if not .Params.sitemapexclude -}} +--- +title: {{ .Title | plainify }} +url: {{ .Permalink }} +--- + +# {{ .Title | plainify }} +{{ with .Description | default .Params.description }} +> {{ . }} +{{ end }} +## Products +{{ range sort hugo.Data.products "weight" }} +{{- if not .sitemapexclude -}} +{{- $key := .name | urlize -}} +{{- $version := "" -}} +{{- if .versions -}} + {{- $latest := cond (gt (len .versions) 1) (index .versions 1) (index .versions 0) -}} + {{- $version = $latest.release -}} +{{- end -}} +### {{ .title }} + +{{ .description }} +{{ if $version -}} +Documentation: [/{{ $key }}/{{ $version }}/](/{{ $key }}/{{ $version }}/) +{{ end }} +{{ end -}} +{{- end -}} +{{- end -}} diff --git a/layouts/_default/home.redirects b/layouts/_default/home.redirects index 7c6328f92..043b4d3d7 100644 --- a/layouts/_default/home.redirects +++ b/layouts/_default/home.redirects @@ -7,6 +7,9 @@ https://docs.kubermatic.io/* https://docs.kubermatic.com/:splat 301! # Optional: Redirect default Netlify subdomain to primary domain https://cranky-newton-4e6ed2.netlify.com/* https://docs.kubermatic.com/:splat 301! +# Serve /llms.txt at the well-known discovery path for LLM agent frameworks +/.well-known/llms.txt /llms.txt 200! + /kubermatic/master/cheat_sheets/alerting_runbook/* /kubermatic/main/cheat-sheets/alerting-runbook/:splat 301! /kubermatic/master/tutorials_howtos/oidc_provider_configuration/share-_clusters_via_delegated_oidc_authentication /kubermatic/main/tutorials-howtos/oidc-provider-configuration/share-clusters-via-delegated-oidc-authentication/ 301! /kubermatic/master/installation/start_kkp /kubermatic/main/installation/start-kkp/ 301! diff --git a/layouts/_default/section.llmsfulltxt.txt b/layouts/_default/section.llmsfulltxt.txt new file mode 100644 index 000000000..b70daafe7 --- /dev/null +++ b/layouts/_default/section.llmsfulltxt.txt @@ -0,0 +1,8 @@ +{{- if not (partial "llms/is-excluded.txt" .) -}} +# {{ .Title | default .Name | plainify }} +{{ with .Description | default .Params.description }} +> {{ . }} +{{ end }} +{{ partial "llms/render-content.txt" . }} +{{ partial "llms/full-content.txt" . }} +{{- end -}} diff --git a/layouts/_default/section.llmstxt.txt b/layouts/_default/section.llmstxt.txt new file mode 100644 index 000000000..51a310b85 --- /dev/null +++ b/layouts/_default/section.llmstxt.txt @@ -0,0 +1,27 @@ +{{- if not (partial "llms/is-excluded.txt" .) -}} +{{- $title := .Title | default .Name | plainify -}} +{{- $pageCount := partial "llms/page-count.txt" . -}} +{{- $segments := split (strings.TrimPrefix "/" (strings.TrimSuffix "/" .RelPermalink)) "/" -}} +{{- if gt $pageCount 0 -}} +# {{ $title }} +{{ with .Description | default .Params.description }} +> {{ . }} +{{ end }} +> Last updated: {{ .Lastmod.Format "2006-01-02" }} +{{- if ge (len $segments) 2 }} +> Version: {{ index $segments 1 }} +{{- end }} +> Pages: {{ $pageCount }} +{{- $llmsFullURL := "" -}} +{{- with .OutputFormats.Get "LLMsFullTxt" -}} + {{- $llmsFullURL = .Permalink -}} +{{- end -}} +{{- with $llmsFullURL }} +## About this section + +- [{{ $title }} — Full Content]({{ . }}) +{{ end }} +## Pages +{{ partial "llms/list-pages.txt" . }} +{{- end -}} +{{- end -}} diff --git a/layouts/_default/section.markdown.md b/layouts/_default/section.markdown.md new file mode 100644 index 000000000..856fceab0 --- /dev/null +++ b/layouts/_default/section.markdown.md @@ -0,0 +1,12 @@ +{{- if not (partial "llms/is-excluded.txt" .) -}} +--- +title: {{ .Title | plainify }} +url: {{ .Permalink }} +--- + +# {{ .Title | plainify }} +{{ with .Description | default .Params.description }} +> {{ . }} +{{ end }} +{{ partial "llms/render-content.txt" . }} +{{- end -}} diff --git a/layouts/_default/single.markdown.md b/layouts/_default/single.markdown.md new file mode 100644 index 000000000..856fceab0 --- /dev/null +++ b/layouts/_default/single.markdown.md @@ -0,0 +1,12 @@ +{{- if not (partial "llms/is-excluded.txt" .) -}} +--- +title: {{ .Title | plainify }} +url: {{ .Permalink }} +--- + +# {{ .Title | plainify }} +{{ with .Description | default .Params.description }} +> {{ . }} +{{ end }} +{{ partial "llms/render-content.txt" . }} +{{- end -}} diff --git a/layouts/partials/llms/full-content.txt b/layouts/partials/llms/full-content.txt new file mode 100644 index 000000000..733964a93 --- /dev/null +++ b/layouts/partials/llms/full-content.txt @@ -0,0 +1,21 @@ +{{- /* Recursive partial: outputs full content of all descendant pages. + Context: a page (section or single). + Skips excluded pages and pages without content. + Outputs each page as a markdown section with heading + content. + Uses .RawContent via render-content.txt partial. */ -}} +{{- range .Pages -}} + {{- if not (partial "llms/is-excluded.txt" .) -}} + {{- if .RawContent -}} + {{- with .Title }} + +--- + +## {{ . | plainify }} +{{ end -}} +{{ partial "llms/render-content.txt" . }} +{{ end -}} + {{- if .IsSection -}} + {{- partial "llms/full-content.txt" . -}} + {{- end -}} + {{- end -}} +{{- end -}} diff --git a/layouts/partials/llms/is-excluded.txt b/layouts/partials/llms/is-excluded.txt new file mode 100644 index 000000000..5816be304 --- /dev/null +++ b/layouts/partials/llms/is-excluded.txt @@ -0,0 +1,22 @@ +{{- /* Returns true if a page should be excluded from llms.txt outputs. + Excluded when: + - .Draft is true, OR + - .Params.sitemapexclude is true, OR + - The page does not belong to the most recent product version + (2nd in the versions list from products.yaml, or 1st if only one). */ -}} +{{- $excluded := or .Draft .Params.sitemapexclude -}} +{{- if not $excluded -}} + {{- with index hugo.Data.products .Section -}} + {{- if .versions -}} + {{- $latestVersion := cond (gt (len .versions) 1) (index .versions 1).release (index .versions 0).release -}} + {{- $pathParts := split $.RelPermalink "/" -}} + {{- if gt (len $pathParts) 2 -}} + {{- $pageVersion := index $pathParts 2 -}} + {{- if ne $pageVersion $latestVersion -}} + {{- $excluded = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- return $excluded -}} diff --git a/layouts/partials/llms/list-pages.txt b/layouts/partials/llms/list-pages.txt new file mode 100644 index 000000000..8b42117b4 --- /dev/null +++ b/layouts/partials/llms/list-pages.txt @@ -0,0 +1,39 @@ +{{- /* Hierarchical partial: list only direct children of the current section. + Context: a page (section). + Skips excluded pages (see llms/is-excluded partial). + Skips pages without main content. + For child sections with >10 pages, links to their llms.txt (navigate deeper). + For child sections with ≤10 pages, links to their llms-full.txt (content directly). + For single pages, lists title and description as plain text. */ -}} +{{- range .Pages -}} + {{- if not (partial "llms/is-excluded.txt" .) -}} + {{- if .RawContent -}} + {{- if .IsSection -}} + {{- $childCount := partial "llms/page-count.txt" . -}} + {{- $desc := .Description | default .Params.description | default "" -}} + {{- if gt $childCount 10 -}} + {{- $llmsTxtURL := "" -}} + {{- with .OutputFormats.Get "LLMsTxt" -}} + {{- $llmsTxtURL = .Permalink -}} + {{- end -}} + {{- if and .Title $llmsTxtURL -}} +- [{{ .Title | plainify }}]({{ $llmsTxtURL }}){{ with $desc }}: {{ . }}{{ end }} +{{ end -}} + {{- else -}} + {{- $llmsFullURL := "" -}} + {{- with .OutputFormats.Get "LLMsFullTxt" -}} + {{- $llmsFullURL = .Permalink -}} + {{- end -}} + {{- if and .Title $llmsFullURL -}} +- [{{ .Title | plainify }}]({{ $llmsFullURL }}){{ with $desc }}: {{ . }}{{ end }} +{{ end -}} + {{- end -}} + {{- else -}} + {{- if .Title -}} + {{- $desc := .Description | default .Params.description | default "" -}} +- {{ .Title | plainify }}{{ with $desc }}: {{ . }}{{ end }} +{{ end -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} diff --git a/layouts/partials/llms/page-count.txt b/layouts/partials/llms/page-count.txt new file mode 100644 index 000000000..83e4695e3 --- /dev/null +++ b/layouts/partials/llms/page-count.txt @@ -0,0 +1,14 @@ +{{- /* Returns the count of non-excluded pages with content under a section. + Context: a section page. */ -}} +{{- $count := 0 -}} +{{- range .Pages -}} + {{- if not (partial "llms/is-excluded.txt" .) -}} + {{- if .RawContent -}} + {{- $count = add $count 1 -}} + {{- end -}} + {{- if .IsSection -}} + {{- $count = add $count (partial "llms/page-count.txt" .) -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- return $count -}} diff --git a/layouts/partials/llms/render-content.txt b/layouts/partials/llms/render-content.txt new file mode 100644 index 000000000..0a991051e --- /dev/null +++ b/layouts/partials/llms/render-content.txt @@ -0,0 +1,126 @@ +{{- /* Hybrid content renderer for LLM/MD output. + Processes .RawContent with regex transformations to preserve native + markdown formatting (fenced code blocks, headings, links, etc.). + External content (render_external_markdown/code) is fetched at build + time via resources.GetRemote and inlined directly. + Context: a single page. + Outputs content directly (no return value). */ -}} + +{{- $s := .RawContent -}} + +{{- /* 1. Strip children shortcode — navigation only, no real content */ -}} +{{- $s = replaceRE `\{\{[%<] *children[^}]*[%>]\}\}` "" $s -}} + +{{- /* 2. Inline readfile shortcodes — output raw file content. + The shortcode is typically placed inside a fenced code block in the + source markdown, so no extra wrapping is added here. */ -}} +{{- range findRE `\{\{< *readfile "[^"]+"[^>]*>\}\}` $s -}} + {{- $match := . -}} + {{- $path := replaceRE `\{\{< *readfile "([^"]+)"[^>]*>\}\}` "$1" $match -}} + {{- with readFile $path -}} + {{- $s = replace $s $match (. | strings.TrimRight "\n") -}} + {{- else -}} + {{- $s = replace $s $match "" -}} + {{- end -}} +{{- end -}} + +{{- /* 3. Inline render_external_markdown / render_external_code: fetch the remote + resource and embed its content directly. Both shortcodes produce raw + content (markdown or code) that is already wrapped appropriately by + the surrounding source markdown, so no extra wrapping is added. */ -}} +{{- range findRE `\{\{< *render_external(?:_markdown|_code) "[^"]+"[^>]*>\}\}` $s -}} + {{- $match := . -}} + {{- $url := replaceRE `\{\{< *render_external(?:_markdown|_code) "([^"]+)"[^>]*>\}\}` "$1" $match -}} + {{- with resources.GetRemote $url (dict "headers" (dict "Cache-Control" "no-cache")) -}} + {{- $s = replace $s $match (.Content | strings.TrimRight "\n") -}} + {{- else -}} + {{- warnf "llms/render-content: failed to fetch %s" $url -}} + {{- $s = replace $s $match "" -}} + {{- end -}} +{{- end -}} + +{{- /* 4. Convert notice shortcodes to labelled blockquotes. + Types: note, warning, tip, info */ -}} +{{- $s = replaceRE `(?s)\{\{% notice (\w+)[^%]*%\}\}\s*(.*?)\s*\{\{% /notice %\}\}` "\n> **$1:** $2\n" $s -}} + +{{- /* 5. Strip tabs wrapper; convert tab names to level-4 headings */ -}} +{{- $s = replaceRE `\{\{< *tabs [^>]*>\}\}` "" $s -}} +{{- $s = replaceRE `\{\{< */tabs *>\}\}` "" $s -}} +{{- $s = replaceRE `\{\{% tab name="([^"]+)"[^%]*%\}\}` "\n#### $1\n\n" $s -}} +{{- $s = replaceRE `\{\{% /tab *%\}\}` "" $s -}} + +{{- /* 6. Convert details shortcodes to heading + content */ -}} +{{- $s = replaceRE `\{\{< *details summary="([^"]+)"[^>]*>\}\}` "\n#### $1\n\n" $s -}} +{{- $s = replaceRE `\{\{< */details *>\}\}` "" $s -}} + +{{- /* 7. Resolve ref/relref in links: [text]({{< ref "path" >}}) → [text](path) + Also handles malformed shortcodes with escaped quotes: {{< ref \"path\" >}} */ -}} +{{- $s = replaceRE `\{\{< *(?:rel)?ref \\?"([^"\\]+)\\?"[^>]*>\}\}` "$1" $s -}} + +{{- /* 8. Resolve relative markdown links to absolute URLs. + After step 7, ref/relref shortcodes are plain relative paths (e.g. ../../tutorials/kkp). + Plain relative links (e.g. ../../installation/) are also still relative at this point. + AI parsers cannot resolve relative paths without knowing the base URL, so we convert + them to absolute URLs here using manual path resolution (not Hugo's relref) to avoid + REF_NOT_FOUND errors for links pointing to non-page resources (images, PDFs, etc.). + Two link forms are handled: + a) Inline links: [text](../path) + b) Reference definitions: [id]: ../path */ -}} +{{- $siteBase := strings.TrimSuffix "/" .Site.BaseURL -}} +{{- $baseParts := slice -}} +{{- range split (strings.TrimSuffix "/" .RelPermalink) "/" -}} + {{- if ne . "" -}} + {{- $baseParts = $baseParts | append . -}} + {{- end -}} +{{- end -}} +{{- /* 8a. Inline links: [text](../path) */ -}} +{{- range findRE `\[([^\]]*)\]\((\.\.?/[^)]*)\)` $s -}} + {{- $fullMatch := . -}} + {{- $linkText := replaceRE `\[([^\]]*)\]\((\.\.?/[^)]*)\)` "$1" . -}} + {{- $href := replaceRE `\[([^\]]*)\]\((\.\.?/[^)]*)\)` "$2" . -}} + {{- $anchor := replaceRE `^[^#]*` "" $href -}} + {{- $href = replaceRE `#.*$` "" $href -}} + {{- $trailingSlash := "" -}} + {{- if strings.HasSuffix $href "/" -}}{{- $trailingSlash = "/" -}}{{- end -}} + {{- $parts := slice -}} + {{- range $baseParts -}}{{- $parts = $parts | append . -}}{{- end -}} + {{- range split $href "/" -}} + {{- if eq . ".." -}} + {{- if gt (len $parts) 0 -}}{{- $parts = first (sub (len $parts) 1) $parts -}}{{- end -}} + {{- else if and (ne . ".") (ne . "") -}} + {{- $parts = $parts | append . -}} + {{- end -}} + {{- end -}} + {{- $s = replace $s $fullMatch (printf "[%s](%s/%s%s%s)" $linkText $siteBase (delimit $parts "/") $trailingSlash $anchor) -}} +{{- end -}} +{{- /* 8b. Reference definitions: [id]: ../path */ -}} +{{- range findRE `\[[^\]]+\]: *(\.\.?/[^\s]+)` $s -}} + {{- $fullMatch := . -}} + {{- $id := replaceRE `\[([^\]]+)\]: *(\.\.?/[^\s]+)` "$1" . -}} + {{- $href := replaceRE `\[([^\]]+)\]: *(\.\.?/[^\s]+)` "$2" . -}} + {{- $anchor := replaceRE `^[^#]*` "" $href -}} + {{- $href = replaceRE `#.*$` "" $href -}} + {{- $trailingSlash := "" -}} + {{- if strings.HasSuffix $href "/" -}}{{- $trailingSlash = "/" -}}{{- end -}} + {{- $parts := slice -}} + {{- range $baseParts -}}{{- $parts = $parts | append . -}}{{- end -}} + {{- range split $href "/" -}} + {{- if eq . ".." -}} + {{- if gt (len $parts) 0 -}}{{- $parts = first (sub (len $parts) 1) $parts -}}{{- end -}} + {{- else if and (ne . ".") (ne . "") -}} + {{- $parts = $parts | append . -}} + {{- end -}} + {{- end -}} + {{- $s = replace $s $fullMatch (printf "[%s]: %s/%s%s%s" $id $siteBase (delimit $parts "/") $trailingSlash $anchor) -}} +{{- end -}} + +{{- /* 9. Replace current_version with actual version string from URL */ -}} +{{- $pathParts := split .RelPermalink "/" -}} +{{- if gt (len $pathParts) 2 -}} + {{- $s = replace $s "{{< current_version >}}" (index $pathParts 2) -}} +{{- end -}} + +{{- /* 10. Strip any remaining unhandled shortcode tags (self-closing and wrappers) */ -}} +{{- $s = replaceRE `\{\{[<%][^}]*[%>]\}\}` "" $s -}} + +{{ $s | safeHTML }} diff --git a/layouts/partials/meta.html b/layouts/partials/meta.html new file mode 100644 index 000000000..e0f3ae36e --- /dev/null +++ b/layouts/partials/meta.html @@ -0,0 +1,42 @@ +{{$trimTagsRegex := "(h1|h2|h3|h4|h5|h6|ul|ol|pre|table)"}} +{{$regexPattern := printf "<%[1]s.*?>(.|\n)*?" $trimTagsRegex}} +{{$htmlContent := strings.ReplaceRE $regexPattern "" (.Content | safeHTML)}} +{{$autoDescription := trim (truncate 160 "" (replace ($htmlContent | htmlUnescape | plainify) "\n" " ")) " " | chomp}} +{{$description := replace $autoDescription "&" "and"}} + +{{if $description}} + {{$sentences := split $description ". "}} + {{range $index, $val := $sentences}} + {{$delimiter := ""}} + + {{if lt $index (sub (len $sentences) 2)}} + {{$delimiter = ". "}} + {{end}} + + {{if eq $index 0}} + {{$description = print . $delimiter}} + {{else if lt $index (sub (len $sentences) 1)}} + {{$description = print $description . $delimiter}} + {{end}} + {{end}} +{{end}} + +{{with .Description}} + {{$description = .}} +{{end}} + +{{with .Site.Params.author}}{{end}} + + + +{{$product := index hugo.Data.products .Section | default (dict "shareImage" nil)}} +{{$shareImage := or .Params.shareImage $product.shareImage | default .Site.Params.shareImage}} +{{with $shareImage}} + + +{{end}} + +{{- /* LLMs.txt discoverability */ -}} +{{- with .OutputFormats.Get "LLMsTxt" }} + +{{- end }} diff --git a/layouts/robots.txt b/layouts/robots.txt index 0af428494..b751ed448 100644 --- a/layouts/robots.txt +++ b/layouts/robots.txt @@ -1,10 +1,13 @@ +{{- /* This is a comment, that is not used in production # We allows Google to crawl website. It should crawl the pages from custom sitemap.xml. # sitemap.xml updates every new release and keep the pages for stable version of product. # The previous versions should be removed from Google index on every new release for product. # These pages are marked with "noindex" rule in the "robots" meta tag on build time and will be deleted from Google after some time. # We dont need to disallow crawling for these pages, because Google should have opportunity to analyze previous pages (with "noindex" tag) and update the state of index for those. - +*/ -}} User-agent: * Disallow: +Content-Signal: ai-train=yes, search=yes, ai-input=yes Sitemap: {{"sitemap.xml" | absURL}} +Llms-txt: {{"llms.txt" | absURL}} diff --git a/layouts/shortcodes/render_external_markdown.html b/layouts/shortcodes/render_external_markdown.html index ff1914427..ee02e0987 100644 --- a/layouts/shortcodes/render_external_markdown.html +++ b/layouts/shortcodes/render_external_markdown.html @@ -2,5 +2,5 @@ {{- with resources.GetRemote $urlToRender (dict "headers" (dict "Cache-Control" "no-cache")) -}} {{- $.Page.RenderString .Content -}} {{- else -}} - {{- errorf "Unable to get remote resource %q" (.Get 0) -}} + {{- warnf "Unable to get remote resource %q" (.Get 0) -}} {{- end -}} diff --git a/netlify.toml b/netlify.toml index 8f06136b2..0fec45373 100644 --- a/netlify.toml +++ b/netlify.toml @@ -12,3 +12,8 @@ directory = "functions" [functions."deploy-succeeded"] included_files = ["public/search.json"] + +[[headers]] + for = "/" + [headers.values] + Link = '''; rel="llms-txt", ; rel="sitemap", ; rel="search"''' diff --git a/netlify/edge-functions/markdown-for-agents.js b/netlify/edge-functions/markdown-for-agents.js new file mode 100644 index 000000000..84e232135 --- /dev/null +++ b/netlify/edge-functions/markdown-for-agents.js @@ -0,0 +1,55 @@ +// Netlify Edge Function: Markdown for Agents +// +// When a request includes "Accept: text/markdown", rewrites to the +// pre-generated index.md that Hugo places alongside each index.html. +// Returns Content-Type: text/markdown and x-markdown-tokens as per +// the Cloudflare Markdown for Agents spec. + +export default async (request, context) => { + const accept = request.headers.get("accept") ?? ""; + if (!accept.includes("text/markdown")) { + return; + } + + const url = new URL(request.url); + let path = url.pathname; + if (!path.endsWith("/")) { + path += "/"; + } + url.pathname = path + "index.md"; + + const response = await context.rewrite(url); + if (!response || !response.ok) { + return; + } + + const body = await response.text(); + if (!body.trim()) { + return; + } + const headers = new Headers(response.headers); + headers.set("content-type", "text/markdown; charset=utf-8"); + headers.set("vary", "Accept"); + // Approximate token count (1 token ≈ 4 chars) + headers.set("x-markdown-tokens", String(Math.ceil(body.length / 4))); + + return new Response(body, { status: 200, headers }); +}; + +export const config = { + path: "/*", + excludedPath: [ + "/_redirects", + "/robots.txt", + "/sitemap.xml", + "/search.json", + "/llms.txt", + "/llms-full.txt", + "/css/*", + "/js/*", + "/fonts/*", + "/webfonts/*", + "/img/*", + "/mermaid/*", + ], +};