yamlover 0.3.2 → 0.3.4

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.
@@ -79,6 +79,9 @@ export interface Chunk {
79
79
  path: string;
80
80
  type: string;
81
81
  format: string | null;
82
+ valueType?: string | null; // renderer dispatch facets (TYPES.md §9) — so a tagged chunk still routes
83
+ hasKeyed?: boolean;
84
+ hasOrdinal?: boolean;
82
85
  /** The JSON-space path of the document this chunk belongs to (the enclosing
83
86
  * chapter's `documentPath`) — the anchor a document-relative (`/…`) marklower
84
87
  * link in the chunk resolves against. */
@@ -99,11 +102,32 @@ export interface TocView {
99
102
  loadDepth?: number;
100
103
  }
101
104
 
105
+ /** The three TYPE FACETS a renderer dispatches on (TYPES.md §9): the scalar self-VALUE's type,
106
+ * the node's `format`, and whether it owns keyed/ordinal elements. */
107
+ export interface TypeFacets {
108
+ valueType: string | null;
109
+ format: string | null;
110
+ hasKeyed: boolean;
111
+ hasOrdinal: boolean;
112
+ }
113
+ /** A renderer's acceptance predicate — a hand-coded type formula. What it does NOT test, it
114
+ * TOLERATES: a `byFormat("text/markdown")` matcher ignores the keyed/ordinal facets, so a
115
+ * markdown chunk that gained `yamlover-annotations` keys (an omni node) still matches. */
116
+ export type Accepts = (f: TypeFacets) => boolean;
117
+
118
+ /** Any projected node/chunk/link shape carrying the facet fields. */
119
+ type FacetSource = { type?: string; format?: string | null; valueType?: string | null; hasKeyed?: boolean; hasOrdinal?: boolean };
120
+ const facetsFrom = (n: FacetSource): TypeFacets => ({ valueType: n.valueType ?? null, format: n.format ?? null, hasKeyed: !!n.hasKeyed, hasOrdinal: !!n.hasOrdinal });
121
+ /** The common matcher: claims a node whose `format` is one of `fmts` — tolerant of all structure. */
122
+ const byFormat = (...fmts: string[]): Accepts => (f) => f.format !== null && fmts.includes(f.format);
123
+
102
124
  export interface Renderer {
103
125
  name: string;
104
- /** The (type, format) tuples this renderer claims. A `null` format matches a
105
- * node that carries no `format`; a string format matches that format exactly. */
106
- accepts: ReadonlyArray<readonly [type: string, format: string | null]>;
126
+ /** Whether this renderer claims a node, from its {@link TypeFacets}. */
127
+ accepts: Accepts;
128
+ /** Tie-break among matches the highest wins. Format matchers are 2; the bare-string
129
+ * default (marklower) is 1. */
130
+ specificity: number;
107
131
  /** Value depth `NodeView` must fetch for this renderer (default 1). A chapter
108
132
  * needs 2: its `chunks`/`children` arrays one level, their elements the next. */
109
133
  depth?: number;
@@ -129,12 +153,10 @@ export interface Renderer {
129
153
  */
130
154
  const EXPLORER: Renderer = {
131
155
  name: "explorer",
132
- accepts: [
133
- ["object", "x-yamlover-tag"],
134
- ["variant", "x-yamlover-tag"],
135
- ["string", "x-yamlover-tag"],
136
- ["null", "x-yamlover-tag"],
137
- ],
156
+ // a tag, whatever shape it projects (object / variant / leaf string / bare null) — the format
157
+ // alone identifies it; the grid shows the tagged MATERIALS. Also the dir-concrete fallback below.
158
+ accepts: byFormat("x-yamlover-tag"),
159
+ specificity: 2,
138
160
  render: (node, onNavigate) => <ExplorerView node={node} onNavigate={onNavigate} />,
139
161
  config: (rerender) => <ExplorerViewControl rerender={rerender} />, // large/small icons (`?view=`)
140
162
  };
@@ -145,7 +167,8 @@ const isDirConcrete = (concrete: string | null | undefined): boolean => concrete
145
167
  const REGISTRY: Renderer[] = [
146
168
  {
147
169
  name: "chapter",
148
- accepts: [["object", "x-yamlover-chapter"]],
170
+ accepts: byFormat("x-yamlover-chapter"),
171
+ specificity: 2,
149
172
  depth: 2, // reach the chunk/subchapter elements (arrays one level, items the next)
150
173
  tocView: chapterTocView,
151
174
  render: (node, onNavigate) => <ChapterView node={node} onNavigate={onNavigate} />,
@@ -155,10 +178,8 @@ const REGISTRY: Renderer[] = [
155
178
  // notch below Markdown. A chapter's prose chunks route here — both the bare
156
179
  // (string, null) form and the explicit `text/marklower` the chunk schema applies.
157
180
  name: "marklower",
158
- accepts: [
159
- ["string", null],
160
- ["string", "text/marklower"],
161
- ],
181
+ accepts: (f) => f.format === "text/marklower" || (f.format === null && f.valueType === "string"),
182
+ specificity: 1, // the bare-string default — a tagged bare string (format-less) still routes here
162
183
  render: (node, onNavigate) => <MarklowerView node={node} onNavigate={onNavigate} />,
163
184
  renderChunk: (chunk, onNavigate) => <MarklowerChunk chunk={chunk} onNavigate={onNavigate} />,
164
185
  },
@@ -166,14 +187,16 @@ const REGISTRY: Renderer[] = [
166
187
  // Markdown (the component file is text.tsx for historical reasons; the renderer —
167
188
  // its tab label and `?format=` key — is named for what it renders).
168
189
  name: "markdown",
169
- accepts: [["string", "text/markdown"]],
190
+ accepts: byFormat("text/markdown"),
191
+ specificity: 2,
170
192
  render: (node) => <TextView node={node} />,
171
193
  renderChunk: (chunk) => <TextChunk chunk={chunk} />,
172
194
  config: (rerender) => <MarkupWidthControl rerender={rerender} />,
173
195
  },
174
196
  {
175
197
  name: "asciidoc",
176
- accepts: [["string", "text/asciidoc"]],
198
+ accepts: byFormat("text/asciidoc"),
199
+ specificity: 2,
177
200
  render: (node) => <AsciidocView node={node} />,
178
201
  renderChunk: (chunk) => <AsciidocChunk chunk={chunk} />,
179
202
  config: (rerender) => <MarkupWidthControl rerender={rerender} />,
@@ -182,10 +205,8 @@ const REGISTRY: Renderer[] = [
182
205
  // Delimited text (CSV/TSV, a string) shown as a table; its parsing options
183
206
  // (separator, header) ride in the URL query — see csv.tsx.
184
207
  name: "csv",
185
- accepts: [
186
- ["string", "text/csv"],
187
- ["string", "text/tab-separated-values"],
188
- ],
208
+ accepts: byFormat("text/csv", "text/tab-separated-values"),
209
+ specificity: 2,
189
210
  render: (node) => <CsvView node={node} />,
190
211
  renderChunk: (chunk) => <CsvChunk chunk={chunk} />,
191
212
  config: (rerender) => <CsvControls rerender={rerender} />,
@@ -195,7 +216,8 @@ const REGISTRY: Renderer[] = [
195
216
  // CP866 / Windows-1251 / KOI8-R / UTF-8 (see plaintext.tsx). Served as raw
196
217
  // bytes so the encoding is the client's to choose.
197
218
  name: "plaintext",
198
- accepts: [["binary", "text/plain"]],
219
+ accepts: byFormat("text/plain"),
220
+ specificity: 2,
199
221
  render: (node) => <PlaintextView node={node} />,
200
222
  renderChunk: (chunk) => <PlaintextChunk chunk={chunk} />,
201
223
  config: (rerender) => <EncodingControl rerender={rerender} />,
@@ -203,41 +225,40 @@ const REGISTRY: Renderer[] = [
203
225
  {
204
226
  // RTF — a dependency-free converter to HTML (see rtf.tsx).
205
227
  name: "rtf",
206
- accepts: [["binary", "application/rtf"]],
228
+ accepts: byFormat("application/rtf"),
229
+ specificity: 2,
207
230
  render: (node) => <RtfView node={node} />,
208
231
  renderChunk: (chunk) => <RtfChunk chunk={chunk} />,
209
232
  },
210
233
  {
211
234
  // .docx (Office Open XML) via mammoth, lazily loaded.
212
235
  name: "docx",
213
- accepts: [["binary", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"]],
236
+ accepts: byFormat("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
237
+ specificity: 2,
214
238
  render: (node) => lazily(<DocxView node={node} />),
215
239
  renderChunk: (chunk) => lazily(<DocxChunk chunk={chunk} />),
216
240
  },
217
241
  {
218
242
  // Excel workbooks — .xlsx and legacy .xls — via SheetJS, lazily loaded.
219
243
  name: "spreadsheet",
220
- accepts: [
221
- ["binary", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
222
- ["binary", "application/vnd.ms-excel"],
223
- ],
244
+ accepts: byFormat("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-excel"),
245
+ specificity: 2,
224
246
  render: (node) => lazily(<SpreadsheetView node={node} />),
225
247
  renderChunk: (chunk) => lazily(<SpreadsheetChunk chunk={chunk} />),
226
248
  },
227
249
  {
228
250
  // Legacy .doc (Word 97–2003 binary) — no in-browser parser; download fallback.
229
251
  name: "doc",
230
- accepts: [["binary", "application/msword"]],
252
+ accepts: byFormat("application/msword"),
253
+ specificity: 2,
231
254
  render: (node) => <DocView node={node} />,
232
255
  renderChunk: (chunk) => <DocChunk chunk={chunk} />,
233
256
  },
234
257
  {
235
258
  // KML / KMZ geographic overlays drawn on a Leaflet map (lazily loaded).
236
259
  name: "map",
237
- accepts: [
238
- ["binary", "application/vnd.google-earth.kml+xml"],
239
- ["binary", "application/vnd.google-earth.kmz"],
240
- ],
260
+ accepts: byFormat("application/vnd.google-earth.kml+xml", "application/vnd.google-earth.kmz"),
261
+ specificity: 2,
241
262
  render: (node) => lazily(<MapView node={node} />),
242
263
  renderChunk: (chunk) => lazily(<MapChunk chunk={chunk} />),
243
264
  },
@@ -245,7 +266,8 @@ const REGISTRY: Renderer[] = [
245
266
  // LaTeX math (a string) typeset with KaTeX, both whole and inline. marklower
246
267
  // reuses the same engine for its `$$…$$` spans.
247
268
  name: "latex",
248
- accepts: [["string", "text/x-latex"]],
269
+ accepts: byFormat("text/x-latex"),
270
+ specificity: 2,
249
271
  render: (node) => <LatexView node={node} />,
250
272
  renderChunk: (chunk) => <LatexChunk chunk={chunk} />,
251
273
  },
@@ -253,7 +275,8 @@ const REGISTRY: Renderer[] = [
253
275
  // PlantUML source (a string) shown as the diagram it compiles to, both as a
254
276
  // whole node and inline as a chapter chunk.
255
277
  name: "plantuml",
256
- accepts: [["string", "text/x-plantuml"]],
278
+ accepts: byFormat("text/x-plantuml"),
279
+ specificity: 2,
257
280
  render: (node) => <PlantumlView node={node} />,
258
281
  renderChunk: (chunk) => <PlantumlChunk chunk={chunk} />,
259
282
  },
@@ -261,64 +284,64 @@ const REGISTRY: Renderer[] = [
261
284
  {
262
285
  // File-backed binaries the server tags with an inferred image format.
263
286
  name: "image",
264
- accepts: [
265
- ["binary", "image/png"],
266
- ["binary", "image/jpeg"],
267
- ["binary", "image/gif"],
268
- ["binary", "image/webp"],
269
- ["binary", "image/avif"],
270
- ["binary", "image/bmp"],
271
- ["binary", "image/x-icon"],
272
- ["binary", "image/svg+xml"],
273
- ],
287
+ accepts: byFormat("image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/bmp", "image/x-icon", "image/svg+xml"),
288
+ specificity: 2,
274
289
  render: (node) => lazily(<ImageView node={node} />),
275
290
  renderChunk: (chunk) => lazily(<ImageChunk chunk={chunk} />),
276
291
  },
277
292
  {
278
293
  name: "html",
279
- accepts: [["binary", "text/html"]],
294
+ accepts: byFormat("text/html"),
295
+ specificity: 2,
280
296
  render: (node) => <HtmlView node={node} />,
281
297
  renderChunk: (chunk) => <HtmlView node={chunkNode(chunk)} />,
282
298
  },
283
299
  {
284
300
  name: "fb2",
285
- accepts: [["binary", "application/x-fictionbook+xml"]],
301
+ accepts: byFormat("application/x-fictionbook+xml"),
302
+ specificity: 2,
286
303
  render: (node) => <Fb2View node={node} />,
287
304
  renderChunk: (chunk) => <Fb2View node={chunkNode(chunk)} />,
288
305
  },
289
306
  {
290
307
  name: "epub",
291
- accepts: [["binary", "application/epub+zip"]],
308
+ accepts: byFormat("application/epub+zip"),
309
+ specificity: 2,
292
310
  render: (node) => <EpubView node={node} />,
293
311
  renderChunk: (chunk) => <EpubView node={chunkNode(chunk)} />,
294
312
  },
295
313
  {
296
314
  name: "pdf",
297
- accepts: [["binary", "application/pdf"]],
315
+ accepts: byFormat("application/pdf"),
316
+ specificity: 2,
298
317
  render: (node) => lazily(<PdfView node={node} />),
299
318
  renderChunk: (chunk) => lazily(<PdfView node={chunkNode(chunk)} />),
300
319
  },
301
320
  {
302
321
  name: "djvu",
303
- accepts: [["binary", "image/vnd.djvu"]],
322
+ accepts: byFormat("image/vnd.djvu"),
323
+ specificity: 2,
304
324
  render: (node) => lazily(<DjvuView node={node} />),
305
325
  renderChunk: (chunk) => lazily(<DjvuView node={chunkNode(chunk)} />),
306
326
  },
307
327
  {
308
328
  name: "psd",
309
- accepts: [["binary", "image/vnd.adobe.photoshop"]],
329
+ accepts: byFormat("image/vnd.adobe.photoshop"),
330
+ specificity: 2,
310
331
  render: (node) => lazily(<PsdView node={node} />),
311
332
  renderChunk: (chunk) => lazily(<PsdView node={chunkNode(chunk)} />),
312
333
  },
313
334
  {
314
335
  name: "tiff",
315
- accepts: [["binary", "image/tiff"]],
336
+ accepts: byFormat("image/tiff"),
337
+ specificity: 2,
316
338
  render: (node) => lazily(<TiffView node={node} />),
317
339
  renderChunk: (chunk) => lazily(<TiffView node={chunkNode(chunk)} />),
318
340
  },
319
341
  {
320
342
  name: "heic",
321
- accepts: [["binary", "image/heic"]],
343
+ accepts: byFormat("image/heic"),
344
+ specificity: 2,
322
345
  render: (node) => lazily(<HeicView node={node} />),
323
346
  renderChunk: (chunk) => lazily(<HeicView node={chunkNode(chunk)} />),
324
347
  },
@@ -356,33 +379,32 @@ function chapterTocView(node: TreeNode): TocView {
356
379
  };
357
380
  }
358
381
 
359
- /** The renderer whose `accepts` covers `(type, format)`, or null when none does. */
360
- export function rendererFor(type: string, format: string | null): Renderer | null {
361
- return REGISTRY.find((r) => r.accepts.some(([t, f]) => t === type && f === format)) ?? null;
382
+ /** The renderer that claims `src`'s facets, or null when none does: of the matchers that accept
383
+ * it, the most SPECIFIC (highest `specificity`) wins (TYPES.md §9). */
384
+ export function rendererFor(src: FacetSource): Renderer | null {
385
+ const f = facetsFrom(src);
386
+ let best: Renderer | null = null;
387
+ for (const r of REGISTRY) if (r.accepts(f) && (best === null || r.specificity > best.specificity)) best = r;
388
+ return best;
362
389
  }
363
390
 
364
- /** The renderer for a node: its (type, format) claim, else — for a node stored as a
365
- * filesystem directory that no format renderer claims (a dir-backed chapter stays a
366
- * chapter) — the explorer, else null → the default tabbed view. */
391
+ /** The renderer for a node: its facet claim, else — for a node stored as a filesystem directory
392
+ * that no format renderer claims (a dir-backed chapter stays a chapter) — the explorer, else
393
+ * null → the default tabbed view. */
367
394
  export function getRenderer(node: NodeJson): Renderer | null {
368
- const r = rendererFor(node.type, node.format ?? null);
369
- if (r) return r;
370
- return isDirConcrete(node.concrete) ? EXPLORER : null;
395
+ return rendererFor(node) ?? (isDirConcrete(node.concrete) ? EXPLORER : null);
371
396
  }
372
397
 
373
- /** The name (= representation key / `?format=` value) of the renderer for
374
- * `(type, format)` — with the same directory-concrete explorer fallback as
375
- * {@link getRenderer} or null when none claims it. */
376
- export function rendererName(type: string, format: string | null, concrete?: string | null): string | null {
377
- const r = rendererFor(type, format);
378
- if (r) return r.name;
379
- return isDirConcrete(concrete) ? EXPLORER.name : null;
398
+ /** The name (= representation key / `?format=` value) of the renderer that claims `src` — with the
399
+ * same directory-concrete explorer fallback as {@link getRenderer} — or null when none claims it. */
400
+ export function rendererName(src: FacetSource, concrete?: string | null): string | null {
401
+ return rendererFor(src)?.name ?? (isDirConcrete(concrete) ? EXPLORER.name : null);
380
402
  }
381
403
 
382
404
  /** How `node` appears in the TOC: its renderer's `tocView`, or — when no renderer
383
405
  * claims it — its own children, lazily loaded (the passive default). */
384
406
  export function tocView(node: TreeNode): TocView {
385
- const r = rendererFor(node.type, node.format);
407
+ const r = rendererFor(node);
386
408
  if (r?.tocView) return r.tocView(node);
387
409
  const loaded = node.children.length > 0;
388
410
  return { children: node.children, expandable: loaded ? node.children.length > 0 : node.hasChildren, loaded };
@@ -1,5 +1,6 @@
1
1
  import { marked } from "marked";
2
2
  import { NodeJson } from "../api";
3
+ import { scalarValue } from "../render";
3
4
  import { Chunk } from "./registry";
4
5
  import { anchorizeHeadings, useHashScroll } from "./headings";
5
6
  import { Markup } from "./markup";
@@ -29,7 +30,7 @@ export function TextView({ node }: { node: NodeJson }) {
29
30
  <div className="text">
30
31
  {node.title && <h1 className="chapter-title">{node.title}</h1>}
31
32
  {node.description && <p className="chapter-subtitle">{node.description}</p>}
32
- <Markup html={md(node.value)} />
33
+ <Markup html={md(scalarValue(node.value))} />
33
34
  </div>
34
35
  );
35
36
  }
@@ -148,6 +148,10 @@ body {
148
148
  flex: 1 1 auto;
149
149
  padding: 14px 18px;
150
150
  }
151
+ /* programmatically focused on TOC click (so the keyboard drives the viewer) — no focus ring */
152
+ .right:focus {
153
+ outline: none;
154
+ }
151
155
  .splitter {
152
156
  flex: 0 0 5px;
153
157
  cursor: col-resize;
@@ -519,6 +523,11 @@ a.chunk-index:hover {
519
523
  height: calc(100vh - 160px);
520
524
  min-height: 360px;
521
525
  }
526
+ /* focused on mount so the keyboard scrolls the document — no focus ring on the scroller */
527
+ .filepdf:focus,
528
+ .filedjvu:focus {
529
+ outline: none;
530
+ }
522
531
  .fileimage {
523
532
  /* keep the image's own aspect ratio — never stretch (007) */
524
533
  max-width: 100%;
@@ -864,7 +873,8 @@ a.chunk-index:hover {
864
873
  flex-wrap: wrap;
865
874
  gap: 4px;
866
875
  }
867
- .annotate-recents .tagtag {
876
+ .annotate-recents .tagtag,
877
+ .annotate-suggest .tagtag {
868
878
  border: 0;
869
879
  font: inherit;
870
880
  font-size: 11px;
@@ -876,10 +886,12 @@ a.chunk-index:hover {
876
886
  unclipped WRAPPER the child is clipped first; then a first round of hard offset drop-shadows
877
887
  in the MENU BACKGROUND color grows the silhouette by 1px (the gap), and a second round in
878
888
  the foreground color rings that — gap + ring, both following the actual tag shape. */
879
- .annotate-recents .tagframe {
889
+ .annotate-recents .tagframe,
890
+ .annotate-suggest .tagframe {
880
891
  display: inline-flex;
881
892
  }
882
- .annotate-recents .tagframe.sel {
893
+ .annotate-recents .tagframe.sel,
894
+ .annotate-suggest .tagframe.sel {
883
895
  filter:
884
896
  drop-shadow(1px 0 0 var(--bg-alt))
885
897
  drop-shadow(-1px 0 0 var(--bg-alt))
@@ -906,6 +918,25 @@ a.chunk-index:hover {
906
918
  outline: none;
907
919
  border-color: var(--dim);
908
920
  }
921
+ /* the typeahead: the path input plus a suggestion list below it, spanning the menu width */
922
+ .annotate-typeahead {
923
+ flex-basis: 100%;
924
+ min-width: 0;
925
+ position: relative;
926
+ }
927
+ .annotate-typeahead .annotate-taginput {
928
+ width: 100%;
929
+ box-sizing: border-box;
930
+ }
931
+ /* matched named tags as badges (the highlighted one rings via .tagframe.sel) */
932
+ .annotate-suggest {
933
+ display: flex;
934
+ flex-wrap: wrap;
935
+ gap: 4px;
936
+ margin-top: 5px;
937
+ max-height: 132px;
938
+ overflow-y: auto;
939
+ }
909
940
  /* a region annotation drawn over a PDF page (the image overlay uses a Leaflet rectangle) */
910
941
  .pdf-page {
911
942
  position: relative;
@@ -1080,6 +1111,12 @@ mark.yo-annotation {
1080
1111
  .dirview-item:hover {
1081
1112
  background: var(--panel);
1082
1113
  }
1114
+ /* keyboard selection (roving focus — arrows walk, Enter opens) */
1115
+ .dirview-item:focus {
1116
+ outline: none;
1117
+ background: var(--panel);
1118
+ box-shadow: 0 0 0 2px var(--accent) inset;
1119
+ }
1083
1120
  .dirview-icon {
1084
1121
  flex: none;
1085
1122
  width: 22px;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * embed.ts — surgical, indentation-aware edits that EMBED fragments and annotations into a
3
+ * yamlover host body (a standalone `*.yamlover` document, or a directory's
4
+ * `.yamlover/body.yamlover` overlay). Pure string→string transforms, like the chapter-list
5
+ * insertion in engine-api.ts — the parser tracks no spans, so we edit the source text directly,
6
+ * preserving the rest of the file (comments, formatting). See ANNOTATIONS.md.
7
+ *
8
+ * A host body is YAML-shaped: a mapping's keys at one indent; a sequence's `- ` items at the
9
+ * SAME indent as their key; an item/value body 2 deeper. We descend a `within` path of mapping
10
+ * KEYS (creating any that are absent, as empty blocks) to reach a target node, then:
11
+ * • append an element to its `yamlover-annotations:` sequence (creating the key), or
12
+ * • upsert a `<slug>:` entry into its `yamlover-fragments:` mapping (creating the key).
13
+ *
14
+ * Index (sequence-position) descent — e.g. tagging a chapter CHUNK, which would turn its
15
+ * block-scalar into an omni node — is intentionally NOT handled here; the server resolves such a
16
+ * target to a key-addressable host. Keep this module free of fs / Store coupling so it unit-tests
17
+ * in isolation (see test/embed.test.ts).
18
+ */
19
+
20
+ const ANNOTATIONS_KEY = "yamlover-annotations";
21
+ const FRAGMENTS_KEY = "yamlover-fragments";
22
+
23
+ const indentOf = (line: string): number => { let i = 0; while (line[i] === " ") i++; return i; };
24
+ const isContentLine = (line: string): boolean => { const t = line.trim(); return t.length > 0 && !t.startsWith("#"); };
25
+
26
+ /** The indent of the first content line — the top mapping's key column (0 for most bodies). */
27
+ function firstContentIndent(lines: string[]): number {
28
+ for (const l of lines) if (isContentLine(l)) return indentOf(l);
29
+ return 0;
30
+ }
31
+
32
+ /** A yamlover plain/quoted key token: bare when it is a safe plain scalar, else double-quoted
33
+ * (JSON escapes — the subset the parser reads back). Mirrors how filenames with dots/spaces are
34
+ * authored as overlay keys (e.g. `"S0002-9904.pdf":`). */
35
+ export function keyToken(key: string): string {
36
+ return /^[A-Za-z0-9_][A-Za-z0-9_-]*$/.test(key) ? key : JSON.stringify(key);
37
+ }
38
+
39
+ /** Line index of `key:` at exactly `indent` within [lo,hi); -1 once the mapping ends (a dedent
40
+ * to a shallower content line). Skips deeper lines (a nested value / block scalar). */
41
+ function findKeyLine(lines: string[], lo: number, hi: number, indent: number, key: string): number {
42
+ const tok = keyToken(key);
43
+ for (let i = lo; i < hi; i++) {
44
+ if (!isContentLine(lines[i])) continue;
45
+ const ind = indentOf(lines[i]);
46
+ if (ind < indent) return -1; // left the mapping
47
+ if (ind !== indent) continue; // deeper — a nested value
48
+ const t = lines[i].trim();
49
+ if (t === `${key}:` || t.startsWith(`${key}: `) || t === `${tok}:` || t.startsWith(`${tok}: `)) return i;
50
+ }
51
+ return -1;
52
+ }
53
+
54
+ /** Walk `end` back over trailing blank lines, so an insert lands right after the last content. */
55
+ function trimBack(lines: string[], floor: number, end: number): number {
56
+ let e = end;
57
+ while (e > floor + 1 && !isContentLine(lines[e - 1])) e--;
58
+ return e;
59
+ }
60
+
61
+ /** The end (exclusive) of the block owned by the content starting at `from`, whose own lines sit
62
+ * at >= `indent`: the first later content line shallower than `indent`, sans trailing blanks. */
63
+ function blockEnd(lines: string[], from: number, hi: number, indent: number): number {
64
+ let last = from;
65
+ for (let i = from; i < hi; i++) {
66
+ if (!isContentLine(lines[i])) continue;
67
+ if (indentOf(lines[i]) < indent) return trimBack(lines, last, i);
68
+ last = i;
69
+ }
70
+ return trimBack(lines, last, hi);
71
+ }
72
+
73
+ interface Region { lo: number; hi: number; indent: number } // a mapping body: child keys at `indent`, within [lo,hi)
74
+
75
+ /** Descend the mapping-KEY path `within` to the target node's body region, CREATING any missing
76
+ * key as an empty block (so a fresh overlay grows the `"file":` → `yamlover-fragments:` →
77
+ * `<slug>:` spine on demand). Mutates `lines` in place; returns the region under the last key. */
78
+ function reachBody(lines: string[], within: string[]): Region {
79
+ let lo = 0;
80
+ let hi = lines.length;
81
+ let indent = firstContentIndent(lines);
82
+ if (lines.length === 1 && lines[0] === "") { lines.length = 0; hi = 0; indent = 0; } // empty file
83
+
84
+ for (const key of within) {
85
+ const L = findKeyLine(lines, lo, hi, indent, key);
86
+ if (L < 0) {
87
+ const at = trimBack(lines, lo - 1, hi); // append the new key at the end of the current body
88
+ lines.splice(at, 0, `${" ".repeat(indent)}${keyToken(key)}:`);
89
+ lo = at + 1; hi = at + 1; indent += 2; // its (empty) body
90
+ continue;
91
+ }
92
+ const inline = lines[L].slice(indentOf(lines[L])).slice(`${lines[L].trim().split(":")[0]}:`.length);
93
+ const bodyLo = L + 1;
94
+ const bodyHi = blockEnd(lines, bodyLo, hi, indent + 1); // anything deeper than the key
95
+ // the child key column: a deeper content line's indent if the node already has a block body,
96
+ // else key-indent + 2 (a leaf/inline value gains its first field there — an omni node).
97
+ let childIndent = indent + 2;
98
+ for (let i = bodyLo; i < bodyHi; i++) {
99
+ if (isContentLine(lines[i]) && indentOf(lines[i]) > indent) { childIndent = indentOf(lines[i]); break; }
100
+ }
101
+ void inline;
102
+ lo = bodyLo; hi = bodyHi; indent = childIndent;
103
+ }
104
+ return { lo, hi, indent };
105
+ }
106
+
107
+ /** Start lines of the `- ` items of a sequence whose key sits at `indent` (items at the same
108
+ * indent), scanning the region body for the `key:` then its items. */
109
+ function seqItemLines(lines: string[], region: Region, key: string): { keyLine: number; items: number[]; end: number } | null {
110
+ const keyLine = findKeyLine(lines, region.lo, region.hi, region.indent, key);
111
+ if (keyLine < 0) return null;
112
+ const items: number[] = [];
113
+ let end = keyLine + 1;
114
+ for (let i = keyLine + 1; i < region.hi; i++) {
115
+ if (!isContentLine(lines[i])) continue;
116
+ const ind = indentOf(lines[i]);
117
+ if (ind < region.indent) break;
118
+ if (ind === region.indent) {
119
+ const t = lines[i].trim();
120
+ if (t === "-" || t.startsWith("- ")) { items.push(i); end = i; continue; }
121
+ break; // a sibling key at the list indent → the sequence ended
122
+ }
123
+ end = i; // deeper — the current item's body
124
+ }
125
+ return { keyLine, items, end: trimBack(lines, end, region.hi) };
126
+ }
127
+
128
+ /** Append one annotation element (rendered at the list indent) to the `yamlover-annotations:`
129
+ * sequence of the node addressed by `within`, creating the key (and any missing path) if absent.
130
+ * `render(indent)` returns the element's source lines (a `- *…tag` item, or a `- {…}` object). */
131
+ export function appendAnnotation(text: string, within: string[], render: (indent: number) => string[]): string {
132
+ const lines = text.replace(/\n$/, "").split("\n");
133
+ const region = reachBody(lines, within);
134
+ const seq = seqItemLines(lines, region, ANNOTATIONS_KEY);
135
+ if (!seq) {
136
+ const at = trimBack(lines, region.lo - 1, region.hi);
137
+ lines.splice(at, 0, `${" ".repeat(region.indent)}${ANNOTATIONS_KEY}:`, ...render(region.indent));
138
+ } else {
139
+ lines.splice(seq.end, 0, ...render(region.indent));
140
+ }
141
+ return lines.join("\n") + "\n";
142
+ }
143
+
144
+ /** Upsert a `<slug>:` entry into the `yamlover-fragments:` mapping of the node addressed by
145
+ * `within`, creating the key (and any missing path) if absent. `render(indent)` returns the
146
+ * fragment's source lines INCLUDING the `<slug>:` line, at the mapping's child indent. A slug
147
+ * that already exists is REPLACED (its whole block). */
148
+ export function upsertFragment(text: string, within: string[], slug: string, render: (indent: number) => string[]): string {
149
+ const lines = text.replace(/\n$/, "").split("\n");
150
+ const region = reachBody(lines, within);
151
+ let fragKey = findKeyLine(lines, region.lo, region.hi, region.indent, FRAGMENTS_KEY);
152
+ if (fragKey < 0) {
153
+ const at = trimBack(lines, region.lo - 1, region.hi);
154
+ lines.splice(at, 0, `${" ".repeat(region.indent)}${FRAGMENTS_KEY}:`);
155
+ fragKey = at;
156
+ }
157
+ const mapIndent = region.indent + 2;
158
+ const mapBody: Region = { lo: fragKey + 1, hi: blockEnd(lines, fragKey + 1, lines.length, region.indent + 1), indent: mapIndent };
159
+ const existing = findKeyLine(lines, mapBody.lo, mapBody.hi, mapIndent, slug);
160
+ if (existing >= 0) {
161
+ const end = blockEnd(lines, existing + 1, mapBody.hi, mapIndent + 1);
162
+ lines.splice(existing, end - existing, ...render(mapIndent));
163
+ } else {
164
+ const at = trimBack(lines, fragKey, mapBody.hi);
165
+ lines.splice(at, 0, ...render(mapIndent));
166
+ }
167
+ return lines.join("\n") + "\n";
168
+ }
169
+
170
+ /** Remove an annotation element from the `yamlover-annotations:` of the node at `within` — the
171
+ * first `- ` item whose trimmed text matches `predicate`. Returns the text unchanged if none
172
+ * matches. The block of a multi-line object item is removed whole. */
173
+ export function removeAnnotation(text: string, within: string[], predicate: (itemText: string) => boolean): string {
174
+ const lines = text.replace(/\n$/, "").split("\n");
175
+ const region = reachBody(lines, within);
176
+ const seq = seqItemLines(lines, region, ANNOTATIONS_KEY);
177
+ if (!seq) return text;
178
+ for (let k = 0; k < seq.items.length; k++) {
179
+ const i = seq.items[k];
180
+ const itemText = lines[i].trim().replace(/^-\s*/, "");
181
+ if (!predicate(itemText)) continue;
182
+ const next = k + 1 < seq.items.length ? seq.items[k + 1] : seq.end;
183
+ lines.splice(i, next - i);
184
+ return lines.join("\n") + "\n";
185
+ }
186
+ return text;
187
+ }