rvlib-shipkit 2.3.0 → 2.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/CHANGELOG.md +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/trailer/url.ts
CHANGED
|
@@ -3,7 +3,19 @@
|
|
|
3
3
|
// it does not answer. Pure first, the io after
|
|
4
4
|
import type { Film } from './grammar.ts'
|
|
5
5
|
|
|
6
|
-
export type UrlFilm = Extract<Film, { kind: 'url' }>
|
|
6
|
+
export type UrlFilm = Extract<Film, { kind: 'url' | 'page' }>
|
|
7
|
+
|
|
8
|
+
/** a page's query words as the url's query, in the order written; a flag a key with no value */
|
|
9
|
+
export function queryOf(words: readonly string[], declaredFlags: ReadonlySet<string> = new Set()): string {
|
|
10
|
+
const q = new URLSearchParams()
|
|
11
|
+
for (let i = 0; i < words.length; i++) {
|
|
12
|
+
const k = words[i]!
|
|
13
|
+
if (declaredFlags.has(k)) q.append(k, '')
|
|
14
|
+
else q.append(k, words[++i] ?? '')
|
|
15
|
+
}
|
|
16
|
+
const s = q.toString()
|
|
17
|
+
return s ? `?${s}` : ''
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
/** a global process of the repo that serves a url */
|
|
9
21
|
export interface DevProcess {
|
|
@@ -98,11 +110,14 @@ export interface UrlTake {
|
|
|
98
110
|
|
|
99
111
|
/** film the page of a `film url` line at `out`; the note says what was filmed and how */
|
|
100
112
|
export async function filmUrl(f: UrlFilm, o: UrlTake): Promise<string> {
|
|
101
|
-
const
|
|
113
|
+
const page = /^https?:\/\//.test(f.route) ? f.route : urlOf(f.route, await baseUrl(o.root, o.base, o.say))
|
|
114
|
+
const url = f.kind === 'page' ? `${page}${f.query.length ? `${page.includes('?') ? '&' : ''}${queryOf(f.query, new Set(f.flags ?? [])).slice(page.includes('?') ? 1 : 0)}` : ''}` : page
|
|
102
115
|
const { clipUrl, shotUrl } = await import('../shot/client.ts')
|
|
103
116
|
const { viewport, dpr } = viewportOf(o.size, f.viewport)
|
|
104
117
|
const wait = f.wait === undefined ? {} : { waitMs: Math.round(f.wait * 1000) }
|
|
105
118
|
const log = (l: string): void => o.say(` ${l}`)
|
|
106
|
-
const
|
|
119
|
+
const step = f.kind === 'page' ? { step: f.step } : {}
|
|
120
|
+
const still = f.kind === 'page' ? { step: f.step, at: f.at ?? 0 } : {}
|
|
121
|
+
const r = o.seconds === null ? await shotUrl({ url, out: o.out, viewport, dpr, ...wait, ...still }, log) : await clipUrl({ url, out: o.out, viewport, dpr, seconds: o.seconds, fps: o.fps, ...wait, ...step }, log)
|
|
107
122
|
return `${url} at ${viewport.width}x${viewport.height}@${dpr}, ${r.via}, ${r.ms} ms${r.note ? `: ${r.note}` : ''}`
|
|
108
123
|
}
|
package/src/trailer/vocab.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import type { FilmVerb } from './grammar.ts'
|
|
7
|
+
import { type Words, wordsProblem } from './words.ts'
|
|
7
8
|
|
|
8
9
|
/** the project CLI's argv from the `cli` script of package.json, null when there is none */
|
|
9
10
|
export function projectCli(root: string): string[] | null {
|
|
@@ -14,11 +15,19 @@ export function projectCli(root: string): string[] | null {
|
|
|
14
15
|
return cli.trim().split(/\s+/)
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** a row's declared words, checked: a declaration that is no declaration is the CLI's error, said loud */
|
|
19
|
+
function wordsOf(path: string[], words: unknown): { words?: Words } {
|
|
20
|
+
if (words === undefined) return {}
|
|
21
|
+
const why = wordsProblem(words)
|
|
22
|
+
if (why !== null) throw new Error(`the film row ${path.join(' ')} declares its words wrong: ${why}`)
|
|
23
|
+
return { words: words as Words }
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
/** the film rows in a `commands --json` answer: an array of rows, or `{ commands: [...] }` */
|
|
18
27
|
export function filmRows(json: unknown): FilmVerb[] {
|
|
19
28
|
const rows = Array.isArray(json) ? json : Array.isArray((json as { commands?: unknown })?.commands) ? (json as { commands: unknown[] }).commands : []
|
|
20
29
|
return rows.flatMap((r) => {
|
|
21
|
-
const o = r as { path?: unknown; film?: unknown; version?: unknown; length?: unknown; description?: unknown }
|
|
30
|
+
const o = r as { path?: unknown; film?: unknown; version?: unknown; length?: unknown; description?: unknown; words?: unknown; stamp?: unknown }
|
|
22
31
|
if (o.film !== 'still' && o.film !== 'clip') return []
|
|
23
32
|
const path = Array.isArray(o.path) ? o.path.map(String) : typeof o.path === 'string' ? o.path.trim().split(/\s+/) : []
|
|
24
33
|
if (path.length === 0) return []
|
|
@@ -28,6 +37,8 @@ export function filmRows(json: unknown): FilmVerb[] {
|
|
|
28
37
|
film: o.film,
|
|
29
38
|
...(typeof o.version === 'number' || typeof o.version === 'string' ? { version: o.version } : {}),
|
|
30
39
|
...(o.length === 'own' ? { length: 'own' as const } : {}),
|
|
40
|
+
...wordsOf(path, o.words),
|
|
41
|
+
...(Array.isArray(o.stamp) && o.stamp.length > 0 ? { stamp: o.stamp.map(String) } : {}),
|
|
31
42
|
...(typeof o.description === 'string' ? { description: o.description } : {}),
|
|
32
43
|
},
|
|
33
44
|
]
|
package/src/trailer/words.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// list, and hands the row its values made plain (argv stays the transport). Pure: times arrive as moments the grammar
|
|
4
4
|
// resolves once the shots have landed
|
|
5
5
|
|
|
6
|
-
export type WordType = 'number' | 'int' | 'enum' | 'point' | 'time' | 'cues' | 'file' | 'text' | 'word' | 'flag'
|
|
7
|
-
export const WORD_TYPES: readonly WordType[] = ['number', 'int', 'enum', 'point', 'time', 'cues', 'file', 'text', 'word', 'flag']
|
|
6
|
+
export type WordType = 'number' | 'int' | 'enum' | 'point' | 'time' | 'length' | 'cues' | 'file' | 'text' | 'word' | 'flag'
|
|
7
|
+
export const WORD_TYPES: readonly WordType[] = ['number', 'int', 'enum', 'point', 'time', 'length', 'cues', 'file', 'text', 'word', 'flag']
|
|
8
8
|
|
|
9
9
|
/** one word a row takes: its type and bounds; `arg: n` makes it the n-th positional */
|
|
10
10
|
export interface WordSpec {
|
|
@@ -65,6 +65,11 @@ export function wordsProblem(words: unknown): string | null {
|
|
|
65
65
|
|
|
66
66
|
const n4 = (x: number): string => String(+x.toFixed(4))
|
|
67
67
|
|
|
68
|
+
// a cut writes plain decimals: Number() alone would take 1e2, 0x10 and Infinity
|
|
69
|
+
const DECIMAL = /^-?(\d+(\.\d*)?|\.\d+)$/
|
|
70
|
+
const INTEGER = /^-?\d+$/
|
|
71
|
+
const decimal = (t: string): number => (DECIMAL.test(t) ? Number(t) : Number.NaN)
|
|
72
|
+
|
|
68
73
|
/** one value read by its type: its plain text, a moment, or why it does not fit */
|
|
69
74
|
function valueOf(key: string, spec: WordSpec, w: LineWord, time: (text: string) => Moment | null): { text: string } | { at: Moment } | { error: string } {
|
|
70
75
|
const t = w.text
|
|
@@ -72,21 +77,26 @@ function valueOf(key: string, spec: WordSpec, w: LineWord, time: (text: string)
|
|
|
72
77
|
switch (spec.type) {
|
|
73
78
|
case 'number':
|
|
74
79
|
case 'int': {
|
|
75
|
-
const v = w.quoted ? Number.NaN :
|
|
76
|
-
if (!Number.isFinite(v) || (spec.type === 'int' && !
|
|
80
|
+
const v = w.quoted ? Number.NaN : decimal(t)
|
|
81
|
+
if (!Number.isFinite(v) || (spec.type === 'int' && !INTEGER.test(t)) || (spec.min !== undefined && v < spec.min) || (spec.max !== undefined && v > spec.max)) return { error: what }
|
|
77
82
|
return { text: t }
|
|
78
83
|
}
|
|
79
84
|
case 'enum':
|
|
80
85
|
return !w.quoted && spec.values!.includes(t) ? { text: t } : { error: what }
|
|
81
86
|
case 'point': {
|
|
82
87
|
const parts = w.quoted ? [] : t.split(',')
|
|
83
|
-
const ok = parts.length === (spec.count ?? 2) && parts.every((p) =>
|
|
88
|
+
const ok = parts.length === (spec.count ?? 2) && parts.every((p) => Number.isFinite(decimal(p)))
|
|
84
89
|
return ok ? { text: t } : { error: what }
|
|
85
90
|
}
|
|
86
91
|
case 'time': {
|
|
87
92
|
const at = w.quoted ? null : time(t)
|
|
88
93
|
return at === null ? { error: what } : { at }
|
|
89
94
|
}
|
|
95
|
+
case 'length': {
|
|
96
|
+
// a duration: never a moment, never moved by the shot's from
|
|
97
|
+
const at = w.quoted ? null : time(t)
|
|
98
|
+
return typeof at === 'number' && (spec.min === undefined || at >= spec.min) && (spec.max === undefined || at <= spec.max) ? { text: n4(at) } : { error: what }
|
|
99
|
+
}
|
|
90
100
|
case 'file':
|
|
91
101
|
case 'word':
|
|
92
102
|
if (w.quoted || t === '' || (spec.pattern !== undefined && !new RegExp(spec.pattern).test(t))) return { error: what }
|
|
@@ -109,6 +119,8 @@ function describe(s: WordSpec): string {
|
|
|
109
119
|
return `${s.count ?? 2} numbers joined by commas`
|
|
110
120
|
case 'time':
|
|
111
121
|
return 'a time (1.5s, 400ms, 0:04, 2beats, a mark, mark+1beat)'
|
|
122
|
+
case 'length':
|
|
123
|
+
return `a length (1.5s, 400ms, 2beats)${s.min !== undefined || s.max !== undefined ? ` from ${s.min ?? 0} to ${s.max ?? '∞'} s` : ''}`
|
|
112
124
|
case 'cues':
|
|
113
125
|
return 'cues, name@<time> each'
|
|
114
126
|
case 'file':
|
|
@@ -144,7 +156,7 @@ interface Entry {
|
|
|
144
156
|
|
|
145
157
|
/** the entries of a run of words: a word that names a key opens it (its value words follow), any other word fills the
|
|
146
158
|
* next positional that takes it */
|
|
147
|
-
function entriesOf(path: string, words: Words, ws: LineWord[], time: (text: string) => Moment | null, errors: Typed['errors']): Entry[] {
|
|
159
|
+
function entriesOf(path: string, words: Words, ws: LineWord[], time: (text: string) => Moment | null, errors: Typed['errors'], keysOnly = false): Entry[] {
|
|
148
160
|
const args = Object.entries(words)
|
|
149
161
|
.filter(([, s]) => s.arg !== undefined)
|
|
150
162
|
.sort((a, b) => a[1].arg! - b[1].arg!)
|
|
@@ -173,6 +185,13 @@ function entriesOf(path: string, words: Words, ws: LineWord[], time: (text: stri
|
|
|
173
185
|
}
|
|
174
186
|
continue
|
|
175
187
|
}
|
|
188
|
+
// a section's defaults name keys: a word there that is no key of this row is a typo, never a positional
|
|
189
|
+
if (keysOnly) {
|
|
190
|
+
errors.push({ line: w.line, message: `defaults: ${w.quoted ? `"${w.text}"` : w.text} is no word film ${path} takes (${path} takes ${wordList(words)})` })
|
|
191
|
+
const v = ws[i + 1]
|
|
192
|
+
if (v && v.line === w.line && !(!v.quoted && words[v.text])) i++
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
176
195
|
// the next positional that takes this word; an optional one that does not is passed over
|
|
177
196
|
let placed = false
|
|
178
197
|
while (next < args.length) {
|
|
@@ -206,8 +225,10 @@ export function typedFilm(path: string[], words: Words, ws: LineWord[], defaults
|
|
|
206
225
|
const errors: Typed['errors'] = []
|
|
207
226
|
const own = entriesOf(path.join(' '), words, ws, time, errors)
|
|
208
227
|
const given = new Set(own.map((e) => e.key))
|
|
209
|
-
const fromDefaults = defaults.length > 0 ? entriesOf(path.join(' '), words, defaults, time, errors).filter((e) => !given.has(e.key)) : []
|
|
210
|
-
|
|
228
|
+
const fromDefaults = defaults.length > 0 ? entriesOf(path.join(' '), words, defaults, time, errors, true).filter((e) => !given.has(e.key)) : []
|
|
229
|
+
// the positionals first, in their arg order, as the row reads them by place; then the keys, the defaults' first
|
|
230
|
+
const args = own.filter((e) => e.positional).sort((a, b) => words[a.key]!.arg! - words[b.key]!.arg!)
|
|
231
|
+
const all = [...args, ...fromDefaults, ...own.filter((e) => !e.positional)]
|
|
211
232
|
const seen = new Set<string>()
|
|
212
233
|
const argv = [...path]
|
|
213
234
|
const times: Typed['times'] = []
|
package/src/wiki/graph-mount.ts
CHANGED
|
@@ -12,6 +12,9 @@ export type GraphMount = {
|
|
|
12
12
|
/** graph page title */
|
|
13
13
|
title: string
|
|
14
14
|
load: () => MdGraph
|
|
15
|
+
/** the folder its node ids are relative to: the wikis page searches and
|
|
16
|
+
* previews the pages there (graph-pages.ts). Absent = no page is read */
|
|
17
|
+
dir?: string
|
|
15
18
|
/** what judges this graph: the built-in md lint over a dir, the repo's own
|
|
16
19
|
* checks by id (their last verdicts), or null for a wiki living in another
|
|
17
20
|
* repo, which the card links and never judges */
|
package/src/wiki/graph-page.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// (graph-colors.ts), eager layer ringed. Directed solid edges with arrowheads,
|
|
4
4
|
// a thick purple one = eager auto-include, a thin grey one = lazy ref. Drag
|
|
5
5
|
// balls, drag background to pan, wheel to zoom, hover to focus a node's links.
|
|
6
|
+
// Embedded in the hub (wikis-view.tsx), it talks to its parent by message: a
|
|
7
|
+
// click on a ball sends `wiki-graph:pick`, and `wiki-graph:focus` rings a ball
|
|
8
|
+
// and glides it to the centre, `wiki-graph:theme` repaints it light or dark
|
|
9
|
+
// (first given as ?theme=). The hub owns the search and the preview.
|
|
6
10
|
import { nodeHues } from 'rvlib-shipkit/src/wiki/graph-colors.ts'
|
|
7
11
|
import { SAVE_AFTER_MS, saveOnceAfter, type Seed } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
|
|
8
12
|
import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
@@ -37,13 +41,16 @@ export function graphHtml(g: MdGraph, opts?: { title?: string; seed?: Seed; save
|
|
|
37
41
|
</style>
|
|
38
42
|
</head>
|
|
39
43
|
<body>
|
|
40
|
-
<header><b>${title}</b><span>thick purple → @ co-load · thin → lazy ref · ringed = eager layer · number = hops from index, bigger = closer · colour = link neighbourhood · drag / pan / zoom · reload = regenerate</span></header>
|
|
44
|
+
<header><b>${title}</b><span>thick purple → @ co-load · thin → lazy ref · ringed = eager layer · number = hops from index, bigger = closer · colour = link neighbourhood · click = open · drag / pan / zoom · reload = regenerate</span></header>
|
|
41
45
|
<canvas id="c"></canvas>
|
|
42
46
|
<script>
|
|
43
47
|
const DATA = ${dataJson}
|
|
44
48
|
const canvas = document.getElementById('c')
|
|
45
49
|
const ctx = canvas.getContext('2d')
|
|
46
|
-
|
|
50
|
+
// the hub's theme, else the system's
|
|
51
|
+
let dark = (new URLSearchParams(location.search).get('theme') ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark'
|
|
52
|
+
const applyTheme = () => { document.documentElement.style.colorScheme = dark ? 'dark' : 'light' }
|
|
53
|
+
applyTheme()
|
|
47
54
|
const N = DATA.nodes, byId = new Map(N.map(n => [n.id, n]))
|
|
48
55
|
const E = DATA.edges.filter(e => byId.has(e.from) && byId.has(e.to))
|
|
49
56
|
const SIZE_BY_DEPTH = [3, 2, 1.5]
|
|
@@ -59,6 +66,8 @@ for (const n of N) {
|
|
|
59
66
|
n.r = 6 * (SIZE_BY_DEPTH[n.depth] ?? 1) + Math.min(4, Math.sqrt(deg.get(n.id) ?? 0))
|
|
60
67
|
}
|
|
61
68
|
let scale = 1, ox = 0, oy = 0, alpha = DATA.alpha, hover = null, drag = null, panning = null
|
|
69
|
+
// the ball the hub's preview shows, and where the view glides to bring it to the centre
|
|
70
|
+
let selected = null, glide = null, down = null
|
|
62
71
|
function resize() {
|
|
63
72
|
const dpr = devicePixelRatio || 1
|
|
64
73
|
canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr
|
|
@@ -101,10 +110,11 @@ function step() {
|
|
|
101
110
|
function draw() {
|
|
102
111
|
ctx.clearRect(0, 0, innerWidth, innerHeight)
|
|
103
112
|
ctx.save(); ctx.translate(ox, oy); ctx.scale(scale, scale)
|
|
104
|
-
const
|
|
113
|
+
const lead = hover ?? selected
|
|
114
|
+
const focus = lead ? new Set([lead.id, ...E.flatMap(e => e.from === lead.id ? [e.to] : e.to === lead.id ? [e.from] : [])]) : null
|
|
105
115
|
for (const e of E) {
|
|
106
116
|
const a = byId.get(e.from), b = byId.get(e.to)
|
|
107
|
-
const lit = focus == null || e.from ===
|
|
117
|
+
const lit = focus == null || e.from === lead.id || e.to === lead.id
|
|
108
118
|
ctx.globalAlpha = lit ? (e.eager ? 1 : 0.45) : 0.06
|
|
109
119
|
ctx.strokeStyle = e.eager ? '#8b5cf6' : (dark ? '#8a8a95' : '#777')
|
|
110
120
|
ctx.lineWidth = (e.eager ? 5 : 1.2) / scale
|
|
@@ -125,6 +135,7 @@ function draw() {
|
|
|
125
135
|
ctx.fillStyle = n.color
|
|
126
136
|
ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, 7); ctx.fill()
|
|
127
137
|
if (n.eager) { ctx.strokeStyle = '#8b5cf6'; ctx.lineWidth = 3 / scale; ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 3 / scale, 0, 7); ctx.stroke() }
|
|
138
|
+
if (n === selected) { ctx.globalAlpha = 1; ctx.strokeStyle = dark ? '#ffffff' : '#1c2026'; ctx.lineWidth = 2.5 / scale; ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 7 / scale, 0, 7); ctx.stroke() }
|
|
128
139
|
if (n.depth != null && scale * n.r > 3) {
|
|
129
140
|
ctx.fillStyle = '#16161c'
|
|
130
141
|
ctx.font = '600 ' + n.r * 1.1 + 'px system-ui'
|
|
@@ -132,18 +143,26 @@ function draw() {
|
|
|
132
143
|
ctx.fillText(n.depth, n.x, n.y + n.r * 0.08)
|
|
133
144
|
ctx.textBaseline = 'alphabetic'
|
|
134
145
|
}
|
|
135
|
-
const showLabel = n === hover || scale > 0.8 || lit && focus != null
|
|
146
|
+
const showLabel = n === hover || n === selected || scale > 0.8 || lit && focus != null
|
|
136
147
|
if (showLabel) {
|
|
137
|
-
ctx.globalAlpha = n === hover ? 1 : lit ? 0.8 : 0.1
|
|
148
|
+
ctx.globalAlpha = n === hover || n === selected ? 1 : lit ? 0.8 : 0.1
|
|
138
149
|
ctx.fillStyle = dark ? '#ddd' : '#333'
|
|
139
|
-
ctx.font = (n === hover ? 13 : 11) / scale + 'px system-ui'
|
|
150
|
+
ctx.font = (n === hover || n === selected ? 13 : 11) / scale + 'px system-ui'
|
|
140
151
|
ctx.textAlign = 'center'
|
|
141
152
|
ctx.fillText(n.group ? n.group + '/' + n.label : n.label, n.x, n.y + n.r + 13 / scale)
|
|
142
153
|
}
|
|
143
154
|
}
|
|
144
155
|
ctx.restore()
|
|
145
156
|
}
|
|
146
|
-
function loop() {
|
|
157
|
+
function loop() {
|
|
158
|
+
step()
|
|
159
|
+
if (glide != null) {
|
|
160
|
+
const tx = innerWidth / 2 - glide.x * scale, ty = innerHeight / 2 - glide.y * scale
|
|
161
|
+
ox += (tx - ox) * 0.15; oy += (ty - oy) * 0.15
|
|
162
|
+
if (Math.abs(tx - ox) < 0.5 && Math.abs(ty - oy) < 0.5) glide = null
|
|
163
|
+
}
|
|
164
|
+
draw(); requestAnimationFrame(loop)
|
|
165
|
+
}
|
|
147
166
|
loop()
|
|
148
167
|
|
|
149
168
|
const saveOnceAfter = ${saveOnceAfter.toString()}
|
|
@@ -160,8 +179,18 @@ const nodeAt = (px, py) => {
|
|
|
160
179
|
const [x, y] = world(px, py)
|
|
161
180
|
return N.find(n => (n.x - x) ** 2 + (n.y - y) ** 2 < (n.r + 4 / scale) ** 2) ?? null
|
|
162
181
|
}
|
|
182
|
+
// the hub: a click on a ball opens it there, and the hub answers with focus and theme
|
|
183
|
+
const embedded = parent !== window
|
|
184
|
+
addEventListener('message', ev => {
|
|
185
|
+
const m = ev.data
|
|
186
|
+
if (m == null || typeof m !== 'object') return
|
|
187
|
+
if (m.type === 'wiki-graph:theme') { dark = m.theme === 'dark'; applyTheme() }
|
|
188
|
+
if (m.type === 'wiki-graph:focus') { selected = byId.get(m.id) ?? null; if (selected) glide = selected }
|
|
189
|
+
})
|
|
163
190
|
canvas.addEventListener('pointerdown', ev => {
|
|
164
191
|
const n = nodeAt(ev.clientX, ev.clientY)
|
|
192
|
+
down = { x: ev.clientX, y: ev.clientY, n }
|
|
193
|
+
glide = null
|
|
165
194
|
if (n) { drag = n; alpha = 1 } else panning = { x: ev.clientX - ox, y: ev.clientY - oy }
|
|
166
195
|
canvas.setPointerCapture(ev.pointerId)
|
|
167
196
|
canvas.style.cursor = 'grabbing'
|
|
@@ -171,7 +200,14 @@ canvas.addEventListener('pointermove', ev => {
|
|
|
171
200
|
else if (panning) { ox = ev.clientX - panning.x; oy = ev.clientY - panning.y }
|
|
172
201
|
else hover = nodeAt(ev.clientX, ev.clientY)
|
|
173
202
|
})
|
|
174
|
-
addEventListener('pointerup',
|
|
203
|
+
addEventListener('pointerup', ev => {
|
|
204
|
+
// a press that barely moved is a click: on a ball, it opens the page
|
|
205
|
+
if (down?.n != null && Math.hypot(ev.clientX - down.x, ev.clientY - down.y) < 4) {
|
|
206
|
+
selected = down.n
|
|
207
|
+
if (embedded) parent.postMessage({ type: 'wiki-graph:pick', id: down.n.id }, '*')
|
|
208
|
+
}
|
|
209
|
+
down = null; drag = null; panning = null; canvas.style.cursor = 'grab'
|
|
210
|
+
})
|
|
175
211
|
canvas.addEventListener('wheel', ev => {
|
|
176
212
|
ev.preventDefault()
|
|
177
213
|
const z = Math.exp(-ev.deltaY * 0.0015)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// a mounted wiki's pages, read from disk for the wikis page: the search over
|
|
2
|
+
// every page of the graph (its title first, then its full text) and one page
|
|
3
|
+
// with the refs it holds resolved to the graph's own nodes, so the preview can
|
|
4
|
+
// open them. Only a node of the graph is ever read: a client names a node id,
|
|
5
|
+
// never a path.
|
|
6
|
+
import { existsSync, readFileSync, statSync } from 'fs'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
import type { MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
9
|
+
|
|
10
|
+
export type WikiHit = { id: string; label: string; title: string; snippet: string; inTitle: boolean }
|
|
11
|
+
/** `links`: a ref token as the page writes it → the node it opens. `file`:
|
|
12
|
+
* where it sits on disk, for the editor button and the copy */
|
|
13
|
+
export type WikiPage = { id: string; title: string; md: string; links: Record<string, string>; file: string }
|
|
14
|
+
|
|
15
|
+
/** the most hits a search answers */
|
|
16
|
+
export const SEARCH_LIMIT = 30
|
|
17
|
+
|
|
18
|
+
const cache = new Map<string, { mtime: number; text: string }>()
|
|
19
|
+
|
|
20
|
+
/** the file of a node, under the folder its ids are relative to; null when
|
|
21
|
+
* the node is not in the graph or its file is gone */
|
|
22
|
+
export function nodeFile(g: MdGraph, dir: string, id: string): string | null {
|
|
23
|
+
if (!g.nodes.some((n) => n.id === id)) return null
|
|
24
|
+
for (const f of [join(dir, id), join(dir, `${id}.md`)]) if (existsSync(f) && statSync(f).isFile()) return f
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a page without its front matter: the `---` block a page opens with holds
|
|
29
|
+
* tooling facts, never prose to read or search */
|
|
30
|
+
export const bodyOf = (md: string): string => (md.startsWith('---\n') ? md.slice(4).replace(/^[\s\S]*?^---\s*$\n?/m, '') : md).replace(/^\s+/, '')
|
|
31
|
+
|
|
32
|
+
function textOf(file: string): string {
|
|
33
|
+
const mtime = statSync(file).mtimeMs
|
|
34
|
+
const hit = cache.get(file)
|
|
35
|
+
if (hit != null && hit.mtime === mtime) return hit.text
|
|
36
|
+
const text = bodyOf(readFileSync(file, 'utf8'))
|
|
37
|
+
cache.set(file, { mtime, text })
|
|
38
|
+
return text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** the page's first `# ` heading, else its label */
|
|
42
|
+
export const titleOf = (md: string, n: Pick<MdNode, 'label'>): string => /^#\s+(.+)$/m.exec(md)?.[1]?.trim() ?? n.label
|
|
43
|
+
|
|
44
|
+
/** every page whose title, path or text holds every word of the query: title
|
|
45
|
+
* and path hits first, then by how often the words appear, a snippet around
|
|
46
|
+
* the first one in the text */
|
|
47
|
+
export function searchWiki(g: MdGraph, dir: string, q: string, limit = SEARCH_LIMIT): WikiHit[] {
|
|
48
|
+
const words = q.toLowerCase().split(/\s+/).filter((w) => w !== '')
|
|
49
|
+
if (words.length === 0) return []
|
|
50
|
+
const scored: (WikiHit & { score: number })[] = []
|
|
51
|
+
for (const n of g.nodes) {
|
|
52
|
+
const file = nodeFile(g, dir, n.id)
|
|
53
|
+
const md = file == null ? '' : textOf(file)
|
|
54
|
+
const title = titleOf(md, n)
|
|
55
|
+
const head = `${n.id} ${title}`.toLowerCase()
|
|
56
|
+
const body = md.toLowerCase()
|
|
57
|
+
if (!words.every((w) => head.includes(w) || body.includes(w))) continue
|
|
58
|
+
let score = 0
|
|
59
|
+
for (const w of words) {
|
|
60
|
+
if (head.includes(w)) score += 100
|
|
61
|
+
score += Math.min(20, body.split(w).length - 1)
|
|
62
|
+
}
|
|
63
|
+
const first = Math.min(...words.map((w) => body.indexOf(w)).filter((i) => i >= 0))
|
|
64
|
+
const snippet = Number.isFinite(first) ? snippetAt(md, first, words) : ''
|
|
65
|
+
scored.push({ id: n.id, label: n.label, title, snippet, inTitle: words.some((w) => head.includes(w)), score })
|
|
66
|
+
}
|
|
67
|
+
return scored
|
|
68
|
+
.sort((a, b) => b.score - a.score || a.id.length - b.id.length || a.id.localeCompare(b.id))
|
|
69
|
+
.slice(0, limit)
|
|
70
|
+
.map(({ score: _, ...h }) => h)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function snippetAt(md: string, at: number, words: string[]): string {
|
|
74
|
+
const from = Math.max(0, at - 60)
|
|
75
|
+
const to = Math.min(md.length, at + Math.max(...words.map((w) => w.length)) + 80)
|
|
76
|
+
return `${from > 0 ? '…' : ''}${md.slice(from, to).replace(/\s+/g, ' ').trim()}${to < md.length ? '…' : ''}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** the ref tokens a page writes, as the wiki scan reads them (scan-md.ts):
|
|
80
|
+
* `@path.md`, `→ path.md`, `[text](path.md)` and `path.md` in a code span */
|
|
81
|
+
export function refTokens(md: string): string[] {
|
|
82
|
+
const prose = md.replace(/^```[\s\S]*?^```\s*$/gm, '')
|
|
83
|
+
const out = new Set<string>()
|
|
84
|
+
for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) out.add(m[1]!)
|
|
85
|
+
for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) out.add(m[1]!)
|
|
86
|
+
for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) out.add(m[1]!)
|
|
87
|
+
for (const m of prose.matchAll(/`([^`\n]+)`/g)) for (const p of m[1]!.matchAll(/[\w.][\w./-]*\.md/g)) out.add(p[0])
|
|
88
|
+
return [...out]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** the node a token names: among the page's own link targets first, then any
|
|
92
|
+
* node; a node whose id is the token, or one ends with the other at a folder
|
|
93
|
+
* edge: a page names `agent/spec.md` where the agent/ graph's id is `spec.md`,
|
|
94
|
+
* and a wiki repo's ids carry a `wiki/` the page leaves out */
|
|
95
|
+
export function resolveToken(g: MdGraph, from: string, token: string): string | null {
|
|
96
|
+
const t = token.replace(/^(\.{1,2}\/)+/, '')
|
|
97
|
+
const bare = (s: string): string => s.replace(/\.md$/, '')
|
|
98
|
+
const edge = (a: string, b: string): boolean => a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`)
|
|
99
|
+
const fits = (id: string): boolean => edge(id, t) || edge(bare(id), bare(t))
|
|
100
|
+
const targets = g.edges.filter((e) => e.from === from).map((e) => e.to)
|
|
101
|
+
const own = targets.filter(fits)
|
|
102
|
+
if (own.length > 0) return own.sort((a, b) => a.length - b.length)[0]!
|
|
103
|
+
const any = g.nodes.map((n) => n.id).filter(fits)
|
|
104
|
+
return any.length === 1 ? any[0]! : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** the page a wiki opens on: its index (the shortest `index.md`), else a
|
|
108
|
+
* README, else a page of the eager layer, else the first */
|
|
109
|
+
export function entryOf(g: MdGraph): string | null {
|
|
110
|
+
const ids = g.nodes.map((n) => n.id)
|
|
111
|
+
const named = (name: string): string | undefined => ids.filter((i) => i === name || i.endsWith(`/${name}`)).sort((a, b) => a.length - b.length)[0]
|
|
112
|
+
return named('index.md') ?? named('index') ?? named('README.md') ?? g.nodes.find((n) => n.depth === 0)?.id ?? ids[0] ?? null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** a page, or the wiki's entry when no node is named */
|
|
116
|
+
export function pageOf(g: MdGraph, dir: string, asked: string): WikiPage | null {
|
|
117
|
+
const id = asked === '' ? entryOf(g) : asked
|
|
118
|
+
if (id == null) return null
|
|
119
|
+
const file = nodeFile(g, dir, id)
|
|
120
|
+
if (file == null) return null
|
|
121
|
+
const md = textOf(file)
|
|
122
|
+
const links: Record<string, string> = {}
|
|
123
|
+
for (const t of refTokens(md)) {
|
|
124
|
+
const to = resolveToken(g, id, t)
|
|
125
|
+
if (to != null && to !== id) links[t] = to
|
|
126
|
+
}
|
|
127
|
+
return { id, title: titleOf(md, g.nodes.find((n) => n.id === id)!), md, links, file }
|
|
128
|
+
}
|