yamlover 0.3.25 → 0.3.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +47 -21
  2. package/bin/ga4.js +77 -0
  3. package/bin/yamlover.js +70 -24
  4. package/dist/agent-docs/AGENTS.md +109 -48
  5. package/dist/agent-docs/CLAUDE.md +1 -1
  6. package/dist/builtin-taxonomy/$defs/{.yamlover/meta.yamlover → .yo/meta.yo} +6 -0
  7. package/dist/builtin-taxonomy/$defs/board +1 -1
  8. package/dist/builtin-taxonomy/$defs/bullets +12 -0
  9. package/dist/builtin-taxonomy/$defs/chapter +20 -15
  10. package/dist/builtin-taxonomy/$defs/chunk +1 -1
  11. package/dist/builtin-taxonomy/$defs/config +1 -1
  12. package/dist/builtin-taxonomy/$defs/numbered +12 -0
  13. package/dist/builtin-taxonomy/$defs/table +17 -10
  14. package/dist/builtin-taxonomy/$defs/task +4 -3
  15. package/dist/builtin-taxonomy/tags/{.yamlover/body.yamlover → .yo/body.yo} +1 -1
  16. package/dist/client/assets/decoded-D_YQdg1-.js +1 -0
  17. package/dist/client/assets/djvu-Di4fPA-J.js +1 -0
  18. package/dist/client/assets/{docx-C3gzsgKj.js → docx-BSduN32X.js} +1 -1
  19. package/dist/client/assets/{heic-CZ82O7cO.js → heic-DOAG7EsW.js} +2 -2
  20. package/dist/client/assets/imagemap-B8RthxNE.js +1 -0
  21. package/dist/client/assets/index-Bf2L7I9e.js +752 -0
  22. package/dist/client/assets/index-N41EyNG2.css +1 -0
  23. package/dist/client/assets/map-Cf7ar1_U.js +1 -0
  24. package/dist/client/assets/openable-CCBlc-Ws.js +4 -0
  25. package/dist/client/assets/paged-bLurvatj.js +1 -0
  26. package/dist/client/assets/{pdf-DYI9E6LP.js → pdf-D1ox8M9A.js} +5 -5
  27. package/dist/client/assets/psd-AT50x-YR.js +11 -0
  28. package/dist/client/assets/{spreadsheet-DQ1DIIfm.js → spreadsheet-DeQh5CNF.js} +1 -1
  29. package/dist/client/assets/tiff-B3-awhzQ.js +1 -0
  30. package/dist/client/assets/xyflow-BnuhLJ6X.css +1 -0
  31. package/dist/client/assets/xyflow-DuQ4jjaq.js +23 -0
  32. package/dist/client/index.html +6 -2
  33. package/dist/client/yo-favicon.svg +16 -0
  34. package/dist/server.js +4013 -1300
  35. package/package.json +12 -4
  36. package/dist/client/assets/decoded-BfD5vW9k.js +0 -1
  37. package/dist/client/assets/djvu-DNElocuz.js +0 -1
  38. package/dist/client/assets/imagemap-B0by6U04.js +0 -1
  39. package/dist/client/assets/index-C3Raqez8.css +0 -1
  40. package/dist/client/assets/index-DHpG_wBG.js +0 -665
  41. package/dist/client/assets/map-Byy6U5tc.js +0 -1
  42. package/dist/client/assets/openable-d9huG_69.js +0 -4
  43. package/dist/client/assets/paged-DFA2Muzj.js +0 -1
  44. package/dist/client/assets/psd-CmNn7r6o.js +0 -11
  45. package/dist/client/assets/tiff-BoTWsGfS.js +0 -1
package/README.md CHANGED
@@ -1,12 +1,20 @@
1
- # server
1
+ # yamlover
2
2
 
3
- Browse a yamlover tree in the web browser.
3
+ A local-first, editable knowledge graph for humans and LLMs — one plain-text
4
+ directory that a person edits like notes, an agent reads and writes like a
5
+ database, and `git` versions like code. This package is the web server: it
6
+ serves a browser UI (tree, editor, renderers, live queries) over a yamlover
7
+ directory, and an HTTP API an agent can drive directly.
8
+
9
+ - **Repo:** https://github.com/dims12/yamlover
10
+ - **Live demo** (no install): https://yamlover.inthemoon.net/
11
+ - **Docs** (written in yamlover, click-through): https://yamlover.inthemoon.net/docs
4
12
 
5
13
  ```console
6
- $ npx yamlover [ROOT] [--port N] [--headless] [--host ADDR] [--base-path PREFIX] [--no-gitignore] [--prod]
14
+ $ npx yamlover [ROOT] [--port N] [--headless] [--host ADDR] [--base-path PREFIX] [--no-gitignore] [--prod] [--read-only]
7
15
  ```
8
16
 
9
- `ROOT` is any yamlover entity — a project directory (one with a `.yamlover/`),
17
+ `ROOT` is any yamlover entity — a project directory (one with a `.yo/`),
10
18
  a plain directory, or a single file. It defaults to the current directory. The
11
19
  command starts a local web server — **bound to `127.0.0.1` (local only) by
12
20
  default**, the safe default for a personal viewer and for the desktop wrapper
@@ -25,6 +33,18 @@ prefix instead of `/` — for hosting several instances behind one reverse proxy
25
33
  SPA shell as `window.__BASE__`. The `BASE_PATH` env var seeds the same setting
26
34
  (an explicit flag wins), so a shell-less container image can inject it.
27
35
 
36
+ `--read-only` serves the tree **content read-only** — e.g. publishing `docs/`:
37
+ every user-data-mutating request (edits, tagging, moves, renames, uploads,
38
+ board edits, agent-docs install) answers **403** `server is read-only`,
39
+ enforced by a route allowlist on the backend, and the UI hides every
40
+ modification affordance (the flag rides into the SPA shell as
41
+ `window.__READONLY__`; `GET /api/info` reports `readOnly` too). The server
42
+ still maintains its own index (`.yo/index.db`), so browsing stays fast and
43
+ live; but it never generates thumbnails (pre-existing ones still serve — a
44
+ miss falls back to the type glyph), never materializes `settings.yo`, and
45
+ never rewrites source files over an externally inferred move. The
46
+ `YAMLOVER_READ_ONLY` env var (`1`/`true`/`yes`) seeds the same setting.
47
+
28
48
  The page is split into two independently scrolling panes:
29
49
 
30
50
  - **Left — a table of contents.** A collapsible tree of **every** node — object
@@ -62,7 +82,7 @@ Surfaced *stray* files (those not described by the tree) honor `.gitignore` by
62
82
  default — `node_modules/`, build output, etc. are hidden. Pass `--no-gitignore`
63
83
  to show everything.
64
84
 
65
- The browser URL is the node's path in **JSON space** — `/examples[0]/markup` —
85
+ The browser URL is the node's path in **JSON space** — `/examples/0/markup` —
66
86
  plus `?format=` for the representation. Each key is percent-encoded, so a key
67
87
  that itself contains a `/` (e.g. `@vitejs/plugin-react`) stays a single segment.
68
88
  It updates as you navigate and is shareable / back-button friendly — opening a
@@ -72,7 +92,7 @@ deep link expands the TOC along the path and selects (and scrolls to) the target
72
92
  splitting its path at the page boundary and replacing that `/` with `#`: for a full
73
93
  path `a/b/c/d` whose served page is `a/b`, the *scrollable* URL is `a/b#c/d` — the
74
94
  page loads `a/b` and scrolls to the `c/d` node within it. A **fragment** (a tagged
75
- region — see `ANNOTATIONS.md`) is one instance: a region of `…/IMG.jpg` lives at
95
+ region — see `docs/server/annotations`) is one instance: a region of `…/IMG.jpg` lives at
76
96
  `…/IMG.jpg/yamlover-fragments/<slug>`, so its scrollable URL is
77
97
  `…/IMG.jpg#yamlover-fragments/<slug>` — opening it (or clicking the region in the
78
98
  fragments panel) scrolls/pans to **and briefly flashes** the region.
@@ -86,7 +106,7 @@ property-graph index) holds nodes and pointer edges. The HTTP layer
86
106
  shapes the React client consumes (the `$yamloverLink` / `$yamloverBinary` /
87
107
  `$yamloverMixed` markers, the schema view).
88
108
 
89
- The on-disk index lives at `<root>/.yamlover/index.db`. It is a derived cache
109
+ The on-disk index lives at `<root>/.yo/index.db`. It is a derived cache
90
110
  with a persistent **file manifest** (path + hash + size + mtime): startup
91
111
  re-indexes against it (an offline reconcile — unchanged blobs are never re-read,
92
112
  so it is cheap), and the FS watcher re-indexes on edits and broadcasts the diff
@@ -127,7 +147,7 @@ take an optional `depth` (container-nesting limit).
127
147
  |----------|---------|
128
148
  | `GET /api/info` | the breadcrumb head (the root label) |
129
149
  | `GET /api/tree?path&depth` | the TOC subtree at `path`, `depth` levels deep (default 3) — fetched again per branch for lazy expansion |
130
- | `GET /api/json?path&depth&binary` | the node's value, one level deep (`depth` default 1); nested containers become link markers (`&binary=1` for a binary leaf's base64) |
150
+ | `GET /api/content/{slash-path}?depth` | **THE ONE WIRE** (`text/yamlover`): the node as a yamlover envelope — header facets, the serialized `source` subtree (cut members respelled as pointers), the fragment-keyed `side` sidecar, `relations`. Clients parse it with the shared `tools/parser` and derive the legacy NodeJson locally |
131
151
  | `GET /api/schema?path&depth` | the node's instance schema, with the same link markers |
132
152
  | `GET /api/blob?path` | a file-backed node's **raw bytes**, with its (inferred) format as the `Content-Type` |
133
153
  | `GET /api/thumb?path&w&h` | a lazily-generated thumbnail of a file-backed blob |
@@ -135,12 +155,12 @@ take an optional `depth` (container-nesting limit).
135
155
  | `GET /api/annotations?path` | the annotations on a node |
136
156
  | `GET /api/query?q&path` | the query evaluator (colon match templates) |
137
157
  | `GET /api/dangling` | pointers that did not resolve at index time |
138
- | `GET /api/config` | the effective `settings.yamlover` — raw source + parsed settings (read-only; written via `/api/edit`, hot-reloaded on change) |
158
+ | `GET /api/config` | the effective `settings.yo` — raw source + parsed settings (read-only; written via `/api/edit`, hot-reloaded on change) |
139
159
  | `GET /api/events` | SSE: `{type:"diff",…}` reindex diffs + `{type:"task",…}` progress |
140
160
  | `GET /api/tasks` | long-running tasks in flight (a snapshot for a fresh page) |
141
161
  | `POST /api/reindex` | manual reconcile (the watcher's fallback) |
142
162
  | `POST /api/edit` | the yamlover **editor** — surgical source edits (see below) |
143
- | `POST /api/preview` | **stateless**: render a standalone yamlover source as `/api/json` would (nothing touches the store) |
163
+ | `POST /api/preview` | **stateless**: render a standalone yamlover source as a content envelope, like `/api/content` (nothing touches the store) |
144
164
  | `POST /api/edit-text` | **stateless**: apply `/api/edit` ops to a standalone source, returning the new source |
145
165
  | `POST /api/paste` | clipboard paste / upload (text or files) |
146
166
  | `POST /api/mv` | mediated move (surgical inbound-ref rewrite + auto-relink) |
@@ -156,9 +176,10 @@ One edit `{ path, op, yamlover?, meta?, concrete?, name? }`, or a batch `{ edits
156
176
  order and grouped by backing file. It **splices source lines** rather than reserializing, so
157
177
  comments, quoting, and block scalars elsewhere in the document survive an edit untouched.
158
178
 
159
- `path` is a plain yamlover path naming the node being edited; each segment is a key (`:doc:title`)
160
- or an **absolute entry index** (`:doc[3]` keyed entries consume indices too). A node has four
161
- **facets**: its scalar value, its keyed entries, its ordinal entries, and its `!!<…>` meta tag.
179
+ `path` is a plain yamlover path naming the node being edited; each segment is a key
180
+ (`:doc:description`) or an **absolute entry index** (`:doc:3`, the bare-integer segment
181
+ keyed entries consume indices too; the retired `:doc[3]` reads as an alias). A node has four **facets**: its scalar value, its keyed entries, its ordinal entries, and
182
+ its `!!<…>` meta tag.
162
183
 
163
184
  | op | facets | `meta` |
164
185
  |----|--------|--------|
@@ -169,11 +190,14 @@ or an **absolute entry index** (`:doc[3]` — keyed entries consume indices too)
169
190
 
170
191
  That is why editing a chunk's prose is an `emplace`: an annotated chunk is an omni node whose tag
171
192
  applications are keyed entries laid over its scalar, and only the scalar facet is being replaced.
193
+ A chapter's **title** works the same way — it is the chapter node's own scalar self-value
194
+ (docs/documents/chapter), so a title edit is an `emplace` on the chapter path itself (an empty payload drops
195
+ the title line).
172
196
 
173
197
  `yamlover` is valid inline yamlover **source**, not prose — the caller escapes its own text (the web
174
198
  client through `escapeYamloverScalar`), and the server parses the fragment to validate it before
175
199
  anything is written. `meta` is a schema pointer (`*::yamlover:$defs:chapter`) written as the tag;
176
- `null` removes it. `concrete` (`yamlover` | `file/yamlover` | `dir/yamlover`) is accepted only where
200
+ `null` removes it. `concrete` (`yamlover` | `file/yamlover` | `dir/.yo`) is accepted only where
177
201
  content is **born**, and rejected on an existing node — converting one is a move, not an edit.
178
202
 
179
203
  Creating an object is therefore just an `insert` carrying a `meta` and a body: a document's body
@@ -217,8 +241,8 @@ Registered today (a representative slice — the registry is the source of truth
217
241
  | `chapter` | `x-yamlover-chapter` | a positional body: numbered chunks + subchapter links |
218
242
  | `tag` / `board` | `x-yamlover-tag` / `x-yamlover-board` | tag-hierarchy diagram / board (handled outside the specificity loop) |
219
243
  | `task` | `x-yamlover-task` | task view |
220
- | `table` | `x-yamlover-table` | a grid (`TABLE.md`): rows/header/caption from the omni entries, merged cells (`colSpan`/`rowSpan`) from resolved relative-index `*` pointers, nested tables inline, marklower cells; prose cells edit in place under the lock |
221
- | `marklower` | `text/marklower` (a chapter's prose chunks, by schema propagation) | its own inline grammar (`MARKLOWER.md`): emphasis, `` `code` ``, `$$math$$` via KaTeX, links, and `*[…](…)` media embeds |
244
+ | `table` | `x-yamlover-table` | a grid (`docs/documents/marklower`): rows/header/caption from the omni entries, merged cells (`colSpan`/`rowSpan`) from resolved relative-index `*` pointers, nested tables inline, marklower cells; prose cells edit in place under the lock |
245
+ | `marklower` | `text/marklower` (a chapter's prose chunks, by schema propagation) | its own inline grammar (`docs/documents/marklower`): emphasis, `` `code` ``, `$$math$$` via KaTeX, links, and `*[…](…)` media embeds |
222
246
  | `markdown` | `text/markdown` | [marked](https://marked.js.org) |
223
247
  | `asciidoc` | `text/asciidoc` | [@asciidoctor/core](https://asciidoctor.org) |
224
248
  | `csv` | `text/csv`, `text/tab-separated-values` | a table |
@@ -285,10 +309,11 @@ $ npm test # run once
285
309
  $ npm run test:watch
286
310
  ```
287
311
 
288
- This server suite is **separate** from the repository root's `npm test`, which
289
- runs only the parser and engine suites. CI gates on the server suite, so run
290
- both before pushing. (Run everything from the project root — see the root
291
- `package.json`.)
312
+ This server suite is **separate** from the repository root's `npm test` (which
313
+ runs only the parser and engine suites) and from the editor package's own suite
314
+ (`npm --prefix tools/yed test` the grammar/corpus conformance gate,
315
+ `docs/server/editor/yed`). CI gates on all of them, so run them before pushing. (Run
316
+ everything from the project root — see the root `package.json`.)
292
317
 
293
318
  ## Layout
294
319
 
@@ -305,7 +330,8 @@ src/server/ the engine-backed JSON API
305
330
  extract/ the thumbnail pipeline: decoder registry (raster/psd/webp/avif) + scaling
306
331
  agent-docs/ the AGENTS.md / CLAUDE.md guide installed by POST /api/agent-docs
307
332
  src/client/ the React SPA (tree, node view, render, icons, paths, live SSE)
308
- renderers/ facet-predicate renderer registry + per-format renderers
333
+ renderers/ facet-predicate renderer registry + per-format renderers; the
334
+ unlocked editors are yed mounts over ../yed (@yamlover/yed) — docs/server/editor
309
335
  vendor/djvu.js prebuilt DjVu.js bundle (GPL-v2; see vendor/README.md)
310
336
  test/ Vitest suite (server logic + client components)
311
337
  index.html SPA shell
package/bin/ga4.js ADDED
@@ -0,0 +1,77 @@
1
+ // The Google Analytics 4 tag injected into the served SPA shell.
2
+ //
3
+ // OFF UNLESS THE SERVER IS TOLD OTHERWISE. Nothing here runs without $GA4_MEASUREMENT_ID,
4
+ // which only the hosted deployment sets — `npx yamlover`, the desktop app and any
5
+ // self-hosted tree serve the same shell and send nothing. A local-first viewer that phoned
6
+ // home by default would be a different product.
7
+ //
8
+ // The tag reports a path the SERVER chose ($GA4_PAGE_PATH), never the one in the address
9
+ // bar, because the address bar is not safe to forward:
10
+ //
11
+ // • a demo instance is mounted at /demo/<hash>, and that hash IS the credential for it —
12
+ // copying it into a third-party report hands out the instance;
13
+ // • below the mount point the URL is the yamlover data path, and in a demo the visitor
14
+ // may have typed the node names themselves.
15
+ //
16
+ // So a demo collapses to a single page ($GA4_COLLAPSE_PATH) and drops document.title, which
17
+ // the SPA rewrites to the node's own labels. The docs instance serves published content, so
18
+ // it reports its real sub-paths — knowing which chapters get read is the point.
19
+ //
20
+ // No `anonymize_ip`: that is a Universal Analytics parameter and GA4 ignores it. GA4
21
+ // truncates the address on receipt and never stores it.
22
+
23
+ /** Embed a value as a JS literal that is safe inside `<script>` (`</script>` in a string
24
+ * would otherwise close the element early). */
25
+ const js = (v) => JSON.stringify(v ?? "").replace(/</g, "\\u003c");
26
+
27
+ /** The GA4 `<script>` for a shell served at `basePath`, or "" when analytics is off.
28
+ *
29
+ * `measurementId` — the G-XXXXXXXXXX stream id.
30
+ * `pagePath` — the mount point as analytics should see it (e.g. `/demo/<id>`, `/docs`).
31
+ * `collapse` — report `pagePath` alone, never a sub-path or the document title. */
32
+ export function ga4Tag({ measurementId, basePath = "", pagePath = "", collapse = false }) {
33
+ if (!measurementId) return "";
34
+ return (
35
+ `<script async src="https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}"></script>` +
36
+ `<script>(function(){` +
37
+ // Initialise once per document. The tag wraps history.pushState, so a second copy in the
38
+ // same page would wrap the wrapper and report every SPA navigation twice.
39
+ `if(window.__yoGa4__)return;window.__yoGa4__=1;` +
40
+ // A collapsed tag is handed no base path at all. `at()` would not read it either way,
41
+ // but not emitting it is what makes "this script cannot know the hash" checkable by
42
+ // reading the page instead of by reasoning about a branch.
43
+ `var ID=${js(measurementId)},BASE=${js(collapse ? "" : basePath)},PUB=${js(pagePath)},COLLAPSE=${collapse ? "1" : "0"};` +
44
+ `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}window.gtag=gtag;` +
45
+ `gtag("js",new Date());` +
46
+ // The reported path. Query and fragment are dropped wholesale: both carry data paths.
47
+ `function at(){if(COLLAPSE)return PUB+"/";var p=location.pathname;` +
48
+ `if(BASE&&p.indexOf(BASE)===0)p=p.slice(BASE.length)||"/";return PUB+p}` +
49
+ // Stamped as GLOBAL parameters rather than per-event: the tag's own enhanced-measurement
50
+ // events (scroll, outbound click, history) are fired by gtag.js and would otherwise
51
+ // report location.href, routing around the redaction above.
52
+ `var last="";function stamp(){var p=at();last=p;` +
53
+ `gtag("set",{page_path:p,page_location:location.origin+p,page_title:COLLAPSE?"yamlover demo":document.title})}` +
54
+ `stamp();gtag("config",ID,{send_page_view:false});gtag("event","page_view");` +
55
+ // The SPA routes on the JSON path with no document load, so page_view is re-sent by hand.
56
+ // A collapsed instance reports one path, so every navigation inside it is a repeat — the
57
+ // `last` guard keeps it to a single page_view instead of one per click.
58
+ `function nav(){if(at()===last)return;stamp();gtag("event","page_view")}` +
59
+ `["pushState","replaceState"].forEach(function(m){var o=history[m];` +
60
+ `history[m]=function(){var r=o.apply(this,arguments);nav();return r}});` +
61
+ `addEventListener("popstate",nav)})()</script>`
62
+ );
63
+ }
64
+
65
+ /** Read the tag's configuration from the environment. `basePath` comes from the caller
66
+ * because the flag may override $BASE_PATH; `GA4_PAGE_PATH` defaults to it, which is
67
+ * right for a root or /docs mount and is overridden for a demo (whose path is a secret). */
68
+ export function ga4ConfigFromEnv(basePath) {
69
+ const measurementId = process.env.GA4_MEASUREMENT_ID ?? "";
70
+ if (!measurementId) return null;
71
+ return {
72
+ measurementId,
73
+ basePath,
74
+ pagePath: process.env.GA4_PAGE_PATH ?? basePath,
75
+ collapse: ["1", "true", "yes"].includes(String(process.env.GA4_COLLAPSE_PATH ?? "").toLowerCase()),
76
+ };
77
+ }
package/bin/yamlover.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * npx yamlover [ROOT] [--port N]
6
6
  *
7
7
  * ROOT is the directory to browse (default: the current directory). It may be a
8
- * yamlover node (with `.yamlover/`), a plain directory, or a single file — the
8
+ * yamlover node (with `.yo/`), a plain directory, or a single file — the
9
9
  * same shapes `tools/walker` understands.
10
10
  *
11
11
  * Two serve modes, chosen automatically:
@@ -36,6 +36,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
36
36
  import { createRequire } from "node:module";
37
37
  import { dirname, join, resolve, extname, sep } from "node:path";
38
38
  import fs from "node:fs";
39
+ import { ga4Tag, ga4ConfigFromEnv } from "./ga4.js";
39
40
 
40
41
  const __dirname = dirname(fileURLToPath(import.meta.url));
41
42
  const pkgRoot = resolve(__dirname, ".."); // tools/server
@@ -67,6 +68,10 @@ let prodFlag = false; // force production (static) mode even in the repo checkou
67
68
  // Seeded from $BASE_PATH so a shell-less image (e.g. distroless) can inject it via env without
68
69
  // needing `sh -c` to expand it into a `--base-path` flag; an explicit flag below still overrides.
69
70
  let basePath = process.env.BASE_PATH ?? "";
71
+ // CONTENT READ-ONLY: every user-data-mutating API route answers 403 and the UI hides its edit
72
+ // affordances; the server still maintains its own index (.yo/index.db). Seeded from
73
+ // $YAMLOVER_READ_ONLY (same shell-less-image rationale as $BASE_PATH); `--read-only` below too.
74
+ let readOnly = ["1", "true", "yes"].includes(String(process.env.YAMLOVER_READ_ONLY ?? "").toLowerCase());
70
75
  // Normalize a base path: leading `/`, no trailing `/`; `""`/`"/"` → disabled.
71
76
  function normBase(s) {
72
77
  let b = (s ?? "").trim();
@@ -88,10 +93,12 @@ for (let i = 0; i < argv.length; i++) {
88
93
  else if (a.startsWith("--base-path=")) basePath = normBase(a.slice("--base-path=".length));
89
94
  else if (a === "--no-gitignore") gitignore = false;
90
95
  else if (a === "--prod") prodFlag = true;
96
+ else if (a === "--read-only") readOnly = true;
91
97
  else if (a === "--help" || a === "-h") {
92
- console.log("usage: npx yamlover [ROOT] [--port N] [--headless] [--host ADDR] [--base-path PREFIX] [--no-gitignore] [--prod]");
98
+ console.log("usage: npx yamlover [ROOT] [--port N] [--headless] [--host ADDR] [--base-path PREFIX] [--no-gitignore] [--prod] [--read-only]");
93
99
  console.log(" default: serve on 127.0.0.1 (local only); --headless serves on all interfaces");
94
100
  console.log(" --base-path PREFIX: serve the whole app under PREFIX (e.g. /demo/abc) instead of /");
101
+ console.log(" --read-only: serve content read-only — every modifying request is refused (403)");
95
102
  process.exit(0);
96
103
  } else if (!a.startsWith("-")) rootArg = a;
97
104
  }
@@ -101,6 +108,10 @@ if (!fs.existsSync(dataRoot)) {
101
108
  process.exit(1);
102
109
  }
103
110
 
111
+ // Analytics: null unless $GA4_MEASUREMENT_ID is set, which only the hosted deployment does.
112
+ // Resolved after the flags so it sees the final base path.
113
+ const ga4 = ga4ConfigFromEnv(basePath);
114
+
104
115
  // --- mode ----------------------------------------------------------------- //
105
116
  // The engine SOURCE is present only in the monorepo checkout; its absence (the
106
117
  // published package) — or an explicit `--prod` — selects production/static mode.
@@ -167,7 +178,7 @@ if (prod) {
167
178
  // misses it, so a raw asset (notably pdf.js's worker) 404s to index.html.
168
179
  // The client re-parses edited yamlover source with the real parser (renderers/value-editors.tsx →
169
180
  // ../../parser/ts/src/yamlover.ts), which lives OUTSIDE pkgRoot — allow Vite to serve it over /@fs.
170
- const fsAllow = [pkgRoot, resolve(pkgRoot, "../parser")];
181
+ const fsAllow = [pkgRoot, resolve(pkgRoot, "../parser"), resolve(pkgRoot, "../yed")];
171
182
  try {
172
183
  const req = createRequire(join(pkgRoot, "package.json"));
173
184
  reactAlias["react"] = dirname(req.resolve("react/package.json"));
@@ -239,7 +250,7 @@ if (prod) {
239
250
  try {
240
251
  let html = fs.readFileSync(indexHtmlPath, "utf-8");
241
252
  html = await vite.transformIndexHtml(url.pathname, html);
242
- html = injectBase(html); // base-path-aware shell (no-op without --base-path)
253
+ html = injectGlobals(html); // base-path/read-only-aware shell (no-op without either)
243
254
  res.setHeader("Content-Type", "text/html; charset=utf-8");
244
255
  res.end(html);
245
256
  } catch (e) {
@@ -256,7 +267,17 @@ if (prod) {
256
267
  // "/src/" source modules; "/node_modules/" pre-bundles); everything else is an app route and
257
268
  // gets the SPA shell directly.
258
269
  const VITE_PREFIXES = ["/@", "/src/", "/node_modules/"];
270
+ // The one package asset the shell asks for by name, so it has to be a REAL FILE in live mode
271
+ // too. Vite's own public/ middleware is unreachable behind the prefix gate above — deliberately,
272
+ // since letting it serve stray package files is exactly what would shadow a served-tree route —
273
+ // so the icon gets a single explicit exception rather than the whole directory. Production needs
274
+ // none of this: `vite build` copies public/ into dist/client, which serveStatic already reads.
275
+ const faviconPath = join(pkgRoot, "public", "yo-favicon.svg");
259
276
  serveClient = (req, res, url) => {
277
+ if (url.pathname === "/yo-favicon.svg" && fs.existsSync(faviconPath)) {
278
+ res.setHeader("Content-Type", "image/svg+xml");
279
+ return fs.createReadStream(faviconPath).pipe(res);
280
+ }
260
281
  if (VITE_PREFIXES.some((p) => url.pathname.startsWith(p))) {
261
282
  vite.middlewares(req, res, () => spaShell(res, url)); // an unmatched vite path still lands on the shell
262
283
  } else {
@@ -265,16 +286,25 @@ if (prod) {
265
286
  };
266
287
  }
267
288
 
289
+ // HH:MM:SS.mmm (local time) on every terminal line, so slow phases are diagnosable
290
+ // against the wall clock.
291
+ const ts = () => {
292
+ const d = new Date();
293
+ const p = (n, w = 2) => String(n).padStart(w, "0");
294
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
295
+ };
296
+
268
297
  // The initial index runs as a BACKGROUND task — the server listens immediately
269
298
  // (serving the previous on-disk index, or an empty tree on a cold start) while
270
299
  // progress lands here and in the web UI (SSE task frames + GET /api/tasks).
271
300
  handle = createHandlers(dataRoot, {
272
301
  gitignore,
273
302
  watch: true, // re-index + push on external edits
274
- ensureSettings: true, // create .yamlover/settings.yamlover with defaults if absent (so the gear opens)
275
- log: (line) => console.log(`yamlover ${line}`),
303
+ ensureSettings: !readOnly, // create .yo/settings.yo with defaults if absent (so the gear opens)
304
+ readOnly,
305
+ log: (line) => console.log(`yamlover ${ts()} ${line}`),
276
306
  });
277
- handle.ready.catch((e) => console.error("yamlover: indexing failed:", e));
307
+ handle.ready.catch((e) => console.error(`yamlover ${ts()} indexing failed:`, e));
278
308
 
279
309
  // --- static file server (production mode) --------------------------------- //
280
310
  const MIME = {
@@ -324,22 +354,33 @@ function serveStatic(res, url, distClient, distIndex) {
324
354
  function serveIndex(res, distIndex) {
325
355
  res.setHeader("Content-Type", "text/html; charset=utf-8");
326
356
  res.setHeader("Cache-Control", "no-cache");
327
- if (basePath) {
328
- // The shell must learn its prefix (the client prepends it to every server URL) and its
329
- // root-absolute asset refs must point under the prefix (the strip above maps them back).
330
- res.end(injectBase(fs.readFileSync(distIndex, "utf-8")));
331
- return;
332
- }
333
- fs.createReadStream(distIndex).pipe(res);
357
+ // Always rewritten, never streamed: the built shell's asset refs are RELATIVE (vite
358
+ // `base: "./"`), and a client route is arbitrarily deep `./assets/…` served at /a/b/c
359
+ // would resolve to /a/b/assets/…. Anchoring them at the mount point is what makes a deep
360
+ // link reloadable, with or without a base path.
361
+ res.end(injectGlobals(fs.readFileSync(distIndex, "utf-8")));
334
362
  }
335
363
 
336
- /** Make a served index.html base-path-aware: expose `window.__BASE__` for the client's URL helper
337
- * and prefix root-absolute `src="/…"` / `href="/…"` asset refs with the base path (protocol-relative
338
- * `//…` left alone). No-op when no base path is set. */
339
- function injectBase(html) {
340
- if (!basePath) return html;
341
- html = html.replace(/((?:src|href)=")\/(?!\/)/g, `$1${basePath}/`);
342
- const tag = `<script>window.__BASE__=${JSON.stringify(basePath)}</script>`;
364
+ /** Anchor a served index.html to its mount point and stamp the server's posture into it.
365
+ *
366
+ * Asset refs (`src`/`href`) are rewritten to absolute `<basePath>/…`: the build emits them
367
+ * relative so LAZY chunks resolve against `import.meta.url` (see vite.config.mjs), but the
368
+ * shell itself is served at every client route, so its own refs must not follow the URL.
369
+ * Both spellings are handled — `./assets/…` as built, `/assets/…` should the base ever go
370
+ * back to absolute; protocol-relative `//…` is left alone.
371
+ *
372
+ * The pre-render globals follow: `window.__BASE__` (the client's URL helper prefix) and
373
+ * `window.__READONLY__` (the client hides every modification affordance), each set only
374
+ * when it applies. Last comes the analytics tag, which the hosted deployment configures
375
+ * through the environment and every other way of running yamlover leaves off (ga4.js). */
376
+ function injectGlobals(html) {
377
+ html = html.replace(/((?:src|href)=")(?:\.\/|\/(?!\/))/g, `$1${basePath}/`);
378
+ const globals = [];
379
+ if (basePath) globals.push(`window.__BASE__=${JSON.stringify(basePath)}`);
380
+ if (readOnly) globals.push("window.__READONLY__=true");
381
+ let tag = globals.length ? `<script>${globals.join(";")}</script>` : "";
382
+ if (ga4) tag += ga4Tag(ga4);
383
+ if (!tag) return html;
343
384
  return html.includes("<head>") ? html.replace("<head>", `<head>${tag}`) : tag + html;
344
385
  }
345
386
 
@@ -352,7 +393,7 @@ function listenWithFallback(p, triesLeft) {
352
393
  const onError = (err) => {
353
394
  server.off("listening", onListening); // drop this attempt's success handler
354
395
  if (err && err.code === "EADDRINUSE" && triesLeft > 0) {
355
- console.log(`yamlover port ${p} in use — trying ${p + 1}…`);
396
+ console.log(`yamlover ${ts()} port ${p} in use — trying ${p + 1}…`);
356
397
  listenWithFallback(p + 1, triesLeft - 1);
357
398
  } else if (err && err.code === "EADDRINUSE") {
358
399
  console.error(`yamlover: no free port found in ${port}–${p}`);
@@ -363,8 +404,13 @@ function listenWithFallback(p, triesLeft) {
363
404
  };
364
405
  const onListening = () => {
365
406
  server.off("error", onError); // bound OK — stop intercepting listen errors
366
- console.log(`yamlover serving ${dataRoot}${prod ? "" : " (live/Vite)"}`);
367
- console.log(` http://${shown}:${p}/ (bound to ${host})`);
407
+ console.log(`yamlover ${ts()} serving ${dataRoot}${prod ? "" : " (live/Vite)"}`);
408
+ console.log(` http://${shown}:${p}/ (bound to ${host})`);
409
+ // Say it out loud: analytics is off in every normal run, so when it IS on the operator
410
+ // should see which stream is being fed and exactly what paths reach it.
411
+ if (ga4) {
412
+ console.log(` analytics ${ga4.measurementId} → ${ga4.pagePath || ""}/${ga4.collapse ? "" : "…"}`);
413
+ }
368
414
  };
369
415
  server.once("error", onError);
370
416
  server.once("listening", onListening);