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.
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- 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/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- 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 +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- 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 +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- 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/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- 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/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- 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,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 [
|
|
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
|
-
|
|
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
|
-
|
|
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(',')
|
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
|
+
}
|