mikser-io 9.40.3 → 9.41.0

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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/utils.js +116 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.40.3",
3
+ "version": "9.41.0",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -46,6 +46,7 @@
46
46
  "is-url": "^1.2.4",
47
47
  "line-reader": "^0.4.0",
48
48
  "lodash": "^4.18.1",
49
+ "mime-types": "^3.0.2",
49
50
  "minimatch": "^10.2.5",
50
51
  "node-cron": "^4.2.1",
51
52
  "p-map": "^7.0.4",
package/src/utils.js CHANGED
@@ -8,6 +8,7 @@ import { minimatch } from 'minimatch'
8
8
  import path from 'path'
9
9
  import fm from 'front-matter'
10
10
  import yaml from 'yaml'
11
+ import { contentType } from 'mime-types'
11
12
  import runtime from './runtime.js'
12
13
  import { trackedInfo, untrack, recordReads } from './track.js'
13
14
 
@@ -188,32 +189,31 @@ export function refFilter(refValue) {
188
189
  // else that produces Content-Type from an entity). Pure function; no
189
190
  // engine state — lives here rather than inside one plugin so other
190
191
  // plugins don't have to reach across the plugin folder for it.
191
- const MIME_BY_EXT = {
192
- pdf: 'application/pdf',
193
- html: 'text/html; charset=utf-8',
194
- xml: 'application/xml; charset=utf-8',
195
- xhtml: 'application/xhtml+xml; charset=utf-8',
196
- rss: 'application/rss+xml; charset=utf-8',
197
- atom: 'application/atom+xml; charset=utf-8',
198
- json: 'application/json; charset=utf-8',
199
- css: 'text/css; charset=utf-8',
200
- js: 'application/javascript; charset=utf-8',
201
- svg: 'image/svg+xml',
202
- png: 'image/png',
203
- jpg: 'image/jpeg',
204
- jpeg: 'image/jpeg',
205
- webp: 'image/webp',
206
- gif: 'image/gif',
207
- mp4: 'video/mp4',
208
- webm: 'video/webm',
209
- txt: 'text/plain; charset=utf-8',
210
- md: 'text/markdown; charset=utf-8',
192
+ // Content types come from `mime-types` — the IANA registry via mime-db —
193
+ // rather than the nineteen-entry table this used to carry. That table was
194
+ // wrong by omission for everything it had not been taught: a .woff2, .avif,
195
+ // .wasm, .ico or .mp3 in the output got no content type at all, and a caller
196
+ // serving it had to guess.
197
+ //
198
+ // One deliberate change came with the swap: `.js` is `text/javascript`, which
199
+ // RFC 9239 made the registered type and `application/javascript` obsolete.
200
+ // Browsers have accepted both for years.
201
+ function mimeForExtension(ext) {
202
+ const type = contentType(ext)
203
+ if (!type) return null
204
+ // mime-db assigns charsets from its own `charset` field, which the XML
205
+ // family does not carry — so `application/rss+xml` came back bare where
206
+ // the old table said `; charset=utf-8`. Restored as a RULE about XML
207
+ // rather than as four more rows to keep. Deliberately not applied to
208
+ // `image/svg+xml`, which the old table also served without a charset.
209
+ return /charset=/i.test(type) || !/^application\/(xml$|.*\+xml$)/.test(type)
210
+ ? type
211
+ : `${type}; charset=utf-8`
211
212
  }
212
213
 
213
214
  export function mimeForEntity(entity) {
214
215
  if (!entity?.destination) return null
215
- const ext = path.extname(entity.destination).toLowerCase().replace(/^\./, '')
216
- return MIME_BY_EXT[ext] ?? null
216
+ return mimeForExtension(path.extname(entity.destination).toLowerCase())
217
217
  }
218
218
 
219
219
  // File-extension allowlist for "is this source readable as utf8?". Used
@@ -237,12 +237,81 @@ const TEXT_EXTENSIONS = new Set([
237
237
  // Returns false for binaries (png/pdf/mp4/etc.) and for entities
238
238
  // without a uri. Pass the entity, not a bare extension — keeps the
239
239
  // call site readable and lines up with mimeForEntity's signature.
240
+ //
241
+ // A HINT, not a verdict. The list above is hand-maintained, so it is wrong
242
+ // about every extension nobody has added yet — `.njk`, `.scss`, `.toml`, and
243
+ // `.ect`, which is an engine mikser itself ships a renderer for. Anything
244
+ // deciding whether content can be READ should ask looksTextual about the
245
+ // bytes instead; this stays for callers that want a cheap guess with no I/O.
240
246
  export function isTextEntity(entity) {
241
247
  if (!entity?.uri) return false
242
248
  const ext = path.extname(entity.uri).slice(1).toLowerCase()
243
249
  return TEXT_EXTENSIONS.has(ext)
244
250
  }
245
251
 
252
+ // How much of a file is enough to tell text from binary. A binary format that
253
+ // hides every NUL and every invalid sequence for 8KB is not one anybody
254
+ // stores in a content repository.
255
+ const SNIFF_BYTES = 8 * 1024
256
+
257
+ // Is this text? Asked of the BYTES, not of the extension.
258
+ //
259
+ // An extension allowlist is a list that goes stale silently, and it fails in
260
+ // the direction that costs most: it refuses a file it has no opinion about.
261
+ // That is how reading a `.liquid` template — from an engine that renders
262
+ // Liquid — came back "Non-text format".
263
+ //
264
+ // Bytes do not go stale. A file with no NUL that decodes as UTF-8 is text,
265
+ // whether it is Nunjucks, TOML, SQL or something nobody has written yet.
266
+ export function looksTextual(buf) {
267
+ if (buf.includes(0)) return false
268
+ try {
269
+ new TextDecoder('utf8', { fatal: true }).decode(trimPartialTail(buf))
270
+ return true
271
+ } catch {
272
+ return false
273
+ }
274
+ }
275
+
276
+ // Drop a trailing codepoint a bounded read cut in half — and ONLY that.
277
+ //
278
+ // The tempting version retries the decode while chopping bytes off the end
279
+ // until it succeeds. That also chops away genuinely corrupt bytes: `74 65 78
280
+ // 74 C3 28` is invalid UTF-8, but drop two bytes and `text` decodes clean, so
281
+ // a JPEG whose tail happens to be bad reads as text. The tail is forgiven only
282
+ // when it is a valid multi-byte sequence that has not finished yet.
283
+ function trimPartialTail(buf) {
284
+ // A lead byte is 11xxxxxx and starts a sequence of a known length; the
285
+ // bytes after it are continuations, 10xxxxxx. Walk back over at most 3
286
+ // continuations — a 4-byte sequence is the longest UTF-8 has.
287
+ for (let back = 1; back <= 4 && back <= buf.length; back++) {
288
+ const byte = buf[buf.length - back]
289
+ if (byte < 0x80) return buf // ASCII: nothing pending
290
+ if ((byte & 0xc0) === 0x80) continue // continuation, keep walking
291
+ const expected = (byte & 0xe0) === 0xc0 ? 2
292
+ : (byte & 0xf0) === 0xe0 ? 3
293
+ : (byte & 0xf8) === 0xf0 ? 4
294
+ : 0 // not a lead byte at all
295
+ // `back` bytes run from the lead to the end. Fewer than the sequence
296
+ // needs means it was cut; anything else is complete, or corrupt, and
297
+ // corrupt is the decoder's call to make rather than ours.
298
+ return expected && back < expected ? buf.subarray(0, buf.length - back) : buf
299
+ }
300
+ return buf
301
+ }
302
+
303
+ // Read the first `limit` bytes of a file, for sniffing.
304
+ async function readPrefix(file, limit) {
305
+ const handle = await open(file, 'r')
306
+ try {
307
+ const buf = Buffer.alloc(limit)
308
+ const { bytesRead } = await handle.read(buf, 0, limit, 0)
309
+ return buf.subarray(0, bytesRead)
310
+ } finally {
311
+ await handle.close()
312
+ }
313
+ }
314
+
246
315
  // Cache resolved provider modules so we don't re-import per read.
247
316
  // Keyed by URI scheme — same scheme → same module → same auth state.
248
317
  const providerModuleCache = new Map()
@@ -306,9 +375,20 @@ async function loadProviderModule(scheme, workingFolder) {
306
375
  // Usage:
307
376
  //
308
377
  // Object.assign(entity, await readEntityContent(entity))
309
- export async function readEntityContent(entity) {
378
+ export async function readEntityContent(entity, { reload = false } = {}) {
310
379
  if (!entity) return {}
311
- if (typeof entity.content === 'string') return { content: entity.content }
380
+ // The fast path exists to avoid re-FETCHING a remote document a source
381
+ // plugin already pulled in, and it short-circuits before any of the
382
+ // dispatch below. That makes it a correctness problem for a caller asking
383
+ // to see the SOURCE: between builds the catalog copy and the file on disk
384
+ // part ways, and this handed back the catalog's — under a name that says
385
+ // it read the file. An agent then rewrites the whole file from a version
386
+ // it never saw, silently discarding whatever changed underneath it.
387
+ //
388
+ // `reload` is how a caller says it wants the bytes as they are now. An
389
+ // entity with no uri has nothing fresher to offer, so it keeps what it
390
+ // has rather than falling through to an error.
391
+ if (typeof entity.content === 'string' && (!reload || !entity.uri)) return { content: entity.content }
312
392
  if (!entity.uri) return { contentError: 'entity has no uri' }
313
393
 
314
394
  const m = URI_SCHEME_RE.exec(entity.uri)
@@ -324,14 +404,19 @@ export async function readEntityContent(entity) {
324
404
 
325
405
  // Built-in filesystem read: no scheme (plain path) or `file://`.
326
406
  if (!scheme || scheme === 'file') {
327
- if (!isTextEntity(entity)) {
328
- const ext = path.extname(entity.uri).slice(1).toLowerCase()
329
- return {
330
- contentSkipped: `Non-text format (.${ext}). Read the file directly at entity.uri, or use a render API to materialize output.`,
331
- }
332
- }
407
+ const target = scheme === 'file' ? entity.uri.replace(/^file:\/\//i, '') : entity.uri
333
408
  try {
334
- const target = scheme === 'file' ? entity.uri.replace(/^file:\/\//i, '') : entity.uri
409
+ // Decided by the bytes, not by the extension. The extension list
410
+ // refused `.njk`, `.scss`, `.toml` and `.ect` — the last of which
411
+ // mikser ships a renderer for — so reading a layout depended on
412
+ // which engine it happened to be written in.
413
+ if (!looksTextual(await readPrefix(target, SNIFF_BYTES))) {
414
+ const ext = path.extname(entity.uri).slice(1).toLowerCase()
415
+ return {
416
+ contentSkipped: `Not text${ext ? ` (.${ext})` : ''} — the bytes are binary, not an unrecognised `
417
+ + 'extension. Read the file directly at entity.uri, or use a render API to materialize output.',
418
+ }
419
+ }
335
420
  return { content: await readFile(target, 'utf8') }
336
421
  } catch (err) {
337
422
  return { contentError: err.message }