@uniweb/press 0.3.0 → 0.4.1
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.
- package/README.md +87 -90
- package/package.json +2 -4
- package/src/adapters/dispatch.js +55 -10
- package/src/adapters/docx.js +40 -13
- package/src/adapters/epub.js +624 -0
- package/src/adapters/pagedjs.js +295 -0
- package/src/adapters/typst.js +16 -1
- package/src/assets/fetch.js +155 -0
- package/src/compileRegistrations.js +289 -1
- package/src/index.js +1 -0
- package/src/ir/attributes.js +18 -0
- package/src/ir/compile.js +93 -25
- package/src/useDocumentCompile.js +31 -1
- package/src/vite-plugin-typst.js +7 -3
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal Paged.js format adapter.
|
|
3
|
+
*
|
|
4
|
+
* Consumes the compile-pipeline output for the `pagedjs` format — a
|
|
5
|
+
* passthrough shape { sections: string[], metadata } — and produces a
|
|
6
|
+
* complete HTML document wired to the Paged.js polyfill.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the docx/typst adapters, Paged.js does NOT walk an IR. The rendered
|
|
9
|
+
* HTML *is* the target format; walking JSX → HTML → IR → HTML would
|
|
10
|
+
* round-trip and lose nested spans, inline styles, and data-* attributes the
|
|
11
|
+
* foundation intentionally put there for CSS Paged Media to pick up.
|
|
12
|
+
*
|
|
13
|
+
* Two modes:
|
|
14
|
+
* - 'html' (Phase 1): return a text/html Blob. The caller loads it in a
|
|
15
|
+
* browser context (new tab or iframe). Paged.js paginates on
|
|
16
|
+
* DOMContentLoaded, the user prints to PDF.
|
|
17
|
+
* - 'server' (Phase 3 — stub): POST the HTML to a backend that runs
|
|
18
|
+
* headless Chromium + Paged.js and returns a PDF.
|
|
19
|
+
*
|
|
20
|
+
* This module is internal. It is NOT listed in package.json's exports field;
|
|
21
|
+
* consumers reach it only via the dynamic import inside adapters/dispatch.js.
|
|
22
|
+
*
|
|
23
|
+
* Entry point: compilePagedjs(compiledInput, options) → Promise<Blob>
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Public API
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compile passthrough output into a Paged.js-ready HTML Blob (html mode)
|
|
32
|
+
* or a PDF Blob (server mode).
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} input - Output of compileOutputs(store, 'pagedjs').
|
|
35
|
+
* @param {string[]} input.sections - Rendered HTML strings in registration
|
|
36
|
+
* order, one per body fragment.
|
|
37
|
+
* @param {Object|null} [input.metadata] - Plain data object from the
|
|
38
|
+
* `role: 'metadata'` registration (title, author, isbn, …).
|
|
39
|
+
* @param {Object} [options]
|
|
40
|
+
* @param {'html'|'server'} [options.mode='html']
|
|
41
|
+
* @param {Object} [options.meta] - Additional metadata to merge over the
|
|
42
|
+
* `metadata` registration (options.meta wins).
|
|
43
|
+
* @param {string} [options.stylesheet] - Foundation-supplied CSS string.
|
|
44
|
+
* When omitted, DEFAULT_STYLESHEET is used.
|
|
45
|
+
* @param {string} [options.polyfillUrl] - Override Paged.js polyfill URL.
|
|
46
|
+
* @param {string} [options.endpoint] - Server-mode endpoint.
|
|
47
|
+
* @returns {Promise<Blob>}
|
|
48
|
+
*/
|
|
49
|
+
export async function compilePagedjs(input, options = {}) {
|
|
50
|
+
const { mode = 'html', meta = {}, stylesheet, polyfillUrl, ...rest } = options
|
|
51
|
+
|
|
52
|
+
const sections = (input && input.sections) || []
|
|
53
|
+
const body = sections.join('\n')
|
|
54
|
+
const resolvedMeta = { ...(input?.metadata || {}), ...meta }
|
|
55
|
+
const doc = emitDocument({
|
|
56
|
+
body,
|
|
57
|
+
meta: resolvedMeta,
|
|
58
|
+
stylesheet,
|
|
59
|
+
polyfillUrl,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
if (mode === 'html') {
|
|
63
|
+
return new Blob([doc], { type: 'text/html; charset=utf-8' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (mode === 'server') {
|
|
67
|
+
return compileServerSide(doc, rest)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error(
|
|
71
|
+
`pagedjs adapter: unknown mode "${mode}". ` +
|
|
72
|
+
`Valid modes: 'html' (returns paged-ready HTML), 'server' (POSTs HTML to endpoint, receives PDF).`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Server mode
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Server mode: POST the assembled HTML to an endpoint that runs headless
|
|
82
|
+
* Chromium + Paged.js and returns a PDF. The endpoint is provided via
|
|
83
|
+
* options.endpoint; the default matches the (future) Vite dev plugin at
|
|
84
|
+
* /__press/pagedjs/compile.
|
|
85
|
+
*
|
|
86
|
+
* Wire protocol: multipart/form-data with one field named `document.html`
|
|
87
|
+
* whose value is a Blob of text/html. Response is application/pdf.
|
|
88
|
+
*/
|
|
89
|
+
async function compileServerSide(htmlDoc, options = {}) {
|
|
90
|
+
const endpoint = options.endpoint || '/__press/pagedjs/compile'
|
|
91
|
+
|
|
92
|
+
const form = new FormData()
|
|
93
|
+
form.append(
|
|
94
|
+
'document.html',
|
|
95
|
+
new Blob([htmlDoc], { type: 'text/html' }),
|
|
96
|
+
'document.html',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
let res
|
|
100
|
+
try {
|
|
101
|
+
res = await fetch(endpoint, { method: 'POST', body: form })
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`pagedjs adapter (server mode): request to ${endpoint} failed. ` +
|
|
105
|
+
`Is the dev server (or your compile endpoint) running? ` +
|
|
106
|
+
`Original error: ${err.message || err}`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
const text = await res.text().catch(() => '(no body)')
|
|
112
|
+
throw new Error(
|
|
113
|
+
`pagedjs adapter (server mode): ${endpoint} returned ${res.status} ${res.statusText}.\n${text}`,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const blob = await res.blob()
|
|
118
|
+
return blob.type === 'application/pdf'
|
|
119
|
+
? blob
|
|
120
|
+
: new Blob([await blob.arrayBuffer()], { type: 'application/pdf' })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// HTML document assembly
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build the full HTML document. Exported for tests and for foundations that
|
|
129
|
+
* want to inspect the emitted document before handing it to a browser.
|
|
130
|
+
*/
|
|
131
|
+
export function emitDocument({ body = '', meta = {}, stylesheet, polyfillUrl } = {}) {
|
|
132
|
+
const lang = meta.language ?? 'en'
|
|
133
|
+
const title = meta.title ?? 'Book'
|
|
134
|
+
const css = stylesheet || DEFAULT_STYLESHEET
|
|
135
|
+
const polyfill = polyfillUrl || DEFAULT_POLYFILL_URL
|
|
136
|
+
|
|
137
|
+
return `<!doctype html>
|
|
138
|
+
<html lang="${escapeAttr(lang)}">
|
|
139
|
+
<head>
|
|
140
|
+
<meta charset="utf-8" />
|
|
141
|
+
<title>${escapeHtml(title)}</title>
|
|
142
|
+
${emitMetaTags(meta)} <style>${css}</style>
|
|
143
|
+
<script src="${escapeAttr(polyfill)}" defer></script>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
${emitMetadataBlock(meta)}${body}
|
|
147
|
+
</body>
|
|
148
|
+
</html>`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Emit a small set of standard `<meta>` tags for common metadata fields
|
|
153
|
+
* (author, description, subject). Kept minimal; not all registered fields
|
|
154
|
+
* translate to HTML meta tags cleanly, so the full set lives in the hidden
|
|
155
|
+
* metadata block below.
|
|
156
|
+
*/
|
|
157
|
+
function emitMetaTags(meta) {
|
|
158
|
+
const out = []
|
|
159
|
+
if (meta?.author) {
|
|
160
|
+
out.push(` <meta name="author" content="${escapeAttr(meta.author)}" />`)
|
|
161
|
+
}
|
|
162
|
+
if (meta?.description) {
|
|
163
|
+
out.push(
|
|
164
|
+
` <meta name="description" content="${escapeAttr(meta.description)}" />`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
if (meta?.subject) {
|
|
168
|
+
out.push(
|
|
169
|
+
` <meta name="subject" content="${escapeAttr(meta.subject)}" />`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
return out.length ? out.join('\n') + '\n' : ''
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Emit a hidden `<div data-pagedjs-metadata>` block with inline `<span>`
|
|
177
|
+
* elements for each metadata field. CSS `string-set: var-name content()`
|
|
178
|
+
* can then pull the values into `@page` margin boxes via `string()`.
|
|
179
|
+
*
|
|
180
|
+
* The block is hidden by default in the DEFAULT_STYLESHEET (and any
|
|
181
|
+
* reasonable replacement should do the same) but remains in the DOM so
|
|
182
|
+
* Paged.js / authors can reference it.
|
|
183
|
+
*/
|
|
184
|
+
function emitMetadataBlock(meta) {
|
|
185
|
+
if (!meta || typeof meta !== 'object') return ''
|
|
186
|
+
const entries = Object.entries(meta).filter(
|
|
187
|
+
([, v]) => v != null && typeof v !== 'object',
|
|
188
|
+
)
|
|
189
|
+
if (!entries.length) return ''
|
|
190
|
+
const spans = entries
|
|
191
|
+
.map(
|
|
192
|
+
([key, value]) =>
|
|
193
|
+
` <span data-field="${escapeAttr(key)}">${escapeHtml(String(value))}</span>`,
|
|
194
|
+
)
|
|
195
|
+
.join('\n')
|
|
196
|
+
return ` <div data-pagedjs-metadata hidden>\n${spans}\n </div>\n`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ============================================================================
|
|
200
|
+
// Escape helpers
|
|
201
|
+
// ============================================================================
|
|
202
|
+
|
|
203
|
+
function escapeHtml(s) {
|
|
204
|
+
return String(s)
|
|
205
|
+
.replace(/&/g, '&')
|
|
206
|
+
.replace(/</g, '<')
|
|
207
|
+
.replace(/>/g, '>')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function escapeAttr(s) {
|
|
211
|
+
return String(s)
|
|
212
|
+
.replace(/&/g, '&')
|
|
213
|
+
.replace(/"/g, '"')
|
|
214
|
+
.replace(/</g, '<')
|
|
215
|
+
.replace(/>/g, '>')
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ============================================================================
|
|
219
|
+
// Defaults
|
|
220
|
+
// ============================================================================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Pinned Paged.js polyfill URL. Pinning the version keeps renders stable
|
|
224
|
+
* across rebuilds — bump deliberately when validating a new Paged.js release.
|
|
225
|
+
*/
|
|
226
|
+
export const DEFAULT_POLYFILL_URL =
|
|
227
|
+
'https://unpkg.com/pagedjs@0.4.3/dist/paged.polyfill.js'
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Minimal CSS Paged Media stylesheet — enough to produce a recognizable
|
|
231
|
+
* book PDF without a custom foundation stylesheet. Foundations override by
|
|
232
|
+
* passing `options.stylesheet`.
|
|
233
|
+
*/
|
|
234
|
+
export const DEFAULT_STYLESHEET = `
|
|
235
|
+
/* Hidden metadata block — the DOM carries it for CSS string-set / string() */
|
|
236
|
+
[data-pagedjs-metadata] { display: none; }
|
|
237
|
+
|
|
238
|
+
/* Page geometry — 6×9 US trade size */
|
|
239
|
+
@page {
|
|
240
|
+
size: 6in 9in;
|
|
241
|
+
margin: 0.75in 0.5in 0.75in 0.75in;
|
|
242
|
+
}
|
|
243
|
+
@page :left { margin: 0.75in 0.75in 0.75in 0.5in; }
|
|
244
|
+
@page :right { margin: 0.75in 0.5in 0.75in 0.75in; }
|
|
245
|
+
|
|
246
|
+
/* Named string for running headers */
|
|
247
|
+
h1 { string-set: chapter content(); }
|
|
248
|
+
@page :left { @top-left { content: string(chapter); font-size: 9pt; color: #666; } }
|
|
249
|
+
@page :right { @top-right { content: string(chapter); font-size: 9pt; color: #666; } }
|
|
250
|
+
|
|
251
|
+
/* Page number footer */
|
|
252
|
+
@page :left { @bottom-left { content: counter(page); font-size: 9pt; } }
|
|
253
|
+
@page :right { @bottom-right { content: counter(page); font-size: 9pt; } }
|
|
254
|
+
|
|
255
|
+
/* Chapter openers: start on recto */
|
|
256
|
+
h1 {
|
|
257
|
+
break-before: recto;
|
|
258
|
+
page: chapter-opener;
|
|
259
|
+
font-size: 22pt;
|
|
260
|
+
margin-top: 2in;
|
|
261
|
+
}
|
|
262
|
+
@page chapter-opener {
|
|
263
|
+
@top-left { content: none; }
|
|
264
|
+
@top-right { content: none; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* Body typography */
|
|
268
|
+
body {
|
|
269
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
270
|
+
font-size: 11pt;
|
|
271
|
+
line-height: 1.45;
|
|
272
|
+
hyphens: auto;
|
|
273
|
+
}
|
|
274
|
+
p { margin: 0 0 0.75em; text-indent: 1.25em; }
|
|
275
|
+
p.lead, p:first-child { text-indent: 0; }
|
|
276
|
+
h2 { font-size: 14pt; margin: 1.5em 0 0.5em; page-break-after: avoid; }
|
|
277
|
+
h3 { font-size: 12pt; margin: 1.2em 0 0.4em; page-break-after: avoid; }
|
|
278
|
+
code { font-family: ui-monospace, Menlo, monospace; font-size: 0.92em; }
|
|
279
|
+
pre {
|
|
280
|
+
font-size: 9.5pt;
|
|
281
|
+
padding: 0.8em;
|
|
282
|
+
background: #f5f5f5;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
page-break-inside: avoid;
|
|
285
|
+
}
|
|
286
|
+
blockquote {
|
|
287
|
+
border-left: 3px solid #ccc;
|
|
288
|
+
margin: 1em 0;
|
|
289
|
+
padding: 0 0 0 1em;
|
|
290
|
+
color: #444;
|
|
291
|
+
font-style: italic;
|
|
292
|
+
}
|
|
293
|
+
ul, ol { margin: 0.5em 0 0.75em 1.5em; }
|
|
294
|
+
figure { break-inside: avoid; }
|
|
295
|
+
`
|
package/src/adapters/typst.js
CHANGED
|
@@ -98,7 +98,20 @@ async function compileServerSide(bundle, options = {}) {
|
|
|
98
98
|
|
|
99
99
|
const form = new FormData()
|
|
100
100
|
for (const [name, contents] of Object.entries(bundle)) {
|
|
101
|
-
|
|
101
|
+
if (contents instanceof Blob) {
|
|
102
|
+
form.append(name, contents, name)
|
|
103
|
+
} else if (
|
|
104
|
+
contents instanceof Uint8Array ||
|
|
105
|
+
contents instanceof ArrayBuffer
|
|
106
|
+
) {
|
|
107
|
+
form.append(
|
|
108
|
+
name,
|
|
109
|
+
new Blob([contents], { type: 'application/octet-stream' }),
|
|
110
|
+
name,
|
|
111
|
+
)
|
|
112
|
+
} else {
|
|
113
|
+
form.append(name, new Blob([contents], { type: 'text/plain' }), name)
|
|
114
|
+
}
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
let res
|
|
@@ -145,6 +158,7 @@ export function buildBundle(input, options = {}) {
|
|
|
145
158
|
preamble = DEFAULT_PREAMBLE,
|
|
146
159
|
template = DEFAULT_TEMPLATE,
|
|
147
160
|
meta: metaOverride = {},
|
|
161
|
+
assets = {}, // extra bundle files (path → Uint8Array / Blob / string)
|
|
148
162
|
} = options
|
|
149
163
|
|
|
150
164
|
// Merge: metadata role (document-level) + options.meta (call-site override)
|
|
@@ -156,6 +170,7 @@ export function buildBundle(input, options = {}) {
|
|
|
156
170
|
'content.typ': emitContent(sections),
|
|
157
171
|
'preamble.typ': preamble,
|
|
158
172
|
'template.typ': template,
|
|
173
|
+
...assets,
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
176
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset fetching for format adapters — internal.
|
|
3
|
+
*
|
|
4
|
+
* Adapters that embed binary assets (images today; fonts, audio in the
|
|
5
|
+
* future) share this helper rather than each inlining a `fetch()` call.
|
|
6
|
+
* Extracted when the second adapter (EPUB) needed asset bytes — principle
|
|
7
|
+
* 6 in docs/architecture/principles.md. The shape is deliberately minimal:
|
|
8
|
+
* docx embeds one image at a time, EPUB collects many URLs and writes them
|
|
9
|
+
* into the ZIP keyed by hash, and both paths flow through the same API.
|
|
10
|
+
*
|
|
11
|
+
* Internal: not in package.json exports. Reach it via adapter imports.
|
|
12
|
+
*
|
|
13
|
+
* Two entry points:
|
|
14
|
+
* fetchAsset(url, options) — one URL, returns { bytes, mime, hash, ext }.
|
|
15
|
+
* Throws on network/HTTP failure; callers that
|
|
16
|
+
* want soft failure wrap in try/catch.
|
|
17
|
+
* fetchAssets(urls, options) — many URLs, Promise.allSettled so one
|
|
18
|
+
* failure doesn't kill the batch. Returns
|
|
19
|
+
* Map<url, { bytes, mime, hash, ext } | { error }>.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a single asset URL and return normalized bytes + metadata.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} url
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {typeof fetch} [options.fetch] - override for tests / node-fetch.
|
|
28
|
+
* @returns {Promise<{ bytes: Uint8Array, mime: string, hash: string, ext: string }>}
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchAsset(url, options = {}) {
|
|
31
|
+
const fetchImpl = options.fetch || globalThis.fetch
|
|
32
|
+
if (typeof fetchImpl !== 'function') {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'fetchAsset: no fetch() available. Pass options.fetch or run in an environment that provides globalThis.fetch.',
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
const res = await fetchImpl(url)
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(`fetchAsset: HTTP ${res.status} ${res.statusText || ''} for ${url}`.trim())
|
|
40
|
+
}
|
|
41
|
+
const buf = await res.arrayBuffer()
|
|
42
|
+
const bytes = new Uint8Array(buf)
|
|
43
|
+
const headerMime = typeof res.headers?.get === 'function'
|
|
44
|
+
? (res.headers.get('content-type') || '').split(';')[0].trim()
|
|
45
|
+
: ''
|
|
46
|
+
const mime = headerMime || detectMime(url, bytes)
|
|
47
|
+
const ext = extForMime(mime) || extFromUrl(url) || 'bin'
|
|
48
|
+
const hash = await hashContent(bytes)
|
|
49
|
+
return { bytes, mime, hash, ext }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fetch many URLs at once. Deduplicates by URL. Uses Promise.allSettled
|
|
54
|
+
* so a single failure doesn't abort the rest — failed entries carry an
|
|
55
|
+
* `error` field instead of bytes.
|
|
56
|
+
*
|
|
57
|
+
* @param {Iterable<string>} urls
|
|
58
|
+
* @param {Object} [options] - forwarded to fetchAsset.
|
|
59
|
+
* @returns {Promise<Map<string, { bytes: Uint8Array, mime: string, hash: string, ext: string } | { error: Error }>>}
|
|
60
|
+
*/
|
|
61
|
+
export async function fetchAssets(urls, options = {}) {
|
|
62
|
+
const unique = Array.from(new Set(urls))
|
|
63
|
+
const results = await Promise.allSettled(
|
|
64
|
+
unique.map((url) => fetchAsset(url, options)),
|
|
65
|
+
)
|
|
66
|
+
const out = new Map()
|
|
67
|
+
unique.forEach((url, i) => {
|
|
68
|
+
const r = results[i]
|
|
69
|
+
if (r.status === 'fulfilled') {
|
|
70
|
+
out.set(url, r.value)
|
|
71
|
+
} else {
|
|
72
|
+
out.set(url, { error: r.reason instanceof Error ? r.reason : new Error(String(r.reason)) })
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detect the MIME type of a byte buffer by magic bytes. Falls back to
|
|
80
|
+
* extension sniffing on the URL. Returns an empty string when neither
|
|
81
|
+
* path yields an answer.
|
|
82
|
+
*/
|
|
83
|
+
export function detectMime(url, bytes) {
|
|
84
|
+
if (bytes && bytes.length >= 4) {
|
|
85
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'
|
|
86
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
|
|
87
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
|
|
88
|
+
if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'
|
|
89
|
+
// SVG detection: "<?xml" or "<svg" after trimming BOM/whitespace.
|
|
90
|
+
const head = String.fromCharCode(...bytes.slice(0, Math.min(256, bytes.length))).trimStart()
|
|
91
|
+
if (head.startsWith('<?xml') || head.startsWith('<svg')) return 'image/svg+xml'
|
|
92
|
+
}
|
|
93
|
+
const ext = extFromUrl(url)
|
|
94
|
+
return ext ? mimeForExt(ext) : ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* SHA-256 hex digest of a byte buffer. Uses SubtleCrypto when available
|
|
99
|
+
* (browsers, modern Node), falls back to a short FNV-1a hash otherwise —
|
|
100
|
+
* good enough for de-duplicating asset filenames inside one compile.
|
|
101
|
+
*/
|
|
102
|
+
export async function hashContent(bytes) {
|
|
103
|
+
const subtle = globalThis.crypto?.subtle
|
|
104
|
+
if (subtle && typeof subtle.digest === 'function') {
|
|
105
|
+
const buf = await subtle.digest('SHA-256', bytes)
|
|
106
|
+
return bytesToHex(new Uint8Array(buf)).slice(0, 16)
|
|
107
|
+
}
|
|
108
|
+
// FNV-1a fallback.
|
|
109
|
+
let h = 0x811c9dc5
|
|
110
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
111
|
+
h ^= bytes[i]
|
|
112
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
|
|
113
|
+
}
|
|
114
|
+
return h.toString(16).padStart(8, '0')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function bytesToHex(bytes) {
|
|
118
|
+
let s = ''
|
|
119
|
+
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
|
|
120
|
+
return s
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function extFromUrl(url) {
|
|
124
|
+
if (!url) return ''
|
|
125
|
+
const path = String(url).split(/[?#]/)[0]
|
|
126
|
+
const m = path.match(/\.([a-zA-Z0-9]+)$/)
|
|
127
|
+
return m ? m[1].toLowerCase() : ''
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const EXT_MIME = {
|
|
131
|
+
png: 'image/png',
|
|
132
|
+
jpg: 'image/jpeg',
|
|
133
|
+
jpeg: 'image/jpeg',
|
|
134
|
+
gif: 'image/gif',
|
|
135
|
+
bmp: 'image/bmp',
|
|
136
|
+
svg: 'image/svg+xml',
|
|
137
|
+
webp: 'image/webp',
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const MIME_EXT = {
|
|
141
|
+
'image/png': 'png',
|
|
142
|
+
'image/jpeg': 'jpg',
|
|
143
|
+
'image/gif': 'gif',
|
|
144
|
+
'image/bmp': 'bmp',
|
|
145
|
+
'image/svg+xml': 'svg',
|
|
146
|
+
'image/webp': 'webp',
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function mimeForExt(ext) {
|
|
150
|
+
return EXT_MIME[ext] || ''
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function extForMime(mime) {
|
|
154
|
+
return MIME_EXT[mime] || ''
|
|
155
|
+
}
|