yamlover 0.3.48 → 0.3.50

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 +39 -19
  2. package/bin/ga4.js +90 -0
  3. package/bin/yamlover.js +56 -18
  4. package/dist/agent-docs/AGENTS.md +95 -60
  5. package/dist/agent-docs/CLAUDE.md +1 -1
  6. package/dist/builtin-taxonomy/$defs/.yo/meta.yo +29 -0
  7. package/dist/builtin-taxonomy/$defs/annotation +6 -6
  8. package/dist/builtin-taxonomy/$defs/board +5 -5
  9. package/dist/builtin-taxonomy/$defs/bullets +3 -3
  10. package/dist/builtin-taxonomy/$defs/chapter +9 -10
  11. package/dist/builtin-taxonomy/$defs/chunk +5 -4
  12. package/dist/builtin-taxonomy/$defs/config +6 -6
  13. package/dist/builtin-taxonomy/$defs/fragment +2 -2
  14. package/dist/builtin-taxonomy/$defs/numbered +3 -3
  15. package/dist/builtin-taxonomy/$defs/{tag → onto} +6 -6
  16. package/dist/builtin-taxonomy/$defs/table +14 -13
  17. package/dist/builtin-taxonomy/$defs/task +6 -6
  18. package/dist/builtin-taxonomy/$defs/workflow +4 -4
  19. package/dist/builtin-taxonomy/ontos/.yo/body.yo +46 -0
  20. package/dist/client/assets/{decoded-Dfxf1BRR.js → decoded-5u9KK4Nb.js} +1 -1
  21. package/dist/client/assets/djvu-CvRKxWcX.js +1 -0
  22. package/dist/client/assets/{docx-aCX_2O0_.js → docx-CFqmrRMY.js} +1 -1
  23. package/dist/client/assets/{heic-8pzTwsCx.js → heic-P0RPecFK.js} +1 -1
  24. package/dist/client/assets/{imagemap-Bp26n33x.js → imagemap-BCrt0kPn.js} +1 -1
  25. package/dist/client/assets/index-_gAtxDG4.css +1 -0
  26. package/dist/client/assets/index-l1SxxFJR.js +754 -0
  27. package/dist/client/assets/{map-Dtw0_sOf.js → map-ClMfiQr8.js} +1 -1
  28. package/dist/client/assets/openable-9ABNW3A9.js +4 -0
  29. package/dist/client/assets/{paged-BGVPKMC7.js → paged-UVifI3SC.js} +1 -1
  30. package/dist/client/assets/{pdf-eI1AsxuB.js → pdf-BGgu5LFZ.js} +2 -2
  31. package/dist/client/assets/{psd-BD_hUWIy.js → psd-D15IXPVu.js} +1 -1
  32. package/dist/client/assets/{spreadsheet-D_yjjR8a.js → spreadsheet-CeOcWWb2.js} +1 -1
  33. package/dist/client/assets/{tiff-BewNDYwu.js → tiff-pZPBtPPg.js} +1 -1
  34. package/dist/client/assets/xyflow-BnuhLJ6X.css +1 -0
  35. package/dist/client/assets/xyflow-uN1JIC1A.js +23 -0
  36. package/dist/client/index.html +6 -2
  37. package/dist/client/yo-favicon.svg +16 -0
  38. package/dist/server.js +3105 -1266
  39. package/package.json +11 -4
  40. package/dist/builtin-taxonomy/$defs/.yamlover/meta.yamlover +0 -34
  41. package/dist/builtin-taxonomy/tags/.yamlover/body.yamlover +0 -46
  42. package/dist/client/assets/djvu-BAFaxONw.js +0 -1
  43. package/dist/client/assets/index-BohJjyVY.css +0 -1
  44. package/dist/client/assets/index-DXrtwPKE.js +0 -754
  45. package/dist/client/assets/openable-Banncb36.js +0 -4
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/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) |
@@ -157,8 +177,8 @@ order and grouped by backing file. It **splices source lines** rather than reser
157
177
  comments, quoting, and block scalars elsewhere in the document survive an edit untouched.
158
178
 
159
179
  `path` is a plain yamlover path naming the node being edited; each segment is a key
160
- (`:doc:description`) or an **absolute entry index** (`:doc[3]` keyed entries consume indices
161
- too). A node has four **facets**: its scalar value, its keyed entries, its ordinal entries, and
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
162
182
  its `!!<…>` meta tag.
163
183
 
164
184
  | op | facets | `meta` |
@@ -171,13 +191,13 @@ its `!!<…>` meta tag.
171
191
  That is why editing a chunk's prose is an `emplace`: an annotated chunk is an omni node whose tag
172
192
  applications are keyed entries laid over its scalar, and only the scalar facet is being replaced.
173
193
  A chapter's **title** works the same way — it is the chapter node's own scalar self-value
174
- (CHAPTER.md), so a title edit is an `emplace` on the chapter path itself (an empty payload drops
194
+ (docs/documents/chapter), so a title edit is an `emplace` on the chapter path itself (an empty payload drops
175
195
  the title line).
176
196
 
177
197
  `yamlover` is valid inline yamlover **source**, not prose — the caller escapes its own text (the web
178
198
  client through `escapeYamloverScalar`), and the server parses the fragment to validate it before
179
199
  anything is written. `meta` is a schema pointer (`*::yamlover:$defs:chapter`) written as the tag;
180
- `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
181
201
  content is **born**, and rejected on an existing node — converting one is a move, not an edit.
182
202
 
183
203
  Creating an object is therefore just an `insert` carrying a `meta` and a body: a document's body
@@ -219,10 +239,10 @@ Registered today (a representative slice — the registry is the source of truth
219
239
  | renderer | matches (format) | draws with |
220
240
  |----------|------------------|------------|
221
241
  | `chapter` | `x-yamlover-chapter` | a positional body: numbered chunks + subchapter links |
222
- | `tag` / `board` | `x-yamlover-tag` / `x-yamlover-board` | tag-hierarchy diagram / board (handled outside the specificity loop) |
242
+ | `tag` / `board` | `x-yamlover-onto` / `x-yamlover-board` | tag-hierarchy diagram / board (handled outside the specificity loop) |
223
243
  | `task` | `x-yamlover-task` | task view |
224
- | `table` | `x-yamlover-table` | a grid (`MARKLOWER.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 |
225
- | `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 |
226
246
  | `markdown` | `text/markdown` | [marked](https://marked.js.org) |
227
247
  | `asciidoc` | `text/asciidoc` | [@asciidoctor/core](https://asciidoctor.org) |
228
248
  | `csv` | `text/csv`, `text/tab-separated-values` | a table |
@@ -292,7 +312,7 @@ $ npm run test:watch
292
312
  This server suite is **separate** from the repository root's `npm test` (which
293
313
  runs only the parser and engine suites) and from the editor package's own suite
294
314
  (`npm --prefix tools/yed test` — the grammar/corpus conformance gate,
295
- `EDITOR.md` §9). CI gates on all of them, so run them before pushing. (Run
315
+ `docs/server/editor/yed`). CI gates on all of them, so run them before pushing. (Run
296
316
  everything from the project root — see the root `package.json`.)
297
317
 
298
318
  ## Layout
@@ -311,7 +331,7 @@ src/server/ the engine-backed JSON API
311
331
  agent-docs/ the AGENTS.md / CLAUDE.md guide installed by POST /api/agent-docs
312
332
  src/client/ the React SPA (tree, node view, render, icons, paths, live SSE)
313
333
  renderers/ facet-predicate renderer registry + per-format renderers; the
314
- unlocked editors are yed mounts over ../yed (@yamlover/yed) — EDITOR.md
334
+ unlocked editors are yed mounts over ../yed (@yamlover/yed) — docs/server/editor
315
335
  vendor/djvu.js prebuilt DjVu.js bundle (GPL-v2; see vendor/README.md)
316
336
  test/ Vitest suite (server logic + client components)
317
337
  index.html SPA shell
package/bin/ga4.js ADDED
@@ -0,0 +1,90 @@
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
+ // Which also drops the `utm_` keys a referral arrives with — and gtag reads campaign
48
+ // attribution off `page_location`, so without the lines below every tagged link (the
49
+ // README's, say) would report as direct traffic. An explicit campaign field overrides
50
+ // its `utm_` twin, so the five standard keys are read once at load and passed by name.
51
+ // By name, and only those five: the allowlist is what keeps the redaction above true.
52
+ //
53
+ // Not on a collapsed instance, though. There the promise is stronger — NOTHING from this
54
+ // URL is forwarded — and a hand-written `?utm_source=<the hash>` would otherwise walk
55
+ // straight through the allowlist. A demo is reached through the registration page anyway,
56
+ // which is a plain document with a stock tag that reads the query itself.
57
+ `var CAMPAIGN={};if(!COLLAPSE){var Q=new URLSearchParams(location.search);` +
58
+ `["source","medium","campaign","term","content"].forEach(function(k){` +
59
+ `var v=Q.get("utm_"+k);if(v)CAMPAIGN["campaign_"+k]=v})}` +
60
+ `function at(){if(COLLAPSE)return PUB+"/";var p=location.pathname;` +
61
+ `if(BASE&&p.indexOf(BASE)===0)p=p.slice(BASE.length)||"/";return PUB+p}` +
62
+ // Stamped as GLOBAL parameters rather than per-event: the tag's own enhanced-measurement
63
+ // events (scroll, outbound click, history) are fired by gtag.js and would otherwise
64
+ // report location.href, routing around the redaction above.
65
+ `var last="";function stamp(){var p=at();last=p;` +
66
+ `gtag("set",{page_path:p,page_location:location.origin+p,page_title:COLLAPSE?"yamlover demo":document.title})}` +
67
+ `stamp();gtag("config",ID,Object.assign({send_page_view:false},CAMPAIGN));gtag("event","page_view");` +
68
+ // The SPA routes on the JSON path with no document load, so page_view is re-sent by hand.
69
+ // A collapsed instance reports one path, so every navigation inside it is a repeat — the
70
+ // `last` guard keeps it to a single page_view instead of one per click.
71
+ `function nav(){if(at()===last)return;stamp();gtag("event","page_view")}` +
72
+ `["pushState","replaceState"].forEach(function(m){var o=history[m];` +
73
+ `history[m]=function(){var r=o.apply(this,arguments);nav();return r}});` +
74
+ `addEventListener("popstate",nav)})()</script>`
75
+ );
76
+ }
77
+
78
+ /** Read the tag's configuration from the environment. `basePath` comes from the caller
79
+ * because the flag may override $BASE_PATH; `GA4_PAGE_PATH` defaults to it, which is
80
+ * right for a root or /docs mount and is overridden for a demo (whose path is a secret). */
81
+ export function ga4ConfigFromEnv(basePath) {
82
+ const measurementId = process.env.GA4_MEASUREMENT_ID ?? "";
83
+ if (!measurementId) return null;
84
+ return {
85
+ measurementId,
86
+ basePath,
87
+ pagePath: process.env.GA4_PAGE_PATH ?? basePath,
88
+ collapse: ["1", "true", "yes"].includes(String(process.env.GA4_COLLAPSE_PATH ?? "").toLowerCase()),
89
+ };
90
+ }
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.
@@ -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 {
@@ -279,7 +300,8 @@ const ts = () => {
279
300
  handle = createHandlers(dataRoot, {
280
301
  gitignore,
281
302
  watch: true, // re-index + push on external edits
282
- ensureSettings: true, // create .yamlover/settings.yamlover with defaults if absent (so the gear opens)
303
+ ensureSettings: !readOnly, // create .yo/settings.yo with defaults if absent (so the gear opens)
304
+ readOnly,
283
305
  log: (line) => console.log(`yamlover ${ts()} ${line}`),
284
306
  });
285
307
  handle.ready.catch((e) => console.error(`yamlover ${ts()} indexing failed:`, e));
@@ -332,22 +354,33 @@ function serveStatic(res, url, distClient, distIndex) {
332
354
  function serveIndex(res, distIndex) {
333
355
  res.setHeader("Content-Type", "text/html; charset=utf-8");
334
356
  res.setHeader("Cache-Control", "no-cache");
335
- if (basePath) {
336
- // The shell must learn its prefix (the client prepends it to every server URL) and its
337
- // root-absolute asset refs must point under the prefix (the strip above maps them back).
338
- res.end(injectBase(fs.readFileSync(distIndex, "utf-8")));
339
- return;
340
- }
341
- 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")));
342
362
  }
343
363
 
344
- /** Make a served index.html base-path-aware: expose `window.__BASE__` for the client's URL helper
345
- * and prefix root-absolute `src="/…"` / `href="/…"` asset refs with the base path (protocol-relative
346
- * `//…` left alone). No-op when no base path is set. */
347
- function injectBase(html) {
348
- if (!basePath) return html;
349
- html = html.replace(/((?:src|href)=")\/(?!\/)/g, `$1${basePath}/`);
350
- 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;
351
384
  return html.includes("<head>") ? html.replace("<head>", `<head>${tag}`) : tag + html;
352
385
  }
353
386
 
@@ -373,6 +406,11 @@ function listenWithFallback(p, triesLeft) {
373
406
  server.off("error", onError); // bound OK — stop intercepting listen errors
374
407
  console.log(`yamlover ${ts()} serving ${dataRoot}${prod ? "" : " (live/Vite)"}`);
375
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
+ }
376
414
  };
377
415
  server.once("error", onError);
378
416
  server.once("listening", onListening);