mikser-io 9.41.0 → 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.
@@ -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.41.0",
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": {
@@ -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
+ }