Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { describe, test, expect } from 'bun:test'
2import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'
3
4describe('rewriteHtmlPaths', () => {
5 const basePath = '/identifier/site/'
6
7 describe('absolute paths', () => {
8 test('rewrites absolute paths with leading slash', () => {
9 const html = '<img src="/image.png">'
10 const result = rewriteHtmlPaths(html, basePath, 'index.html')
11 expect(result).toBe('<img src="/identifier/site/image.png">')
12 })
13
14 test('rewrites nested absolute paths', () => {
15 const html = '<link href="/css/style.css">'
16 const result = rewriteHtmlPaths(html, basePath, 'index.html')
17 expect(result).toBe('<link href="/identifier/site/css/style.css">')
18 })
19 })
20
21 describe('relative paths from root document', () => {
22 test('rewrites relative paths with ./ prefix', () => {
23 const html = '<img src="./image.png">'
24 const result = rewriteHtmlPaths(html, basePath, 'index.html')
25 expect(result).toBe('<img src="/identifier/site/image.png">')
26 })
27
28 test('rewrites relative paths without prefix', () => {
29 const html = '<img src="image.png">'
30 const result = rewriteHtmlPaths(html, basePath, 'index.html')
31 expect(result).toBe('<img src="/identifier/site/image.png">')
32 })
33
34 test('rewrites relative paths with ../ (should stay at root)', () => {
35 const html = '<img src="../image.png">'
36 const result = rewriteHtmlPaths(html, basePath, 'index.html')
37 expect(result).toBe('<img src="/identifier/site/image.png">')
38 })
39 })
40
41 describe('relative paths from nested documents', () => {
42 test('rewrites relative path from nested document', () => {
43 const html = '<img src="./photo.jpg">'
44 const result = rewriteHtmlPaths(
45 html,
46 basePath,
47 'folder1/folder2/index.html'
48 )
49 expect(result).toBe(
50 '<img src="/identifier/site/folder1/folder2/photo.jpg">'
51 )
52 })
53
54 test('rewrites plain filename from nested document', () => {
55 const html = '<script src="app.js"></script>'
56 const result = rewriteHtmlPaths(
57 html,
58 basePath,
59 'folder1/folder2/index.html'
60 )
61 expect(result).toBe(
62 '<script src="/identifier/site/folder1/folder2/app.js"></script>'
63 )
64 })
65
66 test('rewrites ../ to go up one level', () => {
67 const html = '<img src="../image.png">'
68 const result = rewriteHtmlPaths(
69 html,
70 basePath,
71 'folder1/folder2/folder3/index.html'
72 )
73 expect(result).toBe(
74 '<img src="/identifier/site/folder1/folder2/image.png">'
75 )
76 })
77
78 test('rewrites multiple ../ to go up multiple levels', () => {
79 const html = '<link href="../../css/style.css">'
80 const result = rewriteHtmlPaths(
81 html,
82 basePath,
83 'folder1/folder2/folder3/index.html'
84 )
85 expect(result).toBe(
86 '<link href="/identifier/site/folder1/css/style.css">'
87 )
88 })
89
90 test('rewrites ../ with additional path segments', () => {
91 const html = '<img src="../assets/logo.png">'
92 const result = rewriteHtmlPaths(
93 html,
94 basePath,
95 'pages/about/index.html'
96 )
97 expect(result).toBe(
98 '<img src="/identifier/site/pages/assets/logo.png">'
99 )
100 })
101
102 test('handles complex nested relative paths', () => {
103 const html = '<script src="../../lib/vendor/jquery.js"></script>'
104 const result = rewriteHtmlPaths(
105 html,
106 basePath,
107 'pages/blog/post/index.html'
108 )
109 expect(result).toBe(
110 '<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>'
111 )
112 })
113
114 test('handles ../ going past root (stays at root)', () => {
115 const html = '<img src="../../../image.png">'
116 const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html')
117 expect(result).toBe('<img src="/identifier/site/image.png">')
118 })
119 })
120
121 describe('external URLs and special schemes', () => {
122 test('does not rewrite http URLs', () => {
123 const html = '<img src="http://example.com/image.png">'
124 const result = rewriteHtmlPaths(html, basePath, 'index.html')
125 expect(result).toBe('<img src="http://example.com/image.png">')
126 })
127
128 test('does not rewrite https URLs', () => {
129 const html = '<link href="https://cdn.example.com/style.css">'
130 const result = rewriteHtmlPaths(html, basePath, 'index.html')
131 expect(result).toBe(
132 '<link href="https://cdn.example.com/style.css">'
133 )
134 })
135
136 test('does not rewrite protocol-relative URLs', () => {
137 const html = '<script src="//cdn.example.com/script.js"></script>'
138 const result = rewriteHtmlPaths(html, basePath, 'index.html')
139 expect(result).toBe(
140 '<script src="//cdn.example.com/script.js"></script>'
141 )
142 })
143
144 test('does not rewrite data URIs', () => {
145 const html =
146 '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
147 const result = rewriteHtmlPaths(html, basePath, 'index.html')
148 expect(result).toBe(
149 '<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
150 )
151 })
152
153 test('does not rewrite mailto links', () => {
154 const html = '<a href="mailto:test@example.com">Email</a>'
155 const result = rewriteHtmlPaths(html, basePath, 'index.html')
156 expect(result).toBe('<a href="mailto:test@example.com">Email</a>')
157 })
158
159 test('does not rewrite tel links', () => {
160 const html = '<a href="tel:+1234567890">Call</a>'
161 const result = rewriteHtmlPaths(html, basePath, 'index.html')
162 expect(result).toBe('<a href="tel:+1234567890">Call</a>')
163 })
164 })
165
166 describe('different HTML attributes', () => {
167 test('rewrites src attribute', () => {
168 const html = '<img src="/image.png">'
169 const result = rewriteHtmlPaths(html, basePath, 'index.html')
170 expect(result).toBe('<img src="/identifier/site/image.png">')
171 })
172
173 test('rewrites href attribute', () => {
174 const html = '<a href="/page.html">Link</a>'
175 const result = rewriteHtmlPaths(html, basePath, 'index.html')
176 expect(result).toBe('<a href="/identifier/site/page.html">Link</a>')
177 })
178
179 test('rewrites action attribute', () => {
180 const html = '<form action="/submit"></form>'
181 const result = rewriteHtmlPaths(html, basePath, 'index.html')
182 expect(result).toBe('<form action="/identifier/site/submit"></form>')
183 })
184
185 test('rewrites data attribute', () => {
186 const html = '<object data="/document.pdf"></object>'
187 const result = rewriteHtmlPaths(html, basePath, 'index.html')
188 expect(result).toBe(
189 '<object data="/identifier/site/document.pdf"></object>'
190 )
191 })
192
193 test('rewrites poster attribute', () => {
194 const html = '<video poster="/thumbnail.jpg"></video>'
195 const result = rewriteHtmlPaths(html, basePath, 'index.html')
196 expect(result).toBe(
197 '<video poster="/identifier/site/thumbnail.jpg"></video>'
198 )
199 })
200
201 test('rewrites srcset attribute with single URL', () => {
202 const html = '<img srcset="/image.png 1x">'
203 const result = rewriteHtmlPaths(html, basePath, 'index.html')
204 expect(result).toBe(
205 '<img srcset="/identifier/site/image.png 1x">'
206 )
207 })
208
209 test('rewrites srcset attribute with multiple URLs', () => {
210 const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">'
211 const result = rewriteHtmlPaths(html, basePath, 'index.html')
212 expect(result).toBe(
213 '<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">'
214 )
215 })
216
217 test('rewrites srcset with width descriptors', () => {
218 const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">'
219 const result = rewriteHtmlPaths(html, basePath, 'index.html')
220 expect(result).toBe(
221 '<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">'
222 )
223 })
224
225 test('rewrites srcset with relative paths from nested document', () => {
226 const html = '<img srcset="../img1.png 1x, ../img2.png 2x">'
227 const result = rewriteHtmlPaths(
228 html,
229 basePath,
230 'folder1/folder2/index.html'
231 )
232 expect(result).toBe(
233 '<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">'
234 )
235 })
236 })
237
238 describe('quote handling', () => {
239 test('handles double quotes', () => {
240 const html = '<img src="/image.png">'
241 const result = rewriteHtmlPaths(html, basePath, 'index.html')
242 expect(result).toBe('<img src="/identifier/site/image.png">')
243 })
244
245 test('handles single quotes', () => {
246 const html = "<img src='/image.png'>"
247 const result = rewriteHtmlPaths(html, basePath, 'index.html')
248 expect(result).toBe("<img src='/identifier/site/image.png'>")
249 })
250
251 test('handles mixed quotes in same document', () => {
252 const html = '<img src="/img1.png"><link href=\'/style.css\'>'
253 const result = rewriteHtmlPaths(html, basePath, 'index.html')
254 expect(result).toBe(
255 '<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>'
256 )
257 })
258 })
259
260 describe('multiple rewrites in same document', () => {
261 test('rewrites multiple attributes in complex HTML', () => {
262 const html = `
263<!DOCTYPE html>
264<html>
265<head>
266 <link href="/css/style.css" rel="stylesheet">
267 <script src="/js/app.js"></script>
268</head>
269<body>
270 <img src="/images/logo.png" alt="Logo">
271 <a href="/about.html">About</a>
272 <form action="/submit">
273 <button type="submit">Submit</button>
274 </form>
275</body>
276</html>
277 `
278 const result = rewriteHtmlPaths(html, basePath, 'index.html')
279 expect(result).toContain('href="/identifier/site/css/style.css"')
280 expect(result).toContain('src="/identifier/site/js/app.js"')
281 expect(result).toContain('src="/identifier/site/images/logo.png"')
282 expect(result).toContain('href="/identifier/site/about.html"')
283 expect(result).toContain('action="/identifier/site/submit"')
284 })
285
286 test('handles mix of relative and absolute paths', () => {
287 const html = `
288 <img src="/abs/image.png">
289 <img src="./rel/image.png">
290 <img src="../parent/image.png">
291 <img src="https://external.com/image.png">
292 `
293 const result = rewriteHtmlPaths(
294 html,
295 basePath,
296 'folder1/folder2/page.html'
297 )
298 expect(result).toContain('src="/identifier/site/abs/image.png"')
299 expect(result).toContain(
300 'src="/identifier/site/folder1/folder2/rel/image.png"'
301 )
302 expect(result).toContain(
303 'src="/identifier/site/folder1/parent/image.png"'
304 )
305 expect(result).toContain('src="https://external.com/image.png"')
306 })
307 })
308
309 describe('edge cases', () => {
310 test('handles empty src attribute', () => {
311 const html = '<img src="">'
312 const result = rewriteHtmlPaths(html, basePath, 'index.html')
313 expect(result).toBe('<img src="">')
314 })
315
316 test('handles basePath without trailing slash', () => {
317 const html = '<img src="/image.png">'
318 const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html')
319 expect(result).toBe('<img src="/identifier/site/image.png">')
320 })
321
322 test('handles basePath with trailing slash', () => {
323 const html = '<img src="/image.png">'
324 const result = rewriteHtmlPaths(
325 html,
326 '/identifier/site/',
327 'index.html'
328 )
329 expect(result).toBe('<img src="/identifier/site/image.png">')
330 })
331
332 test('handles whitespace around equals sign', () => {
333 const html = '<img src = "/image.png">'
334 const result = rewriteHtmlPaths(html, basePath, 'index.html')
335 expect(result).toBe('<img src="/identifier/site/image.png">')
336 })
337
338 test('preserves query strings in URLs', () => {
339 const html = '<img src="/image.png?v=123">'
340 const result = rewriteHtmlPaths(html, basePath, 'index.html')
341 expect(result).toBe('<img src="/identifier/site/image.png?v=123">')
342 })
343
344 test('preserves hash fragments in URLs', () => {
345 const html = '<a href="/page.html#section">Link</a>'
346 const result = rewriteHtmlPaths(html, basePath, 'index.html')
347 expect(result).toBe(
348 '<a href="/identifier/site/page.html#section">Link</a>'
349 )
350 })
351
352 test('handles paths with special characters', () => {
353 const html = '<img src="/folder-name/file_name.png">'
354 const result = rewriteHtmlPaths(html, basePath, 'index.html')
355 expect(result).toBe(
356 '<img src="/identifier/site/folder-name/file_name.png">'
357 )
358 })
359 })
360
361 describe('real-world scenario', () => {
362 test('handles the example from the bug report', () => {
363 // HTML file at: /folder1/folder2/folder3/index.html
364 // Image at: /folder1/folder2/img.png
365 // Reference: src="../img.png"
366 const html = '<img src="../img.png">'
367 const result = rewriteHtmlPaths(
368 html,
369 basePath,
370 'folder1/folder2/folder3/index.html'
371 )
372 expect(result).toBe(
373 '<img src="/identifier/site/folder1/folder2/img.png">'
374 )
375 })
376
377 test('handles deeply nested static site structure', () => {
378 // A typical static site with nested pages and shared assets
379 const html = `
380<!DOCTYPE html>
381<html>
382<head>
383 <link href="../../css/style.css" rel="stylesheet">
384 <link href="../../css/theme.css" rel="stylesheet">
385 <script src="../../js/main.js"></script>
386</head>
387<body>
388 <img src="../../images/logo.png" alt="Logo">
389 <img src="./post-image.jpg" alt="Post">
390 <a href="../index.html">Back to Blog</a>
391 <a href="../../index.html">Home</a>
392</body>
393</html>
394 `
395 const result = rewriteHtmlPaths(
396 html,
397 basePath,
398 'blog/posts/my-post.html'
399 )
400
401 // Assets two levels up
402 expect(result).toContain('href="/identifier/site/css/style.css"')
403 expect(result).toContain('href="/identifier/site/css/theme.css"')
404 expect(result).toContain('src="/identifier/site/js/main.js"')
405 expect(result).toContain('src="/identifier/site/images/logo.png"')
406
407 // Same directory
408 expect(result).toContain(
409 'src="/identifier/site/blog/posts/post-image.jpg"'
410 )
411
412 // One level up
413 expect(result).toContain('href="/identifier/site/blog/index.html"')
414
415 // Two levels up
416 expect(result).toContain('href="/identifier/site/index.html"')
417 })
418 })
419})
420
421describe('isHtmlContent', () => {
422 test('identifies HTML by content type', () => {
423 expect(isHtmlContent('file.txt', 'text/html')).toBe(true)
424 expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe(
425 true
426 )
427 })
428
429 test('identifies HTML by .html extension', () => {
430 expect(isHtmlContent('index.html')).toBe(true)
431 expect(isHtmlContent('page.html', undefined)).toBe(true)
432 expect(isHtmlContent('/path/to/file.html')).toBe(true)
433 })
434
435 test('identifies HTML by .htm extension', () => {
436 expect(isHtmlContent('index.htm')).toBe(true)
437 expect(isHtmlContent('page.htm', undefined)).toBe(true)
438 })
439
440 test('handles case-insensitive extensions', () => {
441 expect(isHtmlContent('INDEX.HTML')).toBe(true)
442 expect(isHtmlContent('page.HTM')).toBe(true)
443 expect(isHtmlContent('File.HtMl')).toBe(true)
444 })
445
446 test('returns false for non-HTML files', () => {
447 expect(isHtmlContent('script.js')).toBe(false)
448 expect(isHtmlContent('style.css')).toBe(false)
449 expect(isHtmlContent('image.png')).toBe(false)
450 expect(isHtmlContent('data.json')).toBe(false)
451 })
452
453 test('returns false for files with no extension', () => {
454 expect(isHtmlContent('README')).toBe(false)
455 expect(isHtmlContent('Makefile')).toBe(false)
456 })
457})