Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/ext/hx-download.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,14 @@
function parseFilename(headers, url) {
let cd = headers.get('Content-Disposition');
if (cd) {
let match = cd.match(/filename\*?=['"]?(?:UTF-8'')?([^'";]+)/i);
if (match) return decodeURIComponent(match[1]);
// RFC 6266 / 5987: filename*=UTF-8''... takes precedence over filename=
let star = cd.match(/filename\*\s*=\s*(?:UTF-8'')?([^;]+)/i);
if (star) {
let raw = star[1].trim().replace(/^["']|["']$/g, '');
try { return decodeURIComponent(raw); } catch { /* fall through */ }
}
let ascii = cd.match(/filename\s*=\s*(?:UTF-8'')?(?:"([^"]+)"|([^;]+))/i);
if (ascii) return (ascii[1] || ascii[2]).trim();
}
return url.split('/').pop().split('?')[0] || 'download';
}
Expand Down
46 changes: 46 additions & 0 deletions test/tests/ext/hx-download.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,50 @@ describe('hx-download extension', function() {
assert.equal(filename, 'report.txt')
assert.equal(button.innerText, 'Download')
})

it('prefers RFC 5987 filename* over ASCII filename=', async function() {
let filename
URL.createObjectURL = () => 'blob:test'
URL.revokeObjectURL = () => {}
HTMLAnchorElement.prototype.click = function() {
filename = this.download
}

fetchMock.mockResponse('GET', '/report.xlsx', new Response('xlsx', {
headers: {
'Content-Disposition': 'attachment; filename="R_conciliation Inv priv_s.xlsx"; filename*=UTF-8\'\'R%C3%A9conciliation%20Inv%20priv%C3%A9s.xlsx',
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
}))
let button = createProcessedHTML('<button hx-get="/report.xlsx" hx-swap="download">Download</button>')
let complete = waitForEvent('htmx:download:complete')

button.click()
await complete

assert.equal(filename, 'Réconciliation Inv privés.xlsx')
})

it('uses filename* when it is the only parameter', async function() {
let filename
URL.createObjectURL = () => 'blob:test'
URL.revokeObjectURL = () => {}
HTMLAnchorElement.prototype.click = function() {
filename = this.download
}

fetchMock.mockResponse('GET', '/cafe.txt', new Response('cafe', {
headers: {
'Content-Disposition': "attachment; filename*=UTF-8''caf%C3%A9.txt",
'Content-Type': 'text/plain'
}
}))
let button = createProcessedHTML('<button hx-get="/cafe.txt" hx-swap="download">Download</button>')
let complete = waitForEvent('htmx:download:complete')

button.click()
await complete

assert.equal(filename, 'café.txt')
})
})
2 changes: 2 additions & 0 deletions www/src/content/extensions/15-hx-download.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ If the server returns `Content-Disposition: attachment`, the extension triggers
Content-Disposition: attachment; filename="report.pdf"
```

If both `filename=` and `filename*` are present, the RFC 5987 `filename*` value is used (so a UTF-8 name is not overwritten by the ASCII fallback).

### HX-Download header

When the backend cannot stream the file directly as the htmx response (e.g. it needs to redirect to a separate download endpoint), return an `HX-Download` header pointing to the file URL. The extension will fetch that URL as the download while htmx processes the original response body as a normal swap:
Expand Down
Loading