mikser-io 9.40.2 → 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.
- package/docs/diagnostics.md +12 -0
- package/package.json +2 -1
- package/src/manifest.js +41 -0
- package/src/utils.js +116 -31
package/docs/diagnostics.md
CHANGED
|
@@ -563,6 +563,18 @@ cannot model is how the entity's own frontmatter would change — that is
|
|
|
563
563
|
parsed during import, so an edit that moves `meta.layout` moves the
|
|
564
564
|
destination too, and this does not see it.
|
|
565
565
|
|
|
566
|
+
**Layout sidecars are the one entity answered by a different route.** A
|
|
567
|
+
sidecar never renders, nothing points at it by ref, and no query matches
|
|
568
|
+
it, so all three candidate walks come back empty — yet editing one
|
|
569
|
+
re-renders the whole site. Its dependency is the layout's *input digest*,
|
|
570
|
+
not an edge: `mikser-io-layouts` folds every sidecar script under the
|
|
571
|
+
folder into a single `sharedDigest` carried by every layout's checksum, so
|
|
572
|
+
one sidecar edit moves every layout. `affectedBy` therefore reports every
|
|
573
|
+
destination that rendered through any layout, each attributed to the
|
|
574
|
+
layout it went through. Entities with no layout — a copied asset, a
|
|
575
|
+
`files()` passthrough — stay out, or the answer would be "everything,
|
|
576
|
+
always", which is the same non-answer as "nothing" with the sign flipped.
|
|
577
|
+
|
|
566
578
|
### `runtime.provenance`
|
|
567
579
|
|
|
568
580
|
Where a value was **written** — source file, field path, line and column.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
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/manifest.js
CHANGED
|
@@ -484,6 +484,25 @@ export function createManifest(db) {
|
|
|
484
484
|
WHERE refClosure LIKE '%"kind":"query"%'
|
|
485
485
|
`)
|
|
486
486
|
|
|
487
|
+
// Everything that rendered through a layout — the blast radius of a change
|
|
488
|
+
// to a layout SIDECAR.
|
|
489
|
+
//
|
|
490
|
+
// A sidecar is reachable by none of the three routes affectedBy otherwise
|
|
491
|
+
// walks: it never renders, so it has no snapshots; nothing points at it by
|
|
492
|
+
// ref; and no query matches it. Its dependency is the layout's input
|
|
493
|
+
// digest, which is not an edge. So the preview answered "nothing would be
|
|
494
|
+
// affected" for a change that re-renders the entire site — the one
|
|
495
|
+
// direction of wrong that matters, since it says a site-wide edit is safe.
|
|
496
|
+
//
|
|
497
|
+
// The blast radius really is everything: the layouts plugin folds every
|
|
498
|
+
// OTHER sidecar under the folder into each layout's `shared` digest, so any
|
|
499
|
+
// sidecar edit moves every layout's checksum, and every page renders
|
|
500
|
+
// through some layout.
|
|
501
|
+
const stmtSnapshotsWithLayout = db.prepare(`
|
|
502
|
+
SELECT id, destination, refClosure FROM mikser_snapshots
|
|
503
|
+
WHERE refClosure LIKE '%"kind":"layout"%'
|
|
504
|
+
`)
|
|
505
|
+
|
|
487
506
|
// Snapshots holding a non-query edge that names any of the given keys,
|
|
488
507
|
// by the name asked for OR by the entity it bound to. Both, for the same
|
|
489
508
|
// reason skipDecision reads both — a name survives a rename only through
|
|
@@ -572,6 +591,28 @@ export function createManifest(db) {
|
|
|
572
591
|
}
|
|
573
592
|
|
|
574
593
|
const affected = []
|
|
594
|
+
|
|
595
|
+
// Sidecars take the route above rather than the candidate walk:
|
|
596
|
+
// skipDecision compares against the edges a snapshot recorded, and
|
|
597
|
+
// no snapshot records an edge to a sidecar — it would answer "skip"
|
|
598
|
+
// for every page and hide the whole radius.
|
|
599
|
+
if (entity.type === 'sidecar') {
|
|
600
|
+
for (const row of stmtSnapshotsWithLayout.iterate()) {
|
|
601
|
+
let layoutEdge = null
|
|
602
|
+
try {
|
|
603
|
+
layoutEdge = JSON.parse(row.refClosure ?? '[]').find(e => e.kind === 'layout') ?? null
|
|
604
|
+
} catch { /* unreadable closure — still affected, just unattributed */ }
|
|
605
|
+
affected.push({
|
|
606
|
+
id: row.id,
|
|
607
|
+
destination: row.destination,
|
|
608
|
+
reason: 'ref-changed',
|
|
609
|
+
...(layoutEdge?.target ? { dependency: layoutEdge.target } : {}),
|
|
610
|
+
why: 'a layout sidecar feeds every layout through its shared digest',
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
return affected
|
|
614
|
+
}
|
|
615
|
+
|
|
575
616
|
for (const { id, destination } of candidates.values()) {
|
|
576
617
|
// Its own renders: the premise of the question is that this
|
|
577
618
|
// entity changed, so asking skipDecision — which compares the
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|