k0-board 0.1.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/.claude/skills/k0-import/SKILL.md +83 -0
- package/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +688 -0
- package/bin/k0-board.js +456 -0
- package/bin/prompt.js +41 -0
- package/menubar/Info.plist +25 -0
- package/menubar/K0MenuBar.swift +561 -0
- package/menubar/build.sh +28 -0
- package/package.json +45 -0
- package/platform/contract.js +134 -0
- package/platform/darwin/extras.js +254 -0
- package/platform/darwin/index.js +24 -0
- package/platform/darwin/metrics.js +59 -0
- package/platform/darwin/power.js +133 -0
- package/platform/darwin/service.js +143 -0
- package/platform/darwin/shell.js +59 -0
- package/platform/darwin/terminal.js +324 -0
- package/platform/index.js +97 -0
- package/platform/linux/index.js +60 -0
- package/platform/linux/metrics.js +88 -0
- package/platform/linux/power.js +137 -0
- package/platform/linux/service.js +111 -0
- package/platform/linux/shell.js +97 -0
- package/platform/linux/tray.py +229 -0
- package/platform/shared/command.js +26 -0
- package/platform/shared/ps.js +40 -0
- package/platform/shared/run.js +99 -0
- package/platform/shared/tmux.js +242 -0
- package/platform/win32/index.js +53 -0
- package/platform/win32/metrics.js +72 -0
- package/platform/win32/power.js +109 -0
- package/platform/win32/powershell.js +44 -0
- package/platform/win32/service.js +111 -0
- package/platform/win32/shell.js +64 -0
- package/platform/win32/terminal.js +209 -0
- package/platform/win32/tray.ps1 +159 -0
- package/server/db.js +262 -0
- package/server/files.js +488 -0
- package/server/git.js +157 -0
- package/server/index.js +636 -0
- package/server/launcher.js +121 -0
- package/server/machine.js +213 -0
- package/server/mode.js +182 -0
- package/server/paths.js +67 -0
- package/server/pdf.js +110 -0
- package/server/projects.js +152 -0
- package/server/sessions.js +341 -0
- package/server/watcher.js +200 -0
- package/web/base.css +134 -0
- package/web/board.css +561 -0
- package/web/board.js +777 -0
- package/web/favicon.js +41 -0
- package/web/favicon.svg +8 -0
- package/web/files.css +535 -0
- package/web/files.html +63 -0
- package/web/files.js +877 -0
- package/web/fonts/caveat-latin-ext.woff2 +0 -0
- package/web/fonts/caveat-latin.woff2 +0 -0
- package/web/fuzzy.js +57 -0
- package/web/index.html +112 -0
- package/web/md.js +238 -0
- package/web/mentions.js +188 -0
- package/web/refs.js +176 -0
- package/web/title.js +26 -0
- package/web/units.js +33 -0
- package/web/view.js +248 -0
|
Binary file
|
|
Binary file
|
package/web/fuzzy.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ── Searching the names ───────────────────────────────────────────────────────
|
|
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/`.
|
|
5
|
+
//
|
|
6
|
+
// 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.
|
|
9
|
+
|
|
10
|
+
const BOUNDARY = new Set(['/', '-', '_', '.', ' '])
|
|
11
|
+
|
|
12
|
+
/**
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export function score(text, query) {
|
|
17
|
+
const q = String(query ?? '').trim().toLowerCase().replace(/\s+/g, '')
|
|
18
|
+
if (!q) return 0
|
|
19
|
+
const s = String(text ?? '')
|
|
20
|
+
const low = s.toLowerCase()
|
|
21
|
+
|
|
22
|
+
// Where the file name starts: from there on every hit is worth more.
|
|
23
|
+
const nameAt = s.lastIndexOf('/') + 1
|
|
24
|
+
|
|
25
|
+
let total = 0
|
|
26
|
+
let at = 0
|
|
27
|
+
let last = -2
|
|
28
|
+
for (const ch of q) {
|
|
29
|
+
const hit = low.indexOf(ch, at)
|
|
30
|
+
if (hit === -1) return -1
|
|
31
|
+
|
|
32
|
+
let point = 1
|
|
33
|
+
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
|
|
37
|
+
|
|
38
|
+
total += point
|
|
39
|
+
last = hit
|
|
40
|
+
at = hit + 1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// On equal hits the shorter name wins: there is less around what you were looking for.
|
|
44
|
+
return total - s.length * 0.01
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The rows that match, most alike first. `key` says where to look. */
|
|
48
|
+
export function search(items, query, key = (x) => x, limit = 200) {
|
|
49
|
+
if (!String(query ?? '').trim()) return items.slice(0, limit)
|
|
50
|
+
const hits = []
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
const s = score(key(item), query)
|
|
53
|
+
if (s >= 0) hits.push({ item, s })
|
|
54
|
+
}
|
|
55
|
+
hits.sort((a, b) => b.s - a.s)
|
|
56
|
+
return hits.slice(0, limit).map((h) => h.item)
|
|
57
|
+
}
|
package/web/index.html
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>k0 — Dashboard</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
8
|
+
<link rel="stylesheet" href="/board.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<h1>k0</h1>
|
|
13
|
+
<button class="new" id="new-card" title="New card" aria-label="New card">
|
|
14
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
15
|
+
</button>
|
|
16
|
+
<div class="filters" id="filters"></div>
|
|
17
|
+
|
|
18
|
+
<!-- How the computer is doing. Always visible; clicking it switches off the weights on the cards. -->
|
|
19
|
+
<button class="gauge" id="gauge" type="button" hidden></button>
|
|
20
|
+
|
|
21
|
+
<!-- The four modes: how awake the machine has to stay. It is a scale, not four switches —
|
|
22
|
+
sleep, away, nerd, driving, each one including the one before it — and exactly one is
|
|
23
|
+
always lit. Clicking the lit one again does nothing: to leave it you go to another. It
|
|
24
|
+
sits at the end of the bar, where driving mode used to be. -->
|
|
25
|
+
<div class="modes" id="modes" role="radiogroup" aria-label="Wake mode">
|
|
26
|
+
<button type="button" role="radio" data-mode="sleep" aria-checked="false" title="Sleep" aria-label="Sleep">
|
|
27
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h5l-5 6h5"/><path d="M4 12h8l-8 8h8"/></svg>
|
|
28
|
+
</button>
|
|
29
|
+
<button type="button" role="radio" data-mode="away" aria-checked="false" title="Away" aria-label="Away">
|
|
30
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11.6 21.5c0-4.6.8-8.2 2.1-11.2"/><path d="M13.7 10.3c-2.5-1.7-5.4-1.5-7.5.4"/><path d="M13.7 10.3c-1.4-2.7-3.8-4.1-6.4-3.9"/><path d="M13.7 10.3c.4-3 2.3-5.2 4.9-5.8"/><path d="M13.7 10.3c2.6-1.2 5.4-.6 7.2 1.4"/></svg>
|
|
31
|
+
</button>
|
|
32
|
+
<button type="button" role="radio" data-mode="nerd" aria-checked="false" title="Nerd" aria-label="Nerd">
|
|
33
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"/><circle cx="8.2" cy="10.4" r="2.8"/><circle cx="15.8" cy="10.4" r="2.8"/><path d="M11 10.4h2"/><path d="M8.6 16.4q3.4 2 6.8 0"/></svg>
|
|
34
|
+
</button>
|
|
35
|
+
<button type="button" role="radio" data-mode="driving" aria-checked="false" title="Driving" aria-label="Driving">
|
|
36
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 17h14"/><path d="M4 17v-4.2a2 2 0 0 1 .2-.9l1.9-3.8A2 2 0 0 1 7.9 7h8.2a2 2 0 0 1 1.8 1.1l1.9 3.8a2 2 0 0 1 .2.9V17"/><path d="M4 17v2h2v-2"/><path d="M18 17v2h2v-2"/><circle cx="7.5" cy="13.5" r="1"/><circle cx="16.5" cy="13.5" r="1"/></svg>
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
<main id="viewport" class="viewport">
|
|
42
|
+
<div id="board" class="board"></div>
|
|
43
|
+
<p class="empty" id="empty">The dashboard is empty. Add your first card.</p>
|
|
44
|
+
|
|
45
|
+
<!-- Where the board continues past the edge: with no scrollbars, it is the only way to tell. -->
|
|
46
|
+
<div class="edges" id="edges" aria-hidden="true"><i class="l"></i><i class="r"></i><i class="t"></i><i class="b"></i></div>
|
|
47
|
+
|
|
48
|
+
<div class="zoom" id="zoom">
|
|
49
|
+
<button type="button" id="z-out" title="Zoom out" aria-label="Zoom out">
|
|
50
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
|
51
|
+
</button>
|
|
52
|
+
<button type="button" id="z-level" title="Back to 100%">100%</button>
|
|
53
|
+
<button type="button" id="z-in" title="Zoom in" aria-label="Zoom in">
|
|
54
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
55
|
+
</button>
|
|
56
|
+
<button type="button" id="z-fit" title="Fit the whole board on screen" aria-label="Fit to screen">
|
|
57
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
</main>
|
|
61
|
+
|
|
62
|
+
<dialog id="editor">
|
|
63
|
+
<form id="editor-form">
|
|
64
|
+
<div class="head">
|
|
65
|
+
<h2 id="editor-title">New card</h2>
|
|
66
|
+
<button type="button" id="f-close" class="x" title="Close" aria-label="Close">
|
|
67
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<label>Repository
|
|
72
|
+
<div class="picker">
|
|
73
|
+
<input id="f-project" autocomplete="off" placeholder="search a repository…">
|
|
74
|
+
<div class="picker-list" id="f-project-list" hidden></div>
|
|
75
|
+
</div>
|
|
76
|
+
</label>
|
|
77
|
+
|
|
78
|
+
<label>Title
|
|
79
|
+
<input id="f-title" required autocomplete="off" spellcheck="false" placeholder="Fix-Login-Bug">
|
|
80
|
+
<small>this is also the session name</small>
|
|
81
|
+
</label>
|
|
82
|
+
|
|
83
|
+
<label>Description
|
|
84
|
+
<textarea id="f-desc" rows="2" placeholder="a line or two about this session"></textarea>
|
|
85
|
+
</label>
|
|
86
|
+
|
|
87
|
+
<label>What Claude should do when it starts
|
|
88
|
+
<textarea id="f-prompt" rows="6" placeholder="you can leave this empty"></textarea>
|
|
89
|
+
</label>
|
|
90
|
+
|
|
91
|
+
<footer>
|
|
92
|
+
<button type="button" id="f-delete" class="danger">Delete</button>
|
|
93
|
+
<span class="spacer"></span>
|
|
94
|
+
<button type="submit" id="f-save">Save</button>
|
|
95
|
+
<button type="button" id="f-start" class="primary">Start</button>
|
|
96
|
+
</footer>
|
|
97
|
+
</form>
|
|
98
|
+
</dialog>
|
|
99
|
+
|
|
100
|
+
<dialog id="confirm" class="ask">
|
|
101
|
+
<p id="confirm-text"></p>
|
|
102
|
+
<footer>
|
|
103
|
+
<span class="spacer"></span>
|
|
104
|
+
<button type="button" id="confirm-no">Cancel</button>
|
|
105
|
+
<button type="button" id="confirm-yes" class="destructive">Delete</button>
|
|
106
|
+
</footer>
|
|
107
|
+
</dialog>
|
|
108
|
+
|
|
109
|
+
<div id="toast" class="toast"></div>
|
|
110
|
+
<script type="module" src="/board.js"></script>
|
|
111
|
+
</body>
|
|
112
|
+
</html>
|
package/web/md.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// ── Markdown ──────────────────────────────────────────────────────────────────
|
|
2
|
+
// A small layout engine, written by hand because k0 has no dependencies and nothing to
|
|
3
|
+
// compile. It covers what documents actually contain: headings, lists — including the ones with
|
|
4
|
+
// checkboxes, which is the shape an archived plan takes — tables, quotes, code blocks, and
|
|
5
|
+
// inline bold, italic, strikethrough and links.
|
|
6
|
+
//
|
|
7
|
+
// The order matters. First every piece of HTML is escaped, so nothing in the file can become a
|
|
8
|
+
// tag; then the pieces that need protecting — code and links — are set aside and put back at
|
|
9
|
+
// the end, otherwise a `**x**` inside code would come out bold.
|
|
10
|
+
|
|
11
|
+
export const esc = (s) =>
|
|
12
|
+
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The header block between two lines of nothing but dashes, the way Claude Code's skills and
|
|
16
|
+
* plans write it. It is not text of the document: it is its label, and it should be read as
|
|
17
|
+
* one — the title becomes the name of the page, not just another paragraph.
|
|
18
|
+
* Returns `{ data, body }`; with no header, `data` is empty and `body` is everything.
|
|
19
|
+
*/
|
|
20
|
+
export function matter(md) {
|
|
21
|
+
const text = String(md ?? '').replace(/\0/g, '').replace(/\r\n?/g, '\n')
|
|
22
|
+
const m = /^---\n([\s\S]*?)\n---\n?/.exec(text)
|
|
23
|
+
if (!m) return { data: {}, body: text }
|
|
24
|
+
|
|
25
|
+
const data = {}
|
|
26
|
+
for (const line of m[1].split('\n')) {
|
|
27
|
+
// Only `key: value` on one line. The rest — lists, nesting — is of no use here, and
|
|
28
|
+
// trying would mean writing a whole YAML parser.
|
|
29
|
+
const kv = /^([A-Za-z_][\w -]*):\s*(.*)$/.exec(line)
|
|
30
|
+
if (!kv) continue
|
|
31
|
+
data[kv[1].trim().toLowerCase()] = kv[2].trim().replace(/^["'](.*)["']$/s, '$1')
|
|
32
|
+
}
|
|
33
|
+
return { data, body: text.slice(m[0].length) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The title written in the header, whatever it was called. */
|
|
37
|
+
export const titleOf = (data) => data.title || data.name || null
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `md` is the text, `link(target, kind)` decides where a reference leads: the viewer needs it
|
|
41
|
+
* to point images at the real bytes and links at the other documents.
|
|
42
|
+
*/
|
|
43
|
+
export function render(md, link = (t) => t) {
|
|
44
|
+
const lines = matter(md).body.split('\n')
|
|
45
|
+
|
|
46
|
+
const out = []
|
|
47
|
+
let i = 0
|
|
48
|
+
while (i < lines.length) {
|
|
49
|
+
const line = lines[i]
|
|
50
|
+
|
|
51
|
+
if (!line.trim()) {
|
|
52
|
+
i++
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A code fence: nothing inside is looked at, it is simply copied.
|
|
57
|
+
const fence = /^\s*(```+|~~~+)\s*(\S*)/.exec(line)
|
|
58
|
+
if (fence) {
|
|
59
|
+
const close = fence[1][0]
|
|
60
|
+
const body = []
|
|
61
|
+
i++
|
|
62
|
+
while (i < lines.length && !new RegExp(`^\\s*${close}{3,}\\s*$`).test(lines[i])) body.push(lines[i++])
|
|
63
|
+
i++ // the closing line
|
|
64
|
+
const lang = fence[2] ? ` class="lang-${esc(fence[2])}"` : ''
|
|
65
|
+
out.push(`<pre><code${lang}>${esc(body.join('\n'))}</code></pre>`)
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const head = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
70
|
+
if (head) {
|
|
71
|
+
const n = head[1].length
|
|
72
|
+
out.push(`<h${n}>${inline(head[2].replace(/\s+#+\s*$/, ''), link)}</h${n}>`)
|
|
73
|
+
i++
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(line)) {
|
|
78
|
+
out.push('<hr>')
|
|
79
|
+
i++
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (/^\s{0,3}>/.test(line)) {
|
|
84
|
+
const body = []
|
|
85
|
+
while (i < lines.length && (/^\s{0,3}>/.test(lines[i]) || (body.length && lines[i].trim()))) {
|
|
86
|
+
body.push(lines[i++].replace(/^\s{0,3}>\s?/, ''))
|
|
87
|
+
}
|
|
88
|
+
out.push(`<blockquote>${render(body.join('\n'), link)}</blockquote>`)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ITEM.test(line)) {
|
|
93
|
+
const items = []
|
|
94
|
+
while (i < lines.length && (ITEM.test(lines[i]) || (items.length && lines[i].trim() && /^\s{2,}/.test(lines[i])))) {
|
|
95
|
+
const m = ITEM.exec(lines[i])
|
|
96
|
+
if (m) items.push({ indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] })
|
|
97
|
+
// A continuation line: it belongs to the last item.
|
|
98
|
+
else items[items.length - 1].text += ` ${lines[i].trim()}`
|
|
99
|
+
i++
|
|
100
|
+
}
|
|
101
|
+
// One pass is usually enough. A second is only needed when a list starts indented and
|
|
102
|
+
// then comes back to the left: those items are a list of their own.
|
|
103
|
+
for (let k = 0; k < items.length; ) {
|
|
104
|
+
const built = nest(items, k, link)
|
|
105
|
+
out.push(built.html)
|
|
106
|
+
k = built.i
|
|
107
|
+
}
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (line.includes('|') && i + 1 < lines.length && isRule(lines[i + 1])) {
|
|
112
|
+
const header = cells(line)
|
|
113
|
+
const align = cells(lines[i + 1]).map((c) => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : ''))
|
|
114
|
+
i += 2
|
|
115
|
+
const rows = []
|
|
116
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(cells(lines[i++]))
|
|
117
|
+
const th = header.map((c, k) => `<th${style(align[k])}>${inline(c, link)}</th>`).join('')
|
|
118
|
+
const tb = rows
|
|
119
|
+
.map((r) => `<tr>${r.map((c, k) => `<td${style(align[k])}>${inline(c, link)}</td>`).join('')}</tr>`)
|
|
120
|
+
.join('')
|
|
121
|
+
out.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A paragraph runs until something else starts. The lines are joined: documents wrap at
|
|
126
|
+
// seventy characters, and breaking there for real would cut every sentence in half.
|
|
127
|
+
const para = []
|
|
128
|
+
while (i < lines.length && lines[i].trim() && !starts(lines[i])) para.push(lines[i++])
|
|
129
|
+
out.push(`<p>${inline(para.join('\n'), link)}</p>`)
|
|
130
|
+
}
|
|
131
|
+
return out.join('\n')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
|
|
135
|
+
const style = (a) => (a ? ` style="text-align:${a}"` : '')
|
|
136
|
+
const cells = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim())
|
|
137
|
+
const isRule = (line) => {
|
|
138
|
+
const c = cells(line)
|
|
139
|
+
return c.length > 0 && c.every((x) => /^:?-{1,}:?$/.test(x))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The lines that open a different block, and so close the paragraph in progress. */
|
|
143
|
+
const starts = (l) =>
|
|
144
|
+
/^\s*(```|~~~)/.test(l) || /^#{1,6}\s/.test(l) || /^\s{0,3}>/.test(l) || ITEM.test(l) || /^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(l)
|
|
145
|
+
|
|
146
|
+
/** Lists nest by indentation: everything further right is taken. */
|
|
147
|
+
function nest(items, from, link) {
|
|
148
|
+
const indent = items[from].indent
|
|
149
|
+
const ordered = items[from].ordered
|
|
150
|
+
const parts = []
|
|
151
|
+
let i = from
|
|
152
|
+
while (i < items.length && items[i].indent >= indent) {
|
|
153
|
+
if (items[i].indent > indent) {
|
|
154
|
+
const inner = nest(items, i, link)
|
|
155
|
+
parts[parts.length - 1] += inner.html
|
|
156
|
+
i = inner.i
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
parts.push(item(items[i].text, link))
|
|
160
|
+
i++
|
|
161
|
+
}
|
|
162
|
+
const tag = ordered ? 'ol' : 'ul'
|
|
163
|
+
return { html: `<${tag}>${parts.map((p) => `<li>${p}</li>`).join('')}</${tag}>`, i }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** `- [ ]` and `- [x]`: the checkboxes of an archived plan, shown but not tickable. */
|
|
167
|
+
function item(text, link) {
|
|
168
|
+
const box = /^\[( |x|X)\]\s+(.*)$/.exec(text)
|
|
169
|
+
if (!box) return inline(text, link)
|
|
170
|
+
const on = box[1] !== ' '
|
|
171
|
+
// The text has to be wrapped: the label is both a column and a row, and without one
|
|
172
|
+
// container every bold fragment would become a column of its own.
|
|
173
|
+
return `<label class="task"><input type="checkbox" disabled${on ? ' checked' : ''}><span>${inline(
|
|
174
|
+
box[2],
|
|
175
|
+
link
|
|
176
|
+
)}</span></label>`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Inline ───────────────────────────────────────────────────────────────────
|
|
180
|
+
function inline(raw, link) {
|
|
181
|
+
// Anything already finished is set aside behind a placeholder and put back at the end. The
|
|
182
|
+
// placeholder lives in Unicode's private-use area: no real document contains those
|
|
183
|
+
// characters, so there is no way to confuse one with the text.
|
|
184
|
+
const kept = []
|
|
185
|
+
const hold = (html) => `\uE000${kept.push(html) - 1}\uE001`
|
|
186
|
+
|
|
187
|
+
let s = esc(raw)
|
|
188
|
+
|
|
189
|
+
// Code first: what is inside it is not markdown, it is text.
|
|
190
|
+
s = s.replace(/`([^`\n]+)`/g, (_, c) => hold(`<code>${c}</code>`))
|
|
191
|
+
// Images before links: same syntax, with an exclamation mark in front.
|
|
192
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^)]*")?\)/g, (_, alt, src) =>
|
|
193
|
+
hold(`<img src="${href(src, 'image', link)}" alt="${alt}" loading="lazy">`)
|
|
194
|
+
)
|
|
195
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^)]*")?\)/g, (_, text, target) => {
|
|
196
|
+
const u = href(target, 'link', link)
|
|
197
|
+
const out = /^https?:/i.test(u) ? ' target="_blank" rel="noreferrer"' : ''
|
|
198
|
+
return hold(`<a href="${u}"${out}>${text}</a>`)
|
|
199
|
+
})
|
|
200
|
+
// Bare addresses, which in documents are the majority.
|
|
201
|
+
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<)]+)/g, (_, before, u) =>
|
|
202
|
+
`${before}${hold(`<a href="${u}" target="_blank" rel="noreferrer">${u}</a>`)}`
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
// Bold can start on one line and end on the next. Documents wrap at seventy characters, and
|
|
206
|
+
// a bold phrase longer than that is bound to straddle a break: excluding the newline left it
|
|
207
|
+
// on screen with the asterisks still in it. Crossing a newline here is safe — the real break,
|
|
208
|
+
// the one between one paragraph and the next, never reaches this far: every block is laid out
|
|
209
|
+
// on its own.
|
|
210
|
+
s = s
|
|
211
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
212
|
+
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
|
213
|
+
.replace(/(^|[^*\w])\*([^*]+)\*/g, '$1<em>$2</em>')
|
|
214
|
+
.replace(/(^|[^_\w])_([^_]+)_/g, '$1<em>$2</em>')
|
|
215
|
+
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
|
216
|
+
// Two spaces at the end of a line are a deliberate break; other newlines are not.
|
|
217
|
+
.replace(/ {2,}\n/g, '<br>\n')
|
|
218
|
+
|
|
219
|
+
// Several passes, because the placeholders nest: a link whose text is already a piece set
|
|
220
|
+
// aside — `[`name.md`](...)` — brings the code's placeholder back out when it is restored.
|
|
221
|
+
// A single pass would leave it there, on screen, as a little square.
|
|
222
|
+
for (let i = 0; i < 6 && s.includes('\uE000'); i++) {
|
|
223
|
+
s = s.replace(/\uE000(\d+)\uE001/g, (_, k) => kept[Number(k)] ?? '')
|
|
224
|
+
}
|
|
225
|
+
return s
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Where a reference leads. Schemes that are not addresses — `javascript:`, `data:` — do not get
|
|
230
|
+
* through: Claude writes these files, but they are still files that come off the disk and
|
|
231
|
+
* nobody has looked at them before they are shown.
|
|
232
|
+
*/
|
|
233
|
+
function href(target, kind, link) {
|
|
234
|
+
const t = target.trim()
|
|
235
|
+
if (/^(https?:|mailto:|#)/i.test(t)) return t
|
|
236
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(t)) return '#'
|
|
237
|
+
return link(t, kind)
|
|
238
|
+
}
|
package/web/mentions.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// ── The files a piece of text names ───────────────────────────────────────────
|
|
2
|
+
// When a session ends, the summary in the chat says what it touched and names the files:
|
|
3
|
+
// "from-the-call.md", "interviews/onboarding/journal.md", "the survey". To read them again you
|
|
4
|
+
// had to go and find them one by one, remembering what each was called.
|
|
5
|
+
//
|
|
6
|
+
// This does the opposite: it takes the text as it is and works out which of this repository's
|
|
7
|
+
// files it names. It does not look for the subject — only for the names written down — which
|
|
8
|
+
// is why it costs nothing and never goes near the server: the file listing is already in the
|
|
9
|
+
// browser.
|
|
10
|
+
//
|
|
11
|
+
// What is written out in full and what is merely guessed at stay separate, because they are
|
|
12
|
+
// two different degrees of certainty: mixing them would make a word that is only a hunch look
|
|
13
|
+
// like a fact.
|
|
14
|
+
|
|
15
|
+
/** The extension on the end of a name. It has to start with a letter: `1.5` is not a file. */
|
|
16
|
+
const EXT = /\.([A-Za-z][A-Za-z0-9]{0,5})$/
|
|
17
|
+
/** A piece of text that might be a name or a path. */
|
|
18
|
+
const TOKEN = /[A-Za-z0-9][A-Za-z0-9._/-]*/g
|
|
19
|
+
/** Under four characters a word is not the name of anything: it is a word. */
|
|
20
|
+
const MIN = 4
|
|
21
|
+
|
|
22
|
+
const low = (s) => String(s ?? '').toLowerCase()
|
|
23
|
+
const nameOf = (p) => p.slice(p.lastIndexOf('/') + 1)
|
|
24
|
+
const dirOf = (p) => p.slice(0, Math.max(0, p.lastIndexOf('/')))
|
|
25
|
+
const newest = (a, b) => b.m - a.m
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The indexes the search runs on, rebuilt on every call: they cost one pass over the listing,
|
|
29
|
+
* and the caller already knows when the listing changes.
|
|
30
|
+
*
|
|
31
|
+
* `byDir` only knows a directory's **direct** files, not those of its subdirectories: opening
|
|
32
|
+
* a directory means seeing what is in it, not the whole tree. Which is why a directory
|
|
33
|
+
* containing only other directories does not appear here — it would have nothing to show.
|
|
34
|
+
*/
|
|
35
|
+
function index(files) {
|
|
36
|
+
const byName = new Map()
|
|
37
|
+
const byStem = new Map()
|
|
38
|
+
const byDir = new Map()
|
|
39
|
+
const dirTail = new Map() // last piece of a directory → the directories ending that way
|
|
40
|
+
const exts = new Set()
|
|
41
|
+
const push = (map, key, val) => {
|
|
42
|
+
const a = map.get(key)
|
|
43
|
+
if (a) a.push(val)
|
|
44
|
+
else map.set(key, [val])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
const p = low(f.p)
|
|
49
|
+
const n = nameOf(p)
|
|
50
|
+
push(byName, n, f)
|
|
51
|
+
push(byStem, n.replace(EXT, ''), f)
|
|
52
|
+
const ext = EXT.exec(n)
|
|
53
|
+
if (ext) exts.add(ext[1])
|
|
54
|
+
const d = dirOf(p)
|
|
55
|
+
if (d) push(byDir, d, f)
|
|
56
|
+
}
|
|
57
|
+
for (const d of byDir.keys()) push(dirTail, nameOf(d), d)
|
|
58
|
+
|
|
59
|
+
return { byName, byStem, byDir, dirTail, exts, stems: [...byStem.keys()] }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The pieces of text worth trying, lower case and in the order they appear. */
|
|
63
|
+
function tokens(text) {
|
|
64
|
+
const out = []
|
|
65
|
+
for (const m of String(text ?? '').matchAll(TOKEN)) {
|
|
66
|
+
// Punctuation stuck on the end is not part of the name: "…it is in from-the-call.md."
|
|
67
|
+
const t = low(m[0]).replace(/[._/-]+$/, '')
|
|
68
|
+
if (t) out.push(t)
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The files a token with an extension names, or `null`.
|
|
75
|
+
*
|
|
76
|
+
* The path can be written in full, only from the end, or with things in front that do not
|
|
77
|
+
* count here (`research-notes/interviews/onboarding/journal.md`): they are all worth the same.
|
|
78
|
+
* If the path as written matches nobody but the name does, the name wins — people writing from
|
|
79
|
+
* memory get the directory wrong far more often than the name.
|
|
80
|
+
*/
|
|
81
|
+
function filesFor(token, ix) {
|
|
82
|
+
const cands = ix.byName.get(nameOf(token))
|
|
83
|
+
if (!cands) return null
|
|
84
|
+
if (!token.includes('/')) return cands
|
|
85
|
+
const exact = cands.filter((f) => {
|
|
86
|
+
const p = low(f.p)
|
|
87
|
+
return p === token || p.endsWith('/' + token) || token.endsWith('/' + p)
|
|
88
|
+
})
|
|
89
|
+
return exact.length ? exact : cands
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The directory a token names, or `null`. If one name matches several directories and there is
|
|
94
|
+
* no path to tell them apart, it is left alone: a single row that opens the wrong directory is
|
|
95
|
+
* worse than no row at all.
|
|
96
|
+
*/
|
|
97
|
+
function dirFor(token, ix) {
|
|
98
|
+
if (ix.byDir.has(token)) return token
|
|
99
|
+
const tails = ix.dirTail.get(nameOf(token)) || []
|
|
100
|
+
const exact = tails.filter((d) => d.endsWith('/' + token) || token.endsWith('/' + d))
|
|
101
|
+
return exact.length === 1 ? exact[0] : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* This repository's files as named inside `text`.
|
|
106
|
+
*
|
|
107
|
+
* - `named` — what is written out in full: a name with its extension, or a directory path.
|
|
108
|
+
* There is nothing to guess.
|
|
109
|
+
* - `maybe` — what is only guessed at: a word that is a file's name without the extension
|
|
110
|
+
* ("summary"), or the whole beginning of a name ("survey" for `survey-round-two.md`).
|
|
111
|
+
* - `missing` — names written with a dot that do not exist in here. Only those with an
|
|
112
|
+
* extension the repository actually uses, otherwise `example.com` would look like a lost file.
|
|
113
|
+
*
|
|
114
|
+
* Every entry is `{ token, file, more }` for a file — `file` is the most recently touched one
|
|
115
|
+
* when a name points at several, `more` are the rest — or `{ token, dir, files }` for a
|
|
116
|
+
* directory. A file never appears twice, and what is certain takes precedence over what is
|
|
117
|
+
* guessed.
|
|
118
|
+
*/
|
|
119
|
+
export function mentions(text, files) {
|
|
120
|
+
const ix = index(files || [])
|
|
121
|
+
const toks = tokens(text)
|
|
122
|
+
const seen = new Set()
|
|
123
|
+
const named = []
|
|
124
|
+
const maybe = []
|
|
125
|
+
const missing = []
|
|
126
|
+
const missed = new Set()
|
|
127
|
+
|
|
128
|
+
/** Drops the ones already listed and puts the most recent first. */
|
|
129
|
+
const fresh = (hits) => (hits || []).filter((f) => !seen.has(f.p)).sort(newest)
|
|
130
|
+
const keep = (out, entry, taken) => {
|
|
131
|
+
for (const f of taken) seen.add(f.p)
|
|
132
|
+
out.push(entry)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// First pass: what is written out in full.
|
|
136
|
+
for (const t of toks) {
|
|
137
|
+
if (EXT.test(t)) {
|
|
138
|
+
const hits = filesFor(t, ix)
|
|
139
|
+
if (!hits) {
|
|
140
|
+
if (ix.exts.has(EXT.exec(t)[1]) && !missed.has(t)) {
|
|
141
|
+
missed.add(t)
|
|
142
|
+
missing.push(t)
|
|
143
|
+
}
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
const f = fresh(hits)
|
|
147
|
+
if (f.length) keep(named, { token: t, file: f[0], more: f.slice(1) }, f)
|
|
148
|
+
} else if (t.includes('/')) {
|
|
149
|
+
// A directory written as a path is not just any word: it is certain.
|
|
150
|
+
const d = dirFor(t, ix)
|
|
151
|
+
const f = d ? fresh(ix.byDir.get(d)) : []
|
|
152
|
+
if (f.length) keep(named, { token: t, dir: d, files: f }, f)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Second pass: the names that are only guessed at. It comes after the first, so a file
|
|
157
|
+
// already found by its full name does not turn up again down here as a hunch.
|
|
158
|
+
const guess = (t) => t.length >= MIN && !EXT.test(t) && !t.includes('/')
|
|
159
|
+
|
|
160
|
+
// File names first: the whole name without the dot, failing that the whole beginning of a
|
|
161
|
+
// name cut at a dash — "survey" for `survey-round-two.md`.
|
|
162
|
+
for (const t of toks) {
|
|
163
|
+
if (!guess(t)) continue
|
|
164
|
+
const hits =
|
|
165
|
+
ix.byStem.get(t) ||
|
|
166
|
+
ix.stems.filter((s) => s.startsWith(t + '-')).flatMap((s) => ix.byStem.get(s))
|
|
167
|
+
const f = fresh(hits)
|
|
168
|
+
if (f.length) keep(maybe, { token: t, file: f[0], more: f.slice(1) }, f)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Then the directories named with a single word. They come last because they are the weakest
|
|
172
|
+
// clue there is: "onboarding" in a text about onboarding is an ordinary word, and if it came
|
|
173
|
+
// first it would carry off `interviews/onboarding/summary.md`, leaving "summary" to show the
|
|
174
|
+
// summary of something else entirely.
|
|
175
|
+
for (const t of toks) {
|
|
176
|
+
if (!guess(t)) continue
|
|
177
|
+
const d = dirFor(t, ix)
|
|
178
|
+
const f = d ? fresh(ix.byDir.get(d)) : []
|
|
179
|
+
if (f.length) keep(maybe, { token: t, dir: d, files: f }, f)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { named, maybe, missing }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How many files a group of entries opens: a directory counts for what is inside it. */
|
|
186
|
+
export function countIn(entries) {
|
|
187
|
+
return entries.reduce((n, e) => n + (e.files ? e.files.length : 1 + e.more.length), 0)
|
|
188
|
+
}
|