@geml/logseq-sync 2.0.0

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/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GEML contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ NOTE: This MIT license covers the *code* in this repository (geml-parser/,
26
+ integrations/geml-viewer/, integrations/geml-check-action/, docs/examples/
27
+ tooling). The *specification* documents are licensed separately under
28
+ CC-BY-4.0 β€” see LICENSE-spec.md.
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # Sync Vault with GEML
2
+
3
+ Your Logseq DB graph as a **continuously synced, Git-friendly plain-text
4
+ vault** β€” pages and journals back in readable files and folders, the way OG
5
+ vaults felt, kept in step with the database.
6
+
7
+ ![How it works](docs/how-it-works.svg)
8
+
9
+ ## What you get
10
+
11
+ - 🌿 **Real git workflows** β€” clean commits, readable line-by-line diffs, full
12
+ version history for a DB graph
13
+ - πŸ“¦ **A plain-text escape hatch that stays yours** β€” every page a readable
14
+ file, not a database dump
15
+ - πŸ” **Continuous, not one-shot** β€” edit in Logseq, and seconds later the file
16
+ and its git commit exist
17
+
18
+ Logseq 2.0 ships both ends of a trade-off: `logseq export` gives Markdown
19
+ (readable, lossy) and `logseq export-edn` gives EDN (lossless, not something a
20
+ person edits). The vault's format, [GEML](https://github.com/geml-spec/geml),
21
+ is the point between: **as readable as the Markdown export, as lossless as the
22
+ EDN one** β€” and addressable, so external tools and agents can edit one block
23
+ of a graph instead of round-tripping all of it.
24
+
25
+ The tree is laid out the way an OG vault is β€” the thing a file-version user
26
+ recognizes as "my graph, as files again":
27
+
28
+ ```
29
+ {:pages-and-blocks [...]} ontology.geml :properties/:classes, verbatim EDN
30
+ ⇄ graph.geml page ORDER (an addressable data block,
31
+ so filenames need no numeric prefixes)
32
+ journals/2025_02_20.geml journal pages, OG date names
33
+ pages/<name>.geml one per page:
34
+ block title β†’ `=== text` body
35
+ block uuid β†’ `{#uuid}` ← geml get/set address
36
+ outline tree β†’ flat blocks with `level=N`
37
+ everything else rides along in `code {lang=edn}`
38
+ ```
39
+
40
+ (`@logseq/cli` 0.4.3's `export-edn` does not include journal pages, so live
41
+ exports show `pages/` only today; the journal mapping is fixture-tested.)
42
+
43
+ ## How it works β€” two halves, one honest boundary
44
+
45
+ A Logseq 2.0 plugin runs in a sandboxed iframe: no arbitrary-path filesystem,
46
+ no git, no shell (verified against the 2.0.1 app bundle). So the in-app plugin
47
+ (`plugin/`) does the only two things only it can do:
48
+
49
+ - **hear** the graph change (`logseq.DB.onChanged`, debounced) and write a
50
+ dirty-marker file through the plugin storage API;
51
+ - **show** the last sync result in the toolbar (`⇄`) and command palette.
52
+
53
+ Everything with side effects lives in the **watcher** (`watcher/bin/geml-sync.mjs`),
54
+ built on the official `@logseq/cli` export. It reacts to the marker file
55
+ immediately (interval polling stays on as a fallback), writes only the files
56
+ that actually changed β€” so `git diff` is never noise β€” commits with a pathspec
57
+ scoped strictly to the vault, and reports back for the toolbar to display.
58
+ The two halves meet in the plugin's own storage directory
59
+ (`<dotdir>/storages/logseq-plugin-sync-vault-with-geml/`), the one disk location both can
60
+ reach. A file as the bridge beats a local HTTP API: no port, no server, no
61
+ CORS.
62
+
63
+ Real output, real DB graph (exported with the official CLI, validated by
64
+ `logseq validate`):
65
+
66
+ ```text
67
+ $ node watcher/bin/geml-sync.mjs geml-spike ~/vault-demo --git-commit --signal <storage>/geml-sync-dirty.json
68
+ Starting GEML Sync: Graph "geml-spike" βž” ~/vault-demo
69
+ Git auto-commit: enabled (scoped to target paths)
70
+ [19:29:14] Synced: 8 written, 0 unchanged.
71
+ Git: [master (root-commit) 9cc348c] logseq-geml: sync graph "geml-spike"
72
+ 9 files changed, 142 insertions(+)
73
+ create mode 100644 graph.geml
74
+ create mode 100644 pages/contents.geml
75
+ ...
76
+
77
+ $ node watcher/bin/geml-sync.mjs geml-spike ~/vault-demo --git-commit --signal ... # run again
78
+ [19:29:55] Graph is up-to-date (0 written, 8 unchanged).
79
+ ```
80
+
81
+ ## Setup
82
+
83
+ **1. Install the plugin** from the marketplace β€” or build and load it
84
+ unpacked (`dist/` is not checked in):
85
+
86
+ ```sh
87
+ cd plugin && npm install && npm run build
88
+ ```
89
+
90
+ then Settings β†’ Advanced β†’ Developer mode β†’ "Load unpacked plugin" β†’ `plugin/`.
91
+
92
+ **2. Get the watcher** β€” one npm install, gives you the `geml-sync` command:
93
+
94
+ ```sh
95
+ npm install -g @geml/logseq-sync
96
+ ```
97
+
98
+ (or run it ad hoc with `npx @geml/logseq-sync …`; the source lives in this
99
+ repository under `watcher/` and `core/`)
100
+
101
+ **3. Install `@logseq/cli`** (one time, anywhere). On Node 24 its
102
+ `better-sqlite3` has no prebuilt binding until 12.11.1, so pin an override
103
+ (without it, install tries to compile and node-gyp does not recognize
104
+ VS 2026 yet):
105
+
106
+ ```sh
107
+ mkdir logseq-cli && cd logseq-cli && npm init -y
108
+ npm pkg set overrides.better-sqlite3=12.11.1
109
+ npm i @logseq/cli
110
+ ```
111
+
112
+ **4. Run the watcher**, with `LOGSEQ_CLI_DIR` pointing at that directory and
113
+ `--signal` pointing at this plugin's storage directory:
114
+
115
+ ```sh
116
+ geml-sync <your-graph> <your-vault-dir> --watch --git-commit \
117
+ --signal <logseq-dotdir>/storages/logseq-plugin-sync-vault-with-geml/geml-sync-dirty.json
118
+ ```
119
+
120
+ Edit a block in Logseq β†’ the plugin signals β†’ the watcher syncs β†’ the toolbar
121
+ `⇄` button shows `Sync Vault with GEML: last sync at … β€” 1 written, 7 unchanged.`
122
+
123
+ **Settings**: *Debounce (seconds)* β€” quiet period after the last change before
124
+ the watcher is signalled (default 5; syncs feed git commits, so this is
125
+ deliberately calmer than UI-style debounce).
126
+
127
+ ## Honesty corner
128
+
129
+ - Sync is **export-direction** today (graph β†’ files, continuously). The
130
+ write-back path (edit a `.geml` file β†’ import back by UUID) is proven in the
131
+ engine (`syncDiskToEdn`) and lands next; deletions are reported, never
132
+ auto-propagated (`--signal` never deletes your hand-written files either β€” a
133
+ manifest tracks what the sync owns).
134
+ - Journal pages appear as soon as `@logseq/cli` exports them (0.4.3 does not).
135
+ - The watcher half is tested end-to-end in CI (a planted fake CLI exports
136
+ fixture EDN, so the signal β†’ re-sync β†’ status round trip runs with no Logseq
137
+ installed). The in-app half is verified against the 2.0.1 runtime β€” the
138
+ plugin API surface, `hook:db:changed`, the storage-file bridge β€” and its
139
+ SDK is `@logseq/libs` 0.3.x (the `next` tag). If anything misbehaves in
140
+ your setup, an issue with your Logseq version is gold.
141
+
142
+ ## Proven on a live DB graph, judged by Logseq's own validator
143
+
144
+ `npm test` proves, on fixtures lifted from Logseq's own `deps/db` export tests:
145
+
146
+ 1. **EDN β†’ GEML β†’ EDN is a structural identity** (EDN map/set semantics).
147
+ 2. Every generated document parses as GEML with **zero error diagnostics**.
148
+ 3. A block Logseq considers addressable (exported uuid) is **addressable in
149
+ GEML by the same id**.
150
+ 4. **Editing one block's text changes exactly that block** in the EDN β€” no
151
+ collateral change anywhere in the graph.
152
+
153
+ And `bin/live-roundtrip.mjs` has confirmed all four against a real DB graph
154
+ (2026-08-20, schema 65.22): export β†’ 6 clean documents β†’ identity; then with
155
+ `--edit`, a `geml set` on one block imported back with `logseq import-edn`,
156
+ **`logseq validate`: Valid!**, and the re-export showed the edit landed **in
157
+ place by uuid, exactly once β€” whole-graph re-import merges, it does not
158
+ duplicate**.
159
+
160
+ The design and the reasoning live in the
161
+ [GEML monorepo](https://github.com/geml-spec/geml)
162
+ (`docs/design/specs/2026-08-20-logseq-integration-scoping.md`); the community
163
+ threads are
164
+ [logseq/logseq#13086](https://github.com/logseq/logseq/discussions/13086) and
165
+ [the forum post](https://discuss.logseq.com/t/35193).
166
+
167
+ ## Development
168
+
169
+ ```
170
+ core/ converter (mapping.mjs), sync engine, bridge.mjs (the signal/status file contract)
171
+ watcher/ the geml-sync CLI and its end-to-end tests β€” published to npm as @geml/logseq-sync
172
+ plugin/ the in-app half (this package.json is the Logseq plugin manifest)
173
+ ```
174
+
175
+ Source of truth is
176
+ [`integrations/logseq/`](https://github.com/geml-spec/geml/tree/main/integrations/logseq)
177
+ in the GEML monorepo; this repository mirrors it for the marketplace and
178
+ carries the releases. Please open issues here, and PRs against the monorepo.
179
+
180
+ The converter is two pure functions in `core/src/mapping.mjs` β€”
181
+ `ednToGemlFiles(ednText)` and `gemlFilesToEdn(files, lib)` β€” with the reference
182
+ parser injected. The tests import the parser's build:
183
+
184
+ ```sh
185
+ cd geml-parser && npm install && npm run build && cd ../integrations/logseq
186
+ npm install
187
+ npm test
188
+ ```
189
+
190
+ Live-stage demos (need `@logseq/cli` via `LOGSEQ_CLI_DIR`, see Setup step 3):
191
+
192
+ ```sh
193
+ node watcher/bin/create-graph.mjs my-graph # create a DB graph WITHOUT the desktop app
194
+ node watcher/bin/live-roundtrip.mjs my-graph # read-only: export β†’ GEML β†’ back β†’ compare
195
+ node watcher/bin/live-roundtrip.mjs my-graph --edit # + geml set β†’ import-edn β†’ logseq validate
196
+ ```
197
+
198
+ Versioning: the MAJOR version tracks the Logseq major it targets β€” this is
199
+ 2.x because it speaks Logseq 2.x (DB graphs) and nothing older. Minor/patch
200
+ are this package's own.
201
+
202
+ ## Next
203
+
204
+ - **Reference translation**: block refs in titles are literally `[[<uuid>]]`,
205
+ one character away from GEML's checked `[[#uuid]]` β€” translating them lets
206
+ `geml check` catch broken block refs, the actual headline of the proposal.
207
+ - Property readability: scalar `:build/properties` as GEML attributes instead
208
+ of the `.block-meta` EDN ride-along (NAME rules permitting).
209
+ - **Write-back**: wiring `syncDiskToEdn` to the CLI so the vault is
210
+ two-way β€” edit the file, the graph follows.
211
+
212
+ MIT Β© GEML contributors
@@ -0,0 +1,8 @@
1
+ // The contract between the two halves of Sync Vault with GEML. The in-app plugin
2
+ // writes SIGNAL_FILE through logseq.FileStorage; the watcher reacts to it and
3
+ // writes STATUS_FILE back beside it. Both land in the plugin's storage
4
+ // directory (<dotdir>/storages/<plugin-id>/) β€” the one disk location both
5
+ // sides can reach. These names ARE the protocol: change them only together.
6
+
7
+ export const SIGNAL_FILE = "geml-sync-dirty.json";
8
+ export const STATUS_FILE = "geml-sync-status.json";
@@ -0,0 +1,243 @@
1
+ // EDN ⇄ GEML for Logseq DB graphs.
2
+ //
3
+ // Input is what `logseq export-edn` (@logseq/cli) produces β€” sqlite.build EDN:
4
+ //
5
+ // {:pages-and-blocks [{:page {...} :blocks [{:block/title ".." :build/children [..]} ..]} ..]
6
+ // :properties {..} ; ontology: property definitions
7
+ // :classes {..}} ; ontology: class/tag definitions
8
+ //
9
+ // The mapping keeps two promises, in this order:
10
+ //
11
+ // 1. LOSSLESS. The round-trip test is EDN β†’ GEML β†’ EDN structural equality
12
+ // (EDN map/set semantics: entry order does not count). Anything this
13
+ // version does not give a GEML shape of its own rides along VERBATIM as
14
+ // EDN inside `code {lang=edn}` blocks β€” carried, not dropped.
15
+ // 2. ADDRESSABLE where it pays. A block's title becomes the body of a
16
+ // `=== text` block; a block that has a uuid keeps it as `{#uuid}`, so
17
+ // `geml get/set` address exactly the blocks Logseq itself considers
18
+ // addressable (uuids are only exported for referenced blocks).
19
+ //
20
+ // Structure choice: the outline tree is a FLAT sequence of blocks in
21
+ // depth-first order, each carrying `level=N` β€” a complete encoding of the tree
22
+ // (it is how outlines print), without nesting GEML fences to the outline's
23
+ // depth.
24
+ //
25
+ // Everything runs on edn-data's TYPED representation (keywords as {key}, sets
26
+ // as {set}, maps as {map: [[k,v]..]}, vectors as arrays), so nothing is coerced
27
+ // through JSON and nothing un-EDN-able is invented.
28
+
29
+ import { parseEDNString, toEDNString } from "edn-data";
30
+
31
+ // --- typed-EDN helpers -------------------------------------------------------
32
+
33
+ const kw = (name) => ({ key: name });
34
+ const isKw = (v, name) => v !== null && typeof v === "object" && v.key === name;
35
+ const mapEntries = (m) => (m !== null && typeof m === "object" && Array.isArray(m.map) ? m.map : []);
36
+ const mapGet = (m, name) => {
37
+ for (const [k, v] of mapEntries(m)) if (isKw(k, name)) return v;
38
+ return undefined;
39
+ };
40
+ const mapWithout = (m, names) => ({ map: mapEntries(m).filter(([k]) => !names.some((n) => isKw(k, n))) });
41
+ const mapSize = (m) => mapEntries(m).length;
42
+ const edn = (v) => toEDNString(v);
43
+
44
+ // edn-data renders `#uuid "..."` as a tagged value; accept both spellings.
45
+ const uuidOf = (v) => {
46
+ if (typeof v === "string") return v;
47
+ if (v && typeof v === "object") {
48
+ if (typeof v.uuid === "string") return v.uuid;
49
+ if (v.tag === "uuid" && typeof v.val === "string") return v.val;
50
+ }
51
+ return undefined;
52
+ };
53
+
54
+ // --- GEML text helpers -------------------------------------------------------
55
+
56
+ // A fence must be longer than any `=` run opening a line of the body (Β§3).
57
+ function fenceFor(body) {
58
+ let longest = 2;
59
+ for (const m of body.matchAll(/^=+/gm)) longest = Math.max(longest, m[0].length + 1);
60
+ return "=".repeat(Math.max(3, longest));
61
+ }
62
+
63
+ function gemlBlock(type, attrs, body) {
64
+ const f = fenceFor(body);
65
+ const a = attrs ? ` {${attrs}}` : "";
66
+ return `${f} ${type}${a}\n${body}\n${f}\n`;
67
+ }
68
+
69
+ // --- export: EDN β†’ GEML files ------------------------------------------------
70
+
71
+ // Returns Map<relativePath, gemlText>. Page order is preserved by a numeric
72
+ // filename prefix: :pages-and-blocks is a vector, and order is content.
73
+ export function ednToGemlFiles(ednText) {
74
+ const top = parseEDNString(ednText);
75
+ const files = new Map();
76
+
77
+ const pages = mapGet(top, "pages-and-blocks") ?? [];
78
+ const properties = mapGet(top, "properties");
79
+ const classes = mapGet(top, "classes");
80
+ const rest = mapWithout(top, ["pages-and-blocks", "properties", "classes"]);
81
+
82
+ // Ontology and any top-level keys this version does not model: verbatim.
83
+ let onto = '=== meta\ntitle = "Logseq graph ontology"\n===\n\n';
84
+ if (properties !== undefined) onto += gemlBlock("code", "#properties lang=edn", edn(properties));
85
+ if (classes !== undefined) onto += gemlBlock("code", "#classes lang=edn", edn(classes));
86
+ if (mapSize(rest) > 0) onto += gemlBlock("code", "#graph-extra lang=edn", edn(rest));
87
+ files.set("ontology.geml", onto);
88
+
89
+ const order = [];
90
+ pages.forEach((entry) => {
91
+ const page = mapGet(entry, "page") ?? { map: [] };
92
+ const blocksVal = mapGet(entry, "blocks");
93
+ const blocks = blocksVal ?? [];
94
+ const entryRest = mapWithout(entry, ["page", "blocks"]);
95
+ // A present-but-empty :blocks is not the same EDN as an absent one, and
96
+ // real exports write `:blocks []` on block-less pages. An empty vector has
97
+ // no text blocks to speak for it, so it rides along with the rest.
98
+ if (Array.isArray(blocksVal) && blocksVal.length === 0) entryRest.map.push([kw("blocks"), []]);
99
+
100
+ const title = mapGet(page, "block/title");
101
+ const journal = mapGet(page, "build/journal");
102
+ // The tree is laid out the way an OG vault is: journals under `journals/`
103
+ // with the OG date filename (20250220 β†’ 2025_02_20.geml), everything else
104
+ // under `pages/` named by the page itself. No numeric prefixes β€” page
105
+ // ORDER is content, but it belongs in the graph.geml index, not in
106
+ // filenames a person has to look at.
107
+ let path;
108
+ if (typeof journal === "number") {
109
+ const j = String(journal);
110
+ path = `journals/${j.slice(0, 4)}_${j.slice(4, 6)}_${j.slice(6, 8)}.geml`;
111
+ } else {
112
+ const nameSeed = typeof title === "string" ? title : "page";
113
+ const slug = nameSeed.toLowerCase().replace(/[^a-z0-9δΈ€-ιΏΏ]+/gu, "-").replace(/^-+|-+$/g, "") || "page";
114
+ path = `pages/${slug}.geml`;
115
+ }
116
+ // Two titles may slug identically; the index carries order and identity,
117
+ // so filenames only need to be unique.
118
+ for (let n = 2; files.has(path); n++) path = path.replace(/\.geml$/, "") .replace(/-\d+$/, "") + `-${n}.geml`;
119
+ order.push(path);
120
+
121
+ // The page's identity, verbatim β€” reconstruction reads THIS; the heading
122
+ // below is presentation, not data.
123
+ let out = gemlBlock("code", ".page-meta lang=edn", edn(page));
124
+ if (mapSize(entryRest) > 0) out += gemlBlock("code", ".page-extra lang=edn", edn(entryRest));
125
+ if (typeof title === "string") out += `\n# ${title}\n\n`;
126
+
127
+ const walk = (bs, level) => {
128
+ for (const b of bs) {
129
+ const btitle = mapGet(b, "block/title");
130
+ const children = mapGet(b, "build/children") ?? [];
131
+ const meta = mapWithout(b, ["block/title", "build/children"]);
132
+ // The uuid stays inside the meta EDN too β€” losslessness never depends
133
+ // on the id attribute; `{#uuid}` is the ADDRESS.
134
+ const u = uuidOf(mapGet(b, "block/uuid"));
135
+ const id = u ? `#${u} ` : "";
136
+ out += gemlBlock("text", `${id}level=${level}`, typeof btitle === "string" ? btitle : edn(btitle ?? null));
137
+ if (mapSize(meta) > 0) out += gemlBlock("code", ".block-meta lang=edn", edn(meta));
138
+ walk(children, level + 1);
139
+ }
140
+ };
141
+ walk(blocks, 1);
142
+ files.set(path, out);
143
+ });
144
+
145
+ // Page order is content (:pages-and-blocks is a vector), but it lives in the
146
+ // index rather than in filename prefixes: the tree stays human-shaped, and
147
+ // one addressable block carries what the machine needs.
148
+ files.set("graph.geml",
149
+ '=== meta\ntitle = "Logseq graph index"\n===\n\n' +
150
+ gemlBlock("data", "#page-order", JSON.stringify(order, null, 1)));
151
+
152
+ return files;
153
+ }
154
+
155
+ // --- import: GEML files β†’ EDN ------------------------------------------------
156
+
157
+ // The parser library is injected ({parse, addressedUnits, sliceUnit} from
158
+ // @geml/geml), so this module stays dependency-light and the caller decides
159
+ // which parser build to trust.
160
+ //
161
+ // Why two reads per document: `parse` gives structure (types, classes, attrs),
162
+ // but a `text` block is FLOW content β€” its node carries parsed inlines, not
163
+ // raw bytes. The bytes come from `sliceUnit` over the block's span, exactly the
164
+ // route `geml get` takes. Blocks arrive in document order from both, so the
165
+ // two sequences zip.
166
+ export function gemlFilesToEdn(files, lib) {
167
+ const { parse, addressedUnits, sliceUnit } = lib;
168
+ const blocksOf = (text) => {
169
+ const nodes = parse(text).children.filter((c) => c.kind === "block");
170
+ const units = [...addressedUnits(text)].map((a) => a.unit).filter((u) => u.kind === "block");
171
+ return nodes.map((node, i) => ({
172
+ node,
173
+ body: () => {
174
+ const s = sliceUnit(text, units[i].span, "body");
175
+ return s.endsWith("\n") ? s.slice(0, -1) : s;
176
+ },
177
+ }));
178
+ };
179
+
180
+ const onto = blocksOf(files.get("ontology.geml") ?? "");
181
+ const grab = (blocks, id) => {
182
+ const b = blocks.find((x) => x.node.id === id);
183
+ return b ? parseEDNString(b.body()) : undefined;
184
+ };
185
+ const properties = grab(onto, "properties");
186
+ const classes = grab(onto, "classes");
187
+ const graphExtra = grab(onto, "graph-extra");
188
+
189
+ // Page order comes from the graph.geml index; a tree without one (hand-built,
190
+ // or index deleted) falls back to path order, which at least is deterministic.
191
+ const indexBlocks = files.has("graph.geml") ? blocksOf(files.get("graph.geml")) : [];
192
+ const orderBlock = indexBlocks.find((b) => b.node.id === "page-order");
193
+ const pagePaths = (orderBlock && Array.isArray(orderBlock.node.value)
194
+ ? orderBlock.node.value
195
+ : [...files.keys()].filter((p) => p.startsWith("pages/") || p.startsWith("journals/")).sort()
196
+ ).filter((p) => files.has(p));
197
+ const pages = pagePaths.map((p) => {
198
+ let page = { map: [] };
199
+ let entryRest = { map: [] };
200
+
201
+ // Flat level-tagged sequence β†’ tree. Each frame owns the children vector
202
+ // its node's `:build/children` will become; the vector is written into the
203
+ // node only if anything landed in it.
204
+ const roots = [];
205
+ const stack = [{ level: 0, node: null, children: roots }];
206
+ const close = (frame) => {
207
+ if (frame.node && frame.children.length > 0) frame.node.map.push([kw("build/children"), frame.children]);
208
+ };
209
+
210
+ let last = null;
211
+ for (const b of blocksOf(files.get(p))) {
212
+ const { type, classes, attrs } = b.node;
213
+ if (type === "code" && classes.includes("page-meta")) { page = parseEDNString(b.body()); continue; }
214
+ if (type === "code" && classes.includes("page-extra")) { entryRest = parseEDNString(b.body()); continue; }
215
+ if (type === "code" && classes.includes("block-meta")) {
216
+ // Meta re-attaches to the block it followed. Splicing the entries into
217
+ // the node keeps one map, as the export wrote it.
218
+ if (last) last.map.push(...mapEntries(parseEDNString(b.body())));
219
+ continue;
220
+ }
221
+ if (type !== "text") continue;
222
+
223
+ const level = typeof attrs["level"] === "number" ? attrs["level"] : 1;
224
+ const node = { map: [[kw("block/title"), b.body()]] };
225
+ while (stack[stack.length - 1].level >= level) close(stack.pop());
226
+ stack[stack.length - 1].children.push(node);
227
+ stack.push({ level, node, children: [] });
228
+ last = node;
229
+ }
230
+ while (stack.length > 1) close(stack.pop());
231
+
232
+ const entry = { map: [[kw("page"), page]] };
233
+ if (roots.length > 0) entry.map.push([kw("blocks"), roots]);
234
+ entry.map.push(...mapEntries(entryRest));
235
+ return entry;
236
+ });
237
+
238
+ const out = { map: [[kw("pages-and-blocks"), pages]] };
239
+ if (properties !== undefined) out.map.push([kw("properties"), properties]);
240
+ if (classes !== undefined) out.map.push([kw("classes"), classes]);
241
+ if (graphExtra !== undefined) out.map.push(...mapEntries(graphExtra));
242
+ return toEDNString(out);
243
+ }