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.
- package/README.md +47 -21
- package/bin/ga4.js +77 -0
- package/bin/yamlover.js +70 -24
- package/dist/agent-docs/AGENTS.md +109 -48
- package/dist/agent-docs/CLAUDE.md +1 -1
- package/dist/builtin-taxonomy/$defs/{.yamlover/meta.yamlover → .yo/meta.yo} +6 -0
- package/dist/builtin-taxonomy/$defs/board +1 -1
- package/dist/builtin-taxonomy/$defs/bullets +12 -0
- package/dist/builtin-taxonomy/$defs/chapter +20 -15
- package/dist/builtin-taxonomy/$defs/chunk +1 -1
- package/dist/builtin-taxonomy/$defs/config +1 -1
- package/dist/builtin-taxonomy/$defs/numbered +12 -0
- package/dist/builtin-taxonomy/$defs/table +17 -10
- package/dist/builtin-taxonomy/$defs/task +4 -3
- package/dist/builtin-taxonomy/tags/{.yamlover/body.yamlover → .yo/body.yo} +1 -1
- package/dist/client/assets/decoded-D_YQdg1-.js +1 -0
- package/dist/client/assets/djvu-Di4fPA-J.js +1 -0
- package/dist/client/assets/{docx-C3gzsgKj.js → docx-BSduN32X.js} +1 -1
- package/dist/client/assets/{heic-CZ82O7cO.js → heic-DOAG7EsW.js} +2 -2
- package/dist/client/assets/imagemap-B8RthxNE.js +1 -0
- package/dist/client/assets/index-Bf2L7I9e.js +752 -0
- package/dist/client/assets/index-N41EyNG2.css +1 -0
- package/dist/client/assets/map-Cf7ar1_U.js +1 -0
- package/dist/client/assets/openable-CCBlc-Ws.js +4 -0
- package/dist/client/assets/paged-bLurvatj.js +1 -0
- package/dist/client/assets/{pdf-DYI9E6LP.js → pdf-D1ox8M9A.js} +5 -5
- package/dist/client/assets/psd-AT50x-YR.js +11 -0
- package/dist/client/assets/{spreadsheet-DQ1DIIfm.js → spreadsheet-DeQh5CNF.js} +1 -1
- package/dist/client/assets/tiff-B3-awhzQ.js +1 -0
- package/dist/client/assets/xyflow-BnuhLJ6X.css +1 -0
- package/dist/client/assets/xyflow-DuQ4jjaq.js +23 -0
- package/dist/client/index.html +6 -2
- package/dist/client/yo-favicon.svg +16 -0
- package/dist/server.js +4013 -1300
- package/package.json +12 -4
- package/dist/client/assets/decoded-BfD5vW9k.js +0 -1
- package/dist/client/assets/djvu-DNElocuz.js +0 -1
- package/dist/client/assets/imagemap-B0by6U04.js +0 -1
- package/dist/client/assets/index-C3Raqez8.css +0 -1
- package/dist/client/assets/index-DHpG_wBG.js +0 -665
- package/dist/client/assets/map-Byy6U5tc.js +0 -1
- package/dist/client/assets/openable-d9huG_69.js +0 -4
- package/dist/client/assets/paged-DFA2Muzj.js +0 -1
- package/dist/client/assets/psd-CmNn7r6o.js +0 -11
- package/dist/client/assets/tiff-BoTWsGfS.js +0 -1
package/README.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# yamlover
|
|
2
2
|
|
|
3
|
-
|
|
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 `.
|
|
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
|
|
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 `
|
|
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>/.
|
|
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/
|
|
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.
|
|
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/
|
|
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
|
|
160
|
-
or an **absolute entry index** (`:doc
|
|
161
|
-
**facets**: its scalar value, its keyed entries, its ordinal entries, and
|
|
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
|
|
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 (`
|
|
221
|
-
| `marklower` | `text/marklower` (a chapter's prose chunks, by schema propagation) | its own inline grammar (`
|
|
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
|
|
289
|
-
runs only the parser and engine suites
|
|
290
|
-
|
|
291
|
-
`
|
|
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 `.
|
|
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 =
|
|
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:
|
|
275
|
-
|
|
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(
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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(`
|
|
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);
|