Skip to content

Commit e4f41c4

Browse files
committed
Automatically prepend <!DOCTYPE html> in res.html() helper
1 parent 6fa6030 commit e4f41c4

File tree

3 files changed

+235
-22
lines changed

3 files changed

+235
-22
lines changed

packages/fetch-router/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
This is the changelog for [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router). It follows [semantic versioning](https://semver.org/).
44

5+
## Unreleased
6+
7+
- `html()` response helper now automatically prepends `<!DOCTYPE html>` to the body if it is not already present
8+
59
## v0.9.0 (2025-11-18)
610

711
- Add `session` middleware for automatic management of `context.session` across requests

packages/fetch-router/src/lib/response-helpers/html.test.ts

Lines changed: 150 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,36 +8,172 @@ describe('html()', () => {
88
it('creates a Response with HTML content-type header', async () => {
99
let response = html('<h1>Hello</h1>')
1010
assert.equal(response.headers.get('Content-Type'), 'text/html; charset=UTF-8')
11-
assert.equal(await response.text(), '<h1>Hello</h1>')
11+
assert.equal(await response.text(), '<!DOCTYPE html><h1>Hello</h1>')
1212
})
1313

1414
it('preserves custom headers and status from init', async () => {
1515
let response = html('<h1>Hello</h1>', { headers: { 'X-Custom': 'a' }, status: 201 })
1616
assert.equal(response.headers.get('Content-Type'), 'text/html; charset=UTF-8')
1717
assert.equal(response.headers.get('X-Custom'), 'a')
1818
assert.equal(response.status, 201)
19+
assert.equal(await response.text(), '<!DOCTYPE html><h1>Hello</h1>')
1920
})
2021

2122
it('allows overriding Content-Type header', async () => {
2223
let response = html('<h1>Hello</h1>', { headers: { 'Content-Type': 'text/plain' } })
2324
assert.equal(response.headers.get('Content-Type'), 'text/plain')
2425
})
2526

26-
it('handles ReadableStream body without modification', async () => {
27-
let stream = new ReadableStream({
28-
start(controller) {
29-
controller.enqueue(new TextEncoder().encode('<h1>Stream</h1>'))
30-
controller.close()
31-
},
32-
})
33-
let response = html(stream)
34-
assert.equal(response.headers.get('Content-Type'), 'text/html; charset=UTF-8')
35-
assert.equal(await response.text(), '<h1>Stream</h1>')
36-
})
37-
3827
it('accepts SafeHtml from escape tag without re-escaping', async () => {
3928
let snippet = safeHtml`<strong>${'Hi'}</strong>`
4029
let response = html(snippet)
41-
assert.equal(await response.text(), '<strong>Hi</strong>')
30+
assert.equal(await response.text(), '<!DOCTYPE html><strong>Hi</strong>')
31+
})
32+
33+
describe('DOCTYPE prepending', () => {
34+
describe('string body', () => {
35+
it('prepends DOCTYPE to string body', async () => {
36+
let response = html('<html><body>Hello</body></html>')
37+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
38+
})
39+
40+
it('does not prepend DOCTYPE if already present', async () => {
41+
let response = html('<!DOCTYPE html><html><body>Hello</body></html>')
42+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
43+
})
44+
45+
it('handles DOCTYPE with leading whitespace', async () => {
46+
let response = html(' <!DOCTYPE html><html><body>Hello</body></html>')
47+
assert.equal(await response.text(), ' <!DOCTYPE html><html><body>Hello</body></html>')
48+
})
49+
50+
it('handles DOCTYPE case-insensitively', async () => {
51+
let response = html('<!doctype html><html><body>Hello</body></html>')
52+
assert.equal(await response.text(), '<!doctype html><html><body>Hello</body></html>')
53+
})
54+
})
55+
56+
describe('SafeHtml body', () => {
57+
it('prepends DOCTYPE to SafeHtml body', async () => {
58+
let snippet = safeHtml`<html><body>Hello</body></html>`
59+
let response = html(snippet)
60+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
61+
})
62+
63+
it('does not prepend DOCTYPE if already present in SafeHtml', async () => {
64+
let snippet = safeHtml`<!DOCTYPE html><html><body>Hello</body></html>`
65+
let response = html(snippet)
66+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
67+
})
68+
})
69+
70+
describe('Blob body', () => {
71+
it('prepends DOCTYPE to Blob body', async () => {
72+
let blob = new Blob(['<html><body>Hello</body></html>'], { type: 'text/html' })
73+
let response = html(blob)
74+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
75+
})
76+
77+
it('does not prepend DOCTYPE if already present in Blob', async () => {
78+
let blob = new Blob(['<!DOCTYPE html><html><body>Hello</body></html>'], {
79+
type: 'text/html',
80+
})
81+
let response = html(blob)
82+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
83+
})
84+
})
85+
86+
describe('ArrayBuffer body', () => {
87+
it('prepends DOCTYPE to ArrayBuffer body', async () => {
88+
let buffer = new TextEncoder().encode('<html><body>Hello</body></html>')
89+
let response = html(buffer.buffer)
90+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
91+
})
92+
93+
it('does not prepend DOCTYPE if already present in ArrayBuffer', async () => {
94+
let buffer = new TextEncoder().encode('<!DOCTYPE html><html><body>Hello</body></html>')
95+
let response = html(buffer.buffer)
96+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
97+
})
98+
})
99+
100+
describe('Uint8Array body', () => {
101+
it('prepends DOCTYPE to Uint8Array body', async () => {
102+
let buffer = new TextEncoder().encode('<html><body>Hello</body></html>')
103+
let response = html(buffer)
104+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
105+
})
106+
107+
it('does not prepend DOCTYPE if already present in Uint8Array', async () => {
108+
let buffer = new TextEncoder().encode('<!DOCTYPE html><html><body>Hello</body></html>')
109+
let response = html(buffer)
110+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
111+
})
112+
})
113+
114+
describe('DataView body', () => {
115+
it('prepends DOCTYPE to DataView body', async () => {
116+
let buffer = new TextEncoder().encode('<html><body>Hello</body></html>')
117+
let dataView = new DataView(buffer.buffer)
118+
let response = html(dataView)
119+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
120+
})
121+
122+
it('does not prepend DOCTYPE if already present in DataView', async () => {
123+
let buffer = new TextEncoder().encode('<!DOCTYPE html><html><body>Hello</body></html>')
124+
let dataView = new DataView(buffer.buffer)
125+
let response = html(dataView)
126+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
127+
})
128+
})
129+
130+
describe('ReadableStream body', () => {
131+
it('prepends DOCTYPE to ReadableStream body', async () => {
132+
let stream = new ReadableStream({
133+
start(controller) {
134+
controller.enqueue(new TextEncoder().encode('<html><body>Hello</body></html>'))
135+
controller.close()
136+
},
137+
})
138+
let response = html(stream)
139+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
140+
})
141+
142+
it('does not prepend DOCTYPE if already present in ReadableStream', async () => {
143+
let stream = new ReadableStream({
144+
start(controller) {
145+
controller.enqueue(
146+
new TextEncoder().encode('<!DOCTYPE html><html><body>Hello</body></html>'),
147+
)
148+
controller.close()
149+
},
150+
})
151+
let response = html(stream)
152+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
153+
})
154+
155+
it('handles empty ReadableStream', async () => {
156+
let stream = new ReadableStream({
157+
start(controller) {
158+
controller.close()
159+
},
160+
})
161+
let response = html(stream)
162+
assert.equal(await response.text(), '<!DOCTYPE html>')
163+
})
164+
165+
it('handles multi-chunk ReadableStream', async () => {
166+
let stream = new ReadableStream({
167+
start(controller) {
168+
controller.enqueue(new TextEncoder().encode('<html>'))
169+
controller.enqueue(new TextEncoder().encode('<body>'))
170+
controller.enqueue(new TextEncoder().encode('Hello</body></html>'))
171+
controller.close()
172+
},
173+
})
174+
let response = html(stream)
175+
assert.equal(await response.text(), '<!DOCTYPE html><html><body>Hello</body></html>')
176+
})
177+
})
42178
})
43179
})
Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { isSafeHtml, type SafeHtml } from '@remix-run/html-template'
22

3+
const DOCTYPE = '<!DOCTYPE html>'
4+
5+
type HtmlBody = string | SafeHtml | Blob | BufferSource | ReadableStream<Uint8Array>
6+
37
/**
4-
* A helper for working with HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)s.
8+
* A helper for working with HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)s
9+
* that ensures the response has a valid DOCTYPE and appropriate `Content-Type` header.
510
*
611
* @param body The body of the response.
712
* @param init (optional) The `ResponseInit` object for the response.
813
* @returns A `Response` object with a HTML body and the appropriate `Content-Type` header.
914
*/
10-
export function html(body: BodyInit | SafeHtml, init?: ResponseInit): Response {
11-
let payload: BodyInit
12-
if (isSafeHtml(body)) {
13-
payload = String(body)
14-
} else {
15-
payload = body
16-
}
15+
export function html(body: HtmlBody, init?: ResponseInit): Response {
16+
let payload: BodyInit = ensureDoctype(body)
1717

1818
let headers = new Headers(init?.headers)
1919
if (!headers.has('Content-Type')) {
@@ -22,3 +22,76 @@ export function html(body: BodyInit | SafeHtml, init?: ResponseInit): Response {
2222

2323
return new Response(payload, { ...init, headers })
2424
}
25+
26+
function ensureDoctype(body: HtmlBody): BodyInit {
27+
if (isSafeHtml(body)) {
28+
let str = String(body)
29+
return startsWithDoctype(str) ? str : DOCTYPE + str
30+
}
31+
32+
if (typeof body === 'string') {
33+
return startsWithDoctype(body) ? body : DOCTYPE + body
34+
}
35+
36+
if (body instanceof Blob) {
37+
return prependDoctypeToStream(body.stream())
38+
}
39+
40+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
41+
let text = new TextDecoder().decode(body)
42+
return startsWithDoctype(text) ? text : DOCTYPE + text
43+
}
44+
45+
if (body instanceof ReadableStream) {
46+
return prependDoctypeToStream(body)
47+
}
48+
49+
return body
50+
}
51+
52+
function startsWithDoctype(str: string): boolean {
53+
return /^\s*<!doctype html/i.test(str)
54+
}
55+
56+
function prependDoctypeToStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
57+
let doctypeBytes = new TextEncoder().encode(DOCTYPE)
58+
let reader = stream.getReader()
59+
60+
return new ReadableStream({
61+
async start(controller) {
62+
try {
63+
// Read first chunk to check for DOCTYPE
64+
let firstChunk = await reader.read()
65+
66+
if (firstChunk.done) {
67+
// Empty stream, just add DOCTYPE
68+
controller.enqueue(doctypeBytes)
69+
controller.close()
70+
return
71+
}
72+
73+
// Check if the first chunk starts with DOCTYPE
74+
let text = new TextDecoder().decode(firstChunk.value, { stream: true })
75+
if (startsWithDoctype(text)) {
76+
// Already has DOCTYPE, pass through
77+
controller.enqueue(firstChunk.value)
78+
} else {
79+
// Prepend DOCTYPE
80+
controller.enqueue(doctypeBytes)
81+
controller.enqueue(firstChunk.value)
82+
}
83+
84+
// Pass through remaining chunks
85+
while (true) {
86+
let { done, value } = await reader.read()
87+
if (done) break
88+
controller.enqueue(value)
89+
}
90+
91+
controller.close()
92+
} catch (error) {
93+
controller.error(error)
94+
}
95+
},
96+
})
97+
}

0 commit comments

Comments
 (0)