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.
Files changed (80) hide show
  1. package/CHANGELOG.md +11 -2
  2. package/package.json +1 -1
  3. package/src/assets/kits/identity-kit.ts +5 -2
  4. package/src/cli/cmd_Commands.ts +4 -0
  5. package/src/cli/cmd_Review.ts +36 -10
  6. package/src/cli/cmd_Sound.ts +209 -0
  7. package/src/cli/cmd_Trailer.ts +7 -2
  8. package/src/cli/kit/verb.ts +11 -3
  9. package/src/cli.ts +2 -0
  10. package/src/core/app-icon.ts +10 -6
  11. package/src/repo-config.ts +3 -0
  12. package/src/review/format.ts +11 -3
  13. package/src/review/model.ts +47 -5
  14. package/src/review/project.ts +20 -4
  15. package/src/review/routes.ts +12 -1
  16. package/src/review/store.ts +15 -6
  17. package/src/review/unit.ts +19 -1
  18. package/src/serve/hub-app/admin-svc.ts +1 -1
  19. package/src/serve/hub-app/admin-view.tsx +1 -1
  20. package/src/serve/hub-app/hash-route.ts +12 -4
  21. package/src/serve/hub-app/home-st.ts +7 -1
  22. package/src/serve/hub-app/hub.css +38 -1
  23. package/src/serve/hub-app/icons.tsx +6 -0
  24. package/src/serve/hub-app/layout-st.ts +2 -2
  25. package/src/serve/hub-app/md.ts +32 -0
  26. package/src/serve/hub-app/project-list.css +9 -7
  27. package/src/serve/hub-app/project-list.tsx +5 -2
  28. package/src/serve/hub-app/projects-svc.ts +3 -2
  29. package/src/serve/hub-app/repo-st.ts +22 -7
  30. package/src/serve/hub-app/review/clip-st.ts +57 -7
  31. package/src/serve/hub-app/review/kind-view.tsx +78 -9
  32. package/src/serve/hub-app/review/review-answer.tsx +83 -53
  33. package/src/serve/hub-app/review/review-page.tsx +9 -14
  34. package/src/serve/hub-app/review/review-st.ts +141 -18
  35. package/src/serve/hub-app/review/review-views.tsx +154 -9
  36. package/src/serve/hub-app/review.css +70 -10
  37. package/src/serve/hub-app/wiki-st.ts +190 -0
  38. package/src/serve/hub-app/wikis-view.tsx +134 -5
  39. package/src/serve/hub-runtime.ts +4 -4
  40. package/src/serve/project-gutter-route.ts +25 -1
  41. package/src/serve/project-gutter.ts +1 -1
  42. package/src/serve/serve-hub.ts +7 -0
  43. package/src/shot/client.ts +17 -5
  44. package/src/shot/shooter.ts +19 -3
  45. package/src/shot/shot-ask.ts +9 -1
  46. package/src/shot/stepped-clip.ts +53 -0
  47. package/src/trailer/adapter.ts +33 -4
  48. package/src/trailer/build.ts +170 -29
  49. package/src/trailer/fonts.ts +55 -0
  50. package/src/trailer/footage.ts +4 -4
  51. package/src/trailer/grammar.ts +429 -67
  52. package/src/trailer/graph.ts +130 -56
  53. package/src/trailer/look.ts +3 -0
  54. package/src/trailer/motion.ts +207 -0
  55. package/src/trailer/plan.ts +2 -2
  56. package/src/trailer/prune.ts +13 -2
  57. package/src/trailer/score/compose.ts +18 -2
  58. package/src/trailer/score/gm-render.ts +43 -0
  59. package/src/trailer/score/gm.ts +90 -0
  60. package/src/trailer/score/midi.ts +143 -0
  61. package/src/trailer/score/pattern.ts +13 -6
  62. package/src/trailer/score/score.ts +28 -14
  63. package/src/trailer/score/sound.ts +12 -1
  64. package/src/trailer/score/synth.ts +24 -2
  65. package/src/trailer/sound/audio.ts +148 -0
  66. package/src/trailer/sound/brand.ts +42 -0
  67. package/src/trailer/sound/comfy.ts +38 -0
  68. package/src/trailer/sound/eleven.ts +72 -0
  69. package/src/trailer/sound/fluid.ts +71 -0
  70. package/src/trailer/sound/gen.ts +139 -0
  71. package/src/trailer/sound/index.ts +60 -0
  72. package/src/trailer/sound/presets.ts +69 -0
  73. package/src/trailer/sound/run.ts +189 -0
  74. package/src/trailer/titles.ts +451 -235
  75. package/src/trailer/url.ts +18 -3
  76. package/src/trailer/vocab.ts +12 -1
  77. package/src/trailer/words.ts +29 -8
  78. package/src/wiki/graph-mount.ts +3 -0
  79. package/src/wiki/graph-page.ts +45 -9
  80. package/src/wiki/graph-pages.ts +128 -0
@@ -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 url = /^https?:\/\//.test(f.route) ? f.route : urlOf(f.route, await baseUrl(o.root, o.base, o.say))
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 r = o.seconds === null ? await shotUrl({ url, out: o.out, viewport, dpr, ...wait }, log) : await clipUrl({ url, out: o.out, viewport, dpr, seconds: o.seconds, fps: o.fps, ...wait }, log)
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
  }
@@ -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
  ]
@@ -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 : Number(t)
76
- if (!Number.isFinite(v) || (spec.type === 'int' && !Number.isInteger(v)) || (spec.min !== undefined && v < spec.min) || (spec.max !== undefined && v > spec.max)) return { error: what }
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) => p !== '' && Number.isFinite(Number(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
- const all = [...fromDefaults, ...own]
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'] = []
@@ -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 */
@@ -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
- const dark = matchMedia('(prefers-color-scheme: dark)').matches
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 focus = hover ? new Set([hover.id, ...E.flatMap(e => e.from === hover.id ? [e.to] : e.to === hover.id ? [e.from] : [])]) : null
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 === hover.id || e.to === hover.id
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() { step(); draw(); requestAnimationFrame(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', () => { drag = null; panning = null; canvas.style.cursor = 'grab' })
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
+ }