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.
- package/dist/server.js +370 -109
- package/package.json +1 -1
- package/src/client/App.tsx +59 -7
- package/src/client/api.ts +45 -18
- package/src/client/render.tsx +16 -3
- package/src/client/renderers/annotate.tsx +173 -53
- package/src/client/renderers/asciidoc.tsx +2 -1
- package/src/client/renderers/chapter.tsx +5 -1
- package/src/client/renderers/csv.tsx +2 -1
- package/src/client/renderers/djvu.tsx +8 -1
- package/src/client/renderers/explorer.tsx +90 -5
- package/src/client/renderers/imagemap.tsx +19 -6
- package/src/client/renderers/latex.tsx +2 -1
- package/src/client/renderers/marklower.tsx +2 -1
- package/src/client/renderers/pdf.tsx +9 -1
- package/src/client/renderers/plantuml.tsx +2 -1
- package/src/client/renderers/registry.tsx +91 -69
- package/src/client/renderers/text.tsx +2 -1
- package/src/client/styles.css +40 -3
- package/src/server/embed.ts +187 -0
- package/src/server/engine-api.ts +243 -122
- package/src/server/node-kind.ts +15 -2
|
@@ -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
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
365
|
-
*
|
|
366
|
-
*
|
|
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
|
-
|
|
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
|
|
374
|
-
*
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
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
|
}
|
package/src/client/styles.css
CHANGED
|
@@ -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
|
+
}
|