Перейти до основного вмісту
БЛОГ

Markdown для ШІ-агентів без Cloudflare: nginx, Nuxt, Next.js

Як віддавати Markdown ШІ-агентам без Cloudflare: map у nginx для Accept: text/markdown, код для Nuxt і Next.js, Vary: Accept та curl-скрипт на 9 перевірок.

Автор: IMozzОновлено 2026-10-20
Markdown для ШІ-агентів без Cloudflare — aiSiteReady

Віддавати Markdown ШІ-агентам на власному стеку можна, якщо взяти на себе три завдання, які в Cloudflare виконує керована функція. Це вибір варіанту за Accept: text/markdown, підготовка Markdown і прив'язка кожного кешу до варіанту. Перше й третє в nginx закривають кілька рядків, обробник у Nuxt або Next.js упорається з першим і другим, а матриця curl-перевірок підтвердить усі три.

Це збірка рецептів до статті Markdown для ШІ-агентів: content negotiation, де розібрано причини, патерни віддачі та теорію CDN. Тут ви знайдете робочі конфіги, справжнє Nuxt-middleware aiSiteReady, способи конвертації та регресійний скрипт. Тема належить до шару контенту в стеку готовності до ШІ-агентів, припускає, що факти вже лежать у серверно відрендереному HTML (як зробити JavaScript-сайт читабельним), і закриває те, на що вказує llms.txt.

Ключові висновки

  • Керована функція Cloudflare конвертує лише HTML, на відповідних тарифах, і обмежує відповідь origin розміром 2 МБ (документація Cloudflare).
  • Власна реалізація — це три завдання: вибір варіанту з урахуванням q, джерело Markdown і ключ кешу, до якого входить варіант.
  • nginx може класифікувати Accept через map, але не порівнює q-значення, тому рецепт навмисно консервативний (документація nginx).
  • Власне Nuxt-middleware aiSiteReady вийшло 13 червня 2026 року: справжня логіка q-значень, лише GET (станом на жовтень 2026 року), Markdown будується з тих самих джерел, що й HTML.
  • У Next.js 16 угоду про файл middleware перейменували на proxy (документація Next.js).

Що робить Markdown for Agents від Cloudflare і що ви замінюєте?

Функція конвертує ваш HTML у Markdown на edge Cloudflare, коли клієнт надсилає Accept: text/markdown, і на origin нічого змінювати не треба. Cloudflare анонсував її 12 лютого 2026 року (блог Cloudflare). На довідковій сторінці перелічено тарифи Pro, Business і Enterprise, а також клієнтів SSL for SaaS, без доплати; вмикається в дашборді або через API. Заявлені обмеження: конвертується лише HTML, а відповідь origin не може перевищувати 2 МБ (2 097 152 байти) (документація Cloudflare).

Що робить CloudflareЩо будуєте ви
Визначає Accept: text/markdownПеревірку з урахуванням q: у map nginx або в коді застосунку
Конвертує HTML на edgeЕкспорт під час збірки або крок HTML-у-Markdown
Ставить text/markdown; charset=utf-8 і додає Accept до VaryТі самі заголовки на обох варіантах
Додає x-markdown-tokens і x-original-tokensНеобов'язково й у будь-якому разі приблизно
Додає Content-Signal: ai-train=yes, search=yes, ai-input=yes, якщо origin його не задавСвідоме значення або жодного

Останній рядок важливий: керований варіант за замовчуванням дозволяє навчання й зберігає ваше значення, якщо ви його задали. Якщо віддаєте Markdown самі, ніхто не напише цей рядок за вас, тож обирайте самі (вподобання щодо використання контенту). Поведінку CDN розібрано в попередній статті; тут ідеться лише про кеш, який ви тримаєте самі.

Як віддавати Markdown з nginx?

Згорніть заголовок Accept до одного з двох варіантів через map, а потім за цим варіантом обирайте файл або ключ кешу. map працює в контексті http і перебирає регулярні вирази по черзі, перемагає перший збіг (документація nginx). Порівнювати q-значення він не вміє, тому правило консервативне: Markdown, лише якщо text/markdown указано першим без параметрів, крім q=1. Браузери, */*, відсутній заголовок, text/markdown;q=0 і навіть text/markdown; charset=utf-8 отримують HTML.

map $http_accept $variant {
    default "html";
    # У лапках: у шаблоні є "}" і ";" (правило з документації модуля rewrite nginx).
    "~*^\s*text/markdown\s*(;\s*q=1(\.0{0,3})?\s*)?(,|$)" "md";
}

Шлях запиту в nginx: map за Accept дає запитам із text/markdown на першому місці варіант md, а всім іншим html, у кожного свій ключ кешу й Vary: Accept.

Обидва рецепти я перевірив 3 жовтня 2026 року в офіційному образі nginx:alpine (nginx 1.31): nginx -t не знайшов помилок, а скрипт нижче успішно пройшов на рецепті A.

Рецепт A: Markdown-файли поруч зі сторінками

Якщо збірка кладе index.html та index.md у кожен каталог, nginx віддає потрібний файл. Помістіть це всередину http, поруч із map вище.

gzip on;
gzip_vary on;
gzip_types text/markdown;               # text/html стискається завжди; Markdown треба перелічити

map $variant $index_file {
    md      "index.md";
    default "index.html";
}

server {
    listen 80;
    server_name example.com;
    root /var/www/site;

    # /guide/ перетворюється на /guide/index.md або /guide/index.html. Якщо index.md
    # немає, віддається HTML-сторінка, тож узгоджуваний URL не дає 404. Ресурси
    # проходять тут без змін: без Vary на CSS, JS і зображеннях.
    location / {
        index $index_file index.html;
        try_files $uri $uri/ =404;
    }

    # Markdown-файл: запитано напряму або потрапив сюди через redirect від index.
    location ~ \.md$ {
        types { }                                   # тут mime.types не потрібен
        default_type text/markdown;                 # голий тип, щоб збігся gzip_types
        charset utf-8;
        charset_types text/markdown;                # charset діє лише на ці типи
        add_header Vary Accept always;
    }

    # HTML-сторінки: Vary і посилання на Markdown-двійника.
    location ~ ^(?<dir>/(?:.*/)?)index\.html$ {
        add_header Vary Accept always;
        add_header Link "<${dir}index.md>; rel=\"alternate\"; type=\"text/markdown\"" always;
    }
}

Рецепт тримається на чотирьох деталях:

  1. Директива index приймає змінні й працює через внутрішній redirect, тому запит обробляється заново в тому location, що підходить новому URI (документація nginx). Саме тому тип виставляє location для Markdown.
  2. У стандартному mime.types немає запису для md (перевірено в репозиторії nginx), тож types { }, default_type text/markdown, charset utf-8 і charset_types text/markdown задають тип і charset у вигляді, який gzip_types і далі розпізнає. Якщо вписати charset безпосередньо в default_type, gzip для Markdown мовчки вимкнеться.
  3. add_header успадковується лише тоді, коли на поточному рівні немає власних директив, тому заголовки рівня server, наприклад HSTS, доведеться повторити в цих двох location (документація nginx). always зберігає заголовки на 404, якщо index.md немає. Якщо index.md є не в усіх каталогах, приберіть рядок Link або віддавайте його лише там, де файл існує.
  4. gzip_vary on додає другий рядок Vary з Accept-Encoding. Два рядки Vary припустимі, бо Vary — поле-список і одержувач може їх об'єднати (RFC 9110); саме така пара приходить із продакшену aiSiteReady.

Рецепт B: кеш перед застосунком

Коли nginx стоїть перед Nuxt, Next.js або Express, обидва варіанти віддає застосунок, а nginx лише кешує їх окремо. Використовуйте це для сторінок; у JSON API має бути свій Accept.

proxy_cache_path /var/cache/nginx/pages levels=1:2 keys_zone=pages:10m
                 max_size=512m inactive=30m use_temp_path=off;

map $variant $origin_accept {
    md      "text/markdown";
    default "text/html";
}

upstream app {
    server 127.0.0.1:3000;
    keepalive 16;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header Accept $origin_accept;     # origin бачить два значення, а не сотні

        proxy_cache pages;
        proxy_cache_key "$scheme://$host$request_uri|$variant";
        proxy_cache_valid 200 5m;
        proxy_cache_valid 404 1m;
        proxy_ignore_headers Vary;                  # ключ за $variant будуємо самі

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

Навіщо ігнорувати Vary? Починаючи з nginx 1.7.7 він кешує відповідь «з урахуванням відповідних полів заголовка запиту» (документація nginx), і кожен окремий рядок Accept від браузерів отримав би свій запис. Ключ за $variant залишає два.

Ціна: якщо origin варіюється ще й за Cookie чи Accept-Language, додайте їх до ключа або не кешуйте ці шляхи. nginx пропускає Vary застосунку як є, тож застосунок має слати Vary: Accept на узгоджуваних сторінках, а не на ресурсах. Ключ за замовчуванням, $scheme$proxy_host$request_uri, узагалі не містить варіанту; рядок proxy_cache_key і є вашим захистом від отруєння кешу з попередньої статті.

Як це робить сервер на Nuxt або Nitro?

Одним серверним middleware, яке відповідає лише на явний запит Markdown, а все інше передає HTML-рендереру. Це справжній код aiSiteReady, доданий 13 червня 2026 року: спершу маркетингові сторінки, того ж вечора статті блогу. Код починається з перевірок.

// frontend/server/middleware/markdown-negotiation.ts (фрагмент, жовтень 2026)
export default defineEventHandler(async (event) => {
  if (!isGetOrHead(event)) { // HEAD узгоджується так само, як GET
    return;
  }
  if (!prefersMarkdown(getRequestHeader(event, "accept"))) {
    return;
  }
  const pathname = getRequestURL(event).pathname;
  // ...визначити сторінку, зібрати Markdown, повернути sendMarkdown(event, markdown)
});

Якщо нічого не повернути, запит іде далі: h3 викликає обробники по черзі, доки один не поверне відповідь (документація h3). Документація Nuxt описує middleware як «лише дивитися» (документація Nuxt), тож повернення значення вважайте поведінкою h3, а не обіцянкою Nuxt, і закріпіть його матрицею нижче. Відповідь збирається в одному місці:

function sendMarkdown(event: H3Event, markdown: string): string {
  setResponseHeader(event, "content-type", "text/markdown; charset=utf-8");
  setResponseHeader(event, "vary", "Accept");
  setResponseHeader(event, "cache-control", "public, s-maxage=300, stale-while-revalidate=86400");
  return bodyForMethod(event, markdown); // HEAD: порожнє тіло, Content-Length як у GET
}

Три рішення варто перейняти:

  1. HTML тут не конвертується. Маркетингові сторінки збираються з того самого i18n-каталогу й тієї самої структури сторінок, що й Vue-компоненти, а @nuxt/content зберігає статтю блогу як розібране дерево, яке невеликий серіалізатор записує назад у Markdown. Обидва представлення будуються з одного джерела, тому вони не розходяться.
  2. Усе, на що middleware не може відповісти, іде далі: невідомий slug отримує чесний 404 від рендерера, а збій запиту до контенту віддає HTML, а не 500.
  3. Vary для HTML-варіанту береться з route rules, бо SSR формує цю відповідь уже після того, як middleware поступилося дорогою.
// frontend/nuxt.config.ts (фрагмент: правило маршруту, спільне для маркетингових сторінок і статей блогу)
headers: {
  "Cache-Control": "public, s-maxage=300, stale-while-revalidate=86400",
  "Vary": "Cookie, Accept-Language, Accept",
},

Гілка для HEAD теж із перших рук. Перша версія перевіряла лише GET і відправляла запити HEAD у HTML-рендерер: 2 жовтня 2026 року curl -I -H 'Accept: text/markdown' до статті aiSiteReady повернув заголовки text/html, а той самий запит через GET — text/markdown; charset=utf-8. RFC 9110 каже, що на HEAD серверу слід відповідати тими заголовками, що дав би GET (RFC 9110), тому перевірка тепер пропускає HEAD, а bodyForMethod прибирає лише тіло. Якщо копіюєте, виставляйте Content-Length самі: Node не додає його у відповідь на HEAD, якщо обробник не задав його явно.

Як узгоджувати Markdown у Next.js?

Використовуйте файл proxy, щоб переписувати (rewrite) запити Markdown на route handler, і додавайте Vary: Accept до кожної відповіді, яку він обробляє. У Next.js 16.0.0 угоду про файл middleware оголосили застарілою й перейменували на proxy. NextResponse.rewrite віддає інший маршрут під початковим URL, а заголовки відповіді виставляються на поверненому NextResponse (документація Next.js). У Next.js 15 і раніших версіях назвіть файл middleware.ts, а функцію middleware.

// proxy.ts (корінь проєкту, поруч з app/)
import { NextResponse, type NextRequest } from "next/server";
import { prefersMarkdown } from "./lib/prefers-markdown";   // функція з розділу про q-значення
import markdownPaths from "./generated/markdown-paths.json"; // створюється під час збірки

const hasMarkdown = new Set<string>(markdownPaths);          // ["/docs/intro", ...]

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const wants =
    (request.method === "GET" || request.method === "HEAD") &&
    prefersMarkdown(request.headers.get("accept")) &&
    hasMarkdown.has(pathname);

  const response = wants
    ? NextResponse.rewrite(new URL(`/api/md${pathname}`, request.url))
    : NextResponse.next();
  response.headers.append("Vary", "Accept");   // append: Next.js може сам задавати значення Vary
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"],
};

Ціль rewrite — Route Handler із попередньої статті. Маніфест часу збірки не дає сторінці без Markdown-двійника перетворитися на 404 для агентів: вона йде в HTML. matcher виключає api, ресурси _next і все, що має розширення файлу.

Застереження: aiSiteReady працює на Nuxt, тож, на відміну від попереднього розділу, цей фрагмент я в продакшені не запускав; він іде за документованим API. Next.js уміє зіставляти й заголовок запиту всередині rewrites(), але регулярний вираз за заголовком не зважить q-значення, а для Vary потрібне окреме правило headers() (документація Next.js). Запустіть матрицю на своїй збірці, перш ніж довіряти будь-якому з варіантів.

Конвертувати під час збірки чи на льоту?

Генеруйте Markdown з того самого джерела, що й HTML, під час збірки, якщо можете; HTML на льоту конвертуйте, лише коли відрендерений HTML — єдине джерело правди. Результат збірки можна перевірити до деплою, він дешево кешується, а все, що згенеровано з джерела, не може розійтися зі сторінкою. aiSiteReady тримається золотої середини: він рендерить Markdown із джерела контенту на кожен запит, без конвертації HTML.

Конвертація на льоту — це конвеєр: отримати HTML, прибрати обв'язку, сконвертувати, виправити посилання, закешувати за URL і варіантом. Середню частину закривають три справжні бібліотеки.

  • Turndown конвертує HTML у Markdown у Node та в браузері. За замовчуванням він робить setext-заголовки й блоки коду з відступом, тож задайте headingStyle: "atx" і codeBlockStyle: "fenced", а навігацію приберіть через remove().
  • rehype-remark перетворює синтаксичне дерево HTML на дерево Markdown в екосистемі unified: rehype-parse, rehype-remark, потім remark-stringify.
  • Mozilla Readability дістає основний контент сторінки, тож навігація, футер і cookie-банер до конвертера не доходять. Їй потрібен DOM, наприклад jsdom.
import TurndownService from "turndown";

const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
turndown.remove(["script", "style", "nav", "footer", "form"]);

export const htmlToMarkdown = (html: string) => turndown.turndown(html);

// Груба оцінка розміру для логів або заголовка з кількістю токенів. Це не токенізатор.
export const estimateTokens = (text: string) => Math.ceil(text.length / 4);

Будь-яке число токенів — наближення. Cloudflare називає свої заголовки оцінкою, а документація Anthropic називає оцінкою власний підрахунок і зауважує, що новий токенізатор дає приблизно на 30 відсотків більше токенів на той самий ввід, ніж попередні моделі (документація Anthropic). Ділення кількості символів на чотири — це орієнтир, а не обіцянка. Конвертація на льоту до того ж успадковує обмеження HTML: якщо факти підвантажуються клієнтським JavaScript, жоден конвертер їх не знайде.

Які помилки з q-значеннями ламають content negotiation?

Майже всі вони походять від того, що Accept сприймають як рядок для пошуку, а не як список діапазонів із вагами. RFC 9110 визначає вагу так: за замовчуванням 1, за 0,001 тип найменш бажаний, а 0 означає «неприйнятно» (RFC 9110).

ПомилкаПриклад AcceptПравильна відповідь
Пошук підрядкаtext/markdown;q=0HTML: нуль означає відмову
Wildcard сприйнято як згоду*/* (значення curl за замовчуванням)HTML
Рядки браузерівtext/html,…,*/*;q=0.8HTML
Markdown із меншою вагоюtext/markdown;q=0.5, text/html;q=0.9HTML
Регістр і пробілиText/Markdown ; q=1Markdown
Нічого не підходитьapplication/jsonHTML за політикою або 406
Рівні вагиtext/html, text/markdownНа ваш розсуд; перевірте
Токен у VaryVary: Accept-EncodingЦе не Accept: порівнюйте токени повністю

Медіатипи нечутливі до регістру, запит без Accept не виражає вподобань, а коли нічого не підходить, сервер може відповісти 406 або проігнорувати заголовок (RFC 9110). Для URL сторінки, яку відкривають і браузери, безпечніше повернути HTML. Нічия теж питання політики, бо RFC не задає правила для рівних ваг: middleware aiSiteReady за будь-якої нічиєї віддає Markdown, а правило nginx іде за порядком у списку, тож text/markdown, text/html отримує Markdown, а text/html, text/markdown — HTML.

Ось правило у вигляді коду: у text/markdown вага має бути більшою за нуль і не меншою за вагу text/html та wildcard. Я порівняв цю стислу версію з тією, що працює в aiSiteReady, на 200 000 випадкових рядків Accept і розбіжностей не знайшов.

export function prefersMarkdown(accept: string | null | undefined): boolean {
  if (!accept) return false;
  const best = new Map<string, number>();            // медіатип -> найбільший указаний q
  for (const part of accept.split(",")) {
    const [type, ...params] = part.split(";").map((s) => s.trim());
    if (!type) continue;
    let q = 1;
    for (const param of params) {
      const [name, value] = param.split("=").map((s) => s.trim());
      const n = Number.parseFloat(value ?? "");
      if (name?.toLowerCase() === "q" && !Number.isNaN(n)) q = Math.min(1, Math.max(0, n));
    }
    const key = type.toLowerCase();
    best.set(key, Math.max(best.get(key) ?? 0, q));
  }
  const md = best.get("text/markdown") ?? 0;
  return md > 0 && md >= (best.get("text/html") ?? 0) && md >= (best.get("*/*") ?? 0);
}

text/* навмисно ігнорується, тож клієнт, що надіслав лише text/*, отримає HTML. Але клієнт, у якого text/* важить більше за text/markdown, однаково отримає Markdown; якщо потрібна строга поведінка за RFC 9110, порівнюйте і з text/*.

Як перевірити узгодження за Accept через curl?

Надішліть один URL із фіксованим набором заголовків Accept і перевірте статус, Content-Type та токени Vary кожної відповіді. Використовуйте GET, а не curl -I, і перевіряйте публічний URL, щоб кеш по дорозі теж брав участь у тесті.

Accept запитуОчікуваний Content-TypeСтатусVary
text/markdowntext/markdown; charset=utf-8200є Accept
text/markdown, text/html;q=0.8text/markdown; charset=utf-8200є Accept
text/markdown,*/*;q=0.1 (сканер aiSiteReady)text/markdown; charset=utf-8200є Accept
Рядок браузера, */* або без заголовкаtext/html200є Accept
text/markdown;q=0text/html200є Accept
text/markdown;q=0.5, text/html;q=0.9text/html200є Accept
application/jsontext/html (політика fallback)200є Accept
Будь-який, на неіснуючому URLне Markdown404є Accept (застосунок); статичний сервер може його не ставити

Скрипт покриває ці рядки дев'ятьма перевірками Accept. Він порівнює Vary за токенами повністю, бо в Accept-Encoding є літери Accept, і попереджає, коли HEAD розходиться з GET. Передавайте кінцевий URL, наприклад /guide/ для рецепта A: скрипт не йде за редиректами, а для неіснуючого URL перевіряє лише статус.

#!/usr/bin/env bash
# використання: ./check-negotiation.sh https://example.com/guide/ [https://example.com/no-such-page]
set -u
URL="${1:?usage: $0 URL [MISSING_URL]}"; MISSING="${2:-}"
BODY="$(mktemp)"; trap 'rm -f "$BODY"' EXIT; fails=0

vary_has_accept() { tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -qix 'accept'; }

check() {  # мітка | значення Accept ("" — не слати) | очікуваний медіатип
  local label="$1" accept="$2" want="$3" hdrs status ctype problems=""
  local args=(-sS -D - -o "$BODY")
  if [ -n "$accept" ]; then args+=(-H "Accept: $accept"); else args+=(-H 'Accept:'); fi
  hdrs="$(curl "${args[@]}" "$URL" | tr -d '\r')" || { echo "FAIL  $label: request failed"; fails=$((fails+1)); return; }
  status="$(printf '%s\n' "$hdrs" | head -1 | awk '{print $2}')"
  ctype="$(printf '%s\n' "$hdrs" | grep -i '^content-type:' | head -1 | sed 's/^[^:]*: *//' | tr 'A-Z' 'a-z')"
  [ "$status" = 200 ] || problems="$problems status=$status"
  case "$ctype" in "$want"*) ;; *) problems="$problems content-type=[$ctype]" ;; esac
  printf '%s\n' "$hdrs" | grep -i '^vary:' | sed 's/^[^:]*: *//' | vary_has_accept \
    || problems="$problems missing-Vary-Accept"
  if [ "$want" = "text/markdown" ]; then
    case "$ctype" in *charset=utf-8*) ;; *) problems="$problems missing-charset" ;; esac
    head -c 2000 "$BODY" | grep -qiE '<!doctype|<html' && problems="$problems body-is-html"
  fi
  if [ -z "$problems" ]; then echo "ok    $label"; else echo "FAIL  $label:$problems"; fails=$((fails+1)); fi
}

check 'markdown only'        'text/markdown'                         text/markdown
check 'markdown, html q=0.8' 'text/markdown, text/html;q=0.8'        text/markdown
check 'aiSiteReady scanner'  'text/markdown,*/*;q=0.1'               text/markdown
check 'browser'              'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' text/html
check 'curl default */*'     '*/*'                                   text/html
check 'no Accept header'     ''                                      text/html
check 'markdown refused'     'text/markdown;q=0'                     text/html
check 'html preferred'       'text/markdown;q=0.5, text/html;q=0.9'  text/html
check 'json only'            'application/json'                      text/html

head_type="$(curl -sS -I -H 'Accept: text/markdown' "$URL" | tr -d '\r' | grep -i '^content-type:' | head -1 | sed 's/^[^:]*: *//')"
case "$head_type" in text/markdown*) echo "ok    HEAD mirrors GET" ;; *) echo "WARN  HEAD returned [$head_type]" ;; esac

if [ -n "$MISSING" ]; then
  code="$(curl -sS -o /dev/null -w '%{http_code}' -H 'Accept: text/markdown' "$MISSING")"
  case "$code" in 404|410) echo "ok    missing page -> $code" ;; *) echo "FAIL  missing page -> $code"; fails=$((fails+1)) ;; esac
fi
[ "$fails" -eq 0 ] && echo PASS || { echo "$fails check(s) failed"; exit 1; }

Я перевірив його на локальному сервері в трьох зламаних режимах. Перевірка підрядка за Accept завалює перевірки q=0 і «html preferred»; сервер, що віддає лише Vary: Accept-Encoding, завалює всі перевірки; обробник лише для GET проходить із попередженням про HEAD. На статті aiSiteReady 2 жовтня 2026 року пройшли всі дев'ять перевірок Accept, неіснуюча стаття повернула 404, а рядок про HEAD видав попередження. Після виправлення HEAD цей рядок має вигляд ok HEAD mirrors GET.

Який підхід обрати?

Якщо ви вже проксіюєте через Cloudflare і ваш HTML підходить, увімкніть керовану функцію; на власному сервері експортуйте Markdown під час збірки, узгоджуйте варіант у nginx і публікуйте .md-сусідів на додачу. В іншому обирайте за тим, кому належить сервер і звідки береться Markdown.

ВаріантОбирайте, колиЩо лишається вам
Керована конвертація (Cloudflare)Ви вже проксіюєте через нього на відповідному тарифіРішення щодо Content-Signal
Експорт під час збірки плюс map у nginxСтатичний або Markdown-first сайт на вашому серверіКрок збірки, ключ кешу
Рендер із джерела на кожен запит (aiSiteReady)Джерело лежить у CMS або @nuxt/contentСеріалізатор, Vary, ключ кешу
На льоту в застосункуВідрендерений HTML — єдине джерелоКонвертер, тести, CPU
Лише .md-сусідиСтатичний хостинг, невідомий CDNДублі URL

Чотири способи віддачі Markdown (керований edge, експорт під час збірки з map у nginx, на льоту в застосунку, .md-сусіди) за зусиллями, свіжістю, безпекою кешу й мінусами.

Для контентного сайту на власному сервері ця зв'язка з Markdown під час збірки, узгодження в nginx і .md-сусідів — мій вибір за замовчуванням. Сусіди потрібні, щоб клієнт, який ніколи не шле Accept: text/markdown, усе одно міг забрати файл. Пропозиція llms.txt допускає page.md або page.html.md, а для URL-каталогів index.md або index.html.md (llmstxt.org). Запасна проба aiSiteReady робить один запит: page.md для URL сторінки, index.html.md для URL каталогу.

Як перевірка Markdown в aiSiteReady оцінює сторінку?

Перевірка markdown_negotiation в aiSiteReady запитує сторінку так, як це зробив би агент, і оцінює відповідь як PASS, PARTIAL, FAIL або UNKNOWN, з доказами. Вона має високу серйозність і вагу 2,0 всередині категорії Доступність контенту, яка дає 25% оцінки (40% у профілі Контентний сайт). Канонічний URL запитують із Accept: text/markdown,*/*;q=0.1 через SSRF-безпечний fetcher, JavaScript не виконується. Лише якщо цей запит не пройшов, перевірка запитує .md-URL у стилі llms.txt.

  • PASS: Content-Type рівно text/markdown, тіло схоже на Markdown, і є Vary: Accept або оголошений Link: rel="alternate"; type="text/markdown". Сусідній URL із Markdown проходить і без Vary.
  • PARTIAL: застарілий тип text/x-markdown, Markdown без Vary і Link, Link: rel="alternate"; type="text/markdown" на відповіді, яка не є Markdown (ціль посилання сканер не запитує), або HTML із Vary: Accept. Будь-яка інша ознака Markdown, наприклад схоже на Markdown тіло з іншим типом, теж дає PARTIAL.
  • FAIL: у жодній із відповідей немає ознак Markdown або відповідь не 2xx. Виняток: 403 і 429 дають UNKNOWN, бо блокування бота — не вердикт; так само трактуються таймаути й тіла понад 2 МБ.

«Схоже на Markdown» означає щонайменше два сигнали (заголовок, посилання, список, блок коду, відсутність HTML-тегів) і не більше трьох HTML-тегів (до чверті рядків, якщо відповідь віддано як text/markdown).

Ця евристика зіграла проти нас: коли блог почав віддавати text/markdown, одна технічна стаття оцінилася як не-Markdown через 32 тегоподібні рядки, і всі вони були в прикладах коду. Виправлення сканера того ж вечора, 13 червня 2026 року, навчило його ігнорувати теги всередині fenced- та inline-коду; теги в прозі, як і раніше, враховуються.

Точні ваги описано на сторінці методології; перевірка Markdown стоїть поруч із llms.txt у шарі доступності контенту зі статті що означає готовність до ШІ-агентів. Запустіть безкоштовне сканування, щоб побачити, чи віддає ваш сервер справжній Markdown на Accept: text/markdown, якого заголовка бракує й що виправити першим, — англійською, українською чи російською.

Якщо коротко: Markdown для агентів на власному сервері — це три завдання, а не платформа. Оберіть варіант за Accept з урахуванням q, підготуйте Markdown з джерела, яке вже маєте, і зробіть так, щоб кожен кеш між вами та агентом знав: в одного URL два представлення.

IMozz має 20 років у розробці ПЗ, а останній рік будує продукти за допомогою LLM. Він розвиває aiSiteReady, сканер лише для читання, що перевіряє, чи може ШІ-агент прочитати сайт. Сканер серверно рендерить власний контент як робочий приклад.

Часті запитання

Чи може nginx віддавати Markdown ШІ-агентам на тому самому URL?
Так. map за заголовком Accept обирає варіант, варіант потрапляє у вибір файлу або в ключ proxy-кешу, а кожна узгоджена відповідь несе Vary: Accept. nginx не вміє порівнювати q-значення, тож map консервативний: Markdown, лише якщо text/markdown указано першим без параметрів, крім q=1. Усе інше, зокрема браузери й голий wildcard, отримує HTML.
Чи працює Markdown for Agents від Cloudflare на будь-якому тарифі?
У документації Cloudflare перелічено тарифи Pro, Business і Enterprise, а також клієнтів SSL for SaaS, без доплати; тариф Free там не названо. Функція конвертує лише HTML, а відповідь origin не може перевищувати 2 МБ. Якщо ви не вкладаєтеся в ці умови або не користуєтеся Cloudflare, Markdown доведеться готувати самостійно.
Чи достатньо перевіряти Accept пошуком підрядка?
Ні. Перевірка на підрядок text/markdown спрацьовує і на text/markdown;q=0, що за RFC 9110 означає «неприйнятно», і на запити, де HTML має вищу вагу. Розбирайте діапазони та ваги: у text/markdown вага має бути більшою за нуль і не нижчою, ніж у text/html і в wildcard.
Чи мають HEAD-запити повертати ті самі заголовки для Markdown?
В ідеалі так. RFC 9110 каже, що серверу слід (SHOULD) надсилати на HEAD ті самі поля заголовка, що й на GET. Middleware самого aiSiteReady спершу узгоджувало лише GET: 2 жовтня 2026 року curl -I з Accept: text/markdown показував там HTML-заголовки, а відтоді воно відповідає на HEAD заголовками Markdown. Тести узгодження запускайте з GET, а розбіжність за HEAD вважайте попередженням, а не помилкою.