rvlib-shipkit 2.2.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 (140) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +1 -1
  3. package/src/agent-time/mask.ts +43 -0
  4. package/src/agent-time/render.ts +187 -0
  5. package/src/agent-time/report.ts +335 -0
  6. package/src/agent-time/shape.ts +271 -0
  7. package/src/agent-time/signals.ts +84 -0
  8. package/src/agent-time/store.ts +379 -0
  9. package/src/agent-time/timeline.ts +218 -0
  10. package/src/agent-time/turns.ts +135 -0
  11. package/src/assets/kits/identity-kit.ts +5 -2
  12. package/src/cli/cmd_Commands.ts +9 -0
  13. package/src/cli/cmd_Review.ts +974 -0
  14. package/src/cli/cmd_Sound.ts +209 -0
  15. package/src/cli/cmd_Time.ts +112 -0
  16. package/src/cli/cmd_Trailer.ts +264 -6
  17. package/src/cli/kit/verb.ts +22 -4
  18. package/src/cli.ts +8 -4
  19. package/src/collections/collection-types.ts +6 -1
  20. package/src/collections/providers.ts +3 -0
  21. package/src/collections/review.ts +70 -0
  22. package/src/core/app-icon.ts +10 -6
  23. package/src/core/layout.ts +2 -0
  24. package/src/reflect/prompt-corpus.ts +15 -119
  25. package/src/reflect/transcript.ts +254 -0
  26. package/src/repo-config.ts +3 -0
  27. package/src/review/anim.ts +228 -0
  28. package/src/review/auto.ts +141 -0
  29. package/src/review/format.ts +158 -0
  30. package/src/review/http.ts +23 -0
  31. package/src/review/marks.ts +43 -0
  32. package/src/review/media.ts +45 -0
  33. package/src/review/model.ts +896 -0
  34. package/src/review/project.ts +126 -0
  35. package/src/review/role.ts +48 -0
  36. package/src/review/routes.ts +239 -0
  37. package/src/review/store.ts +1011 -0
  38. package/src/review/threads.ts +237 -0
  39. package/src/review/trailer-desk.ts +385 -0
  40. package/src/review/trailer-model.ts +183 -0
  41. package/src/review/trailer-routes.ts +136 -0
  42. package/src/review/unit.ts +183 -0
  43. package/src/review/watchers.ts +54 -0
  44. package/src/serve/embed.ts +35 -0
  45. package/src/serve/hub-app/activity-view.tsx +1 -1
  46. package/src/serve/hub-app/activity.css +0 -4
  47. package/src/serve/hub-app/admin-svc.ts +1 -1
  48. package/src/serve/hub-app/admin-view.tsx +1 -1
  49. package/src/serve/hub-app/dock-view.tsx +5 -4
  50. package/src/serve/hub-app/hash-route.ts +36 -1
  51. package/src/serve/hub-app/header-bar.tsx +3 -1
  52. package/src/serve/hub-app/home-st.ts +17 -3
  53. package/src/serve/hub-app/home-strip.tsx +3 -2
  54. package/src/serve/hub-app/home.html +1 -0
  55. package/src/serve/hub-app/hub-app.tsx +80 -11
  56. package/src/serve/hub-app/hub.css +83 -14
  57. package/src/serve/hub-app/icons.tsx +15 -0
  58. package/src/serve/hub-app/index.html +1 -0
  59. package/src/serve/hub-app/insights-view.tsx +4 -3
  60. package/src/serve/hub-app/keys.ts +14 -10
  61. package/src/serve/hub-app/layout-st.ts +63 -2
  62. package/src/serve/hub-app/md.ts +32 -0
  63. package/src/serve/hub-app/planned-view.tsx +3 -2
  64. package/src/serve/hub-app/planned.css +0 -1
  65. package/src/serve/hub-app/project-list.css +26 -6
  66. package/src/serve/hub-app/project-list.tsx +46 -10
  67. package/src/serve/hub-app/projects-svc.ts +7 -2
  68. package/src/serve/hub-app/repo-sections.ts +15 -3
  69. package/src/serve/hub-app/repo-st.ts +61 -4
  70. package/src/serve/hub-app/review/clip-st.ts +258 -0
  71. package/src/serve/hub-app/review/kind-view.tsx +240 -0
  72. package/src/serve/hub-app/review/kit.tsx +113 -0
  73. package/src/serve/hub-app/review/review-answer.tsx +269 -0
  74. package/src/serve/hub-app/review/review-board.tsx +46 -0
  75. package/src/serve/hub-app/review/review-list.tsx +156 -0
  76. package/src/serve/hub-app/review/review-page.tsx +96 -0
  77. package/src/serve/hub-app/review/review-st.ts +902 -0
  78. package/src/serve/hub-app/review/review-views.tsx +648 -0
  79. package/src/serve/hub-app/review/trailer-page.tsx +416 -0
  80. package/src/serve/hub-app/review/trailer-st.ts +602 -0
  81. package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
  82. package/src/serve/hub-app/review.css +389 -0
  83. package/src/serve/hub-app/theme-st.ts +56 -0
  84. package/src/serve/hub-app/tree-build.ts +4 -0
  85. package/src/serve/hub-app/tree-row.tsx +1 -1
  86. package/src/serve/hub-app/tree-svc.ts +4 -0
  87. package/src/serve/hub-app/wiki-st.ts +190 -0
  88. package/src/serve/hub-app/wikis-view.tsx +134 -5
  89. package/src/serve/hub-runtime.ts +9 -7
  90. package/src/serve/project-gutter-route.ts +25 -1
  91. package/src/serve/project-gutter.ts +1 -1
  92. package/src/serve/same-origin.ts +5 -1
  93. package/src/serve/serve-hub.ts +15 -0
  94. package/src/shipkit-files.ts +9 -5
  95. package/src/shot/client.ts +17 -5
  96. package/src/shot/shooter.ts +19 -3
  97. package/src/shot/shot-ask.ts +9 -1
  98. package/src/shot/stepped-clip.ts +53 -0
  99. package/src/trailer/adapter.ts +90 -6
  100. package/src/trailer/build.ts +459 -59
  101. package/src/trailer/ffmpeg.ts +22 -0
  102. package/src/trailer/fonts.ts +55 -0
  103. package/src/trailer/footage.ts +112 -43
  104. package/src/trailer/grammar.ts +864 -127
  105. package/src/trailer/graph.ts +308 -49
  106. package/src/trailer/holes.ts +72 -0
  107. package/src/trailer/load-unit.ts +20 -3
  108. package/src/trailer/look.ts +20 -3
  109. package/src/trailer/motion.ts +207 -0
  110. package/src/trailer/passage.ts +129 -0
  111. package/src/trailer/plan.ts +82 -23
  112. package/src/trailer/prune.ts +204 -0
  113. package/src/trailer/score/compose.ts +256 -0
  114. package/src/trailer/score/dsp.ts +272 -0
  115. package/src/trailer/score/gm-render.ts +43 -0
  116. package/src/trailer/score/gm.ts +90 -0
  117. package/src/trailer/score/midi.ts +143 -0
  118. package/src/trailer/score/palette.ts +188 -0
  119. package/src/trailer/score/pattern.ts +98 -0
  120. package/src/trailer/score/score.ts +228 -0
  121. package/src/trailer/score/sound.ts +123 -0
  122. package/src/trailer/score/synth.ts +383 -0
  123. package/src/trailer/sound/audio.ts +148 -0
  124. package/src/trailer/sound/brand.ts +42 -0
  125. package/src/trailer/sound/comfy.ts +38 -0
  126. package/src/trailer/sound/eleven.ts +72 -0
  127. package/src/trailer/sound/fluid.ts +71 -0
  128. package/src/trailer/sound/gen.ts +139 -0
  129. package/src/trailer/sound/index.ts +60 -0
  130. package/src/trailer/sound/presets.ts +69 -0
  131. package/src/trailer/sound/run.ts +189 -0
  132. package/src/trailer/states.ts +76 -0
  133. package/src/trailer/stitch.ts +92 -9
  134. package/src/trailer/titles.ts +471 -150
  135. package/src/trailer/url.ts +18 -3
  136. package/src/trailer/vocab.ts +34 -4
  137. package/src/trailer/words.ts +275 -0
  138. package/src/wiki/graph-mount.ts +3 -0
  139. package/src/wiki/graph-page.ts +45 -9
  140. 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,26 +15,55 @@ 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; 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 []
25
- return [{ path, film: o.film, ...(typeof o.description === 'string' ? { description: o.description } : {}) }]
34
+ return [
35
+ {
36
+ path,
37
+ film: o.film,
38
+ ...(typeof o.version === 'number' || typeof o.version === 'string' ? { version: o.version } : {}),
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) } : {}),
42
+ ...(typeof o.description === 'string' ? { description: o.description } : {}),
43
+ },
44
+ ]
26
45
  })
27
46
  }
28
47
 
29
- /** the project CLI's film rows; null when the repo has no project CLI. A CLI that does not answer is an error */
48
+ const asked = new Map<string, FilmVerb[]>()
49
+
50
+ /** the rows asked again on the next read: a watch builds as a fresh process would */
51
+ export const forgetVocab = (): void => asked.clear()
52
+
53
+ /** the project CLI's film rows; null when the repo has no project CLI. A CLI that does not answer is an error. Asked
54
+ * once per process: the prune and a watch read many cuts */
30
55
  export function readVocab(root: string, cli: readonly string[] | null): FilmVerb[] | null {
31
56
  if (cli === null) return null
57
+ const k = `${root}\0${cli.join(' ')}`
58
+ const had = asked.get(k)
59
+ if (had) return had
32
60
  const r = Bun.spawnSync([...cli, 'commands', '--json'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
33
61
  const text = r.stdout.toString()
34
62
  try {
35
63
  if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}`)
36
- return filmRows(JSON.parse(text))
64
+ const rows = filmRows(JSON.parse(text))
65
+ asked.set(k, rows)
66
+ return rows
37
67
  } catch (e) {
38
68
  throw new Error(`the project CLI (${cli.join(' ')}) did not answer \`commands --json\` with its rows: ${(e as Error).message}${r.stderr.length ? `\n${r.stderr.toString().trim().split('\n').slice(-4).join('\n')}` : ''}`)
39
69
  }
@@ -0,0 +1,275 @@
1
+ // a film row's declared words (wiki build/trailer.md, A row's words): the row says which words it takes and their
2
+ // types, the core reads every word of a film line at its own line against that, refuses an unknown one with the row's
3
+ // list, and hands the row its values made plain (argv stays the transport). Pure: times arrive as moments the grammar
4
+ // resolves once the shots have landed
5
+
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
+
9
+ /** one word a row takes: its type and bounds; `arg: n` makes it the n-th positional */
10
+ export interface WordSpec {
11
+ type: WordType
12
+ arg?: number
13
+ optional?: boolean
14
+ min?: number
15
+ max?: number
16
+ values?: string[]
17
+ /** a point's numbers, 2 by default */
18
+ count?: number
19
+ /** a word's shape, a regular expression */
20
+ pattern?: string
21
+ /** what it means, for the error that lists the row's words */
22
+ doc?: string
23
+ }
24
+
25
+ export type Words = Record<string, WordSpec>
26
+
27
+ /** a word of a film line, with the line it sits on (an option line under the film line keeps its own) */
28
+ export interface LineWord {
29
+ text: string
30
+ quoted: boolean
31
+ line: number
32
+ }
33
+
34
+ /** a time: seconds, or a section or mark of the cut and a time from it */
35
+ export type Moment = number | { mark: string; offset: number }
36
+
37
+ export interface Typed {
38
+ /** the row's path, then its words in the order written, each value plain; a time's slot is filled by `times` */
39
+ argv: string[]
40
+ /** argv slots made of moments: a time (`name` null) or cues (`name@seconds`, comma joined) */
41
+ times: { index: number; parts: { name: string | null; at: Moment; line: number }[] }[]
42
+ /** argv slots that are files, as written: the build finds them and keys their content */
43
+ files: number[]
44
+ errors: { line: number; message: string }[]
45
+ }
46
+
47
+ /** the row's words checked as a declaration: every type known, its bounds sane; null when it is one */
48
+ export function wordsProblem(words: unknown): string | null {
49
+ if (typeof words !== 'object' || words === null) return 'words is a record of word → { type }'
50
+ for (const [k, v] of Object.entries(words as Record<string, unknown>)) {
51
+ const w = v as Partial<WordSpec> | null
52
+ if (!/^[a-z][a-z0-9-]*$/.test(k)) return `word ${k}: a word is lowercase letters, digits and -`
53
+ if (!w || !WORD_TYPES.includes(w.type as WordType)) return `word ${k}: type is one of ${WORD_TYPES.join(', ')}`
54
+ if (w.type === 'enum' && (!Array.isArray(w.values) || w.values.length === 0)) return `word ${k}: an enum names its values`
55
+ if (w.pattern !== undefined) {
56
+ try {
57
+ new RegExp(w.pattern)
58
+ } catch {
59
+ return `word ${k}: pattern ${w.pattern} is no regular expression`
60
+ }
61
+ }
62
+ }
63
+ return null
64
+ }
65
+
66
+ const n4 = (x: number): string => String(+x.toFixed(4))
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
+
73
+ /** one value read by its type: its plain text, a moment, or why it does not fit */
74
+ function valueOf(key: string, spec: WordSpec, w: LineWord, time: (text: string) => Moment | null): { text: string } | { at: Moment } | { error: string } {
75
+ const t = w.text
76
+ const what = `${key} wants ${describe(spec)}, got ${w.quoted ? `"${t}"` : t}`
77
+ switch (spec.type) {
78
+ case 'number':
79
+ case '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 }
82
+ return { text: t }
83
+ }
84
+ case 'enum':
85
+ return !w.quoted && spec.values!.includes(t) ? { text: t } : { error: what }
86
+ case 'point': {
87
+ const parts = w.quoted ? [] : t.split(',')
88
+ const ok = parts.length === (spec.count ?? 2) && parts.every((p) => Number.isFinite(decimal(p)))
89
+ return ok ? { text: t } : { error: what }
90
+ }
91
+ case 'time': {
92
+ const at = w.quoted ? null : time(t)
93
+ return at === null ? { error: what } : { at }
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
+ }
100
+ case 'file':
101
+ case 'word':
102
+ if (w.quoted || t === '' || (spec.pattern !== undefined && !new RegExp(spec.pattern).test(t))) return { error: what }
103
+ return { text: t }
104
+ case 'text':
105
+ return t.trim() === '' ? { error: `${key}: the text is empty` } : { text: t }
106
+ default:
107
+ return { error: what }
108
+ }
109
+ }
110
+
111
+ function describe(s: WordSpec): string {
112
+ switch (s.type) {
113
+ case 'number':
114
+ case 'int':
115
+ return `${s.type === 'int' ? 'a whole number' : 'a number'}${s.min !== undefined || s.max !== undefined ? ` from ${s.min ?? '-∞'} to ${s.max ?? '∞'}` : ''}`
116
+ case 'enum':
117
+ return s.values!.join('|')
118
+ case 'point':
119
+ return `${s.count ?? 2} numbers joined by commas`
120
+ case 'time':
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` : ''}`
124
+ case 'cues':
125
+ return 'cues, name@<time> each'
126
+ case 'file':
127
+ return 'a file'
128
+ case 'word':
129
+ return `a word${s.pattern ? ` like ${s.pattern}` : ''}`
130
+ case 'text':
131
+ return 'a text'
132
+ case 'flag':
133
+ return 'no value'
134
+ }
135
+ }
136
+
137
+ /** the row's words, for an error: positionals in order, then keys */
138
+ export const wordList = (words: Words): string =>
139
+ [
140
+ ...Object.entries(words)
141
+ .filter(([, s]) => s.arg !== undefined)
142
+ .sort((a, b) => a[1].arg! - b[1].arg!)
143
+ .map(([k, s]) => `<${k}>${s.optional ? '?' : ''}`),
144
+ ...Object.entries(words)
145
+ .filter(([, s]) => s.arg === undefined)
146
+ .map(([k]) => k),
147
+ ].join(' ')
148
+
149
+ interface Entry {
150
+ key: string
151
+ /** a positional's words carry no key on the line */
152
+ positional: boolean
153
+ values: LineWord[]
154
+ line: number
155
+ }
156
+
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
158
+ * next positional that takes it */
159
+ function entriesOf(path: string, words: Words, ws: LineWord[], time: (text: string) => Moment | null, errors: Typed['errors'], keysOnly = false): Entry[] {
160
+ const args = Object.entries(words)
161
+ .filter(([, s]) => s.arg !== undefined)
162
+ .sort((a, b) => a[1].arg! - b[1].arg!)
163
+ let next = 0
164
+ const out: Entry[] = []
165
+ for (let i = 0; i < ws.length; i++) {
166
+ const w = ws[i]!
167
+ const spec = !w.quoted && words[w.text] && words[w.text]!.arg === undefined ? words[w.text]! : null
168
+ if (spec) {
169
+ if (spec.type === 'flag') out.push({ key: w.text, positional: false, values: [], line: w.line })
170
+ else if (spec.type === 'cues') {
171
+ const values: LineWord[] = []
172
+ while (i + 1 < ws.length && !ws[i + 1]!.quoted && /^[^@\s]+@\S+$/.test(ws[i + 1]!.text) && !words[ws[i + 1]!.text]) {
173
+ const c = ws[++i]!
174
+ for (const text of c.text.split(',')) values.push({ ...c, text })
175
+ }
176
+ if (values.length === 0) errors.push({ line: w.line, message: `film ${path} ${w.text} wants cues, name@<time> each` })
177
+ out.push({ key: w.text, positional: false, values, line: w.line })
178
+ } else {
179
+ const v = ws[i + 1]
180
+ if (!v || v.line !== w.line) errors.push({ line: w.line, message: `film ${path} ${w.text} wants ${describe(spec)}` })
181
+ else {
182
+ out.push({ key: w.text, positional: false, values: [v], line: w.line })
183
+ i++
184
+ }
185
+ }
186
+ continue
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
+ }
195
+ // the next positional that takes this word; an optional one that does not is passed over
196
+ let placed = false
197
+ while (next < args.length) {
198
+ const [k, s] = args[next++]!
199
+ if (!('error' in valueOf(k, s, w, time))) {
200
+ out.push({ key: k, positional: true, values: [w], line: w.line })
201
+ placed = true
202
+ break
203
+ }
204
+ if (!s.optional) {
205
+ errors.push({ line: w.line, message: `film ${path}: ${w.quoted ? `"${w.text}"` : w.text} fits no word here, <${k}> wants ${describe(s)} (${path} takes ${wordList(words)})` })
206
+ placed = true
207
+ break
208
+ }
209
+ }
210
+ if (!placed) {
211
+ errors.push({ line: w.line, message: `film ${path}: unknown word ${w.quoted ? `"${w.text}"` : w.text} (${path} takes ${wordList(words)})` })
212
+ // its value goes with it: one error a word
213
+ const v = ws[i + 1]
214
+ if (v && v.line === w.line && !(!v.quoted && words[v.text])) i++
215
+ }
216
+ }
217
+ return out
218
+ }
219
+
220
+ /**
221
+ * a film line of a row that declares its words: `ws` its words after the row's path, `defaults` its section's (a key
222
+ * the line gives itself wins). The plain argv, the slots that wait on a moment, the file slots, every error at its line
223
+ */
224
+ export function typedFilm(path: string[], words: Words, ws: LineWord[], defaults: LineWord[], time: (text: string) => Moment | null): Typed {
225
+ const errors: Typed['errors'] = []
226
+ const own = entriesOf(path.join(' '), words, ws, time, errors)
227
+ const given = new Set(own.map((e) => e.key))
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)]
232
+ const seen = new Set<string>()
233
+ const argv = [...path]
234
+ const times: Typed['times'] = []
235
+ const files: number[] = []
236
+ for (const e of all) {
237
+ if (seen.has(e.key)) {
238
+ errors.push({ line: e.line, message: `film ${path.join(' ')}: ${e.key} given twice` })
239
+ continue
240
+ }
241
+ seen.add(e.key)
242
+ const spec = words[e.key]!
243
+ if (!e.positional) argv.push(e.key)
244
+ if (spec.type === 'flag') continue
245
+ if (spec.type === 'cues') {
246
+ const parts: Typed['times'][number]['parts'] = []
247
+ for (const v of e.values) {
248
+ const at = v.text.indexOf('@')
249
+ const name = v.text.slice(0, at)
250
+ const m = time(v.text.slice(at + 1))
251
+ if (m === null) errors.push({ line: v.line, message: `film ${path.join(' ')} ${e.key}: ${v.text}, the time after @ is no time (1.5s, 2beats, a mark, mark+1beat)` })
252
+ else parts.push({ name, at: m, line: v.line })
253
+ }
254
+ times.push({ index: argv.length, parts })
255
+ argv.push('')
256
+ continue
257
+ }
258
+ const got = valueOf(e.key, spec, e.values[0]!, time)
259
+ if ('error' in got) {
260
+ errors.push({ line: e.values[0]!.line, message: `film ${path.join(' ')}: ${got.error}` })
261
+ argv.push(e.values[0]!.text)
262
+ } else if ('at' in got) {
263
+ times.push({ index: argv.length, parts: [{ name: null, at: got.at, line: e.values[0]!.line }] })
264
+ argv.push('')
265
+ } else {
266
+ if (spec.type === 'file') files.push(argv.length)
267
+ argv.push(got.text)
268
+ }
269
+ }
270
+ for (const [k, s] of Object.entries(words)) if (s.arg !== undefined && !s.optional && !seen.has(k)) errors.push({ line: ws[0]?.line ?? defaults[0]?.line ?? 0, message: `film ${path.join(' ')}: <${k}> is missing (${path.join(' ')} takes ${wordList(words)})` })
271
+ return { argv, times, files, errors }
272
+ }
273
+
274
+ /** a slot's text once its moments are seconds: a time, or cues joined by commas */
275
+ export const slotText = (parts: readonly { name: string | null; seconds: number }[]): string => parts.map((p) => (p.name === null ? n4(p.seconds) : `${p.name}@${n4(p.seconds)}`)).join(',')
@@ -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
+ }