k0-board 0.2.0 → 0.4.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/web/fuzzy.js CHANGED
@@ -1,47 +1,99 @@
1
1
  // ── Searching the names ───────────────────────────────────────────────────────
2
2
  // It does not look for the exact word: it looks for the letters you typed, in the order you
3
- // typed them, scattered through the path. So `audrep` finds `docs/audit-report.md` and `bapl`
4
- // finds `docs/backlog/plans/`.
3
+ // typed them, scattered through the file's **name**. So `audrep` finds `docs/audit-report.md`.
4
+ //
5
+ // The directory is never searched. It was, once: `.env` scattered its four letters across
6
+ // `.planning/milestones/` and turned up `v1.0-ROADMAP.md`, a file that has nothing to do with
7
+ // what was typed and gives no way to see why it is there — three of the four letters are in a
8
+ // folder name nobody is looking at. A path is not a sentence you meant to write; a file name is.
5
9
  //
6
10
  // The score exists to put what you actually meant at the top. Worth more: a letter next to the
7
- // previous one, a letter at the start of a word, and anything in the file's name rather than in
8
- // the directory — because you usually reach a file through its name, not through where it is.
11
+ // previous one, and a letter at the start of a word.
12
+
13
+ const BOUNDARY = new Set(['-', '_', '.', ' '])
9
14
 
10
- const BOUNDARY = new Set(['/', '-', '_', '.', ' '])
15
+ /** What is really searched for: the spaces mean nothing here, and neither does the case. */
16
+ const wanted = (query) => String(query ?? '').trim().toLowerCase().replace(/\s+/g, '')
11
17
 
12
18
  /**
13
- * How much `text` looks like `query`: the higher, the more alike. `-1` when the letters simply
14
- * are not there. With an empty search it is 0 for everybody, and the caller decides the order.
19
+ * The one walk through the file's name, which produces both numbers this file exists for: how
20
+ * alike the two are, and **which letters** made it so.
21
+ *
22
+ * They come out of the same walk on purpose. Underlining letters that a second, separate pass had
23
+ * chosen would sooner or later underline something the ranking had not used — and a row that says
24
+ * "this is why I am here" has to be telling the truth.
25
+ *
26
+ * `at` comes back as positions inside the whole `text`, not just the name: the caller was handed
27
+ * a path, and a position only means something next to the path it was taken from.
28
+ *
29
+ * Returns `null` when the letters are not there at all.
15
30
  */
16
- export function score(text, query) {
17
- const q = String(query ?? '').trim().toLowerCase().replace(/\s+/g, '')
18
- if (!q) return 0
31
+ function trace(text, q) {
19
32
  const s = String(text ?? '')
20
- const low = s.toLowerCase()
21
-
22
- // Where the file name starts: from there on every hit is worth more.
33
+ // Where the file name starts. Everything before this is the directory, and it is not searched.
23
34
  const nameAt = s.lastIndexOf('/') + 1
35
+ const name = s.slice(nameAt)
36
+ const low = name.toLowerCase()
24
37
 
38
+ const at = []
25
39
  let total = 0
26
- let at = 0
40
+ let from = 0
27
41
  let last = -2
28
42
  for (const ch of q) {
29
- const hit = low.indexOf(ch, at)
30
- if (hit === -1) return -1
43
+ const hit = low.indexOf(ch, from)
44
+ if (hit === -1) return null
31
45
 
32
46
  let point = 1
33
47
  if (hit === last + 1) point += 4 // next to the previous one
34
- if (hit === 0 || BOUNDARY.has(s[hit - 1])) point += 3 // the start of a word
35
- else if (s[hit] >= 'A' && s[hit] <= 'Z') point += 2 // the start of a word in camelCase
36
- if (hit >= nameAt) point += 2 // in the file name, not the directory
48
+ if (hit === 0 || BOUNDARY.has(name[hit - 1])) point += 3 // the start of a word
49
+ else if (name[hit] >= 'A' && name[hit] <= 'Z') point += 2 // the start of a word in camelCase
37
50
 
38
51
  total += point
52
+ at.push(hit + nameAt)
39
53
  last = hit
40
- at = hit + 1
54
+ from = hit + 1
41
55
  }
42
56
 
43
57
  // On equal hits the shorter name wins: there is less around what you were looking for.
44
- return total - s.length * 0.01
58
+ return { total: total - name.length * 0.01, at }
59
+ }
60
+
61
+ /**
62
+ * How much `text` looks like `query`: the higher, the more alike. `-1` when the letters simply
63
+ * are not there. With an empty search it is 0 for everybody, and the caller decides the order.
64
+ */
65
+ export function score(text, query) {
66
+ const q = wanted(query)
67
+ if (!q) return 0
68
+ return trace(text, q)?.total ?? -1
69
+ }
70
+
71
+ /**
72
+ * Where in `text` the letters of `query` were found, in order — the positions the score was built
73
+ * from, so a row can underline them and show why it matched. `null` when there was no match, or
74
+ * nothing to search for.
75
+ */
76
+ export function positions(text, query) {
77
+ const q = wanted(query)
78
+ if (!q) return null
79
+ return trace(text, q)?.at ?? null
80
+ }
81
+
82
+ /**
83
+ * The same positions gathered into stretches — `[[from, to], …]`, the end not included.
84
+ *
85
+ * Neighbouring letters belong together: `audrep` against `audit-report.md` found two pieces of
86
+ * two words, and that is what it should look like. Marked one letter at a time it looks like six
87
+ * separate coincidences, which is the opposite of what happened.
88
+ */
89
+ export function runs(at) {
90
+ const out = []
91
+ for (const i of at ?? []) {
92
+ const last = out[out.length - 1]
93
+ if (last && last[1] === i) last[1] = i + 1
94
+ else out.push([i, i + 1])
95
+ }
96
+ return out
45
97
  }
46
98
 
47
99
  /** The rows that match, most alike first. `key` says where to look. */
package/web/json.js ADDED
@@ -0,0 +1,145 @@
1
+ // ── A JSON file, laid out ─────────────────────────────────────────────────────
2
+ // A `package.json` shown as text is a wall you read with your finger. What you actually want to
3
+ // know is what is under `scripts`, and everything else can stay shut until you ask for it.
4
+ //
5
+ // So: a tree that folds. The folding is `<details>`, which the browser already knows how to do —
6
+ // no state to keep, nothing to wire up, and it works before any script has run.
7
+ //
8
+ // And with the folding comes the reason this file also does the searching. The browser's own find
9
+ // does not look inside a closed `<details>`: on a folded tree ⌘F says "not found" for something
10
+ // that is right there. So the search is done here, on the data, and the branches that hold a
11
+ // result are handed back already open.
12
+ //
13
+ // It knows nothing about the page: text in, a string of HTML out. That is what makes it testable.
14
+
15
+ /**
16
+ * Written again rather than imported: this module is pulled in by the tests, which run in Node,
17
+ * where the browser's absolute `/md.js` does not resolve. Four lines are cheaper than making
18
+ * every pure module share a build.
19
+ */
20
+ const esc = (s) =>
21
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
22
+
23
+ /** How deep it arrives open. Two levels is the shape of the file; three is already its contents. */
24
+ const OPEN_DEPTH = 2
25
+
26
+ /**
27
+ * Past this it is not a document any more, it is a dump — and the tree would be a megabyte of
28
+ * HTML that no browser lays out quickly. The page falls back to showing the text as it is.
29
+ */
30
+ const MAX_NODES = 20000
31
+
32
+ /**
33
+ * The word found inside a piece of text, wrapped so it can be seen, and how many times it is
34
+ * there. The count is what the page shows next to the box: without it a search that finds
35
+ * nothing looks exactly like a search that has not run.
36
+ */
37
+ export function mark(value, query) {
38
+ const text = String(value ?? '')
39
+ if (!query) return { html: esc(text), n: 0 }
40
+ const hay = text.toLowerCase()
41
+ const needle = query.toLowerCase()
42
+ let out = ''
43
+ let at = 0
44
+ let n = 0
45
+ for (let i = hay.indexOf(needle, at); i !== -1; i = hay.indexOf(needle, at)) {
46
+ out += `${esc(text.slice(at, i))}<mark>${esc(text.slice(i, i + needle.length))}</mark>`
47
+ at = i + needle.length
48
+ n += 1
49
+ }
50
+ return { html: out + esc(text.slice(at)), n }
51
+ }
52
+
53
+ /** How a value is written, and what colour it is: the type is the colour. */
54
+ function leafValue(value) {
55
+ if (value === null) return { text: 'null', cls: 'nil' }
56
+ if (typeof value === 'string') return { text: value, cls: 'str' }
57
+ if (typeof value === 'number') return { text: String(value), cls: 'num' }
58
+ if (typeof value === 'boolean') return { text: String(value), cls: 'bool' }
59
+ return { text: String(value), cls: 'str' }
60
+ }
61
+
62
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
63
+
64
+ /**
65
+ * One entry of the tree, and everything under it.
66
+ *
67
+ * The number of results comes back up from the leaves rather than being counted again at the
68
+ * top, because it is the same walk: whoever found a result also knows which branch has to be
69
+ * open for it to be visible.
70
+ */
71
+ function node(key, value, depth, query, budget) {
72
+ if (budget.left <= 0) return { html: '', n: 0 }
73
+ budget.left -= 1
74
+
75
+ const label = key === null ? null : mark(key, query)
76
+ const name = label ? `<b class="key">${label.html}</b>` : ''
77
+
78
+ if (value !== null && typeof value === 'object') {
79
+ const entries = Array.isArray(value)
80
+ ? value.map((v, i) => [String(i), v])
81
+ : Object.entries(value)
82
+ const brace = Array.isArray(value) ? ['[', ']'] : ['{', '}']
83
+ const size = Array.isArray(value)
84
+ ? plural(entries.length, 'item', 'items')
85
+ : plural(entries.length, 'key', 'keys')
86
+
87
+ // An empty object has nothing to open: a summary with no contents is a fold that lies.
88
+ if (!entries.length) {
89
+ return {
90
+ html: `<div class="leaf">${name}<span class="v empty">${brace[0]}${brace[1]}</span></div>`,
91
+ n: label?.n ?? 0,
92
+ }
93
+ }
94
+
95
+ let inner = ''
96
+ let n = label?.n ?? 0
97
+ for (const [k, v] of entries) {
98
+ const child = node(k, v, depth + 1, query, budget)
99
+ inner += child.html
100
+ n += child.n
101
+ }
102
+ // With something searched for, only the branches holding a result open — that is the whole
103
+ // point of doing the search here. With nothing searched for, the first levels open, which is
104
+ // the shape of the file without its contents.
105
+ const open = query ? n > 0 : depth < OPEN_DEPTH
106
+ return {
107
+ html:
108
+ `<details class="node"${open ? ' open' : ''}><summary>${name}` +
109
+ `<span class="brace">${brace[0]}${brace[1]}</span><em>${size}</em></summary>` +
110
+ `<div class="kids">${inner}</div></details>`,
111
+ n,
112
+ }
113
+ }
114
+
115
+ const { text, cls } = leafValue(value)
116
+ const hit = mark(text, query)
117
+ return {
118
+ html: `<div class="leaf">${name}<span class="v ${cls}">${hit.html}</span></div>`,
119
+ n: (label?.n ?? 0) + hit.n,
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The whole file as a tree, with `query` picked out inside it.
125
+ *
126
+ * When it does not parse, nothing is invented: the reason comes back and the page shows the text
127
+ * exactly as it is on disk, which is the only honest thing to show for a file that is broken —
128
+ * and usually the reason you opened it.
129
+ *
130
+ * Returns `{ html, matches, error }`.
131
+ */
132
+ export function tree(text, query = '') {
133
+ let data
134
+ try {
135
+ data = JSON.parse(text)
136
+ } catch (e) {
137
+ return { html: '', matches: 0, error: String(e.message || e) }
138
+ }
139
+ const budget = { left: MAX_NODES }
140
+ const out = node(null, data, 0, String(query || '').trim(), budget)
141
+ if (budget.left <= 0) {
142
+ return { html: '', matches: 0, error: 'This file is too big to lay out as a tree.' }
143
+ }
144
+ return { html: `<div class="json">${out.html}</div>`, matches: out.n, error: null }
145
+ }