mikser-io 9.40.3 → 9.42.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/api-reference.md +62 -0
- package/index.js +1 -0
- package/package.json +2 -1
- package/src/builtin-tools.js +39 -0
- package/src/search.js +284 -0
- package/src/utils.js +116 -31
package/docs/api-reference.md
CHANGED
|
@@ -483,6 +483,68 @@ Query types throughout: function, lodash match object, or `undefined` for all.
|
|
|
483
483
|
|
|
484
484
|
---
|
|
485
485
|
|
|
486
|
+
## Search
|
|
487
|
+
|
|
488
|
+
`queryEntities` sifts **meta**. `searchEntities` answers the other question —
|
|
489
|
+
*where does this text appear?* — across structured values, source files and
|
|
490
|
+
built output.
|
|
491
|
+
|
|
492
|
+
### `searchEntities(options)`
|
|
493
|
+
|
|
494
|
+
```js
|
|
495
|
+
import { searchEntities } from 'mikser-io'
|
|
496
|
+
|
|
497
|
+
const { hits, count, truncated } = await searchEntities({
|
|
498
|
+
query: 'NOVAPRESS',
|
|
499
|
+
in: ['meta', 'content'], // default
|
|
500
|
+
})
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
| Option | Meaning |
|
|
504
|
+
| --- | --- |
|
|
505
|
+
| `query` | Text to find. A plain substring unless `regex` is true. Required. |
|
|
506
|
+
| `in` | `'meta'`, `'content'`, `'output'`. Default `['meta', 'content']`. |
|
|
507
|
+
| `collection` | Restrict to one collection. |
|
|
508
|
+
| `filter` | Sift filter narrowing **what may be searched at all** — see below. |
|
|
509
|
+
| `regex` | Treat `query` as a JavaScript regular expression. |
|
|
510
|
+
| `ignoreCase` | Case-insensitive matching. Default false. |
|
|
511
|
+
| `limit` | Maximum hits (default 50). `truncated` says when it stopped early. |
|
|
512
|
+
|
|
513
|
+
The three scopes answer different questions and none implies another. `meta`
|
|
514
|
+
walks structured values as dotted paths and touches no files. `content` reads
|
|
515
|
+
the **source**. `output` walks the **built** folder and reports `occurrences`
|
|
516
|
+
per destination — the blast-radius question, and the one where a string can be
|
|
517
|
+
present because a layout writes it, with no source entity containing it
|
|
518
|
+
anywhere.
|
|
519
|
+
|
|
520
|
+
Whether a file is text is decided by reading its bytes, not by its extension,
|
|
521
|
+
so a `.njk` source or a `.webmanifest` output is searched without first being
|
|
522
|
+
added to a list of known formats.
|
|
523
|
+
|
|
524
|
+
`truncated` is load-bearing: it separates "these are the hits" from "these are
|
|
525
|
+
the first N", which a caller acting on a blast radius cannot afford to guess.
|
|
526
|
+
|
|
527
|
+
> **Unscoped by default.** A bare call reads every entity and every source
|
|
528
|
+
> file — drafts, unpublished documents, layouts, sidecar JavaScript. The `api`
|
|
529
|
+
> plugin narrows `list` through each endpoint's own sift scope; nothing
|
|
530
|
+
> narrows this. Anything that puts search behind a request **must** pass that
|
|
531
|
+
> same scope as `filter`, or a public endpoint listing only published
|
|
532
|
+
> documents will happily search the unpublished ones. There is deliberately no
|
|
533
|
+
> `search` operation in the `api` plugin and no route in this module.
|
|
534
|
+
|
|
535
|
+
### Primitives
|
|
536
|
+
|
|
537
|
+
Exported so a caller building a different question out of the same parts does
|
|
538
|
+
not reimplement them: `countMatches`, `snippetAround`, `lineOfFirstMatch`,
|
|
539
|
+
`flattenMeta`, `findOccurrences`, `walkFiles`.
|
|
540
|
+
|
|
541
|
+
`findOccurrences(text, needle)` returns every occurrence with `line`, `col`,
|
|
542
|
+
the line's `text`, and `leading` — whether the match begins its line. That one
|
|
543
|
+
flag separates the file **declaring** something from the files merely using
|
|
544
|
+
it, in any text format, with no per-language grammar involved. It returns the
|
|
545
|
+
line rather than a verdict about it, so where the heuristic is wrong the
|
|
546
|
+
evidence is in the result.
|
|
547
|
+
|
|
486
548
|
## Database
|
|
487
549
|
|
|
488
550
|
```js
|
package/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export * from './src/lifecycle.js'
|
|
|
11
11
|
export * from './src/database/index.js'
|
|
12
12
|
export * from './src/journal.js'
|
|
13
13
|
export * from './src/catalog.js'
|
|
14
|
+
export * from './src/search.js'
|
|
14
15
|
export * from './src/refs.js'
|
|
15
16
|
export * from './src/manifest.js'
|
|
16
17
|
export * from './src/provenance.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.42.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/builtin-tools.js
CHANGED
|
@@ -96,6 +96,45 @@ export function registerBuiltinTools() {
|
|
|
96
96
|
},
|
|
97
97
|
)
|
|
98
98
|
|
|
99
|
+
registerTool(
|
|
100
|
+
'search',
|
|
101
|
+
{
|
|
102
|
+
description:
|
|
103
|
+
'Find a string across the catalog in ONE call — "where does this appear?". Searches entity meta '
|
|
104
|
+
+ 'values and, when asked, the source files themselves and the built output, returning '
|
|
105
|
+
+ '{ id, collection, path, field, snippet } per hit.\n\n'
|
|
106
|
+
+ 'This is how you locate content you can only describe by what it says: a menu label, a phone '
|
|
107
|
+
+ 'number, a sentence you were asked to change. Paging the catalog to find it means reading '
|
|
108
|
+
+ 'everything, most of which is fonts and image derivatives, and finding a SECOND copy of the same '
|
|
109
|
+
+ 'label somewhere else is then a matter of luck.\n\n'
|
|
110
|
+
+ 'in: ["meta"] searches structured values, no file I/O. in: ["content"] reads source files, '
|
|
111
|
+
+ 'binaries skipped. Default is both. in: ["output"] searches the BUILT files instead and reports '
|
|
112
|
+
+ '`occurrences` per destination — the blast-radius question, which is what you want before editing '
|
|
113
|
+
+ 'anything shared. The scopes answer different questions and none implies another: a string can be '
|
|
114
|
+
+ 'in the output because a layout writes it, with no source entity containing it anywhere.',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
query: { type: 'string', required: true,
|
|
117
|
+
description: 'Text to find. A plain substring unless `regex` is true. Case-sensitive by default.' },
|
|
118
|
+
collection: { type: 'string',
|
|
119
|
+
description: 'Restrict to one collection (e.g. "documents"). Omit to search all.' },
|
|
120
|
+
in: { type: 'array',
|
|
121
|
+
description: 'Where to look: "meta", "content", "output". Default is meta + content.' },
|
|
122
|
+
regex: { type: 'boolean',
|
|
123
|
+
description: 'Treat `query` as a JavaScript regular expression rather than a literal substring.' },
|
|
124
|
+
ignoreCase: { type: 'boolean', description: 'Case-insensitive matching. Default false.' },
|
|
125
|
+
limit: { type: 'number', description: 'Maximum hits to return (default 50).' },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
async (args) => {
|
|
129
|
+
try {
|
|
130
|
+
const { searchEntities } = await import('./search.js')
|
|
131
|
+
return ok(await searchEntities(args ?? {}))
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return fail(err.message)
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
99
138
|
registerTool(
|
|
100
139
|
'sources',
|
|
101
140
|
{
|
package/src/search.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Find a string across the catalog and the built output.
|
|
2
|
+
//
|
|
3
|
+
// `queryEntities` sifts META. This answers the other question — "where does
|
|
4
|
+
// this text appear?" — over structured values, source files and built output,
|
|
5
|
+
// which is what locating a phone number in a document body or a class name in
|
|
6
|
+
// the shipped CSS requires.
|
|
7
|
+
//
|
|
8
|
+
// Three scopes, answering three different questions, none implying another:
|
|
9
|
+
//
|
|
10
|
+
// meta — structured values, walked as dotted paths. No file I/O.
|
|
11
|
+
// content — the SOURCE files. What an editor is going to change.
|
|
12
|
+
// output — the BUILT files. The blast radius: a string can be in the
|
|
13
|
+
// output because a layout writes it, with no source entity
|
|
14
|
+
// containing it anywhere.
|
|
15
|
+
//
|
|
16
|
+
// UNSCOPED BY DEFAULT, and that is a security property, not a detail. A bare
|
|
17
|
+
// call reads every entity and every source file — drafts, unpublished
|
|
18
|
+
// documents, layouts, sidecar JavaScript. The `api` plugin's public endpoints
|
|
19
|
+
// narrow `list` through the endpoint's own sift scope; nothing narrows this.
|
|
20
|
+
// So anything that puts search behind a request MUST pass that same scope as
|
|
21
|
+
// `filter`, or a public endpoint that lists only published documents will
|
|
22
|
+
// happily search the unpublished ones. There is deliberately no `search`
|
|
23
|
+
// operation in the api plugin, and no route here.
|
|
24
|
+
//
|
|
25
|
+
// The primitives are exported alongside `searchEntities` so a caller building
|
|
26
|
+
// a different question out of the same parts does not reimplement counting,
|
|
27
|
+
// snippets or line numbers to do it.
|
|
28
|
+
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
31
|
+
|
|
32
|
+
import runtime from './runtime.js'
|
|
33
|
+
import { findEntities } from './catalog.js'
|
|
34
|
+
import { readEntityContent, looksTextual } from './utils.js'
|
|
35
|
+
|
|
36
|
+
// How much of an output file to read before deciding it is text. Same rule
|
|
37
|
+
// the source read uses, so the two scopes cannot disagree about a file that
|
|
38
|
+
// appears in both.
|
|
39
|
+
const SNIFF_BYTES = 8 * 1024
|
|
40
|
+
|
|
41
|
+
// Every leaf value under meta as [dottedPath, value], arrays included.
|
|
42
|
+
// The same shape refs_inbound reports, so a hit here and a referrer there
|
|
43
|
+
// name the same field.
|
|
44
|
+
export function* flattenMeta(node, prefix = '') {
|
|
45
|
+
if (node === null || node === undefined) return
|
|
46
|
+
if (Array.isArray(node)) {
|
|
47
|
+
for (let i = 0; i < node.length; i++) yield* flattenMeta(node[i], `${prefix}[${i}]`)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (typeof node === 'object') {
|
|
51
|
+
for (const [key, value] of Object.entries(node)) {
|
|
52
|
+
yield* flattenMeta(value, prefix ? `${prefix}.${key}` : key)
|
|
53
|
+
}
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
yield [prefix, node]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// How many times the needle appears. A count, not a boolean, because "this
|
|
60
|
+
// class is on nine pages" and "this class is on nine pages and seven times on
|
|
61
|
+
// one of them" are different facts, and the second one names the component.
|
|
62
|
+
export function countMatches(text, query, regex, ignoreCase) {
|
|
63
|
+
if (regex) {
|
|
64
|
+
const re = new RegExp(query, ignoreCase ? 'gi' : 'g')
|
|
65
|
+
let n = 0
|
|
66
|
+
// Guard the zero-width case: /a*/g on a non-matching position returns
|
|
67
|
+
// an empty match forever and never advances.
|
|
68
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
69
|
+
n++
|
|
70
|
+
if (m.index === re.lastIndex) re.lastIndex++
|
|
71
|
+
}
|
|
72
|
+
return n
|
|
73
|
+
}
|
|
74
|
+
const haystack = ignoreCase ? text.toLowerCase() : text
|
|
75
|
+
const needle = ignoreCase ? query.toLowerCase() : query
|
|
76
|
+
if (!needle) return 0
|
|
77
|
+
let n = 0
|
|
78
|
+
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) n++
|
|
79
|
+
return n
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Offset of the first match, or -1. The one place the regex/substring
|
|
83
|
+
// difference is resolved, so the line number and the snippet cannot end up
|
|
84
|
+
// pointing at different matches.
|
|
85
|
+
function firstMatchAt(text, query, regex, ignoreCase) {
|
|
86
|
+
if (regex) {
|
|
87
|
+
const m = new RegExp(query, ignoreCase ? 'i' : '').exec(text)
|
|
88
|
+
return m ? m.index : -1
|
|
89
|
+
}
|
|
90
|
+
const haystack = ignoreCase ? text.toLowerCase() : text
|
|
91
|
+
return haystack.indexOf(ignoreCase ? query.toLowerCase() : query)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 1-based line of the first match, so a hit is somewhere to go rather than
|
|
95
|
+
// something to grep for again.
|
|
96
|
+
export function lineOfFirstMatch(text, query, regex, ignoreCase) {
|
|
97
|
+
const at = firstMatchAt(text, query, regex, ignoreCase)
|
|
98
|
+
if (at < 0) return null
|
|
99
|
+
let line = 1
|
|
100
|
+
for (let i = 0; i < at; i++) if (text.charCodeAt(i) === 10) line++
|
|
101
|
+
return line
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Enough text around the match to recognise it without returning the file.
|
|
105
|
+
export function snippetAround(text, query, regex, ignoreCase) {
|
|
106
|
+
const at = firstMatchAt(text, query, regex, ignoreCase)
|
|
107
|
+
if (at < 0) return text.slice(0, 120)
|
|
108
|
+
const start = Math.max(0, at - 60)
|
|
109
|
+
const end = Math.min(text.length, at + query.length + 60)
|
|
110
|
+
return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ') + (end < text.length ? '…' : '')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Every occurrence of a needle in a text, with the one signal that separates
|
|
114
|
+
// "this file DECLARES it" from "this file uses it" without knowing the
|
|
115
|
+
// language: a declaration begins its line in nearly every text format, while a
|
|
116
|
+
// use sits mid-line.
|
|
117
|
+
//
|
|
118
|
+
// The LINE is returned rather than a computed verdict about it. A caller can
|
|
119
|
+
// tell a declaration from something that merely starts with the same token by
|
|
120
|
+
// reading it, which needs no grammar here at all — and where the heuristic is
|
|
121
|
+
// wrong, the evidence for that is right there in the result.
|
|
122
|
+
export function findOccurrences(text, needle, { limit = 200 } = {}) {
|
|
123
|
+
const sites = []
|
|
124
|
+
if (!needle || typeof text !== 'string') return sites
|
|
125
|
+
let line = 1
|
|
126
|
+
let lineStart = 0
|
|
127
|
+
let scanned = 0
|
|
128
|
+
for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + needle.length)) {
|
|
129
|
+
while (scanned < at) {
|
|
130
|
+
if (text.charCodeAt(scanned) === 10) { line++; lineStart = scanned + 1 }
|
|
131
|
+
scanned++
|
|
132
|
+
}
|
|
133
|
+
const lineEnd = text.indexOf('\n', at)
|
|
134
|
+
sites.push({
|
|
135
|
+
line,
|
|
136
|
+
col: at - lineStart,
|
|
137
|
+
leading: text.slice(lineStart, at).trim() === '',
|
|
138
|
+
text: text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd).trim().slice(0, 160),
|
|
139
|
+
})
|
|
140
|
+
if (sites.length >= limit) break
|
|
141
|
+
}
|
|
142
|
+
return sites
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Every file under a folder, depth-first. A generator so a search that hits
|
|
146
|
+
// its limit stops walking rather than materializing the tree.
|
|
147
|
+
export async function* walkFiles(folder) {
|
|
148
|
+
let entries
|
|
149
|
+
try {
|
|
150
|
+
entries = await readdir(folder, { withFileTypes: true })
|
|
151
|
+
} catch {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
const full = path.join(folder, entry.name)
|
|
156
|
+
if (entry.isDirectory()) yield* walkFiles(full)
|
|
157
|
+
else if (entry.isFile()) yield full
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Read a built file as text, or null when it is not text.
|
|
162
|
+
//
|
|
163
|
+
// Decided by the bytes, so a `.webmanifest`, a `.map` or whatever a renderer
|
|
164
|
+
// plugin emits is searchable without first being added to a list of known
|
|
165
|
+
// extensions — and a font still comes back null rather than as convincing
|
|
166
|
+
// garbage.
|
|
167
|
+
async function readOutputText(file) {
|
|
168
|
+
let buf
|
|
169
|
+
try {
|
|
170
|
+
buf = await readFile(file)
|
|
171
|
+
} catch {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
if (!looksTextual(buf.subarray(0, Math.min(buf.length, SNIFF_BYTES)))) return null
|
|
175
|
+
return buf.toString('utf8')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build the test used for meta values and for a whole-file contains check.
|
|
179
|
+
// Throws on an invalid regex so the caller can report it as bad input rather
|
|
180
|
+
// than as an empty result.
|
|
181
|
+
function buildMatcher(query, regex, ignoreCase) {
|
|
182
|
+
if (regex) {
|
|
183
|
+
const re = new RegExp(query, ignoreCase ? 'i' : '')
|
|
184
|
+
return { test: (value) => re.test(String(value)) }
|
|
185
|
+
}
|
|
186
|
+
const needle = ignoreCase ? query.toLowerCase() : query
|
|
187
|
+
return {
|
|
188
|
+
test: (value) => (ignoreCase ? String(value).toLowerCase() : String(value)).includes(needle),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Find `query` across the catalog and, when asked, the built output.
|
|
193
|
+
//
|
|
194
|
+
// Returns { query, scopes, count, truncated, searched, hits }. Each hit
|
|
195
|
+
// carries where it was found and enough to go there:
|
|
196
|
+
//
|
|
197
|
+
// meta { id, collection, path, where, field, snippet }
|
|
198
|
+
// content { id, collection, path, where, field: null, occurrences, line, snippet }
|
|
199
|
+
// output { destination, where, occurrences, snippet }
|
|
200
|
+
//
|
|
201
|
+
// `truncated` says the limit stopped the walk, which is the difference
|
|
202
|
+
// between "these are the hits" and "these are the first N" — a distinction a
|
|
203
|
+
// caller acting on a blast radius cannot afford to guess at.
|
|
204
|
+
export async function searchEntities({
|
|
205
|
+
query,
|
|
206
|
+
collection,
|
|
207
|
+
filter,
|
|
208
|
+
in: where,
|
|
209
|
+
regex = false,
|
|
210
|
+
ignoreCase = false,
|
|
211
|
+
limit = 50,
|
|
212
|
+
} = {}) {
|
|
213
|
+
if (!query) throw new Error('searchEntities: query is required')
|
|
214
|
+
const scopes = where?.length ? where : ['meta', 'content']
|
|
215
|
+
const matcher = buildMatcher(query, regex, ignoreCase)
|
|
216
|
+
|
|
217
|
+
const hits = []
|
|
218
|
+
let truncated = false
|
|
219
|
+
|
|
220
|
+
// The output scope walks the deployed folder rather than the catalog, so
|
|
221
|
+
// it runs on its own. Counting is the point here rather than first-match:
|
|
222
|
+
// seven occurrences on one page and one on nine others is the shape of a
|
|
223
|
+
// shared component, and a list of nine equal-looking filenames hides it.
|
|
224
|
+
if (scopes.includes('output')) {
|
|
225
|
+
const outputFolder = runtime.options?.outputFolder
|
|
226
|
+
if (!outputFolder) throw new Error('No output folder configured, so there is no built output to search.')
|
|
227
|
+
for await (const file of walkFiles(outputFolder)) {
|
|
228
|
+
if (hits.length >= limit) { truncated = true; break }
|
|
229
|
+
const text = await readOutputText(file)
|
|
230
|
+
if (text === null) continue
|
|
231
|
+
const occurrences = countMatches(text, query, regex, ignoreCase)
|
|
232
|
+
if (!occurrences) continue
|
|
233
|
+
hits.push({
|
|
234
|
+
destination: '/' + path.relative(outputFolder, file).split(path.sep).join('/'),
|
|
235
|
+
where: 'output',
|
|
236
|
+
occurrences,
|
|
237
|
+
snippet: snippetAround(text, query, regex, ignoreCase),
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
hits.sort((a, b) => b.occurrences - a.occurrences || a.destination.localeCompare(b.destination))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// `filter` is how a caller narrows what may be searched at all. It merges
|
|
244
|
+
// with `collection` and pushes into the catalog query, so a scoped caller
|
|
245
|
+
// never materializes a row it is not allowed to see rather than filtering
|
|
246
|
+
// one out after reading it off disk.
|
|
247
|
+
const searchesCatalog = scopes.some(scope => scope !== 'output')
|
|
248
|
+
const catalogQuery = { ...(filter ?? {}), ...(collection ? { collection } : {}) }
|
|
249
|
+
const entities = searchesCatalog
|
|
250
|
+
? await findEntities(Object.keys(catalogQuery).length ? catalogQuery : undefined)
|
|
251
|
+
: []
|
|
252
|
+
|
|
253
|
+
for (const entity of entities) {
|
|
254
|
+
if (hits.length >= limit) { truncated = true; break }
|
|
255
|
+
if (scopes.includes('meta') && entity.meta) {
|
|
256
|
+
for (const [field, value] of flattenMeta(entity.meta)) {
|
|
257
|
+
if (!matcher.test(value)) continue
|
|
258
|
+
hits.push({
|
|
259
|
+
id: entity.id, collection: entity.collection ?? null, path: entity.uri ?? null,
|
|
260
|
+
where: 'meta', field, snippet: snippetAround(String(value), query, regex, ignoreCase),
|
|
261
|
+
})
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (hits.length >= limit) { truncated = true; break }
|
|
266
|
+
if (scopes.includes('content')) {
|
|
267
|
+
// readEntityContent owns the text/binary decision and makes it by
|
|
268
|
+
// reading the bytes, so a .njk or .toml is searched like anything
|
|
269
|
+
// else. Deciding it a second time here on an extension would make
|
|
270
|
+
// a skipped format indistinguishable from a real absence.
|
|
271
|
+
const { content } = await readEntityContent(entity)
|
|
272
|
+
if (typeof content !== 'string' || !matcher.test(content)) continue
|
|
273
|
+
hits.push({
|
|
274
|
+
id: entity.id, collection: entity.collection ?? null, path: entity.uri ?? null,
|
|
275
|
+
where: 'content', field: null,
|
|
276
|
+
occurrences: countMatches(content, query, regex, ignoreCase),
|
|
277
|
+
line: lineOfFirstMatch(content, query, regex, ignoreCase),
|
|
278
|
+
snippet: snippetAround(content, query, regex, ignoreCase),
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { query, scopes, count: hits.length, truncated, searched: entities.length, hits }
|
|
284
|
+
}
|
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 }
|