Skip to content

Commit 416e5ee

Browse files
authored
veb: support markdown content negotiation, compliant with https://llmstxt.org/ (#25782)
1 parent 2a12104 commit 416e5ee

File tree

12 files changed

+220
-2
lines changed

12 files changed

+220
-2
lines changed

vlib/veb/README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,84 @@ curl -H "Accept-Encoding: gzip" -i http://localhost:8080/style.css
527527
to disable auto-compression completely. For optimal performance on read-only systems,
528528
pre-compress all files with `gzip -k`.
529529

530+
### Markdown content negotiation
531+
532+
veb can provide automatic content negotiation for markdown files, allowing you to serve
533+
markdown content when the client explicitly requests it via the `Accept` header.
534+
This is compliant to [llms.txt](https://llmstxt.org/) proposal and useful for documentations that can serve
535+
the same content in multiple formats, more efficiently to AI services using it.
536+
537+
**How it works:**
538+
539+
When `enable_markdown_negotiation` is enabled and a client sends `Accept: text/markdown`,
540+
veb will try to serve markdown variants in the following priority order:
541+
542+
1. `path.md` - Direct markdown file
543+
2. `path.html.md` - HTML-flavored markdown (for content that can be rendered as both)
544+
3. `path/index.html.md` - Directory index in markdown format
545+
546+
Without the `Accept: text/markdown` header, files are served normally based on their
547+
actual extension. This ensures backward compatibility - direct access to `.md` files
548+
always works regardless of the setting.
549+
550+
**Example:**
551+
552+
```v
553+
module main
554+
555+
import veb
556+
557+
pub struct Context {
558+
veb.Context
559+
}
560+
561+
pub struct App {
562+
veb.StaticHandler
563+
}
564+
565+
fn main() {
566+
mut app := &App{}
567+
568+
// Enable markdown content negotiation (disabled by default)
569+
app.enable_markdown_negotiation = true
570+
571+
// Serve files from the 'docs' directory
572+
app.handle_static('docs', true)!
573+
574+
veb.run[App, Context](mut app, 8080)
575+
}
576+
```
577+
578+
**Setup and testing:**
579+
580+
Create test files in the `docs` directory:
581+
```bash
582+
mkdir -p docs
583+
echo "# API Documentation" > docs/api.md
584+
echo "# User Guide" > docs/guide.html.md
585+
echo "<h1>HTML Version</h1>" > docs/api.html
586+
```
587+
588+
Run the server:
589+
```bash
590+
v run server.v
591+
```
592+
593+
Test content negotiation with cURL:
594+
```bash
595+
# Request markdown version with content negotiation - serves api.md
596+
curl -H "Accept: text/markdown" http://localhost:8080/api
597+
598+
# Direct access to .md file always works, regardless of Accept header
599+
curl http://localhost:8080/api.md
600+
601+
# Direct access to .html file
602+
curl http://localhost:8080/api.html
603+
604+
# Without Accept: text/markdown header - returns 404 since 'api' without extension doesn't exist
605+
curl http://localhost:8080/api
606+
```
607+
530608
## Middleware
531609

532610
Middleware in web development is (loosely defined) a hidden layer that sits between

vlib/veb/consts.v

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ pub const mime_types = {
9595
'.js': 'text/javascript'
9696
'.json': 'application/json'
9797
'.jsonld': 'application/ld+json'
98+
'.md': 'text/markdown'
9899
'.mid': 'audio/midi audio/x-midi'
99100
'.midi': 'audio/midi audio/x-midi'
100101
'.mjs': 'text/javascript'

vlib/veb/static_handler.v

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ pub mut:
3030
// Default: 1MB (1024*1024 bytes). Set to 0 to disable auto-compression completely (only pre-compressed .gz files will be served).
3131
// Note: On readonly filesystems, if .gz caching fails, compressed content is served from memory as fallback.
3232
static_gzip_max_size int = 1048576
33+
// enable_markdown_negotiation allows the client sends Accept: text/markdown, then the server will serve .md files, if any.
34+
// Default: false (for backward compatibility)
35+
enable_markdown_negotiation bool
3336
}
3437

3538
// scan_static_directory recursively scans `directory_path` and returns an error if

vlib/veb/tests/static_handler_test.v

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ fn run_app_test() {
6161

6262
app.handle_static('testdata', true) or { panic(err) }
6363

64+
// Enable markdown content negotiation for testing
65+
app.enable_markdown_negotiation = true
66+
6467
if _ := app.mount_static_folder_at('testdata', 'static') {
6568
assert true == false, 'should throw invalid mount path error'
6669
} else {
@@ -126,3 +129,86 @@ fn test_upper_case_mime_type() {
126129
assert x.status() == .ok
127130
assert x.body == 'body'
128131
}
132+
133+
// Content negotiation tests - Priority order
134+
// Tests verify: path.md > path.html.md > path/index.html.md
135+
136+
fn test_markdown_negotiation_priority_first() {
137+
// When all three variants exist, path.md (priority 1) is served
138+
config := http.FetchConfig{
139+
url: '${localserver}/about'
140+
header: http.new_header(key: .accept, value: 'text/markdown')
141+
}
142+
x := http.fetch(config)!
143+
144+
assert x.status() == .ok
145+
assert x.header.get(.content_type)! == 'text/markdown'
146+
assert x.body.contains('This is the about page in markdown format.')
147+
assert !x.body.contains('about.html.md variant')
148+
assert !x.body.contains('about/index.html.md variant')
149+
}
150+
151+
fn test_markdown_negotiation_priority_second() {
152+
// When only path.html.md exists (priority 2), it is served
153+
config := http.FetchConfig{
154+
url: '${localserver}/page'
155+
header: http.new_header(key: .accept, value: 'text/markdown')
156+
}
157+
x := http.fetch(config)!
158+
159+
assert x.status() == .ok
160+
assert x.header.get(.content_type)! == 'text/markdown'
161+
assert x.body.contains('# Page HTML Markdown')
162+
}
163+
164+
fn test_markdown_negotiation_directory_index() {
165+
// For directories, index.html.md is served when Accept: text/markdown
166+
config := http.FetchConfig{
167+
url: '${localserver}/sub_folder/'
168+
header: http.new_header(key: .accept, value: 'text/markdown')
169+
}
170+
x := http.fetch(config)!
171+
172+
assert x.status() == .ok
173+
assert x.header.get(.content_type)! == 'text/markdown'
174+
assert x.body.contains('# Index HTML Markdown')
175+
}
176+
177+
// Direct access tests - Verifies backward compatibility
178+
179+
fn test_markdown_direct_access() {
180+
// Without Accept header
181+
x_no_header := http.get('${localserver}/test.md')!
182+
assert x_no_header.status() == .ok
183+
assert x_no_header.header.get(.content_type)! == 'text/markdown'
184+
assert x_no_header.body.contains('# Test Markdown')
185+
186+
// With Accept: text/markdown header - same result
187+
config := http.FetchConfig{
188+
url: '${localserver}/test.md'
189+
header: http.new_header(key: .accept, value: 'text/markdown')
190+
}
191+
x_with_header := http.fetch(config)!
192+
assert x_with_header.status() == .ok
193+
assert x_with_header.header.get(.content_type)! == 'text/markdown'
194+
assert x_with_header.body.contains('# Test Markdown')
195+
}
196+
197+
fn test_markdown_variants_direct_access() {
198+
// All markdown variants remain accessible via their full paths
199+
x_html_md := http.get('${localserver}/about.html.md')!
200+
assert x_html_md.status() == .ok
201+
assert x_html_md.body.contains('about.html.md variant')
202+
203+
x_index := http.get('${localserver}/about/index.html.md')!
204+
assert x_index.status() == .ok
205+
assert x_index.body.contains('about/index.html.md variant')
206+
}
207+
208+
// Negative tests - Verifies correct behavior without Accept header
209+
210+
fn test_markdown_no_negotiation_without_header() {
211+
// Without Accept: text/markdown, content is not found for directories with no index.html
212+
x := http.get('${localserver}/about')!
213+
assert x.status() == .not_found
214+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# About HTML Markdown
2+
3+
This is about.html.md variant.

vlib/veb/tests/testdata/about.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# About Page
2+
3+
This is the about page in markdown format.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# About Index HTML Markdown
2+
3+
This is about/index.html.md variant.

vlib/veb/tests/testdata/page.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<html><body>HTML Page</body></html>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Page HTML Markdown
2+
3+
This is a page.html.md file.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Index HTML Markdown
2+
3+
This is an index.html.md file in a subfolder.

0 commit comments

Comments
 (0)