openapi: 3.0.3
info:
  title: Markdown for AI Tester API
  description: |
    Check whether any public URL serves Markdown to AI-style clients.

    The Markdown for AI Tester probes a public URL with three strategies used by
    LLM crawlers and AI-assistant fetchers:

      1. `accept`  — request with `Accept: text/markdown` header.
      2. `suffix`  — append `.md` to the URL path.
      3. `query`   — append `?format=markdown` to the URL.

    The API returns a structured report describing which strategy (if any)
    succeeded, whether the origin is served through Cloudflare, and up to 64 KB
    of the returned Markdown preview.

    ### Rate limits

    Public endpoints are protected by Cloudflare rate-limit rules. Typical
    steady-state limits are `20 req/min` and `200 req/hour` per IP address for
    `/api/check`. Abusive traffic is Managed-Challenged or blocked.

    ### Tolerant input

    The `url` parameter accepts any of:

      - `https://example.com`
      - `http://example.com`
      - `example.com` / `www.example.com`
      - `//example.com` (protocol-relative)
      - `  example.com  ` (leading / trailing whitespace)
      - `<https://example.com>` / `"https://example.com"` (wrapping chars)
      - `URL: example.com` / `link=example.com` (labelled)

    Internal whitespace, zero-width characters, and trailing sentence
    punctuation (`,;!`) are stripped. Private/internal hosts
    (`localhost`, `127.0.0.1`, RFC1918, link-local, `.internal`) are refused.

    ### Stability

    `v1` is stable. Field additions are non-breaking. Field removals or type
    changes will bump the major version in the server URL.
  version: "1.0.0"
  contact:
    name: Markdown for AI Tester
    url: https://ai-markdown.com
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://ai-markdown.com
    description: Production

# The API is intentionally public; no authentication is required.
security: []

tags:
  - name: check
    description: Run a Markdown-for-AI check against a public URL.
  - name: pages
    description: HTML landing and shareable result pages.
  - name: seo
    description: Sitemap and robots.txt for crawlers.
  - name: meta
    description: API metadata (OpenAPI spec, docs).

paths:
  /api/check:
    get:
      tags: [check]
      summary: Run a Markdown-for-AI check
      description: |
        Probes the target URL with the three Markdown strategies and returns a
        structured report. Always returns HTTP 200 when the URL is parseable,
        even if the probe itself fails — the body's `result.success` field is
        the source of truth.
      operationId: runCheck
      parameters:
        - name: url
          in: query
          required: true
          description: |
            Target URL to probe. Tolerant to messy input (whitespace, missing
            scheme, wrapping punctuation). See the "Tolerant input" section
            above for all accepted forms.
          schema:
            type: string
            minLength: 1
            maxLength: 2048
            example: "https://developers.cloudflare.com/workers/"
      responses:
        "200":
          description: Check completed (may be a success or failure; see `result.success`).
          headers:
            Cache-Control:
              schema: { type: string }
              example: "no-store"
            Access-Control-Allow-Origin:
              schema: { type: string }
              example: "*"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CheckSuccessResponse"
              examples:
                pass:
                  summary: URL passes the check
                  value:
                    ok: true
                    result:
                      input: "https://developers.cloudflare.com/workers/"
                      normalisedUrl: "https://developers.cloudflare.com/workers/"
                      cloudflare: "yes"
                      cloudflareSignals:
                        - "server: cloudflare"
                        - "cf-ray: 9ef27b338fe2c45a-AMS"
                      success: true
                      winningStrategy: "accept"
                      attempts:
                        - strategy: "accept"
                          requestUrl: "https://developers.cloudflare.com/workers/"
                          ok: true
                          status: 200
                          contentType: "text/markdown; charset=utf-8"
                          size: 15234
                          looksLikeMarkdown: true
                          preview: "# Cloudflare Workers\n\nBuild serverless applications..."
                      reasonCodes: []
                      durationMs: 82
                invalidUrl:
                  summary: Unparseable URL
                  value:
                    ok: true
                    result:
                      input: "not a url"
                      normalisedUrl: "not a url"
                      cloudflare: "unknown"
                      cloudflareSignals: []
                      success: false
                      winningStrategy: null
                      attempts: []
                      reasonCodes: ["invalidUrl"]
                      durationMs: 1
                notCloudflare:
                  summary: Origin not on Cloudflare
                  value:
                    ok: true
                    result:
                      input: "https://example.org/"
                      normalisedUrl: "https://example.org/"
                      cloudflare: "no"
                      cloudflareSignals: []
                      success: false
                      winningStrategy: null
                      attempts:
                        - strategy: "accept"
                          requestUrl: "https://example.org/"
                          ok: true
                          status: 200
                          contentType: "text/html; charset=utf-8"
                          size: 1256
                          looksLikeMarkdown: false
                          preview: "<!doctype html><html>..."
                      reasonCodes: ["notCloudflare", "blocked"]
                      durationMs: 412
        "400":
          description: Missing `url` query parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                ok: false
                error: "missing_url_param"
        "405":
          description: Method not allowed.
          headers:
            Allow:
              schema: { type: string }
              example: "GET, OPTIONS"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                ok: false
                error: "method_not_allowed"
        "500":
          description: Unexpected server error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    options:
      tags: [check]
      summary: CORS preflight
      description: Returns permissive CORS headers for browser callers.
      operationId: checkPreflight
      responses:
        "204":
          description: CORS preflight successful.
          headers:
            Access-Control-Allow-Origin:
              schema: { type: string }
              example: "*"
            Access-Control-Allow-Methods:
              schema: { type: string }
              example: "GET, OPTIONS"
            Access-Control-Allow-Headers:
              schema: { type: string }
              example: "content-type"
            Access-Control-Max-Age:
              schema: { type: string }
              example: "86400"

  /check:
    post:
      tags: [check]
      summary: Back-compat JSON submission
      description: |
        Accepts a JSON body and returns either a rendered HTML result page
        (default) or the same JSON payload as `/api/check` when the client
        sends `Accept: application/json`.

        Retained for backward compatibility with older embeds. New integrations
        should use `GET /api/check`.

        > **Note:** the Worker also tolerates `application/x-www-form-urlencoded`
        > request bodies so that browser `<form>` POSTs from legacy embeds
        > continue to work. That surface is intentionally excluded from this
        > schema — programmatic clients must use `application/json`.
      operationId: postCheck
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  description: Target URL (same tolerant rules as `/api/check`).
      responses:
        "200":
          description: Check completed.
          content:
            text/html:
              schema: { type: string, description: "Rendered result page." }
            application/json:
              schema:
                $ref: "#/components/schemas/CheckSuccessResponse"
        "400":
          description: Missing or invalid URL.
          content:
            text/html:
              schema: { type: string }
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "405":
          description: Method not allowed.
          content:
            text/plain:
              schema: { type: string }

  /:
    get:
      tags: [pages]
      summary: Landing page (or shared result)
      description: |
        Returns the localised landing page. When a `url` query parameter is
        present, runs the check and renders the result inline, producing a
        **shareable URL**. Shared result pages are served with
        `X-Robots-Tag: noindex` so per-URL variants do not pollute search
        results.

        The root path redirects to `/:locale` only when `Accept-Language`
        negotiates a non-default locale **and** no `url` parameter is present.
      operationId: getLanding
      parameters:
        - $ref: "#/components/parameters/ShareUrl"
      responses:
        "200":
          description: Landing page or shared result.
          content:
            text/html:
              schema: { type: string }
        "302":
          description: Redirect to preferred locale (only when no shared URL).

  /{locale}:
    get:
      tags: [pages]
      summary: Localised landing page (or shared result)
      description: Localised equivalent of `/`. Accepts the same optional `url` param.
      operationId: getLocalisedLanding
      parameters:
        - name: locale
          in: path
          required: true
          description: Supported locale code.
          schema:
            $ref: "#/components/schemas/Locale"
        - $ref: "#/components/parameters/ShareUrl"
      responses:
        "200":
          description: Localised landing page or shared result.
          content:
            text/html:
              schema: { type: string }
        "301":
          description: Default locale `/en` collapses to `/` to avoid duplicate content.

  /sitemap.xml:
    get:
      tags: [seo]
      summary: Sitemap for all localised landing pages
      operationId: getSitemap
      responses:
        "200":
          description: XML sitemap listing all `hreflang` variants.
          content:
            application/xml:
              schema: { type: string }

  /robots.txt:
    get:
      tags: [seo]
      summary: robots.txt
      operationId: getRobots
      responses:
        "200":
          description: robots.txt file.
          content:
            text/plain:
              schema: { type: string }

  /api/openapi.yaml:
    get:
      tags: [meta]
      summary: This OpenAPI spec (YAML)
      operationId: getOpenApiYaml
      responses:
        "200":
          description: OpenAPI 3.1 specification in YAML.
          content:
            application/yaml:
              schema: { type: string }

  /api/openapi.json:
    get:
      tags: [meta]
      summary: This OpenAPI spec (JSON)
      operationId: getOpenApiJson
      responses:
        "200":
          description: OpenAPI 3.1 specification in JSON.
          content:
            application/json:
              schema: { type: object }

  /api/docs:
    get:
      tags: [meta]
      summary: Human-readable API documentation
      description: Bootstrap-styled reference page rendered from this spec.
      operationId: getApiDocs
      responses:
        "200":
          description: HTML docs page.
          content:
            text/html:
              schema: { type: string }

components:
  parameters:
    ShareUrl:
      name: url
      in: query
      required: false
      description: |
        When present, runs a check against the given URL and renders the
        result inline. Produces a **shareable link** — anyone opening the
        same URL will see the same check result. The page is served with
        `X-Robots-Tag: noindex`.
      schema:
        type: string
        maxLength: 2048
        example: "https://developers.cloudflare.com/workers/"

  schemas:
    Locale:
      type: string
      enum: [en, es, fr, pt, ru, ar, zh, hi, bn, ja]
      description: Supported UI locale codes.

    CheckStrategy:
      type: string
      enum: [accept, suffix, query]
      description: |
        Which strategy was used for a given probe attempt:
          * `accept` — `Accept: text/markdown` request header.
          * `suffix` — `.md` appended to the URL path.
          * `query`  — `?format=markdown` appended to the URL.

    CloudflareStatus:
      type: string
      enum: ["yes", "no", "unknown"]
      description: |
        Whether the target URL is served through Cloudflare:
          * `yes`     — at least one Cloudflare response header was observed.
          * `no`      — no Cloudflare signals found.
          * `unknown` — the precheck request failed before headers could be read.

    ReasonCode:
      type: string
      enum:
        - notCloudflare
        - notEnabled
        - blocked
        - redirect
        - status4xx
        - status5xx
        - timeout
        - invalidUrl
        - fetchFailed
      description: |
        Machine-readable reason codes explaining why a check did not succeed.
        Each localised human-readable description is available in the UI. Any
        combination may appear.

    StrategyAttempt:
      type: object
      required:
        - strategy
        - requestUrl
        - ok
        - status
        - contentType
        - size
        - looksLikeMarkdown
        - preview
      properties:
        strategy:
          $ref: "#/components/schemas/CheckStrategy"
        requestUrl:
          type: string
          format: uri
          description: Exact URL that was requested for this attempt.
        ok:
          type: boolean
          description: Shorthand for `response.ok` (HTTP 2xx).
        status:
          type: integer
          nullable: true
          description: HTTP status code, or `null` if the request failed before a response was received.
          minimum: 100
          maximum: 599
        contentType:
          type: string
          nullable: true
          description: Response `Content-Type` header.
        size:
          type: integer
          nullable: true
          description: Number of bytes read from the response body (capped at 64 KB).
          minimum: 0
        looksLikeMarkdown:
          type: boolean
          description: Whether the response body passed heuristic Markdown detection.
        preview:
          type: string
          nullable: true
          description: |
            Up to 1200 chars of the response body if Markdown was detected,
            400 chars otherwise. May be `null` if the request failed.
        error:
          type: string
          description: Error message from the fetch (set only when the request failed).

    CheckResult:
      type: object
      required:
        - input
        - normalisedUrl
        - cloudflare
        - cloudflareSignals
        - success
        - winningStrategy
        - attempts
        - reasonCodes
        - durationMs
      properties:
        input:
          type: string
          description: Raw user input, unchanged.
        normalisedUrl:
          type: string
          description: |
            URL after whitespace stripping, scheme prepending, and `new URL()`
            normalisation. Equal to `input` if parsing failed.
        cloudflare:
          $ref: "#/components/schemas/CloudflareStatus"
        cloudflareSignals:
          type: array
          items: { type: string }
          description: |
            Human-readable list of Cloudflare indicators observed (e.g.
            `server: cloudflare`, `cf-ray: <id>`). Empty when `cloudflare !== "yes"`.
        success:
          type: boolean
          description: |
            `true` if at least one strategy returned a valid Markdown response.
            Source of truth for "does this URL pass the check".
        winningStrategy:
          type: string
          nullable: true
          enum: [accept, suffix, query, null]
          description: |
            The strategy that succeeded, or `null` if none did. Values match
            `CheckStrategy` — the enum is inlined (with `null`) here because
            OpenAPI 3.0 cannot mark a `$ref` as nullable.
        attempts:
          type: array
          items: { $ref: "#/components/schemas/StrategyAttempt" }
          description: |
            One entry per strategy tried, in order (`accept`, `suffix`, `query`).
            The array ends early when a strategy wins.
        reasonCodes:
          type: array
          items: { $ref: "#/components/schemas/ReasonCode" }
          description: Zero or more machine-readable reasons explaining the result.
        durationMs:
          type: integer
          minimum: 0
          description: Wall-clock duration of the check in milliseconds.

    CheckSuccessResponse:
      type: object
      required: [ok, result]
      properties:
        ok:
          type: boolean
          enum: [true]
          description: Always `true` for this response shape.
        result:
          $ref: "#/components/schemas/CheckResult"

    ErrorResponse:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          enum: [false]
          description: Always `false` for this response shape.
        error:
          type: string
          description: |
            Short machine-readable error code:
              * `missing_url_param` — no `url` query parameter provided.
              * `method_not_allowed` — wrong HTTP method for the endpoint.
              * other — unexpected server-side errors include the raw message.
