jekyll-agent-markdown 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -1
  3. data/README.md +231 -42
  4. data/docs/deployment.md +138 -0
  5. data/examples/cloudflare/src/worker.js +254 -0
  6. data/examples/cloudflare/wrangler.toml +8 -0
  7. data/examples/netlify/netlify/edge-functions/markdown-negotiation.ts +281 -0
  8. data/examples/netlify/netlify.toml +6 -0
  9. data/examples/nginx/negotiation.js +267 -0
  10. data/examples/nginx/nginx.conf +40 -0
  11. data/lib/jekyll/agent_markdown/agent_markdown_link_tag.rb +20 -0
  12. data/lib/jekyll/agent_markdown/author_metadata.rb +28 -0
  13. data/lib/jekyll/agent_markdown/collection_validator.rb +73 -0
  14. data/lib/jekyll/agent_markdown/configuration.rb +67 -6
  15. data/lib/jekyll/agent_markdown/date_metadata.rb +2 -8
  16. data/lib/jekyll/agent_markdown/document_exporter.rb +113 -0
  17. data/lib/jekyll/agent_markdown/document_header.rb +59 -0
  18. data/lib/jekyll/agent_markdown/document_settings.rb +170 -0
  19. data/lib/jekyll/agent_markdown/exported_document.rb +17 -0
  20. data/lib/jekyll/agent_markdown/generator.rb +78 -70
  21. data/lib/jekyll/agent_markdown/llms_document_index.rb +125 -0
  22. data/lib/jekyll/agent_markdown/llms_document_ordering.rb +40 -0
  23. data/lib/jekyll/agent_markdown/llms_full_renderer.rb +72 -0
  24. data/lib/jekyll/agent_markdown/llms_headings.rb +10 -15
  25. data/lib/jekyll/agent_markdown/llms_index_renderer.rb +102 -0
  26. data/lib/jekyll/agent_markdown/llms_text.rb +30 -0
  27. data/lib/jekyll/agent_markdown/metadata_footer.rb +26 -0
  28. data/lib/jekyll/agent_markdown/source_documents.rb +66 -0
  29. data/lib/jekyll/agent_markdown/version.rb +1 -1
  30. data/lib/jekyll-agent-markdown.rb +3 -0
  31. metadata +25 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aefdb0b717baa8666d581d48746d9b0afad4f730bbf25af6e534dc4a13800ec8
4
- data.tar.gz: c702b617f5b8b022f7f3ddc5fe05ca69eeeadc94ed8ecbb87d20b84406c33291
3
+ metadata.gz: 239761c2150364ff0d2966b3b7999f03a65c578bffeb144f2f69c4ad5da2c6cc
4
+ data.tar.gz: 9b7d54ce53a9f8cf68c6278a6811bd1405c89d21440a99920f64ac509dfdfc4c
5
5
  SHA512:
6
- metadata.gz: aef9d1e51132fd68ea7f6e0936addd32c5d69d1da0ef0a9c721df1076d6e9ee99680f7bd2b6c1005af3080b1fd0cf8091da49a68d423acfb8d587afa2ad64007
7
- data.tar.gz: db2b8abb8fdb9c4d622483bffc2fadf7c32d65b20b1f338d6c2e09bcdefe6a51c351d32cdd203befdb4a97ec111689833432f794592bb2ac81a78799e56b19c9
6
+ metadata.gz: e955bf1a3cc544b9b0e4c5b0fe76e0f95d2550ee57f2ccc73d31e0f2f817e460391833ff9a4cd8097e72bcdc94b901afdd017e7343c9613fb51f4b9b19e95328
7
+ data.tar.gz: ca1cb1d9b76c276a34d83a515ca0f1211014b43c27e05e6aee398884c483354b3825906ef7b527d739ec7a6b909abbbc81d2862d539e9d36fb1bca19194752e3
data/CHANGELOG.md CHANGED
@@ -1,4 +1,15 @@
1
- ## [Unreleased]
1
+ ## [0.4.0] - 2026-08-25
2
+
3
+ - Export Markdown-backed pages and selected output collections alongside posts.
4
+ - Add per-document export, index, section, optional, and document-header settings.
5
+ - Curate `llms.txt` with sections and descriptions, and add optional `llms-full.txt` output.
6
+ - Register `{% agent_markdown_link %}` and expose `agent_markdown_url` for custom layouts.
7
+ - Package Cloudflare Workers, Netlify Edge, and nginx+njs negotiation recipes.
8
+ - Preserve existing posts-only defaults, collision handling, raw Markdown, and metadata output.
9
+
10
+ ## [0.3.1] - 2026-08-20
11
+
12
+ - Include the site author in each exported article's metadata footer on the same line as published and updated dates.
2
13
 
3
14
  ## [0.3.0] - 2026-08-20
4
15
 
data/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # jekyll-agent-markdown
2
2
 
3
- Export Jekyll posts as raw Markdown next to their normal HTML output, and publish a small `llms.txt` index.
3
+ Export Jekyll content as Markdown siblings and curated `llms.txt` and `llms-full.txt` indexes.
4
+
5
+ AI agents and LLM crawlers read Markdown better than rendered HTML.
6
+ This plugin publishes a Markdown copy of each exported post, page, and collection document next to its HTML.
7
+ It also writes the index files described by the [llms.txt convention](https://llmstxt.org).
4
8
 
5
9
  ## Installation
6
10
 
7
- Add the gem to your site's `Gemfile` plugin group:
11
+ Add the gem to your site's `Gemfile`.
8
12
 
9
13
  ```ruby
10
14
  group :jekyll_plugins do
@@ -12,44 +16,68 @@ group :jekyll_plugins do
12
16
  end
13
17
  ```
14
18
 
15
- Then run `bundle install` and enable it in `_config.yml`:
19
+ Install dependencies.
20
+
21
+ ```sh
22
+ bundle install
23
+ ```
24
+
25
+ The `:jekyll_plugins` group already enables the plugin.
26
+ If you keep the gem outside that group, list it under `plugins:` instead.
16
27
 
17
28
  ```yaml
18
29
  plugins:
19
30
  - jekyll-agent-markdown
20
31
  ```
21
32
 
22
- ## Configuration
33
+ ## Quick Start
23
34
 
24
- Both exports are enabled by default:
35
+ Configure the sources you want and build the site.
25
36
 
26
37
  ```yaml
38
+ url: https://example.com
39
+ collections:
40
+ guides:
41
+ output: true
27
42
  agent_markdown:
28
- posts: true
29
- llms_txt: true
30
- sort: desc
31
- include_author: true
32
- include_dates: true
43
+ pages: true
44
+ collections:
45
+ - guides
33
46
  ```
34
47
 
35
- `posts`, `llms_txt`, `include_author`, and `include_dates` accept `true`, `false`, or one of the false-style strings `"false"`, `"no"`, and `"off"` (case-insensitively). `sort` accepts `asc` or `desc` and defaults to `desc`, ordering the `llms.txt` article list by normalized published date. Posts with a missing, incomplete, or invalid published date are listed last in their existing order. Unknown keys and other values stop the build with a configuration error instead of being silently ignored. An explicit per-post `agent_markdown` value follows the boolean-setting rules, so a mistyped opt-out cannot publish raw source by accident.
48
+ ```sh
49
+ bundle exec jekyll build
50
+ ```
36
51
 
37
- By default, `llms.txt` includes the blog author's name from either a scalar Jekyll setting (`author: Jane Doe`) or a mapping (`author: { name: Jane Doe }`). If no author name is configured, the line is omitted. Set `include_author: false` under `agent_markdown` to omit it explicitly.
52
+ The build now writes:
38
53
 
39
- Set `url` to your site's absolute HTTP(S) URL; the `llms.txt` index uses it to generate absolute article links:
54
+ - `/llms.txt`, an index of the exported documents
55
+ - a Markdown sibling for every post, Markdown page, and `guides` document, such as `/guides/start.md` next to `/guides/start/`
40
56
 
41
- ```yaml
42
- url: https://example.com
43
- ```
57
+ ## Usage
58
+
59
+ Enable the sources you want.
44
60
 
45
- When `url` is missing, contains credentials, a query, or a fragment, or is not an absolute HTTP(S) URL, `llms.txt` is skipped with a warning and the rest of the build continues. The build only fails when you set `llms_txt` explicitly in `_config.yml`, since that states clear intent.
61
+ - Posts export by default.
62
+ - Markdown-backed pages need `pages: true`.
63
+ - Markdown collection documents need `collections` and `output: true`.
64
+ - Non-Markdown pages, non-Markdown collection documents, and generated pages are ignored.
65
+ - A missing or non-output collection fails the build.
66
+ - A selected collection with a Markdown document lacking a public document URL fails.
67
+ - The reserved `posts` collection cannot appear in `collections`.
46
68
 
47
- Set `agent_markdown: false` to disable both exports, or `agent_markdown: true` to enable both explicitly (same as leaving it unset). The strings `"false"`, `"no"`, and `"off"` are also treated as false, case-insensitively.
69
+ Use site-level `agent_markdown: true` to enable the defaults.
70
+ Use site-level `agent_markdown: false` to disable every export.
71
+ Boolean settings accept `true` and `false` scalars.
72
+ They also accept `"false"`, `"no"`, and `"off"` as false.
73
+ False-style strings are case-insensitive and work in front matter.
74
+ Unknown keys, wrong types, and invalid combinations fail the build.
75
+ Duplicate collection names also fail the build.
48
76
 
49
- Markdown sibling paths are deterministic:
77
+ Markdown siblings follow the permalink.
50
78
 
51
79
  | Post URL | Markdown URL |
52
- |---|---|
80
+ | --- | --- |
53
81
  | `/foo/` | `/foo.md` |
54
82
  | `/foo.html`, `/foo.htm` | `/foo.md` |
55
83
  | `/foo.html/` | `/foo.html.md` |
@@ -57,34 +85,71 @@ Markdown sibling paths are deterministic:
57
85
  | `/foo` | `/foo.md` |
58
86
  | `/` | `/index.md` |
59
87
 
60
- Extensions are matched case-insensitively, and percent-encoded aliases are compared by their final decoded destination. Destination ownership also treats case-only and Unicode-normalization aliases as equivalent on every platform, keeping builds portable across filesystems. File-versus-directory conflicts are reserved as well. Before adding an export, the plugin checks every page, static file, and writable collection document already known to Jekyll. The existing destination owner wins; later post exports are skipped with a warning and omitted from `llms.txt`. A committed `llms.txt` wins in the same way.
61
-
62
- The generated file preserves the post's original Markdown body as its initial content, with no front matter, HTML conversion, or Liquid rendering. Liquid tags and directives such as `{{ site.title }}` are published literally. By default, the plugin appends the post's published `date` and optional `last_modified_at` value as a small footer:
88
+ Each post export ends with a metadata footer built from the available dates and the site author.
89
+ Disable it with `include_dates: false` and `include_author: false`.
63
90
 
64
91
  ```text
92
+ Post body.
93
+
65
94
  ---
66
- Published at: 2026-01-01 | Updated at: 2026-02-03
95
+ Published at: 2026-01-01 | Updated at: 2026-01-05 | Author: Example Author
67
96
  ```
68
97
 
69
- Each available date is formatted as `YYYY-MM-DD`. String values must contain an explicit year, month, and day; missing, incomplete, and invalid values and their labels are omitted. An empty post contains only the metadata line, without a leading `---` that could be mistaken for front matter. Set `include_dates: false` to omit date metadata from both Markdown exports and `llms.txt`, leaving the body-only export shape used before date metadata was introduced.
70
-
71
- Exclude one post with front matter:
98
+ Set per-document settings in front matter.
72
99
 
73
100
  ```yaml
74
101
  agent_markdown: false
75
102
  ```
76
103
 
77
- Expose the alternate representation from a layout:
104
+ ```yaml
105
+ agent_markdown:
106
+ export: true
107
+ index: true
108
+ section: Documentation
109
+ optional: false
110
+ include_document_header: true
111
+ ```
78
112
 
79
- ```liquid
80
- {% if page.agent_markdown_url %}
81
- <link rel="alternate" type="text/markdown" href="{{ page.agent_markdown_url | relative_url }}">
82
- {% endif %}
113
+ Use `optional: true` without `section` for the `Optional` section.
114
+ Front matter cannot enable a globally excluded source kind.
115
+ `export: false` also disables `index`.
116
+ Do not combine `export: false` with `index: true`.
117
+ Do not combine `optional: true` with `section`.
118
+
119
+ Add `include_document_header: true` to prepend a small header.
120
+ That setting needs a valid absolute `url`.
121
+ A valid `url` uses HTTP(S) without credentials, a query, or a fragment.
122
+
123
+ The header holds the title, the front matter description when present, and a `Source:` link to the HTML page.
124
+ The body below the divider stays untouched.
125
+
126
+ ```markdown
127
+ # About this site
128
+
129
+ A one-line description from front matter
130
+
131
+ Source: https://example.com/about/
132
+
133
+ ---
134
+
135
+ ## What we do
136
+
137
+ Raw page body.
83
138
  ```
84
139
 
85
- The conditional omits the link for opted-out and colliding posts. The `relative_url` filter adds `baseurl` for sites deployed below the domain root.
140
+ Use `llms_txt: true` for the compact index.
141
+ `llms.txt` has two layouts.
142
+ With the posts-only defaults it keeps the original single-list layout, byte-for-byte identical with releases before 0.4.0.
143
+ Enabling pages, collections, or descriptions, or using `section` or `optional` in front matter, switches to the sectioned layout shown below.
86
144
 
87
- The generated `/llms.txt` is a compact index, for example:
145
+ Add `include_descriptions: true` for sanitized descriptions.
146
+ Descriptions fall back to the document excerpt.
147
+ Use `sort: asc` or `sort: desc` for each section.
148
+ Set `include_author: false` or `include_dates: false` to trim metadata.
149
+ Mark a document `optional: true` to move it under `Optional`.
150
+ Default sections appear before custom sections.
151
+ Custom sections keep their first-occurrence order.
152
+ The `Optional` section always appears last.
88
153
 
89
154
  ```text
90
155
  # Example Site
@@ -95,24 +160,148 @@ Author: Example Author
95
160
 
96
161
  ## Articles
97
162
 
98
- > Posts only. Pages and collections are not included.
99
-
100
163
  - [First article](https://example.com/articles/first.md) | Published at: 2026-01-01
164
+
165
+ ## Pages
166
+
167
+ - [About](https://example.com/about.md): About page
168
+
169
+ ## Guides
170
+
171
+ - [Getting started](https://example.com/guides/start.md)
172
+
173
+ ## Optional
174
+
175
+ - [Reference](https://example.com/reference.md)
101
176
  ```
102
177
 
103
- ## Deployment notes
178
+ Use `llms_full_txt: true` for the full index.
179
+ It uses the same document selection as `llms.txt`.
180
+ It still works when `llms_txt: false`.
181
+ Each entry expands into a full Markdown document block.
182
+ Set `url` to a valid absolute HTTP(S) URL first.
183
+ The generator warns once when the rendered file exceeds 1 MiB.
184
+
185
+ ```text
186
+ # Example Site
187
+
188
+ ## Articles
104
189
 
105
- Configure your host to serve generated `.md` files as `Content-Type: text/markdown; charset=utf-8` and, when appropriate, `X-Robots-Tag: noindex`. This gem writes files only; it cannot set HTTP response headers. Generated files refuse to write through symlinks inside the destination. GitHub Pages safe mode may not run arbitrary plugins, so use a separate build/deploy pipeline there.
190
+ ### [First article](https://example.com/articles/first.md)
106
191
 
107
- ## v0.1.0 limitations
192
+ Source: https://example.com/articles/first/
108
193
 
109
- Only posts are exported. Pages and custom collections, custom Markdown transformations or templates, front-matter allowlisting, automatic response headers, sitemaps, and richer article metadata are intentionally deferred.
194
+ Body of the first article.
195
+ ```
196
+
197
+ Add the alternate link from a layout.
198
+
199
+ ```liquid
200
+ {% agent_markdown_link %}
201
+ ```
202
+
203
+ It renders a discovery link for the current page.
204
+
205
+ ```html
206
+ <link rel="alternate" type="text/markdown" href="/about.md">
207
+ ```
208
+
209
+ The tag is baseurl-aware.
210
+ It is empty for opted-out or collided documents.
211
+ Use `page.agent_markdown_url` when custom markup needs the generated path.
212
+
213
+ On collision, existing destinations win.
214
+ Generated claims run through posts, then pages, then configured collections.
215
+ Later exports skip with a warning and receive no `agent_markdown_url`.
216
+ They are also omitted from both indexes.
217
+ A committed `llms.txt` or `llms-full.txt` also wins on collision.
218
+ Collision detection only sees files Jekyll knows about when this plugin runs at `priority :low`.
219
+ Another plugin generating files at `priority :lowest` runs later and can still claim the same destination.
220
+
221
+ ## Options
222
+
223
+ Boolean settings accept `true`, `false`, and false-style strings.
224
+ Unknown keys and invalid values raise `Jekyll::Errors::FatalException`.
225
+
226
+ These are the complete defaults.
227
+
228
+ ```yaml
229
+ agent_markdown:
230
+ posts: true
231
+ pages: false
232
+ collections: []
233
+ llms_txt: true
234
+ llms_full_txt: false
235
+ include_descriptions: false
236
+ include_document_header: false
237
+ include_author: true
238
+ include_dates: true
239
+ sort: desc
240
+ ```
241
+
242
+ | Option | Default | Notes |
243
+ | --- | --- | --- |
244
+ | `posts` | `true` | Export posts. |
245
+ | `pages` | `false` | Export pages. |
246
+ | `collections` | `[]` | Export output collections named here. |
247
+ | `llms_txt` | `true` | Write `llms.txt`. |
248
+ | `llms_full_txt` | `false` | Write `llms-full.txt`. |
249
+ | `include_descriptions` | `false` | Append sanitized descriptions to `llms.txt`. |
250
+ | `include_document_header` | `false` | Prepend document headers to Markdown exports. |
251
+ | `include_author` | `true` | Include author metadata. |
252
+ | `include_dates` | `true` | Include published and updated dates. |
253
+ | `sort` | `desc` | Order each section by normalized publish date. |
254
+
255
+ The default `llms_txt` warns and skips when `url` is missing or invalid.
256
+ Explicitly configuring `llms_txt` makes an invalid `url` fatal.
257
+ `llms_full_txt` needs a valid absolute `url` whenever enabled.
258
+ Document headers enforce the same URL rules only when enabled.
259
+
260
+ ## Deployment
261
+
262
+ The plugin writes static files; any host can serve them as-is.
263
+ Content negotiation is optional and happens at the host, not in Ruby.
264
+ A negotiating host serves the Markdown sibling when a request prefers `text/markdown`.
265
+ It falls back to HTML when the sibling does not exist.
266
+
267
+ [examples/](examples/) ships dependency-free recipes that share one negotiation contract:
268
+
269
+ - [Cloudflare Workers](examples/cloudflare)
270
+ - [Netlify Edge Functions](examples/netlify)
271
+ - [nginx with njs](examples/nginx)
272
+
273
+ All three negotiate only `GET` and `HEAD` requests and skip static assets.
274
+ See the [deployment guide](docs/deployment.md) for the full negotiation contract and host setup.
275
+
276
+ ## Compatibility
277
+
278
+ Ruby 3.2 or newer is required.
279
+ Jekyll 4.3 or newer is required, but Jekyll 5 is unsupported.
280
+ The plugin writes files only.
281
+ There is no automatic injection into rendered HTML.
282
+ Layouts must invoke `{% agent_markdown_link %}` explicitly.
283
+ It does not handle content negotiation.
284
+ It does not generate crawler policy, analytics, middleware, response headers, or crawler permissions.
285
+ It publishes raw Markdown without rendering Liquid.
286
+ Post exports append enabled date and author metadata.
287
+ Document headers prepend content only when enabled.
288
+ Destination ownership is normalized across case, Unicode normalization, encoded aliases, and file-versus-directory conflicts.
289
+
290
+ ### Limitations
291
+
292
+ Page and collection exports require authored Markdown and public URLs.
293
+ Generated pages and non-Markdown page or collection sources are ignored.
294
+ Raw Liquid may expose source directives to Markdown readers.
295
+ GitHub Pages safe mode may require an external build pipeline.
110
296
 
111
297
  ## Development
112
298
 
113
- Run `bundle exec rake` after `bundle install` to execute both Minitest and RuboCop.
299
+ Run the tests and RuboCop.
114
300
 
115
- The entry point explicitly loads the plugin's small implementation tree without an additional runtime loader dependency. Implementation files also declare their own prerequisites so public constants can be required directly in isolation.
301
+ ```sh
302
+ bundle install
303
+ bundle exec rake
304
+ ```
116
305
 
117
306
  ## License
118
307
 
@@ -0,0 +1,138 @@
1
+ # Deploy HTML and Markdown variants
2
+
3
+ `jekyll-agent-markdown` writes files; the web host decides how those files are represented over HTTP. This guide defines one negotiation contract and provides dependency-free starting points for Cloudflare Workers, Netlify Edge Functions, and nginx with njs.
4
+
5
+ The packaged examples assume Jekyll's pretty permalinks: an HTML URL such as `/articles/example/` is backed by `_site/articles/example/index.html`, while this plugin writes `_site/articles/example.md`. They also map `/` to `/index.md`, remove `.html` or `.htm` before adding `.md`, and map an extensionless `/example` to `/example.md`. Adapt the path helpers if the site uses a different permalink scheme. Build the site first and point each example's `_site` setting at that output.
6
+
7
+ ## Negotiation contract
8
+
9
+ Apply negotiation to page URLs, not to CSS, JavaScript, images, fonts, or other static assets.
10
+
11
+ | Request | Result |
12
+ | --- | --- |
13
+ | Missing `Accept` or wildcard-only `Accept: */*` | HTML |
14
+ | A more specific media range | Its `q` value overrides a less-specific wildcard for that representation |
15
+ | Different effective HTML and Markdown quality | The supported representation with the larger quality wins |
16
+ | At equal quality | Markdown wins only when the request explicitly names `text/markdown`; otherwise HTML wins |
17
+ | A representation with effective `q=0` | Excluded, even if a less-specific wildcard has a positive quality |
18
+ | Neither `text/html` nor `text/markdown` acceptable | `406 Not Acceptable` |
19
+ | A negotiated Markdown variant that does not exist | HTML when HTML is acceptable; otherwise the Markdown `404` |
20
+ | A URL ending in `.md` | Markdown regardless of `Accept`; this URL is not negotiated |
21
+ | A method other than `GET` or `HEAD` | Not negotiated |
22
+
23
+ The examples calculate an effective quality independently for `text/html` and `text/markdown`. Media-range specificity is applied before explicit `q` values are compared: an exact type is more specific than `text/*`, which is more specific than `*/*`. Supported representation parameters, currently `charset=utf-8`, add specificity. Invalid quality values make that range unacceptable instead of silently broadening access.
24
+
25
+ This follows HTTP's [`Accept` precedence and quality rules](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1). Returning 406 is an intentional server policy when none of the available representations is acceptable; HTTP also permits a server to disregard the preference, but these examples do not. See [406 Not Acceptable](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.7).
26
+
27
+ ### Representation availability
28
+
29
+ Negotiation selects among representations that actually exist. This plugin exports posts by default, so on a site that has not enabled `pages` or `collections` most page URLs have no `.md` sibling. A request preferring Markdown for such a URL must not become a 404 when the host could have served HTML.
30
+
31
+ Each example therefore fetches the selected variant and, when a negotiated Markdown variant returns 404 and HTML is acceptable, retries the HTML variant and labels the response as HTML. Two cases deliberately do not fall back: an explicit `.md` URL, because that URL names one representation rather than negotiating, and a request that excluded HTML with `q=0`, because no acceptable representation exists.
32
+
33
+ ### Request methods
34
+
35
+ Negotiation applies to `GET` and `HEAD`. The nginx example rejects other methods with `405 Method Not Allowed` and an `Allow` header, because an njs subrequest is always a `GET` and would otherwise turn a `POST` into a static read of the page body. The Cloudflare and Netlify examples hand non-page and non-matching requests to the platform's own asset handler, which applies its method rules.
36
+
37
+ ## Response contract
38
+
39
+ Every successful variant has an explicit UTF-8 content type:
40
+
41
+ ```http
42
+ Content-Type: text/html; charset=utf-8
43
+ ```
44
+
45
+ or:
46
+
47
+ ```http
48
+ Content-Type: text/markdown; charset=utf-8
49
+ ```
50
+
51
+ Every response selected from `Accept`, including a 406 response, carries:
52
+
53
+ ```http
54
+ Vary: Accept
55
+ ```
56
+
57
+ `Vary` prevents a shared cache from reusing an HTML response for a Markdown request or the reverse. Preserve existing `Vary` fields when adding `Accept`; see the HTTP [`Vary` definition](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.5).
58
+
59
+ HTML and Markdown advertise each other with a [`Link` response header](https://www.rfc-editor.org/rfc/rfc8288.html). For example:
60
+
61
+ ```http
62
+ Link: </articles/example.md>; rel="alternate"; type="text/markdown"
63
+ ```
64
+
65
+ and the Markdown response advertises HTML:
66
+
67
+ ```http
68
+ Link: </articles/example/>; rel="alternate"; type="text/html"
69
+ ```
70
+
71
+ The explicit `.md` URL always returns `text/markdown; charset=utf-8`, even if a caller sends `Accept: text/html`. The `text/markdown` media type is registered by [RFC 7763](https://www.rfc-editor.org/rfc/rfc7763.html).
72
+
73
+ ## Cloudflare Workers
74
+
75
+ Copy [`examples/cloudflare`](../examples/cloudflare) into the deployment project, or adapt its paths. `wrangler.toml` binds the Jekyll output as `ASSETS` and sets `run_worker_first = true`; without that setting, a matching static asset can bypass negotiation. The Worker passes non-page assets straight through and uses `env.ASSETS.fetch()` for selected variants.
76
+
77
+ The relevant primary documentation is Cloudflare's [static assets binding and `run_worker_first` reference](https://developers.cloudflare.com/workers/static-assets/binding/). Set `compatibility_date` to the deployment project's chosen current date and confirm the `_site` path before deploying.
78
+
79
+ ## Netlify Edge Functions
80
+
81
+ Copy [`examples/netlify/netlify.toml`](../examples/netlify/netlify.toml) and [`markdown-negotiation.ts`](../examples/netlify/netlify/edge-functions/markdown-negotiation.ts) to the corresponding locations in the site repository. The catch-all declaration is minimal and easy to test; narrow the declared paths or add exclusions on a large site to avoid invoking the function for assets that it only passes through.
82
+
83
+ The Edge Function calls `context.next()` for the requested HTML or explicit Markdown asset. When a page URL negotiates Markdown, it fetches the `.md` sibling; that same-site request runs the Edge Function again but terminates on the explicit `.md` branch. Review Netlify's [Edge Functions API](https://docs.netlify.com/build/edge-functions/api/) and [declaration and request-chain rules](https://docs.netlify.com/build/edge-functions/declarations/) before combining this example with other rewrites or Edge Functions.
84
+
85
+ ## nginx with njs
86
+
87
+ Install nginx's JavaScript module, place [`negotiation.js`](../examples/nginx/negotiation.js) at `/etc/nginx/njs/negotiation.js`, adapt the `_site` filesystem path in [`nginx.conf`](../examples/nginx/nginx.conf), then validate and reload nginx. Current njs recommends the QuickJS engine; the example selects it with `js_engine qjs`. The example also sets `include mime.types` and a binary `default_type`, without which its pass-through location would serve CSS, JavaScript, fonts, and images as `text/plain`.
88
+
89
+ The public location delegates page requests to `js_content`. The handler performs an internal subrequest to a private static location, sets the variant headers, and returns the buffer. `subrequest_output_buffer_size` must exceed the largest generated page, so change the example's `10m` ceiling to fit the site's real output and memory budget. Consult the official [`ngx_http_js_module` directives](https://nginx.org/en/docs/http/ngx_http_js_module.html) and [njs request/subrequest API](https://nginx.org/en/docs/njs/reference.html).
90
+
91
+ ## Pre-release verification
92
+
93
+ Replace the sample origin and article paths, deploy to a staging hostname, and run this curl matrix. Use GET with discarded bodies so the check exercises the same response path as production clients.
94
+
95
+ ```sh
96
+ origin=https://staging.example.com
97
+ article=/articles/example/
98
+ markdown=/articles/example.md
99
+ page=/about/
100
+
101
+ curl -sS -D - -o /dev/null -H 'Accept:' "$origin$article"
102
+ curl -sS -D - -o /dev/null -H 'Accept: */*' "$origin$article"
103
+ curl -sS -D - -o /dev/null -H 'Accept: text/markdown' "$origin$article"
104
+ curl -sS -D - -o /dev/null -H 'Accept: text/html, text/markdown' "$origin$article"
105
+ curl -sS -D - -o /dev/null -H 'Accept: text/*;q=0.8, text/html;q=0.4' "$origin$article"
106
+ curl -sS -D - -o /dev/null -H 'Accept: text/markdown;q=0, */*;q=0.8' "$origin$article"
107
+ curl -sS -D - -o /dev/null -H 'Accept: application/json' "$origin$article"
108
+ curl -sS -D - -o /dev/null -H 'Accept: text/markdown;q="1"' "$origin$article"
109
+ curl -sS -D - -o /dev/null -H 'Accept: text/html' "$origin$markdown"
110
+ curl -sS -D - -o /dev/null -H 'Accept: text/markdown' "$origin$page"
111
+ curl -sS -D - -o /dev/null -H 'Accept: text/markdown, text/html;q=0' "$origin$page"
112
+ ```
113
+
114
+ Expected results, in order:
115
+
116
+ | Case | Status | Content-Type |
117
+ | --- | --- | --- |
118
+ | Missing `Accept` | 200 | `text/html; charset=utf-8` |
119
+ | Wildcard-only | 200 | `text/html; charset=utf-8` |
120
+ | Explicit Markdown | 200 | `text/markdown; charset=utf-8` |
121
+ | Equal explicit HTML and Markdown | 200 | `text/markdown; charset=utf-8` |
122
+ | More-specific HTML is lower quality than `text/*` Markdown | 200 | `text/markdown; charset=utf-8` |
123
+ | Exact Markdown exclusion overrides wildcard | 200 | `text/html; charset=utf-8` |
124
+ | Neither supported | 406 | `text/plain; charset=utf-8` |
125
+ | Invalid quoted quality | 406 | `text/plain; charset=utf-8` |
126
+ | Explicit `.md` despite HTML preference | 200 | `text/markdown; charset=utf-8` |
127
+ | Page with no Markdown sibling | 200 | `text/html; charset=utf-8` |
128
+ | Same page with HTML excluded | 404 | the host's own error representation |
129
+
130
+ For every negotiated 200 response, also verify `Vary: Accept` and the appropriate alternate `Link`. Check the explicit Markdown URL's HTML `Link`. Finally, submit representative public staging URLs to the [acceptmarkdown.com public scanner](https://acceptmarkdown.com/public) and retain its results with the release evidence. The scanner is a useful external pre-release check, not a substitute for the matrix or host logs.
131
+
132
+ ## Crawler policy is separate
133
+
134
+ `Content-Signal` is an evolving crawler-policy convention, not a transport requirement for HTML/Markdown negotiation. Whether a site permits search, AI input, or model training is a decision for the site owner, informed by the site's content rights and legal advice. The examples deliberately do not generate crawler permissions. Review the current [Content Signals project](https://contentsignals.org/) and the behavior of the crawlers that matter to the site, then write and audit an owner-approved policy; do not copy a generated crawler policy without that review.
135
+
136
+ ## Runtime gaps to validate
137
+
138
+ These packaged examples statically lock the shared HTTP behavior, but they are not deployed by this gem. Before production, validate host-specific routing, redirects, custom error pages, caching, compression, conditional requests, range requests, base paths, non-pretty permalinks, large response bodies, and interaction with any existing middleware. Confirm that a missing Markdown sibling produces the site's intended error rather than mislabeled content, and monitor 406 rates after launch for clients with unexpected `Accept` headers.