yamlover 0.3.48 → 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 +38 -18
- package/bin/ga4.js +77 -0
- package/bin/yamlover.js +56 -18
- package/dist/agent-docs/AGENTS.md +86 -51
- package/dist/agent-docs/CLAUDE.md +1 -1
- package/dist/builtin-taxonomy/$defs/board +1 -1
- package/dist/builtin-taxonomy/$defs/bullets +1 -1
- package/dist/builtin-taxonomy/$defs/chapter +4 -4
- package/dist/builtin-taxonomy/$defs/chunk +1 -1
- package/dist/builtin-taxonomy/$defs/config +1 -1
- package/dist/builtin-taxonomy/$defs/numbered +1 -1
- package/dist/builtin-taxonomy/$defs/table +2 -2
- package/dist/builtin-taxonomy/tags/{.yamlover/body.yamlover → .yo/body.yo} +1 -1
- package/dist/client/assets/{decoded-Dfxf1BRR.js → decoded-D_YQdg1-.js} +1 -1
- package/dist/client/assets/djvu-Di4fPA-J.js +1 -0
- package/dist/client/assets/{docx-aCX_2O0_.js → docx-BSduN32X.js} +1 -1
- package/dist/client/assets/{heic-8pzTwsCx.js → heic-DOAG7EsW.js} +1 -1
- package/dist/client/assets/{imagemap-Bp26n33x.js → imagemap-B8RthxNE.js} +1 -1
- package/dist/client/assets/index-Bf2L7I9e.js +752 -0
- package/dist/client/assets/index-N41EyNG2.css +1 -0
- package/dist/client/assets/{map-Dtw0_sOf.js → map-Cf7ar1_U.js} +1 -1
- package/dist/client/assets/openable-CCBlc-Ws.js +4 -0
- package/dist/client/assets/{paged-BGVPKMC7.js → paged-bLurvatj.js} +1 -1
- package/dist/client/assets/{pdf-eI1AsxuB.js → pdf-D1ox8M9A.js} +2 -2
- package/dist/client/assets/{psd-BD_hUWIy.js → psd-AT50x-YR.js} +1 -1
- package/dist/client/assets/{spreadsheet-D_yjjR8a.js → spreadsheet-DeQh5CNF.js} +1 -1
- package/dist/client/assets/{tiff-BewNDYwu.js → tiff-B3-awhzQ.js} +1 -1
- 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 +1862 -1018
- package/package.json +11 -4
- package/dist/client/assets/djvu-BAFaxONw.js +0 -1
- package/dist/client/assets/index-BohJjyVY.css +0 -1
- package/dist/client/assets/index-DXrtwPKE.js +0 -754
- package/dist/client/assets/openable-Banncb36.js +0 -4
- /package/dist/builtin-taxonomy/$defs/{.yamlover/meta.yamlover → .yo/meta.yo} +0 -0
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) |
|
|
@@ -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
|
|
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
|
-
(
|
|
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
|
|
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
|
|
@@ -221,8 +241,8 @@ Registered today (a representative slice — the registry is the source of truth
|
|
|
221
241
|
| `chapter` | `x-yamlover-chapter` | a positional body: numbered chunks + subchapter links |
|
|
222
242
|
| `tag` / `board` | `x-yamlover-tag` / `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 (`
|
|
225
|
-
| `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 |
|
|
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
|
-
`
|
|
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) —
|
|
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,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.
|
|
@@ -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 {
|
|
@@ -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:
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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);
|