@abraca/cli 1.8.0 → 2.3.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as Y from "yjs";
3
- import { AbracadabraClient, AbracadabraProvider } from "@abraca/dabra";
3
+ import { AbracadabraClient, AbracadabraProvider, DocumentManager, Kind } from "@abraca/dabra";
4
4
  import * as ed from "@noble/ed25519";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import * as fs from "node:fs";
@@ -8,6 +8,8 @@ import { existsSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import * as path from "node:path";
10
10
  import { dirname, join } from "node:path";
11
+ import wtf from "wtf_wikipedia";
12
+ import wtfApiPlugin from "wtf-plugin-api";
11
13
 
12
14
  //#region packages/cli/src/parser.ts
13
15
  /**
@@ -54,97 +56,205 @@ function parseArgs(argv) {
54
56
  }
55
57
 
56
58
  //#endregion
57
- //#region packages/cli/node_modules/@noble/hashes/esm/utils.js
58
- /** Checks if something is Uint8Array. Be careful: nodejs Buffer will return true. */
59
+ //#region node_modules/@noble/hashes/utils.js
60
+ /**
61
+ * Checks if something is Uint8Array. Be careful: nodejs Buffer will return true.
62
+ * @param a - value to test
63
+ * @returns `true` when the value is a Uint8Array-compatible view.
64
+ * @example
65
+ * Check whether a value is a Uint8Array-compatible view.
66
+ * ```ts
67
+ * isBytes(new Uint8Array([1, 2, 3]));
68
+ * ```
69
+ */
59
70
  function isBytes(a) {
60
- return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array";
71
+ return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
61
72
  }
62
- /** Asserts something is Uint8Array. */
63
- function abytes(b, ...lengths) {
64
- if (!isBytes(b)) throw new Error("Uint8Array expected");
65
- if (lengths.length > 0 && !lengths.includes(b.length)) throw new Error("Uint8Array expected of length " + lengths + ", got length=" + b.length);
73
+ /**
74
+ * Asserts something is Uint8Array.
75
+ * @param value - value to validate
76
+ * @param length - optional exact length constraint
77
+ * @param title - label included in thrown errors
78
+ * @returns The validated byte array.
79
+ * @throws On wrong argument types. {@link TypeError}
80
+ * @throws On wrong argument ranges or values. {@link RangeError}
81
+ * @example
82
+ * Validate that a value is a byte array.
83
+ * ```ts
84
+ * abytes(new Uint8Array([1, 2, 3]));
85
+ * ```
86
+ */
87
+ function abytes(value, length, title = "") {
88
+ const bytes = isBytes(value);
89
+ const len = value?.length;
90
+ const needsLen = length !== void 0;
91
+ if (!bytes || needsLen && len !== length) {
92
+ const prefix = title && `"${title}" `;
93
+ const ofLen = needsLen ? ` of length ${length}` : "";
94
+ const got = bytes ? `length=${len}` : `type=${typeof value}`;
95
+ const message = prefix + "expected Uint8Array" + ofLen + ", got " + got;
96
+ if (!bytes) throw new TypeError(message);
97
+ throw new RangeError(message);
98
+ }
99
+ return value;
66
100
  }
67
- /** Asserts a hash instance has not been destroyed / finished */
101
+ /**
102
+ * Asserts a hash instance has not been destroyed or finished.
103
+ * @param instance - hash instance to validate
104
+ * @param checkFinished - whether to reject finalized instances
105
+ * @throws If the hash instance has already been destroyed or finalized. {@link Error}
106
+ * @example
107
+ * Validate that a hash instance is still usable.
108
+ * ```ts
109
+ * import { aexists } from '@noble/hashes/utils.js';
110
+ * import { sha256 } from '@noble/hashes/sha2.js';
111
+ * const hash = sha256.create();
112
+ * aexists(hash);
113
+ * ```
114
+ */
68
115
  function aexists(instance, checkFinished = true) {
69
116
  if (instance.destroyed) throw new Error("Hash instance has been destroyed");
70
117
  if (checkFinished && instance.finished) throw new Error("Hash#digest() has already been called");
71
118
  }
72
- /** Asserts output is properly-sized byte array */
119
+ /**
120
+ * Asserts output is a sufficiently-sized byte array.
121
+ * @param out - destination buffer
122
+ * @param instance - hash instance providing output length
123
+ * Oversized buffers are allowed; downstream code only promises to fill the first `outputLen` bytes.
124
+ * @throws On wrong argument types. {@link TypeError}
125
+ * @throws On wrong argument ranges or values. {@link RangeError}
126
+ * @example
127
+ * Validate a caller-provided digest buffer.
128
+ * ```ts
129
+ * import { aoutput } from '@noble/hashes/utils.js';
130
+ * import { sha256 } from '@noble/hashes/sha2.js';
131
+ * const hash = sha256.create();
132
+ * aoutput(new Uint8Array(hash.outputLen), hash);
133
+ * ```
134
+ */
73
135
  function aoutput(out, instance) {
74
- abytes(out);
136
+ abytes(out, void 0, "digestInto() output");
75
137
  const min = instance.outputLen;
76
- if (out.length < min) throw new Error("digestInto() expects output buffer of length at least " + min);
138
+ if (out.length < min) throw new RangeError("\"digestInto() output\" expected to be of length >=" + min);
77
139
  }
78
- /** Zeroize a byte array. Warning: JS provides no guarantees. */
140
+ /**
141
+ * Zeroizes typed arrays in place. Warning: JS provides no guarantees.
142
+ * @param arrays - arrays to overwrite with zeros
143
+ * @example
144
+ * Zeroize sensitive buffers in place.
145
+ * ```ts
146
+ * clean(new Uint8Array([1, 2, 3]));
147
+ * ```
148
+ */
79
149
  function clean(...arrays) {
80
150
  for (let i = 0; i < arrays.length; i++) arrays[i].fill(0);
81
151
  }
82
- /** Create DataView of an array for easy byte-level manipulation. */
152
+ /**
153
+ * Creates a DataView for byte-level manipulation.
154
+ * @param arr - source typed array
155
+ * @returns DataView over the same buffer region.
156
+ * @example
157
+ * Create a DataView over an existing buffer.
158
+ * ```ts
159
+ * createView(new Uint8Array(4));
160
+ * ```
161
+ */
83
162
  function createView(arr) {
84
163
  return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
85
164
  }
86
- /** Is current platform little-endian? Most are. Big-Endian platform: IBM */
165
+ /** Whether the current platform is little-endian. */
87
166
  const isLE = new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68;
88
167
  const hasHexBuiltin = typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function";
89
168
  /**
90
- * Converts string to bytes using UTF8 encoding.
91
- * @example utf8ToBytes('abc') // Uint8Array.from([97, 98, 99])
92
- */
93
- function utf8ToBytes(str) {
94
- if (typeof str !== "string") throw new Error("string expected");
95
- return new Uint8Array(new TextEncoder().encode(str));
96
- }
97
- /**
98
- * Normalizes (non-hex) string or Uint8Array to Uint8Array.
99
- * Warning: when Uint8Array is passed, it would NOT get copied.
100
- * Keep in mind for future mutable operations.
169
+ * Creates a callable hash function from a stateful class constructor.
170
+ * @param hashCons - hash constructor or factory
171
+ * @param info - optional metadata such as DER OID
172
+ * @returns Frozen callable hash wrapper with `.create()`.
173
+ * Wrapper construction eagerly calls `hashCons(undefined)` once to read
174
+ * `outputLen` / `blockLen`, so constructor side effects happen at module
175
+ * init time.
176
+ * @example
177
+ * Wrap a stateful hash constructor into a callable helper.
178
+ * ```ts
179
+ * import { createHasher } from '@noble/hashes/utils.js';
180
+ * import { sha256 } from '@noble/hashes/sha2.js';
181
+ * const wrapped = createHasher(sha256.create, { oid: sha256.oid });
182
+ * wrapped(new Uint8Array([1]));
183
+ * ```
101
184
  */
102
- function toBytes(data) {
103
- if (typeof data === "string") data = utf8ToBytes(data);
104
- abytes(data);
105
- return data;
106
- }
107
- /** For runtime check if class implements interface */
108
- var Hash = class {};
109
- /** Wraps hash function, creating an interface on top of it */
110
- function createHasher(hashCons) {
111
- const hashC = (msg) => hashCons().update(toBytes(msg)).digest();
112
- const tmp = hashCons();
185
+ function createHasher(hashCons, info = {}) {
186
+ const hashC = (msg, opts) => hashCons(opts).update(msg).digest();
187
+ const tmp = hashCons(void 0);
113
188
  hashC.outputLen = tmp.outputLen;
114
189
  hashC.blockLen = tmp.blockLen;
115
- hashC.create = () => hashCons();
116
- return hashC;
190
+ hashC.canXOF = tmp.canXOF;
191
+ hashC.create = (opts) => hashCons(opts);
192
+ Object.assign(hashC, info);
193
+ return Object.freeze(hashC);
117
194
  }
195
+ /**
196
+ * Creates OID metadata for NIST hashes with prefix `06 09 60 86 48 01 65 03 04 02`.
197
+ * @param suffix - final OID byte for the selected hash.
198
+ * The helper accepts any byte even though only the documented NIST hash
199
+ * suffixes are meaningful downstream.
200
+ * @returns Object containing the DER-encoded OID.
201
+ * @example
202
+ * Build OID metadata for a NIST hash.
203
+ * ```ts
204
+ * oidNist(0x01);
205
+ * ```
206
+ */
207
+ const oidNist = (suffix) => ({ oid: Uint8Array.from([
208
+ 6,
209
+ 9,
210
+ 96,
211
+ 134,
212
+ 72,
213
+ 1,
214
+ 101,
215
+ 3,
216
+ 4,
217
+ 2,
218
+ suffix
219
+ ]) });
118
220
 
119
221
  //#endregion
120
- //#region packages/cli/node_modules/@noble/hashes/esm/_md.js
222
+ //#region node_modules/@noble/hashes/_md.js
121
223
  /**
122
224
  * Internal Merkle-Damgard hash utils.
123
225
  * @module
124
226
  */
125
- /** Polyfill for Safari 14. https://caniuse.com/mdn-javascript_builtins_dataview_setbiguint64 */
126
- function setBigUint64(view, byteOffset, value, isLE) {
127
- if (typeof view.setBigUint64 === "function") return view.setBigUint64(byteOffset, value, isLE);
128
- const _32n = BigInt(32);
129
- const _u32_max = BigInt(4294967295);
130
- const wh = Number(value >> _32n & _u32_max);
131
- const wl = Number(value & _u32_max);
132
- const h = isLE ? 4 : 0;
133
- const l = isLE ? 0 : 4;
134
- view.setUint32(byteOffset + h, wh, isLE);
135
- view.setUint32(byteOffset + l, wl, isLE);
136
- }
137
227
  /**
138
228
  * Merkle-Damgard hash construction base class.
139
229
  * Could be used to create MD5, RIPEMD, SHA1, SHA2.
230
+ * Accepts only byte-aligned `Uint8Array` input, even when the underlying spec describes bit
231
+ * strings with partial-byte tails.
232
+ * @param blockLen - internal block size in bytes
233
+ * @param outputLen - digest size in bytes
234
+ * @param padOffset - trailing length field size in bytes
235
+ * @param isLE - whether length and state words are encoded in little-endian
236
+ * @example
237
+ * Use a concrete subclass to get the shared Merkle-Damgard update/digest flow.
238
+ * ```ts
239
+ * import { _SHA1 } from '@noble/hashes/legacy.js';
240
+ * const hash = new _SHA1();
241
+ * hash.update(new Uint8Array([97, 98, 99]));
242
+ * hash.digest();
243
+ * ```
140
244
  */
141
- var HashMD = class extends Hash {
245
+ var HashMD = class {
246
+ blockLen;
247
+ outputLen;
248
+ canXOF = false;
249
+ padOffset;
250
+ isLE;
251
+ buffer;
252
+ view;
253
+ finished = false;
254
+ length = 0;
255
+ pos = 0;
256
+ destroyed = false;
142
257
  constructor(blockLen, outputLen, padOffset, isLE) {
143
- super();
144
- this.finished = false;
145
- this.length = 0;
146
- this.pos = 0;
147
- this.destroyed = false;
148
258
  this.blockLen = blockLen;
149
259
  this.outputLen = outputLen;
150
260
  this.padOffset = padOffset;
@@ -154,7 +264,6 @@ var HashMD = class extends Hash {
154
264
  }
155
265
  update(data) {
156
266
  aexists(this);
157
- data = toBytes(data);
158
267
  abytes(data);
159
268
  const { view, buffer, blockLen } = this;
160
269
  const len = data.length;
@@ -190,11 +299,11 @@ var HashMD = class extends Hash {
190
299
  pos = 0;
191
300
  }
192
301
  for (let i = pos; i < blockLen; i++) buffer[i] = 0;
193
- setBigUint64(view, blockLen - 8, BigInt(this.length * 8), isLE);
302
+ view.setBigUint64(blockLen - 8, BigInt(this.length * 8), isLE);
194
303
  this.process(view, 0);
195
304
  const oview = createView(out);
196
305
  const len = this.outputLen;
197
- if (len % 4) throw new Error("_sha2: outputLen should be aligned to 32bit");
306
+ if (len % 4) throw new Error("_sha2: outputLen must be aligned to 32bit");
198
307
  const outLen = len / 4;
199
308
  const state = this.get();
200
309
  if (outLen > state.length) throw new Error("_sha2: outputLen bigger than state");
@@ -208,7 +317,7 @@ var HashMD = class extends Hash {
208
317
  return res;
209
318
  }
210
319
  _cloneInto(to) {
211
- to || (to = new this.constructor());
320
+ to ||= new this.constructor();
212
321
  to.set(...this.get());
213
322
  const { blockLen, buffer, length, finished, destroyed, pos } = this;
214
323
  to.destroyed = destroyed;
@@ -222,7 +331,10 @@ var HashMD = class extends Hash {
222
331
  return this._cloneInto();
223
332
  }
224
333
  };
225
- /** Initial SHA512 state. Bits 0..64 of frac part of sqrt of primes 2..19 */
334
+ /** Initial SHA512 state from RFC 6234 §6.3: eight RFC 64-bit `H(0)` words stored as sixteen
335
+ * big-endian 32-bit halves. Derived from the fractional parts of the square roots of the first
336
+ * eight prime numbers. Exported as a shared table; callers must treat it as read-only because
337
+ * constructors copy halves from it by index. */
226
338
  const SHA512_IV = /* @__PURE__ */ Uint32Array.from([
227
339
  1779033703,
228
340
  4089235720,
@@ -243,12 +355,7 @@ const SHA512_IV = /* @__PURE__ */ Uint32Array.from([
243
355
  ]);
244
356
 
245
357
  //#endregion
246
- //#region packages/cli/node_modules/@noble/hashes/esm/_u64.js
247
- /**
248
- * Internal helpers for u64. BigUint64Array is too slow as per 2025, so we implement it using Uint32Array.
249
- * @todo re-check https://issues.chromium.org/issues/42212588
250
- * @module
251
- */
358
+ //#region node_modules/@noble/hashes/_u64.js
252
359
  const U32_MASK64 = /* @__PURE__ */ BigInt(2 ** 32 - 1);
253
360
  const _32n = /* @__PURE__ */ BigInt(32);
254
361
  function fromBig(n, le = false) {
@@ -292,12 +399,12 @@ const add5L = (Al, Bl, Cl, Dl, El) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl
292
399
  const add5H = (low, Ah, Bh, Ch, Dh, Eh) => Ah + Bh + Ch + Dh + Eh + (low / 2 ** 32 | 0) | 0;
293
400
 
294
401
  //#endregion
295
- //#region packages/cli/node_modules/@noble/hashes/esm/sha2.js
402
+ //#region node_modules/@noble/hashes/sha2.js
296
403
  /**
297
404
  * SHA2 hash function. A.k.a. sha256, sha384, sha512, sha512_224, sha512_256.
298
405
  * SHA256 is the fastest hash implementable in JS, even faster than Blake3.
299
- * Check out [RFC 4634](https://datatracker.ietf.org/doc/html/rfc4634) and
300
- * [FIPS 180-4](https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf).
406
+ * Check out {@link https://www.rfc-editor.org/rfc/rfc4634 | RFC 4634} and
407
+ * {@link https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf | FIPS 180-4}.
301
408
  * @module
302
409
  */
303
410
  const K512 = split([
@@ -386,25 +493,10 @@ const SHA512_Kh = K512[0];
386
493
  const SHA512_Kl = K512[1];
387
494
  const SHA512_W_H = /* @__PURE__ */ new Uint32Array(80);
388
495
  const SHA512_W_L = /* @__PURE__ */ new Uint32Array(80);
389
- var SHA512 = class extends HashMD {
390
- constructor(outputLen = 64) {
496
+ /** Internal SHA-384 / SHA-512 compression engine from RFC 6234 §6.4. */
497
+ var SHA2_64B = class extends HashMD {
498
+ constructor(outputLen) {
391
499
  super(128, outputLen, 16, false);
392
- this.Ah = SHA512_IV[0] | 0;
393
- this.Al = SHA512_IV[1] | 0;
394
- this.Bh = SHA512_IV[2] | 0;
395
- this.Bl = SHA512_IV[3] | 0;
396
- this.Ch = SHA512_IV[4] | 0;
397
- this.Cl = SHA512_IV[5] | 0;
398
- this.Dh = SHA512_IV[6] | 0;
399
- this.Dl = SHA512_IV[7] | 0;
400
- this.Eh = SHA512_IV[8] | 0;
401
- this.El = SHA512_IV[9] | 0;
402
- this.Fh = SHA512_IV[10] | 0;
403
- this.Fl = SHA512_IV[11] | 0;
404
- this.Gh = SHA512_IV[12] | 0;
405
- this.Gl = SHA512_IV[13] | 0;
406
- this.Hh = SHA512_IV[14] | 0;
407
- this.Hl = SHA512_IV[15] | 0;
408
500
  }
409
501
  get() {
410
502
  const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
@@ -507,12 +599,44 @@ var SHA512 = class extends HashMD {
507
599
  clean(SHA512_W_H, SHA512_W_L);
508
600
  }
509
601
  destroy() {
602
+ this.destroyed = true;
510
603
  clean(this.buffer);
511
604
  this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
512
605
  }
513
606
  };
514
- /** SHA2-512 hash function from RFC 4634. */
515
- const sha512 = /* @__PURE__ */ createHasher(() => new SHA512());
607
+ /** Internal SHA-512 hash class grounded in RFC 6234 §6.3 and §6.4. */
608
+ var _SHA512 = class extends SHA2_64B {
609
+ Ah = SHA512_IV[0] | 0;
610
+ Al = SHA512_IV[1] | 0;
611
+ Bh = SHA512_IV[2] | 0;
612
+ Bl = SHA512_IV[3] | 0;
613
+ Ch = SHA512_IV[4] | 0;
614
+ Cl = SHA512_IV[5] | 0;
615
+ Dh = SHA512_IV[6] | 0;
616
+ Dl = SHA512_IV[7] | 0;
617
+ Eh = SHA512_IV[8] | 0;
618
+ El = SHA512_IV[9] | 0;
619
+ Fh = SHA512_IV[10] | 0;
620
+ Fl = SHA512_IV[11] | 0;
621
+ Gh = SHA512_IV[12] | 0;
622
+ Gl = SHA512_IV[13] | 0;
623
+ Hh = SHA512_IV[14] | 0;
624
+ Hl = SHA512_IV[15] | 0;
625
+ constructor() {
626
+ super(64);
627
+ }
628
+ };
629
+ /**
630
+ * SHA2-512 hash function from RFC 4634.
631
+ * @param msg - message bytes to hash
632
+ * @returns Digest bytes.
633
+ * @example
634
+ * Hash a message with SHA2-512.
635
+ * ```ts
636
+ * sha512(new Uint8Array([97, 98, 99]));
637
+ * ```
638
+ */
639
+ const sha512 = /* @__PURE__ */ createHasher(() => new _SHA512(), /* @__PURE__ */ oidNist(3));
516
640
 
517
641
  //#endregion
518
642
  //#region packages/cli/src/crypto.ts
@@ -520,7 +644,8 @@ const sha512 = /* @__PURE__ */ createHasher(() => new SHA512());
520
644
  * Ed25519 key generation, persistence, and challenge signing for CLI auth.
521
645
  * Mirrors @abraca/mcp/src/crypto.ts — standalone to avoid MCP SDK dependency.
522
646
  */
523
- ed.etc.sha512Sync = (...msgs) => sha512(ed.etc.concatBytes(...msgs));
647
+ ed.hashes.sha512 = sha512;
648
+ ed.hashes.sha512Async = (m) => Promise.resolve(sha512(m));
524
649
  const DEFAULT_KEY_PATH = join(homedir(), ".abracadabra", "cli.key");
525
650
  function toBase64url(bytes) {
526
651
  return Buffer.from(bytes).toString("base64url");
@@ -543,7 +668,7 @@ async function loadOrCreateKeypair(keyPath) {
543
668
  publicKeyB64: toBase64url(ed.getPublicKey(privateKey))
544
669
  };
545
670
  }
546
- const privateKey = ed.utils.randomPrivateKey();
671
+ const privateKey = ed.utils.randomSecretKey();
547
672
  const publicKey = ed.getPublicKey(privateKey);
548
673
  const dir = dirname(path);
549
674
  if (!existsSync(dir)) await mkdir(dir, {
@@ -585,24 +710,6 @@ function waitForSync(provider, timeoutMs = 15e3) {
585
710
  provider.on("synced", handler);
586
711
  });
587
712
  }
588
- /** Map a DocumentMeta to SpaceMeta shape for display compatibility. */
589
- function docToSpaceMeta(doc) {
590
- const publicAccess = doc.public_access;
591
- let visibility = "private";
592
- if (publicAccess && publicAccess !== "none") visibility = "public";
593
- return {
594
- id: doc.id,
595
- doc_id: doc.id,
596
- name: doc.label ?? doc.id,
597
- description: doc.description ?? null,
598
- visibility,
599
- is_hub: doc.is_hub ?? false,
600
- owner_id: doc.owner_id ?? null,
601
- created_at: 0,
602
- updated_at: doc.updated_at ?? 0,
603
- public_access: publicAccess ?? null
604
- };
605
- }
606
713
  var CLIConnection = class {
607
714
  config;
608
715
  client;
@@ -672,29 +779,12 @@ var CLIConnection = class {
672
779
  }
673
780
  this.log(`Authenticated as ${this.displayName} (${keypair.publicKeyB64.slice(0, 12)}...)`);
674
781
  this._serverInfo = await this.client.serverInfo();
675
- let initialDocId = this._serverInfo.index_doc_id ?? null;
676
- try {
677
- const roots = await this.client.listRootDocuments();
678
- this._spaces = roots.map(docToSpaceMeta);
679
- const hub = roots.find((d) => d.is_hub);
680
- if (hub) {
681
- initialDocId = hub.id;
682
- this.log(`Hub document: ${hub.label ?? hub.id} (${hub.id})`);
683
- } else if (roots.length > 0) {
684
- initialDocId = roots[0].id;
685
- this.log(`No hub, using first root doc: ${roots[0].label ?? roots[0].id}`);
686
- }
687
- } catch {
688
- try {
689
- this._spaces = await this.client.listSpaces();
690
- const hub = this._spaces.find((s) => s.is_hub);
691
- if (hub) initialDocId = hub.doc_id;
692
- else if (this._spaces.length > 0) initialDocId = this._spaces[0].doc_id;
693
- } catch {
694
- this.log("Neither /docs?root=true nor /spaces available, using index_doc_id");
695
- }
696
- }
697
- if (!initialDocId) throw new Error("No entry point found: server has neither spaces nor index_doc_id configured.");
782
+ const roots = await this.client.listChildren();
783
+ this._spaces = roots.filter((d) => d.kind === Kind.Space);
784
+ const first = this._spaces[0] ?? roots[0];
785
+ const initialDocId = first?.id ?? null;
786
+ if (first) this.log(`Entry document: ${first.label ?? first.id} (${first.id})`);
787
+ if (!initialDocId) throw new Error("No entry point found: server has no top-level documents. Create a Space first.");
698
788
  this._rootDocId = initialDocId;
699
789
  const doc = new Y.Doc({ guid: initialDocId });
700
790
  const provider = new AbracadabraProvider({
@@ -1069,7 +1159,7 @@ registerCommand({
1069
1159
  `URL: ${conn.config.url}`,
1070
1160
  `Version: ${si.version ?? "—"}`,
1071
1161
  `Protocol: ${si.protocol_version ?? "—"}`,
1072
- `Hub Doc: ${conn.rootDocId ?? "—"}`,
1162
+ `Active Doc: ${conn.rootDocId ?? "—"}`,
1073
1163
  `Auth: ${si.auth_methods?.join(", ") ?? "—"}`,
1074
1164
  `Registration:${si.registration_allowed ? " open" : " closed"}${si.invite_only ? " (invite only)" : ""}`,
1075
1165
  `Documents: ${docCount}`,
@@ -1079,7 +1169,7 @@ registerCommand({
1079
1169
  });
1080
1170
  registerCommand({
1081
1171
  name: "spaces",
1082
- description: "List available spaces/root documents.",
1172
+ description: "List available spaces (top-level documents).",
1083
1173
  usage: "spaces [--format=json|tsv]",
1084
1174
  async run(conn, args) {
1085
1175
  if (!conn) return "Not connected";
@@ -1089,19 +1179,20 @@ registerCommand({
1089
1179
  const active = conn.rootDocId;
1090
1180
  if (format === "json") return printJson(spaces.map((s) => ({
1091
1181
  ...s,
1092
- active: s.doc_id === active
1182
+ active: s.id === active
1093
1183
  })));
1094
- return printTable(spaces.map((s) => [
1095
- s.doc_id === active ? "" : " ",
1096
- s.doc_id.slice(0, 8) + "…",
1097
- s.name,
1098
- s.is_hub ? "hub" : "",
1099
- s.visibility
1100
- ]), [
1184
+ return printTable(spaces.map((s) => {
1185
+ const visibility = s.public_access === "observer" ? "public" : "private";
1186
+ return [
1187
+ s.id === active ? "▸" : " ",
1188
+ s.id.slice(0, 8) + "",
1189
+ s.label ?? s.id,
1190
+ visibility
1191
+ ];
1192
+ }), [
1101
1193
  "",
1102
1194
  "ID",
1103
1195
  "NAME",
1104
- "HUB",
1105
1196
  "VISIBILITY"
1106
1197
  ]);
1107
1198
  }
@@ -1119,12 +1210,12 @@ registerCommand({
1119
1210
  if (targetId) docId = targetId;
1120
1211
  else if (targetName) {
1121
1212
  const lower = targetName.toLowerCase();
1122
- const space = conn.spaces.find((s) => s.name.toLowerCase() === lower || s.doc_id === targetName);
1123
- if (space) docId = space.doc_id;
1213
+ const space = conn.spaces.find((s) => (s.label ?? s.id).toLowerCase() === lower || s.id === targetName);
1214
+ if (space) docId = space.id;
1124
1215
  }
1125
1216
  if (!docId) return "Space not found. Use \"abracadabra spaces\" to list available spaces.";
1126
1217
  await conn.switchSpace(docId);
1127
- return `Switched to space "${conn.spaces.find((s) => s.doc_id === docId)?.name ?? docId}"`;
1218
+ return `Switched to space "${conn.spaces.find((s) => s.id === docId)?.label ?? docId}"`;
1128
1219
  }
1129
1220
  });
1130
1221
 
@@ -1155,7 +1246,7 @@ registerCommand({
1155
1246
  const tree = buildTree(readEntries(treeMap), rootId, maxDepth);
1156
1247
  if (format === "json") return printJson(tree);
1157
1248
  if (tree.length === 0) return "(empty tree)";
1158
- return (conn.spaces.find((s) => s.doc_id === conn.rootDocId)?.name ?? conn.rootDocId ?? "Workspace") + "\n" + printTree(toTreeNodes(tree));
1249
+ return (conn.spaces.find((s) => s.id === conn.rootDocId)?.label ?? conn.rootDocId ?? "Workspace") + "\n" + printTree(toTreeNodes(tree));
1159
1250
  }
1160
1251
  });
1161
1252
  registerCommand({
@@ -1227,238 +1318,14 @@ registerCommand({
1227
1318
  });
1228
1319
 
1229
1320
  //#endregion
1230
- //#region packages/mcp/src/converters/yjsToMarkdown.ts
1231
- /**
1232
- * Y.XmlFragment → Markdown serializer.
1233
- * Walks the TipTap document structure and produces markdown text.
1234
- */
1235
- function deltaToMarkdown(delta) {
1236
- return delta.map((op) => {
1237
- let text = op.insert;
1238
- if (!op.attributes) return text;
1239
- const a = op.attributes;
1240
- if (a.code) text = `\`${text}\``;
1241
- if (a.bold) text = `**${text}**`;
1242
- if (a.italic) text = `*${text}*`;
1243
- if (a.strike) text = `~~${text}~~`;
1244
- if (a.link?.href) text = `[${text}](${a.link.href})`;
1245
- if (a.badge) text = `:badge[${a.badge.label || text}]`;
1246
- if (a.kbd) text = `:kbd{value="${a.kbd.value || text}"}`;
1247
- if (a.proseIcon) text = `:icon{name="${a.proseIcon.name}"}`;
1248
- return text;
1249
- }).join("");
1250
- }
1251
- function xmlTextToMarkdown(xmlText) {
1252
- return deltaToMarkdown(xmlText.toDelta());
1253
- }
1254
- function elementTextContent(el) {
1255
- const parts = [];
1256
- for (let i = 0; i < el.length; i++) {
1257
- const child = el.get(i);
1258
- if (child instanceof Y.XmlText) parts.push(xmlTextToMarkdown(child));
1259
- else if (child instanceof Y.XmlElement) parts.push(elementTextContent(child));
1260
- }
1261
- return parts.join("");
1262
- }
1263
- function serializeElement(el, indent = "") {
1264
- switch (el.nodeName) {
1265
- case "documentHeader":
1266
- case "documentMeta": return "";
1267
- case "heading": {
1268
- const level = Number(el.getAttribute("level")) || 1;
1269
- return `${"#".repeat(level)} ${elementTextContent(el)}`;
1270
- }
1271
- case "paragraph": return elementTextContent(el);
1272
- case "bulletList": return serializeList(el, "bullet", indent);
1273
- case "orderedList": return serializeList(el, "ordered", indent);
1274
- case "taskList": return serializeTaskList(el, indent);
1275
- case "codeBlock": return `\`\`\`${el.getAttribute("language") || ""}\n${elementTextContent(el)}\n\`\`\``;
1276
- case "blockquote": {
1277
- const lines = [];
1278
- for (let i = 0; i < el.length; i++) {
1279
- const child = el.get(i);
1280
- if (child instanceof Y.XmlElement) lines.push(serializeElement(child, indent));
1281
- }
1282
- return lines.map((l) => `> ${l}`).join("\n");
1283
- }
1284
- case "horizontalRule": return "---";
1285
- case "table": return serializeTable(el);
1286
- case "docEmbed": {
1287
- const docId = el.getAttribute("docId");
1288
- return docId ? `![[${docId}]]` : "";
1289
- }
1290
- case "svgEmbed": {
1291
- const svg = el.getAttribute("svg") || "";
1292
- const svgTitle = el.getAttribute("title") || "";
1293
- if (!svg) return "";
1294
- return `\`\`\`svg${svgTitle ? ` ${svgTitle}` : ""}\n${svg}\n\`\`\``;
1295
- }
1296
- case "image": {
1297
- const src = el.getAttribute("src") || "";
1298
- const alt = el.getAttribute("alt") || "";
1299
- const w = el.getAttribute("width");
1300
- const h = el.getAttribute("height");
1301
- let attrs = "";
1302
- if (w || h) {
1303
- const parts = [];
1304
- if (w) parts.push(`width="${w}"`);
1305
- if (h) parts.push(`height="${h}"`);
1306
- attrs = `{${parts.join(" ")}}`;
1307
- }
1308
- return `![${alt}](${src})${attrs}`;
1309
- }
1310
- case "callout": return `::${el.getAttribute("type") || "note"}\n${serializeChildren(el, indent)}\n::`;
1311
- case "collapsible": {
1312
- const label = el.getAttribute("label") || "Details";
1313
- const open = el.getAttribute("open");
1314
- const props = [`label="${label}"`];
1315
- if (open === true || open === "true") props.push("open=\"true\"");
1316
- const inner = serializeChildren(el, indent);
1317
- return `::collapsible{${props.join(" ")}}\n${inner}\n::`;
1318
- }
1319
- case "steps": return `::steps\n${serializeChildren(el, indent)}\n::`;
1320
- case "card": {
1321
- const title = el.getAttribute("title") || "";
1322
- const icon = el.getAttribute("icon") || "";
1323
- const to = el.getAttribute("to") || "";
1324
- const props = [];
1325
- if (title) props.push(`title="${title}"`);
1326
- if (icon) props.push(`icon="${icon}"`);
1327
- if (to) props.push(`to="${to}"`);
1328
- const inner = serializeChildren(el, indent);
1329
- return `::card{${props.join(" ")}}\n${inner}\n::`;
1330
- }
1331
- case "cardGroup": return `::card-group\n${serializeChildren(el, indent)}\n::`;
1332
- case "codeCollapse": return `::code-collapse\n${serializeChildren(el, indent)}\n::`;
1333
- case "codeGroup": return `::code-group\n${serializeChildren(el, indent)}\n::`;
1334
- case "codePreview": return `::code-preview\n${serializeChildren(el, indent)}\n::`;
1335
- case "codeTree": return `::code-tree{files="${el.getAttribute("files") || "[]"}"}\n::`;
1336
- case "accordion": return serializeSlottedComponent(el, "accordion", "accordionItem", "item");
1337
- case "tabs": return serializeSlottedComponent(el, "tabs", "tabsItem", "tab");
1338
- case "field": {
1339
- const fieldName = el.getAttribute("name") || "";
1340
- const fieldType = el.getAttribute("type") || "string";
1341
- const required = el.getAttribute("required");
1342
- const props = [];
1343
- if (fieldName) props.push(`name="${fieldName}"`);
1344
- props.push(`type="${fieldType}"`);
1345
- if (required === true || required === "true") props.push("required=\"true\"");
1346
- const inner = serializeChildren(el, indent);
1347
- return `::field{${props.join(" ")}}\n${inner}\n::`;
1348
- }
1349
- case "fieldGroup": return `::field-group\n${serializeChildren(el, indent)}\n::`;
1350
- default: return serializeChildren(el, indent);
1351
- }
1352
- }
1353
- function serializeList(el, type, indent) {
1354
- const lines = [];
1355
- for (let i = 0; i < el.length; i++) {
1356
- const item = el.get(i);
1357
- if (item instanceof Y.XmlElement && item.nodeName === "listItem") {
1358
- const prefix = type === "bullet" ? "- " : `${i + 1}. `;
1359
- const content = elementTextContent(item);
1360
- lines.push(`${indent}${prefix}${content}`);
1361
- }
1362
- }
1363
- return lines.join("\n");
1364
- }
1365
- function serializeTaskList(el, indent) {
1366
- const lines = [];
1367
- for (let i = 0; i < el.length; i++) {
1368
- const item = el.get(i);
1369
- if (item instanceof Y.XmlElement && item.nodeName === "taskItem") {
1370
- const checked = item.getAttribute("checked");
1371
- const marker = checked === true || checked === "true" ? "[x]" : "[ ]";
1372
- const content = elementTextContent(item);
1373
- lines.push(`${indent}- ${marker} ${content}`);
1374
- }
1375
- }
1376
- return lines.join("\n");
1377
- }
1378
- function serializeTable(el) {
1379
- const rows = [];
1380
- for (let i = 0; i < el.length; i++) {
1381
- const row = el.get(i);
1382
- if (!(row instanceof Y.XmlElement) || row.nodeName !== "tableRow") continue;
1383
- const cells = [];
1384
- for (let j = 0; j < row.length; j++) {
1385
- const cell = row.get(j);
1386
- if (cell instanceof Y.XmlElement) cells.push(elementTextContent(cell));
1387
- }
1388
- rows.push(cells);
1389
- }
1390
- if (!rows.length) return "";
1391
- const headerRow = rows[0];
1392
- const lines = [`| ${headerRow.join(" | ")} |`];
1393
- lines.push(`| ${headerRow.map(() => "---").join(" | ")} |`);
1394
- for (let i = 1; i < rows.length; i++) lines.push(`| ${rows[i].join(" | ")} |`);
1395
- return lines.join("\n");
1396
- }
1397
- function serializeSlottedComponent(el, componentName, childNodeName, slotName) {
1398
- const parts = [`::${componentName}`];
1399
- for (let i = 0; i < el.length; i++) {
1400
- const child = el.get(i);
1401
- if (!(child instanceof Y.XmlElement) || child.nodeName !== childNodeName) continue;
1402
- const label = child.getAttribute("label") || `Item ${i + 1}`;
1403
- const icon = child.getAttribute("icon") || "";
1404
- const props = [`label="${label}"`];
1405
- if (icon) props.push(`icon="${icon}"`);
1406
- parts.push(`#${slotName}{${props.join(" ")}}`);
1407
- const inner = serializeChildren(child, "");
1408
- if (inner) parts.push(inner);
1409
- }
1410
- parts.push("::");
1411
- return parts.join("\n");
1412
- }
1413
- function serializeChildren(el, indent) {
1414
- const parts = [];
1415
- for (let i = 0; i < el.length; i++) {
1416
- const child = el.get(i);
1417
- if (child instanceof Y.XmlElement) {
1418
- const serialized = serializeElement(child, indent);
1419
- if (serialized) parts.push(serialized);
1420
- } else if (child instanceof Y.XmlText) {
1421
- const text = xmlTextToMarkdown(child);
1422
- if (text) parts.push(text);
1423
- }
1424
- }
1425
- return parts.join("\n\n");
1426
- }
1427
- /**
1428
- * Converts a Y.XmlFragment (TipTap document) to markdown.
1429
- * Extracts the title from the documentHeader element.
1430
- *
1431
- * @returns `{ title, markdown }` where title is the H1/header text
1432
- */
1433
- function yjsToMarkdown(fragment) {
1434
- let title = "Untitled";
1435
- const bodyParts = [];
1436
- for (let i = 0; i < fragment.length; i++) {
1437
- const child = fragment.get(i);
1438
- if (!(child instanceof Y.XmlElement)) continue;
1439
- if (child.nodeName === "documentHeader") {
1440
- title = elementTextContent(child) || "Untitled";
1441
- continue;
1442
- }
1443
- if (child.nodeName === "documentMeta") continue;
1444
- const serialized = serializeElement(child);
1445
- if (serialized !== "") bodyParts.push(serialized);
1446
- }
1447
- return {
1448
- title,
1449
- markdown: bodyParts.join("\n\n")
1450
- };
1451
- }
1452
-
1453
- //#endregion
1454
- //#region packages/mcp/src/converters/markdownToYjs.ts
1455
- /**
1456
- * Markdown → Y.js converter.
1457
- * Ported from cou-sh/app/utils/markdownToYjs.ts with Vue dependency removed.
1458
- */
1321
+ //#region packages/convert/src/markdown-to-yjs.ts
1459
1322
  function parseInlineArray(raw) {
1460
1323
  return raw.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
1461
1324
  }
1325
+ function stripQuotes(s) {
1326
+ if (s.length >= 2 && (s.startsWith("\"") && s.endsWith("\"") || s.startsWith("'") && s.endsWith("'"))) return s.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
1327
+ return s;
1328
+ }
1462
1329
  function parseFrontmatter(markdown) {
1463
1330
  const noResult = {
1464
1331
  meta: {},
@@ -1489,8 +1356,8 @@ function parseFrontmatter(markdown) {
1489
1356
  if (kvMatch) {
1490
1357
  const key = kvMatch[1];
1491
1358
  const val = kvMatch[2].trim();
1492
- if (val.startsWith("[") && val.endsWith("]")) raw[key] = parseInlineArray(val);
1493
- else raw[key] = val;
1359
+ if (val.startsWith("[") && val.endsWith("]")) raw[key] = parseInlineArray(val).map(stripQuotes);
1360
+ else raw[key] = stripQuotes(val);
1494
1361
  }
1495
1362
  i++;
1496
1363
  }
@@ -1517,54 +1384,27 @@ function parseFrontmatter(markdown) {
1517
1384
  }[priorityRaw.toLowerCase()] ?? (Number(priorityRaw) || 0);
1518
1385
  const checkedRaw = raw["checked"] ?? raw["done"];
1519
1386
  if (checkedRaw !== void 0) meta.checked = checkedRaw === "true" || checkedRaw === true;
1520
- const dateStart = getStr(["date", "created"]);
1387
+ const dateStart = getStr([
1388
+ "dateStart",
1389
+ "date",
1390
+ "created"
1391
+ ]);
1521
1392
  if (dateStart) meta.dateStart = dateStart;
1522
- const dateEnd = getStr(["due"]);
1393
+ const dateEnd = getStr(["dateEnd", "due"]);
1523
1394
  if (dateEnd) meta.dateEnd = dateEnd;
1524
- const subtitle = getStr(["description", "subtitle"]);
1395
+ const subtitle = getStr(["subtitle", "description"]);
1525
1396
  if (subtitle) meta.subtitle = subtitle;
1526
1397
  const url = getStr(["url"]);
1527
1398
  if (url) meta.url = url;
1528
- const email = getStr(["email"]);
1529
- if (email) meta.email = email;
1530
- const phone = getStr(["phone"]);
1531
- if (phone) meta.phone = phone;
1532
1399
  const ratingRaw = getStr(["rating"]);
1533
1400
  if (ratingRaw !== void 0) {
1534
1401
  const n = Number(ratingRaw);
1535
1402
  if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n));
1536
1403
  }
1537
- const datetimeStart = getStr(["datetimeStart"]);
1538
- if (datetimeStart) meta.datetimeStart = datetimeStart;
1539
- const datetimeEnd = getStr(["datetimeEnd"]);
1540
- if (datetimeEnd) meta.datetimeEnd = datetimeEnd;
1541
- const allDayRaw = raw["allDay"];
1542
- if (allDayRaw !== void 0) meta.allDay = allDayRaw === "true" || allDayRaw === true;
1543
- const geoLatRaw = getStr(["geoLat"]);
1544
- if (geoLatRaw !== void 0) {
1545
- const n = Number(geoLatRaw);
1546
- if (!Number.isNaN(n)) meta.geoLat = n;
1547
- }
1548
- const geoLngRaw = getStr(["geoLng"]);
1549
- if (geoLngRaw !== void 0) {
1550
- const n = Number(geoLngRaw);
1551
- if (!Number.isNaN(n)) meta.geoLng = n;
1552
- }
1553
- const geoType = getStr(["geoType"]);
1554
- if (geoType && (geoType === "marker" || geoType === "line" || geoType === "measure")) meta.geoType = geoType;
1555
- const geoDescription = getStr(["geoDescription"]);
1556
- if (geoDescription) meta.geoDescription = geoDescription;
1557
- const numberRaw = getStr(["number"]);
1558
- if (numberRaw !== void 0) {
1559
- const n = Number(numberRaw);
1560
- if (!Number.isNaN(n)) meta.number = n;
1561
- }
1562
- const unit = getStr(["unit"]);
1563
- if (unit) meta.unit = unit;
1564
- const note = getStr(["note"]);
1565
- if (note) meta.note = note;
1404
+ const rawTitle = typeof raw["title"] === "string" ? raw["title"] : void 0;
1566
1405
  return {
1567
- title: typeof raw["title"] === "string" ? raw["title"] : void 0,
1406
+ title: rawTitle !== void 0 ? stripQuotes(rawTitle) : void 0,
1407
+ type: getStr(["type"]),
1568
1408
  meta,
1569
1409
  body
1570
1410
  };
@@ -1572,63 +1412,74 @@ function parseFrontmatter(markdown) {
1572
1412
  function parseInline(text) {
1573
1413
  const stripped = text.replace(/\{lang="[^"]*"\}/g, "").replace(/:(?!badge|icon|kbd)(\w[\w-]*)\[([^\]]*)\](\{[^}]*\})?/g, "$2").replace(/:(?!badge|icon|kbd)(\w[\w-]*)(\{[^}]*\})/g, "");
1574
1414
  const tokens = [];
1575
- const re = /:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|!?\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g;
1415
+ const re = /\$([^$\n]+?)\$|@\[([^\]]+?)\]\(user:([^)]+?)\)|:badge\[([^\]]*)\](\{[^}]*\})?|:icon\{([^}]*)\}|:kbd\{([^}]*)\}|\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+?))?\]\]|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)/g;
1576
1416
  let lastIndex = 0;
1577
1417
  let match;
1578
1418
  while ((match = re.exec(stripped)) !== null) {
1579
1419
  if (match.index > lastIndex) tokens.push({ text: stripped.slice(lastIndex, match.index) });
1580
- if (match[1] !== void 0) {
1581
- const badgeProps = parseMdcProps(match[2]);
1420
+ if (match[1] !== void 0) tokens.push({
1421
+ text: match[1],
1422
+ attrs: { mathInline: { expression: match[1] } }
1423
+ });
1424
+ else if (match[2] !== void 0 && match[3] !== void 0) tokens.push({
1425
+ text: match[2],
1426
+ attrs: { mention: {
1427
+ userId: match[3],
1428
+ label: match[2]
1429
+ } }
1430
+ });
1431
+ else if (match[4] !== void 0) {
1432
+ const badgeProps = parseMdcProps(match[5]);
1582
1433
  tokens.push({
1583
- text: match[1] || "Badge",
1434
+ text: match[4] || "Badge",
1584
1435
  attrs: { badge: {
1585
- label: match[1] || "Badge",
1436
+ label: match[4] || "Badge",
1586
1437
  color: badgeProps["color"] || "neutral",
1587
1438
  variant: badgeProps["variant"] || "subtle"
1588
1439
  } }
1589
1440
  });
1590
- } else if (match[3] !== void 0) {
1591
- const iconProps = parseMdcProps(`{${match[3]}}`);
1441
+ } else if (match[6] !== void 0) {
1442
+ const iconProps = parseMdcProps(`{${match[6]}}`);
1592
1443
  tokens.push({
1593
1444
  text: "​",
1594
1445
  attrs: { proseIcon: { name: iconProps["name"] || "i-lucide-star" } }
1595
1446
  });
1596
- } else if (match[4] !== void 0) {
1597
- const kbdProps = parseMdcProps(`{${match[4]}}`);
1447
+ } else if (match[7] !== void 0) {
1448
+ const kbdProps = parseMdcProps(`{${match[7]}}`);
1598
1449
  tokens.push({
1599
1450
  text: kbdProps["value"] || "",
1600
1451
  attrs: { kbd: { value: kbdProps["value"] || "" } }
1601
1452
  });
1602
- } else if (match[5] !== void 0) {
1603
- const docId = match[5];
1604
- const displayText = match[6] ?? docId;
1453
+ } else if (match[8] !== void 0) {
1454
+ const docId = match[8];
1455
+ const label = match[9] ?? docId;
1605
1456
  tokens.push({
1606
- text: displayText,
1607
- attrs: { link: { href: `/doc/${docId}` } }
1457
+ text: label,
1458
+ attrs: { docLink: { docId } }
1608
1459
  });
1609
- } else if (match[7] !== void 0) tokens.push({
1610
- text: match[7],
1460
+ } else if (match[10] !== void 0) tokens.push({
1461
+ text: match[10],
1611
1462
  attrs: { strike: true }
1612
1463
  });
1613
- else if (match[8] !== void 0) tokens.push({
1614
- text: match[8],
1464
+ else if (match[11] !== void 0) tokens.push({
1465
+ text: match[11],
1615
1466
  attrs: { bold: true }
1616
1467
  });
1617
- else if (match[9] !== void 0) tokens.push({
1618
- text: match[9],
1468
+ else if (match[12] !== void 0) tokens.push({
1469
+ text: match[12],
1619
1470
  attrs: { italic: true }
1620
1471
  });
1621
- else if (match[10] !== void 0) tokens.push({
1622
- text: match[10],
1472
+ else if (match[13] !== void 0) tokens.push({
1473
+ text: match[13],
1623
1474
  attrs: { italic: true }
1624
1475
  });
1625
- else if (match[11] !== void 0) tokens.push({
1626
- text: match[11],
1476
+ else if (match[14] !== void 0) tokens.push({
1477
+ text: match[14],
1627
1478
  attrs: { code: true }
1628
1479
  });
1629
- else if (match[12] !== void 0 && match[13] !== void 0) tokens.push({
1630
- text: match[12],
1631
- attrs: { link: { href: match[13] } }
1480
+ else if (match[15] !== void 0 && match[16] !== void 0) tokens.push({
1481
+ text: match[15],
1482
+ attrs: { link: { href: match[16] } }
1632
1483
  });
1633
1484
  lastIndex = match.index + match[0].length;
1634
1485
  }
@@ -1642,6 +1493,7 @@ function parseTableRow(line) {
1642
1493
  function isTableSeparator(line) {
1643
1494
  return /^\|[\s|:-]+\|$/.test(line.trim());
1644
1495
  }
1496
+ /** Extract fenced code blocks from MDC #code slot lines. */
1645
1497
  function extractFencedCode(lines) {
1646
1498
  const result = [];
1647
1499
  let i = 0;
@@ -1668,14 +1520,23 @@ function extractFencedCode(lines) {
1668
1520
  }
1669
1521
  return result;
1670
1522
  }
1523
+ /** Extract key="value" pairs from MDC prop syntax `{key="value" other="x"}` */
1671
1524
  function parseMdcProps(propsStr) {
1672
1525
  if (!propsStr) return {};
1673
1526
  const result = {};
1674
- const re = /(\w[\w-]*)="([^"]*)"/g;
1527
+ let s = propsStr.trim();
1528
+ if (s.startsWith("{") && s.endsWith("}")) s = s.slice(1, -1);
1529
+ const re = /(\w[\w-]*)(?:=(?:"([^"]*)"|([^\s"}]+)))?/g;
1675
1530
  let m;
1676
- while ((m = re.exec(propsStr)) !== null) result[m[1]] = m[2];
1531
+ while ((m = re.exec(s)) !== null) {
1532
+ const key = m[1];
1533
+ if (m[2] !== void 0) result[key] = m[2];
1534
+ else if (m[3] !== void 0) result[key] = m[3];
1535
+ else result[key] = "true";
1536
+ }
1677
1537
  return result;
1678
1538
  }
1539
+ /** Parse named child MDC blocks from inner lines (e.g. #item for accordion, #tab for tabs) */
1679
1540
  function parseMdcChildren(innerLines, slotPrefix) {
1680
1541
  const items = [];
1681
1542
  let current = null;
@@ -1707,6 +1568,95 @@ function parseMdcChildren(innerLines, slotPrefix) {
1707
1568
  }));
1708
1569
  }
1709
1570
  const TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)/;
1571
+ /**
1572
+ * Consume a list (bullet / ordered / task) starting at `start`. Indented
1573
+ * continuation lines and nested lists are captured into each item's
1574
+ * `innerBlocks` so the parse → serialise → parse cycle preserves tree
1575
+ * structure instead of flattening nested lists onto a single line.
1576
+ *
1577
+ * `indent` is the column of the item marker for the current list. A
1578
+ * nested list starts ≥2 columns deeper. Lines with less indent than
1579
+ * `indent` belong to the outer block and stop consumption.
1580
+ */
1581
+ function consumeList(lines, start, indent, kind) {
1582
+ const items = [];
1583
+ let i = start;
1584
+ while (i < lines.length) {
1585
+ const line = lines[i];
1586
+ if (line.trim() === "") {
1587
+ let j = i + 1;
1588
+ while (j < lines.length && lines[j].trim() === "") j++;
1589
+ if (j >= lines.length) break;
1590
+ const lookahead = lines[j];
1591
+ if (leadingSpaces(lookahead) < indent) break;
1592
+ if (!matchMarker(lookahead.slice(indent), kind)) break;
1593
+ i = j;
1594
+ continue;
1595
+ }
1596
+ const leading = leadingSpaces(line);
1597
+ if (leading < indent) break;
1598
+ if (leading > indent) break;
1599
+ const m = matchMarker(line.slice(indent), kind);
1600
+ if (!m) break;
1601
+ const item = { text: m.text };
1602
+ if (kind === "task") item.checked = m.checked;
1603
+ i++;
1604
+ const contLines = [];
1605
+ while (i < lines.length) {
1606
+ const next = lines[i];
1607
+ if (next.trim() === "") {
1608
+ let k = i + 1;
1609
+ while (k < lines.length && lines[k].trim() === "") k++;
1610
+ if (k >= lines.length) break;
1611
+ if (leadingSpaces(lines[k]) <= indent) break;
1612
+ contLines.push("");
1613
+ i++;
1614
+ continue;
1615
+ }
1616
+ const nextIndent = leadingSpaces(next);
1617
+ if (nextIndent <= indent) break;
1618
+ const deindentBy = Math.min(nextIndent, indent + 2);
1619
+ contLines.push(next.slice(deindentBy));
1620
+ i++;
1621
+ }
1622
+ if (contLines.length > 0) item.innerBlocks = parseBlocks(contLines.join("\n"));
1623
+ items.push(item);
1624
+ }
1625
+ return {
1626
+ items,
1627
+ next: i
1628
+ };
1629
+ }
1630
+ function leadingSpaces(s) {
1631
+ let n = 0;
1632
+ while (n < s.length && s[n] === " ") n++;
1633
+ return n;
1634
+ }
1635
+ function matchMarker(s, kind) {
1636
+ if (kind === "task") {
1637
+ const m = s.match(TASK_RE);
1638
+ if (!m) return null;
1639
+ return {
1640
+ text: m[2],
1641
+ checked: m[1].toLowerCase() === "x"
1642
+ };
1643
+ }
1644
+ if (kind === "bullet") {
1645
+ if (TASK_RE.test(s)) return null;
1646
+ const m = s.match(/^[-*+]\s+(.*)$/);
1647
+ if (!m) return null;
1648
+ return {
1649
+ text: m[1],
1650
+ checked: false
1651
+ };
1652
+ }
1653
+ const m = s.match(/^\d+\.\s+(.*)$/);
1654
+ if (!m) return null;
1655
+ return {
1656
+ text: m[1],
1657
+ checked: false
1658
+ };
1659
+ }
1710
1660
  function parseBlocks(markdown) {
1711
1661
  const rawLines = markdown.split("\n");
1712
1662
  let firstContentLine = 0;
@@ -1732,17 +1682,15 @@ function parseBlocks(markdown) {
1732
1682
  i++;
1733
1683
  }
1734
1684
  i++;
1735
- if (lang === "svg" || lang.startsWith("svg ")) {
1736
- const svgTitle = lang === "svg" ? "" : lang.slice(4).trim();
1737
- blocks.push({
1738
- type: "svgEmbed",
1739
- svg: codeLines.join("\n"),
1740
- title: svgTitle
1741
- });
1742
- } else blocks.push({
1685
+ const code = codeLines.join("\n");
1686
+ if (lang === "math") blocks.push({
1687
+ type: "mathBlock",
1688
+ expression: code
1689
+ });
1690
+ else blocks.push({
1743
1691
  type: "codeBlock",
1744
1692
  lang,
1745
- code: codeLines.join("\n")
1693
+ code
1746
1694
  });
1747
1695
  continue;
1748
1696
  }
@@ -1761,11 +1709,16 @@ function parseBlocks(markdown) {
1761
1709
  i++;
1762
1710
  continue;
1763
1711
  }
1764
- const docEmbedMatch = line.match(/^!\[\[([^\]|]+?)(?:\|[^\]]*?)?\]\]\s*$/);
1765
- if (docEmbedMatch) {
1712
+ const embedMatch = line.match(/^!\[\[([0-9a-fA-F-]{36})(?:\|([^\]]+))?\]\](\{[^}]*\})?\s*$/);
1713
+ if (embedMatch) {
1714
+ const docId = embedMatch[1];
1715
+ const label = embedMatch[2] ?? "";
1716
+ const props = parseMdcProps(embedMatch[3]);
1766
1717
  blocks.push({
1767
1718
  type: "docEmbed",
1768
- docId: docEmbedMatch[1]
1719
+ docId,
1720
+ label,
1721
+ props
1769
1722
  });
1770
1723
  i++;
1771
1724
  continue;
@@ -1817,6 +1770,23 @@ function parseBlocks(markdown) {
1817
1770
  });
1818
1771
  continue;
1819
1772
  }
1773
+ const atomMatch = line.match(/^:(\w[\w-]*)(\{[^}]*\})?\s*$/);
1774
+ if (atomMatch && atomMatch[1] === "file") {
1775
+ const props = parseMdcProps(atomMatch[2]);
1776
+ const uploadId = props["upload-id"] ?? props["uploadId"] ?? "";
1777
+ const filename = props["filename"] ?? "";
1778
+ const mime = props["mime"] ?? "";
1779
+ const src = props["src"] ?? (uploadId && filename ? `.abracadabra/files/${uploadId}-${filename}` : "");
1780
+ blocks.push({
1781
+ type: "fileBlock",
1782
+ src,
1783
+ mime,
1784
+ uploadId,
1785
+ filename
1786
+ });
1787
+ i++;
1788
+ continue;
1789
+ }
1820
1790
  const MDC_OPEN = /^\s*(:{2,})(\w[\w-]*)(\{[^}]*\})?\s*$/;
1821
1791
  if (MDC_OPEN.test(line)) {
1822
1792
  const colons = line.match(/^\s*(:+)/)?.[1]?.length ?? 2;
@@ -1985,15 +1955,8 @@ function parseBlocks(markdown) {
1985
1955
  continue;
1986
1956
  }
1987
1957
  if (TASK_RE.test(line)) {
1988
- const items = [];
1989
- while (i < lines.length && TASK_RE.test(lines[i])) {
1990
- const m = lines[i].match(TASK_RE);
1991
- items.push({
1992
- checked: m[1].toLowerCase() === "x",
1993
- text: m[2]
1994
- });
1995
- i++;
1996
- }
1958
+ const { items, next } = consumeList(lines, i, 0, "task");
1959
+ i = next;
1997
1960
  blocks.push({
1998
1961
  type: "taskList",
1999
1962
  items
@@ -2001,12 +1964,9 @@ function parseBlocks(markdown) {
2001
1964
  continue;
2002
1965
  }
2003
1966
  if (/^[-*+]\s+/.test(line)) {
2004
- const items = [];
2005
- while (i < lines.length && /^[-*+]\s+/.test(lines[i]) && !TASK_RE.test(lines[i])) {
2006
- items.push(lines[i].replace(/^[-*+]\s+/, ""));
2007
- i++;
2008
- }
2009
- if (items.length) {
1967
+ const { items, next } = consumeList(lines, i, 0, "bullet");
1968
+ if (items.length > 0) {
1969
+ i = next;
2010
1970
  blocks.push({
2011
1971
  type: "bulletList",
2012
1972
  items
@@ -2015,16 +1975,15 @@ function parseBlocks(markdown) {
2015
1975
  }
2016
1976
  }
2017
1977
  if (/^\d+\.\s+/.test(line)) {
2018
- const items = [];
2019
- while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
2020
- items.push(lines[i].replace(/^\d+\.\s+/, ""));
2021
- i++;
1978
+ const { items, next } = consumeList(lines, i, 0, "ordered");
1979
+ if (items.length > 0) {
1980
+ i = next;
1981
+ blocks.push({
1982
+ type: "orderedList",
1983
+ items
1984
+ });
1985
+ continue;
2022
1986
  }
2023
- blocks.push({
2024
- type: "orderedList",
2025
- items
2026
- });
2027
- continue;
2028
1987
  }
2029
1988
  if (line.trim() === "") {
2030
1989
  i++;
@@ -2042,6 +2001,10 @@ function parseBlocks(markdown) {
2042
2001
  }
2043
2002
  return blocks;
2044
2003
  }
2004
+ /**
2005
+ * Insert formatted inline tokens into an already-attached Y.XmlElement.
2006
+ * Creates one Y.XmlText per token (attach first, fill second).
2007
+ */
2045
2008
  function fillTextInto(el, tokens) {
2046
2009
  const filtered = tokens.filter((t) => t.text.length > 0);
2047
2010
  if (!filtered.length) return;
@@ -2078,9 +2041,19 @@ function blockElName(b) {
2078
2041
  case "fieldGroup": return "fieldGroup";
2079
2042
  case "image": return "image";
2080
2043
  case "docEmbed": return "docEmbed";
2081
- case "svgEmbed": return "svgEmbed";
2044
+ case "mathBlock": return "mathBlock";
2045
+ case "fileBlock": return "fileBlock";
2082
2046
  }
2083
2047
  }
2048
+ function populateListItemChildren(itemEl, item, _itemKind) {
2049
+ const paraEl = new Y.XmlElement("paragraph");
2050
+ itemEl.insert(itemEl.length, [paraEl]);
2051
+ fillTextInto(paraEl, parseInline(item.text));
2052
+ if (!item.innerBlocks?.length) return;
2053
+ const innerEls = item.innerBlocks.map((b) => new Y.XmlElement(blockElName(b)));
2054
+ itemEl.insert(itemEl.length, innerEls);
2055
+ item.innerBlocks.forEach((b, i) => fillBlock(innerEls[i], b));
2056
+ }
2084
2057
  function fillBlock(el, block) {
2085
2058
  switch (block.type) {
2086
2059
  case "heading":
@@ -2094,10 +2067,8 @@ function fillBlock(el, block) {
2094
2067
  case "orderedList": {
2095
2068
  const listItemEls = block.items.map(() => new Y.XmlElement("listItem"));
2096
2069
  el.insert(0, listItemEls);
2097
- block.items.forEach((text, i) => {
2098
- const paraEl = new Y.XmlElement("paragraph");
2099
- listItemEls[i].insert(0, [paraEl]);
2100
- fillTextInto(paraEl, parseInline(text));
2070
+ block.items.forEach((item, i) => {
2071
+ populateListItemChildren(listItemEls[i], item, "listItem");
2101
2072
  });
2102
2073
  break;
2103
2074
  }
@@ -2105,10 +2076,8 @@ function fillBlock(el, block) {
2105
2076
  const taskItemEls = block.items.map(() => new Y.XmlElement("taskItem"));
2106
2077
  el.insert(0, taskItemEls);
2107
2078
  block.items.forEach((item, i) => {
2108
- taskItemEls[i].setAttribute("checked", item.checked);
2109
- const paraEl = new Y.XmlElement("paragraph");
2110
- taskItemEls[i].insert(0, [paraEl]);
2111
- fillTextInto(paraEl, parseInline(item.text));
2079
+ taskItemEls[i].setAttribute("checked", !!item.checked);
2080
+ populateListItemChildren(taskItemEls[i], item, "taskItem");
2112
2081
  });
2113
2082
  break;
2114
2083
  }
@@ -2296,31 +2265,55 @@ function fillBlock(el, block) {
2296
2265
  break;
2297
2266
  case "docEmbed":
2298
2267
  el.setAttribute("docId", block.docId);
2268
+ for (const flag of [
2269
+ "collapsed",
2270
+ "tall",
2271
+ "seamless"
2272
+ ]) if (block.props[flag] === "true" || block.props[flag] === "1") el.setAttribute(flag, true);
2299
2273
  break;
2300
- case "svgEmbed":
2301
- el.setAttribute("svg", block.svg);
2302
- if (block.title) el.setAttribute("title", block.title);
2274
+ case "mathBlock":
2275
+ el.setAttribute("expression", block.expression);
2276
+ break;
2277
+ case "fileBlock":
2278
+ if (block.src) el.setAttribute("src", block.src);
2279
+ if (block.mime) el.setAttribute("mime", block.mime);
2280
+ if (block.uploadId) el.setAttribute("uploadId", block.uploadId);
2281
+ if (block.filename) el.setAttribute("filename", block.filename);
2303
2282
  break;
2304
2283
  }
2305
2284
  }
2285
+ /**
2286
+ * Parses markdown text and writes the result into a Y.XmlFragment that
2287
+ * TipTap's Collaboration extension can read.
2288
+ *
2289
+ * Requires `fragment.doc` to be set (i.e. the fragment must already be
2290
+ * obtained from a live Y.Doc via `ydoc.getXmlFragment('default')`).
2291
+ *
2292
+ * @param fragment The target `Y.Doc.getXmlFragment('default')`
2293
+ * @param markdown Raw markdown string
2294
+ * @param fallbackTitle Used as the title when the markdown has no H1
2295
+ */
2306
2296
  function populateYDocFromMarkdown(fragment, markdown, fallbackTitle = "Untitled") {
2307
2297
  const ydoc = fragment.doc;
2308
2298
  if (!ydoc) {
2309
2299
  console.warn("[markdownToYjs] fragment has no doc — skipping population");
2310
2300
  return;
2311
2301
  }
2312
- const blocks = parseBlocks(markdown);
2302
+ const fm = parseFrontmatter(markdown);
2303
+ const blocks = parseBlocks(fm.body);
2313
2304
  let title = fallbackTitle;
2305
+ let titleSource;
2306
+ if (fm.title !== void 0) {
2307
+ title = fm.title;
2308
+ titleSource = "frontmatter";
2309
+ }
2314
2310
  let contentBlocks = blocks;
2315
2311
  const h1 = blocks.findIndex((b) => b.type === "heading" && b.level === 1);
2316
2312
  if (h1 !== -1) {
2317
2313
  title = blocks[h1].text;
2318
2314
  contentBlocks = blocks.filter((_, i) => i !== h1);
2315
+ titleSource = "h1";
2319
2316
  }
2320
- if (!contentBlocks.length) contentBlocks = [{
2321
- type: "paragraph",
2322
- text: ""
2323
- }];
2324
2317
  ydoc.transact(() => {
2325
2318
  const headerEl = new Y.XmlElement("documentHeader");
2326
2319
  const metaEl = new Y.XmlElement("documentMeta");
@@ -2350,7 +2343,8 @@ function populateYDocFromMarkdown(fragment, markdown, fallbackTitle = "Untitled"
2350
2343
  case "fieldGroup": return new Y.XmlElement("fieldGroup");
2351
2344
  case "image": return new Y.XmlElement("image");
2352
2345
  case "docEmbed": return new Y.XmlElement("docEmbed");
2353
- case "svgEmbed": return new Y.XmlElement("svgEmbed");
2346
+ case "mathBlock": return new Y.XmlElement("mathBlock");
2347
+ case "fileBlock": return new Y.XmlElement("fileBlock");
2354
2348
  }
2355
2349
  });
2356
2350
  fragment.insert(0, [
@@ -2358,61 +2352,1324 @@ function populateYDocFromMarkdown(fragment, markdown, fallbackTitle = "Untitled"
2358
2352
  metaEl,
2359
2353
  ...bodyEls
2360
2354
  ]);
2355
+ if (titleSource) headerEl.setAttribute("titleSource", titleSource);
2361
2356
  const headerXt = new Y.XmlText();
2362
2357
  headerEl.insert(0, [headerXt]);
2363
2358
  headerXt.insert(0, title);
2359
+ for (const k of Object.keys(fm.meta)) {
2360
+ const v = fm.meta[k];
2361
+ if (v === void 0 || v === null) continue;
2362
+ metaEl.setAttribute(k, v);
2363
+ }
2364
+ if (fm.type) metaEl.setAttribute("type", fm.type);
2364
2365
  contentBlocks.forEach((block, i) => fillBlock(bodyEls[i], block));
2365
2366
  });
2366
2367
  }
2367
2368
 
2368
2369
  //#endregion
2369
- //#region packages/cli/src/commands/documents.ts
2370
- /**
2371
- * Document CRUD commands: read, create, rename, move, delete, type, doc.
2372
- */
2373
- /** Safely read a tree map value, converting Y.Map to plain object if needed. */
2374
- function toPlain$1(val) {
2375
- return val instanceof Y.Map ? val.toJSON() : val;
2376
- }
2377
- registerCommand({
2378
- name: "doc",
2379
- aliases: ["info:doc"],
2380
- description: "Show document metadata (label, type, meta, dates).",
2381
- usage: "doc id=<docId> | name=<label> | path=<a/b/c>",
2382
- async run(conn, args) {
2383
- if (!conn) return "Not connected";
2384
- const docId = resolveDocument(conn, args.params, args.positional);
2385
- if (!docId) return "Document not found. Specify id=, name=, or path= to identify the document.";
2386
- const treeMap = conn.getTreeMap();
2387
- if (!treeMap) return "Not connected";
2388
- const raw = treeMap.get(docId);
2389
- if (!raw) return `Document ${docId} not found in tree.`;
2390
- const entry = toPlain$1(raw);
2391
- const lines = [
2392
- `id: ${docId}`,
2393
- `label: ${entry.label || "Untitled"}`,
2394
- `type: ${entry.type ?? "—"}`,
2395
- `parent: ${entry.parentId ?? "(root)"}`,
2396
- `order: ${entry.order ?? 0}`,
2397
- `created: ${entry.createdAt ? new Date(entry.createdAt).toISOString() : "—"}`,
2398
- `updated: ${entry.updatedAt ? new Date(entry.updatedAt).toISOString() : "—"} (${relativeTime(entry.updatedAt)})`
2399
- ];
2400
- if (entry.meta && Object.keys(entry.meta).length > 0) {
2401
- lines.push(`meta:`);
2402
- for (const [k, v] of Object.entries(entry.meta)) lines.push(` ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
2370
+ //#region packages/convert/src/yjs-to-markdown.ts
2371
+ function serializeDelta(delta) {
2372
+ let result = "";
2373
+ for (const op of delta) {
2374
+ if (typeof op.insert !== "string") continue;
2375
+ let text = op.insert;
2376
+ const attrs = op.attributes ?? {};
2377
+ if (attrs.code) {
2378
+ result += `\`${text}\``;
2379
+ continue;
2403
2380
  }
2404
- const children = childrenOf(readEntries(treeMap), docId);
2405
- lines.push(`children: ${children.length}`);
2406
- return lines.join("\n");
2407
- }
2408
- });
2409
- registerCommand({
2410
- name: "read",
2411
- aliases: ["cat", "r"],
2412
- description: "Read document content as markdown.",
2413
- usage: "read id=<docId> | name=<label> | path=<a/b/c> [--format=json|md]",
2414
- async run(conn, args) {
2415
- if (!conn) return "Not connected";
2381
+ if (attrs.badge) {
2382
+ const b = attrs.badge;
2383
+ const props = [];
2384
+ if (b.color && b.color !== "neutral") props.push(`color="${b.color}"`);
2385
+ if (b.variant && b.variant !== "subtle") props.push(`variant="${b.variant}"`);
2386
+ result += `:badge[${b.label || text}]${props.length ? `{${props.join(" ")}}` : ""}`;
2387
+ continue;
2388
+ }
2389
+ if (attrs.proseIcon) {
2390
+ const icon = attrs.proseIcon.name || "i-lucide-star";
2391
+ result += `:icon{name="${icon}"}`;
2392
+ continue;
2393
+ }
2394
+ if (attrs.kbd) {
2395
+ const value = attrs.kbd.value || text;
2396
+ result += `:kbd{value="${value}"}`;
2397
+ continue;
2398
+ }
2399
+ if (attrs.docLink) {
2400
+ const docId = attrs.docLink.docId;
2401
+ if (docId) {
2402
+ result += text === docId ? `[[${docId}]]` : `[[${docId}|${text}]]`;
2403
+ continue;
2404
+ }
2405
+ }
2406
+ if (attrs.mention) {
2407
+ const { userId, label } = attrs.mention;
2408
+ if (userId) {
2409
+ result += `@[${label || text}](user:${userId})`;
2410
+ continue;
2411
+ }
2412
+ }
2413
+ if (attrs.mathInline) {
2414
+ const expr = attrs.mathInline.expression ?? text;
2415
+ result += `$${expr}$`;
2416
+ continue;
2417
+ }
2418
+ if (attrs.bold) text = `**${text}**`;
2419
+ if (attrs.italic) text = `*${text}*`;
2420
+ if (attrs.strike) text = `~~${text}~~`;
2421
+ if (attrs.link) {
2422
+ const href = attrs.link.href ?? "";
2423
+ text = `[${text}](${href})`;
2424
+ }
2425
+ result += text;
2426
+ }
2427
+ return result;
2428
+ }
2429
+ function serializeInline(el) {
2430
+ const parts = [];
2431
+ for (const child of el.toArray()) if (child instanceof Y.XmlText) parts.push(serializeDelta(child.toDelta()));
2432
+ else if (child instanceof Y.XmlElement) parts.push(serializeInline(child));
2433
+ return parts.join("");
2434
+ }
2435
+ function serializeBlock(el, indent = "") {
2436
+ if (el instanceof Y.XmlText) return serializeDelta(el.toDelta());
2437
+ switch (el.nodeName) {
2438
+ case "documentHeader":
2439
+ case "documentMeta": return "";
2440
+ case "heading": {
2441
+ const level = Number(el.getAttribute("level") ?? 2);
2442
+ return `${"#".repeat(level)} ${serializeInline(el)}`;
2443
+ }
2444
+ case "paragraph": return serializeInline(el);
2445
+ case "bulletList": return serializeListItems(el, "bullet", indent);
2446
+ case "orderedList": return serializeListItems(el, "ordered", indent);
2447
+ case "taskList": return serializeTaskList(el, indent);
2448
+ case "codeBlock": {
2449
+ const lang = el.getAttribute("language") ?? "";
2450
+ const code = getCodeBlockText(el);
2451
+ if (code === "") return `\`\`\`${lang}\n\`\`\``;
2452
+ return `\`\`\`${lang}\n${code}\n\`\`\``;
2453
+ }
2454
+ case "blockquote": {
2455
+ const lines = [];
2456
+ for (const child of el.toArray()) if (child instanceof Y.XmlElement) {
2457
+ const text = serializeBlock(child);
2458
+ for (const line of text.split("\n")) lines.push(`> ${line}`);
2459
+ }
2460
+ return lines.join("\n");
2461
+ }
2462
+ case "table": return serializeTable(el);
2463
+ case "horizontalRule": return "---";
2464
+ case "image": {
2465
+ const src = el.getAttribute("src") ?? "";
2466
+ const alt = el.getAttribute("alt") ?? "";
2467
+ const width = el.getAttribute("width");
2468
+ const height = el.getAttribute("height");
2469
+ const attrs = [];
2470
+ if (width) attrs.push(`width=${width}`);
2471
+ if (height) attrs.push(`height=${height}`);
2472
+ return `![${alt}](${src})${attrs.length ? `{${attrs.join(" ")}}` : ""}`;
2473
+ }
2474
+ case "docEmbed": {
2475
+ const docId = el.getAttribute("docId") ?? "";
2476
+ const collapsed = el.getAttribute("collapsed");
2477
+ const tall = el.getAttribute("tall");
2478
+ const seamless = el.getAttribute("seamless");
2479
+ const flags = [];
2480
+ if (collapsed === true || collapsed === "true") flags.push("collapsed");
2481
+ if (tall === true || tall === "true") flags.push("tall");
2482
+ if (seamless === true || seamless === "true") flags.push("seamless");
2483
+ return `![[${docId}]]${flags.length ? `{${flags.join(" ")}}` : ""}`;
2484
+ }
2485
+ case "mathBlock": return `\`\`\`math\n${el.getAttribute("expression") ?? ""}\n\`\`\``;
2486
+ case "fileBlock": {
2487
+ const uploadId = el.getAttribute("uploadId") ?? "";
2488
+ const filename = el.getAttribute("filename") ?? "";
2489
+ const mime = el.getAttribute("mime") ?? "";
2490
+ const src = el.getAttribute("src") ?? (uploadId && filename ? `.abracadabra/files/${uploadId}-${filename}` : "");
2491
+ const props = [];
2492
+ if (src) props.push(`src="${src}"`);
2493
+ if (mime) props.push(`mime="${mime}"`);
2494
+ if (uploadId) props.push(`upload-id="${uploadId}"`);
2495
+ if (filename) props.push(`filename="${filename}"`);
2496
+ return `:file{${props.join(" ")}}`;
2497
+ }
2498
+ case "callout": return `::${el.getAttribute("type") ?? "note"}\n${serializeChildren(el)}\n::`;
2499
+ case "collapsible": {
2500
+ const label = el.getAttribute("label") ?? "Details";
2501
+ const open = el.getAttribute("open");
2502
+ const props = [`label="${label}"`];
2503
+ if (open === true || open === "true") props.push("open=\"true\"");
2504
+ return `::collapsible{${props.join(" ")}}\n${serializeChildren(el)}\n::`;
2505
+ }
2506
+ case "steps": return `::steps\n${serializeChildren(el)}\n::`;
2507
+ case "card": {
2508
+ const props = [];
2509
+ const title = el.getAttribute("title");
2510
+ const icon = el.getAttribute("icon");
2511
+ const to = el.getAttribute("to");
2512
+ if (title) props.push(`title="${title}"`);
2513
+ if (icon) props.push(`icon="${icon}"`);
2514
+ if (to) props.push(`to="${to}"`);
2515
+ return `::card${props.length ? `{${props.join(" ")}}` : ""}\n${serializeChildren(el)}\n::`;
2516
+ }
2517
+ case "cardGroup": return `::card-group\n${el.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeBlock(c)).join("\n\n")}\n::`;
2518
+ case "codeCollapse": return `::code-collapse\n${el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n")}\n::`;
2519
+ case "codeGroup": return `::code-group\n${el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n")}\n::`;
2520
+ case "codePreview": {
2521
+ const children = el.toArray().filter((c) => c instanceof Y.XmlElement);
2522
+ const nonCode = children.filter((c) => c.nodeName !== "codeBlock").map((c) => serializeBlock(c)).join("\n\n");
2523
+ const code = children.filter((c) => c.nodeName === "codeBlock").map((c) => serializeBlock(c)).join("\n\n");
2524
+ const parts = [nonCode];
2525
+ if (code) parts.push(`#code\n${code}`);
2526
+ return `::code-preview\n${parts.filter(Boolean).join("\n\n")}\n::`;
2527
+ }
2528
+ case "codeTree": return `::code-tree{files="${el.getAttribute("files") ?? "[]"}"}\n::`;
2529
+ case "accordion": return serializeSlottedContainer(el, "accordion", "accordionItem", "item");
2530
+ case "tabs": return serializeSlottedContainer(el, "tabs", "tabsItem", "tab");
2531
+ case "field": {
2532
+ const fieldName = el.getAttribute("name") ?? "";
2533
+ const fieldType = el.getAttribute("type") ?? "string";
2534
+ const required = el.getAttribute("required");
2535
+ const props = [`name="${fieldName}"`, `type="${fieldType}"`];
2536
+ if (required === true || required === "true") props.push("required=\"true\"");
2537
+ return `::field{${props.join(" ")}}\n${serializeChildren(el)}\n::`;
2538
+ }
2539
+ case "fieldGroup": return `::field-group\n${el.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeBlock(c)).join("\n\n")}\n::`;
2540
+ default: return serializeChildren(el);
2541
+ }
2542
+ }
2543
+ function serializeChildren(el) {
2544
+ const blocks = [];
2545
+ for (const child of el.toArray()) if (child instanceof Y.XmlElement) {
2546
+ const text = serializeBlock(child);
2547
+ if (text) blocks.push(text);
2548
+ } else if (child instanceof Y.XmlText) {
2549
+ const text = serializeDelta(child.toDelta());
2550
+ if (text) blocks.push(text);
2551
+ }
2552
+ return blocks.join("\n\n");
2553
+ }
2554
+ function serializeListItems(el, type, indent) {
2555
+ const lines = [];
2556
+ let counter = 1;
2557
+ for (const child of el.toArray()) {
2558
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== "listItem") continue;
2559
+ const prefix = type === "bullet" ? "- " : `${counter++}. `;
2560
+ const subParts = [];
2561
+ for (const sub of child.toArray()) {
2562
+ if (!(sub instanceof Y.XmlElement)) continue;
2563
+ if (sub.nodeName === "bulletList") subParts.push(serializeListItems(sub, "bullet", indent + " "));
2564
+ else if (sub.nodeName === "orderedList") subParts.push(serializeListItems(sub, "ordered", indent + " "));
2565
+ else subParts.push(serializeInline(sub));
2566
+ }
2567
+ if (subParts.length <= 1) lines.push(`${indent}${prefix}${subParts[0] ?? ""}`);
2568
+ else {
2569
+ lines.push(`${indent}${prefix}${subParts[0] ?? ""}`);
2570
+ for (let i = 1; i < subParts.length; i++) lines.push(subParts[i]);
2571
+ }
2572
+ }
2573
+ return lines.join("\n");
2574
+ }
2575
+ function serializeTaskList(el, indent) {
2576
+ const lines = [];
2577
+ for (const child of el.toArray()) {
2578
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== "taskItem") continue;
2579
+ const checked = child.getAttribute("checked");
2580
+ const marker = checked === true || checked === "true" ? "[x]" : "[ ]";
2581
+ let header = "";
2582
+ const nestedParts = [];
2583
+ for (const sub of child.toArray()) {
2584
+ if (!(sub instanceof Y.XmlElement)) continue;
2585
+ if (sub.nodeName === "paragraph" && header === "") header = serializeInline(sub);
2586
+ else if (sub.nodeName === "bulletList") nestedParts.push(serializeListItems(sub, "bullet", indent + " "));
2587
+ else if (sub.nodeName === "orderedList") nestedParts.push(serializeListItems(sub, "ordered", indent + " "));
2588
+ else if (sub.nodeName === "taskList") nestedParts.push(serializeTaskList(sub, indent + " "));
2589
+ else nestedParts.push(indent + " " + serializeBlock(sub, indent + " "));
2590
+ }
2591
+ lines.push(`${indent}- ${marker} ${header}`);
2592
+ for (const part of nestedParts) lines.push(part);
2593
+ }
2594
+ return lines.join("\n");
2595
+ }
2596
+ function getCodeBlockText(el) {
2597
+ for (const child of el.toArray()) if (child instanceof Y.XmlText) return child.toString();
2598
+ return "";
2599
+ }
2600
+ function serializeTable(el) {
2601
+ const rows = el.toArray().filter((c) => c instanceof Y.XmlElement);
2602
+ if (!rows.length) return "";
2603
+ const serializedRows = [];
2604
+ for (const row of rows) {
2605
+ const cells = row.toArray().filter((c) => c instanceof Y.XmlElement).map((cell) => {
2606
+ return cell.toArray().filter((c) => c instanceof Y.XmlElement).map((c) => serializeInline(c)).join(" ");
2607
+ });
2608
+ serializedRows.push(cells);
2609
+ }
2610
+ if (!serializedRows.length) return "";
2611
+ const colCount = Math.max(...serializedRows.map((r) => r.length));
2612
+ const headerRow = serializedRows[0];
2613
+ const separator = Array(colCount).fill("---");
2614
+ const dataRows = serializedRows.slice(1);
2615
+ const formatRow = (cells) => {
2616
+ return `| ${Array(colCount).fill("").map((_, i) => cells[i] ?? "").join(" | ")} |`;
2617
+ };
2618
+ return [
2619
+ formatRow(headerRow),
2620
+ formatRow(separator),
2621
+ ...dataRows.map(formatRow)
2622
+ ].join("\n");
2623
+ }
2624
+ function serializeSlottedContainer(el, containerName, childName, slotPrefix) {
2625
+ return `::${containerName}\n${el.toArray().filter((c) => c instanceof Y.XmlElement && c.nodeName === childName).map((item) => {
2626
+ const label = item.getAttribute("label") ?? "";
2627
+ const icon = item.getAttribute("icon") ?? "";
2628
+ const props = [];
2629
+ if (label) props.push(`label="${label}"`);
2630
+ if (icon) props.push(`icon="${icon}"`);
2631
+ const content = serializeChildren(item);
2632
+ return `#${slotPrefix}{${props.join(" ")}}\n${content}`;
2633
+ }).join("\n\n")}\n::`;
2634
+ }
2635
+ function generateFrontmatter(label, meta, type) {
2636
+ const lines = [];
2637
+ if (label !== void 0) lines.push(`title: "${escapeYaml(label)}"`);
2638
+ if (type && type !== "doc") lines.push(`type: ${type}`);
2639
+ if (!meta) return `---\n${lines.join("\n")}\n---`;
2640
+ if (meta.tags?.length) lines.push(`tags: [${meta.tags.join(", ")}]`);
2641
+ if (meta.color) lines.push(`color: ${yamlScalar(meta.color)}`);
2642
+ if (meta.icon) lines.push(`icon: ${yamlScalar(meta.icon)}`);
2643
+ if (meta.status) lines.push(`status: ${yamlScalar(meta.status)}`);
2644
+ if (meta.priority !== void 0 && meta.priority !== 0) lines.push(`priority: ${{
2645
+ 1: "low",
2646
+ 2: "medium",
2647
+ 3: "high",
2648
+ 4: "urgent"
2649
+ }[meta.priority] ?? meta.priority}`);
2650
+ if (meta.checked !== void 0) lines.push(`checked: ${meta.checked}`);
2651
+ if (meta.dateStart) lines.push(`dateStart: "${escapeYaml(meta.dateStart)}"`);
2652
+ if (meta.dateEnd) lines.push(`dateEnd: "${escapeYaml(meta.dateEnd)}"`);
2653
+ if (meta.subtitle) lines.push(`subtitle: "${escapeYaml(meta.subtitle)}"`);
2654
+ if (meta.url) lines.push(`url: ${meta.url}`);
2655
+ if (meta.rating !== void 0 && meta.rating !== 0) lines.push(`rating: ${meta.rating}`);
2656
+ return `---\n${lines.join("\n")}\n---`;
2657
+ }
2658
+ /**
2659
+ * Render a YAML scalar — bare when safe, double-quoted when the value
2660
+ * needs escaping. YAML treats `#`, `:`, leading whitespace, and a few
2661
+ * other characters as syntactically significant, so anything starting
2662
+ * with one of those gets quoted to stay round-trip safe.
2663
+ */
2664
+ function yamlScalar(s) {
2665
+ if (s === "") return "\"\"";
2666
+ if (/^[#&*!|>%@`]/.test(s)) return `"${escapeYaml(s)}"`;
2667
+ if (/[:"]/.test(s)) return `"${escapeYaml(s)}"`;
2668
+ if (/^\s|\s$/.test(s)) return `"${escapeYaml(s)}"`;
2669
+ return s;
2670
+ }
2671
+ function escapeYaml(s) {
2672
+ return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
2673
+ }
2674
+ function yjsToMarkdown(fragment, label, meta, type) {
2675
+ const { text: headerText, source: titleSource } = readDocumentHeader(fragment);
2676
+ const effectiveTitle = headerText || label;
2677
+ const docMeta = readDocumentMeta(fragment);
2678
+ const effectiveMeta = meta ?? docMeta.meta;
2679
+ const effectiveType = type ?? docMeta.type;
2680
+ const metaIsEmpty = isMetaEmpty(effectiveMeta);
2681
+ const typeIsDefault = !effectiveType || effectiveType === "doc";
2682
+ const bodyBlocks = collectBodyBlocks(fragment);
2683
+ let body;
2684
+ if (titleSource === "h1" && effectiveTitle) {
2685
+ const tail = serializeBlocksClean(bodyBlocks);
2686
+ body = tail === "" ? `# ${effectiveTitle}` : `# ${effectiveTitle}\n\n${tail}`;
2687
+ } else body = serializeBlocksClean(bodyBlocks);
2688
+ const wantFrontmatterTitle = titleSource === "frontmatter";
2689
+ if (!wantFrontmatterTitle && !(!metaIsEmpty || !typeIsDefault)) return body === "" ? "" : `${body}\n`;
2690
+ const frontmatter = generateFrontmatter(wantFrontmatterTitle ? effectiveTitle : void 0, effectiveMeta, effectiveType);
2691
+ if (body === "") return `${frontmatter}\n`;
2692
+ return `${frontmatter}\n\n${body}\n`;
2693
+ }
2694
+ function readDocumentMeta(fragment) {
2695
+ const meta = {};
2696
+ let type;
2697
+ for (const child of fragment.toArray()) {
2698
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== "documentMeta") continue;
2699
+ const attrs = child.getAttributes();
2700
+ for (const k of Object.keys(attrs)) {
2701
+ const v = attrs[k];
2702
+ if (v === void 0 || v === null) continue;
2703
+ if (k === "type" && typeof v === "string") {
2704
+ type = v;
2705
+ continue;
2706
+ }
2707
+ meta[k] = v;
2708
+ }
2709
+ break;
2710
+ }
2711
+ return {
2712
+ meta,
2713
+ type
2714
+ };
2715
+ }
2716
+ function readDocumentHeader(fragment) {
2717
+ for (const child of fragment.toArray()) {
2718
+ if (!(child instanceof Y.XmlElement) || child.nodeName !== "documentHeader") continue;
2719
+ const text = child.toArray().find((c) => c instanceof Y.XmlText);
2720
+ const src = child.getAttribute("titleSource");
2721
+ const source = src === "h1" || src === "frontmatter" ? src : void 0;
2722
+ return {
2723
+ text: text ? text.toString() : "",
2724
+ source
2725
+ };
2726
+ }
2727
+ return { text: "" };
2728
+ }
2729
+ function collectBodyBlocks(fragment) {
2730
+ const out = [];
2731
+ for (const child of fragment.toArray()) {
2732
+ if (!(child instanceof Y.XmlElement)) continue;
2733
+ if (child.nodeName === "documentHeader" || child.nodeName === "documentMeta") continue;
2734
+ out.push(child);
2735
+ }
2736
+ return out;
2737
+ }
2738
+ function serializeBlocksClean(blocks) {
2739
+ const parts = [];
2740
+ for (const block of blocks) {
2741
+ if (block.nodeName === "paragraph" && block.length === 0) {
2742
+ parts.push("");
2743
+ continue;
2744
+ }
2745
+ parts.push(serializeBlock(block));
2746
+ }
2747
+ while (parts.length && parts[parts.length - 1] === "") parts.pop();
2748
+ return parts.join("\n\n");
2749
+ }
2750
+ function isMetaEmpty(meta) {
2751
+ if (!meta) return true;
2752
+ for (const key of Object.keys(meta)) {
2753
+ const v = meta[key];
2754
+ if (v === void 0 || v === null) continue;
2755
+ if (typeof v === "string" && v === "") continue;
2756
+ if (Array.isArray(v) && v.length === 0) continue;
2757
+ return false;
2758
+ }
2759
+ return true;
2760
+ }
2761
+
2762
+ //#endregion
2763
+ //#region packages/convert/src/spec/nodes.ts
2764
+ const BOOL_FALSE_DEFAULT = {
2765
+ key: "",
2766
+ type: "boolean",
2767
+ default: false,
2768
+ optional: true
2769
+ };
2770
+ const bool = (key) => ({
2771
+ ...BOOL_FALSE_DEFAULT,
2772
+ key
2773
+ });
2774
+ const str = (key, def) => ({
2775
+ key,
2776
+ type: "string",
2777
+ default: def,
2778
+ optional: true
2779
+ });
2780
+ const num = (key) => ({
2781
+ key,
2782
+ type: "number",
2783
+ optional: true
2784
+ });
2785
+ const int = (key) => ({
2786
+ key,
2787
+ type: "integer",
2788
+ optional: true
2789
+ });
2790
+ const VANILLA_BLOCKS = [
2791
+ {
2792
+ name: "documentHeader",
2793
+ group: "block",
2794
+ wire: "special",
2795
+ doc: "Holds the title; hoisted to frontmatter on serialise."
2796
+ },
2797
+ {
2798
+ name: "documentMeta",
2799
+ group: "block",
2800
+ wire: "special",
2801
+ doc: "Holds page-level meta; serialised into frontmatter."
2802
+ },
2803
+ {
2804
+ name: "paragraph",
2805
+ group: "block",
2806
+ wire: "vanilla",
2807
+ contentBearing: true
2808
+ },
2809
+ {
2810
+ name: "heading",
2811
+ group: "block",
2812
+ wire: "vanilla",
2813
+ attrs: [int("level")],
2814
+ contentBearing: true
2815
+ },
2816
+ {
2817
+ name: "blockquote",
2818
+ group: "block",
2819
+ wire: "vanilla",
2820
+ contentBearing: true
2821
+ },
2822
+ {
2823
+ name: "codeBlock",
2824
+ group: "block",
2825
+ wire: "fence",
2826
+ attrs: [str("language", "")]
2827
+ },
2828
+ {
2829
+ name: "bulletList",
2830
+ group: "block",
2831
+ wire: "vanilla"
2832
+ },
2833
+ {
2834
+ name: "orderedList",
2835
+ group: "block",
2836
+ wire: "vanilla"
2837
+ },
2838
+ {
2839
+ name: "listItem",
2840
+ group: "block",
2841
+ wire: "vanilla",
2842
+ contentBearing: true
2843
+ },
2844
+ {
2845
+ name: "taskList",
2846
+ group: "block",
2847
+ wire: "vanilla"
2848
+ },
2849
+ {
2850
+ name: "taskItem",
2851
+ group: "block",
2852
+ wire: "vanilla",
2853
+ attrs: [bool("checked")],
2854
+ contentBearing: true
2855
+ },
2856
+ {
2857
+ name: "table",
2858
+ group: "block",
2859
+ wire: "vanilla"
2860
+ },
2861
+ {
2862
+ name: "tableRow",
2863
+ group: "block",
2864
+ wire: "vanilla"
2865
+ },
2866
+ {
2867
+ name: "tableHeader",
2868
+ group: "block",
2869
+ wire: "vanilla",
2870
+ contentBearing: true
2871
+ },
2872
+ {
2873
+ name: "tableCell",
2874
+ group: "block",
2875
+ wire: "vanilla",
2876
+ contentBearing: true
2877
+ },
2878
+ {
2879
+ name: "horizontalRule",
2880
+ group: "block",
2881
+ wire: "vanilla"
2882
+ },
2883
+ {
2884
+ name: "image",
2885
+ group: "block",
2886
+ wire: "special",
2887
+ attrs: [
2888
+ str("src"),
2889
+ str("alt", ""),
2890
+ int("width"),
2891
+ int("height")
2892
+ ]
2893
+ },
2894
+ {
2895
+ name: "hardBreak",
2896
+ group: "inline",
2897
+ wire: "vanilla"
2898
+ }
2899
+ ];
2900
+ const MDC_CONTAINERS = [
2901
+ {
2902
+ name: "callout",
2903
+ group: "block",
2904
+ wire: "mdc-container",
2905
+ attrs: [
2906
+ {
2907
+ key: "type",
2908
+ type: "string",
2909
+ default: "note",
2910
+ optional: true,
2911
+ values: [
2912
+ "note",
2913
+ "tip",
2914
+ "warning",
2915
+ "danger",
2916
+ "info",
2917
+ "caution",
2918
+ "alert",
2919
+ "success",
2920
+ "error"
2921
+ ]
2922
+ },
2923
+ str("title"),
2924
+ str("icon")
2925
+ ]
2926
+ },
2927
+ {
2928
+ name: "collapsible",
2929
+ group: "block",
2930
+ wire: "mdc-container",
2931
+ attrs: [str("label", "Details"), bool("open")]
2932
+ },
2933
+ {
2934
+ name: "accordion",
2935
+ group: "block",
2936
+ wire: "mdc-slotted",
2937
+ slotChild: "accordionItem"
2938
+ },
2939
+ {
2940
+ name: "accordionItem",
2941
+ group: "block",
2942
+ wire: "mdc-container",
2943
+ mdcTag: "accordion-item",
2944
+ attrs: [str("label", "Item"), str("icon")]
2945
+ },
2946
+ {
2947
+ name: "tabs",
2948
+ group: "block",
2949
+ wire: "mdc-slotted",
2950
+ slotChild: "tabsItem"
2951
+ },
2952
+ {
2953
+ name: "tabsItem",
2954
+ group: "block",
2955
+ wire: "mdc-container",
2956
+ mdcTag: "tabs-item",
2957
+ attrs: [str("label"), str("icon")]
2958
+ },
2959
+ {
2960
+ name: "steps",
2961
+ group: "block",
2962
+ wire: "mdc-container"
2963
+ },
2964
+ {
2965
+ name: "card",
2966
+ group: "block",
2967
+ wire: "mdc-container",
2968
+ attrs: [
2969
+ str("title"),
2970
+ str("icon"),
2971
+ str("to")
2972
+ ]
2973
+ },
2974
+ {
2975
+ name: "cardGroup",
2976
+ group: "block",
2977
+ wire: "mdc-slotted",
2978
+ mdcTag: "card-group",
2979
+ slotChild: "card"
2980
+ },
2981
+ {
2982
+ name: "field",
2983
+ group: "block",
2984
+ wire: "mdc-container",
2985
+ attrs: [
2986
+ str("name"),
2987
+ str("type", "string"),
2988
+ bool("required")
2989
+ ]
2990
+ },
2991
+ {
2992
+ name: "fieldGroup",
2993
+ group: "block",
2994
+ wire: "mdc-slotted",
2995
+ mdcTag: "field-group",
2996
+ slotChild: "field"
2997
+ },
2998
+ {
2999
+ name: "codeGroup",
3000
+ group: "block",
3001
+ wire: "mdc-slotted",
3002
+ mdcTag: "code-group",
3003
+ slotChild: "codeBlock"
3004
+ },
3005
+ {
3006
+ name: "codeCollapse",
3007
+ group: "block",
3008
+ wire: "mdc-container",
3009
+ mdcTag: "code-collapse"
3010
+ },
3011
+ {
3012
+ name: "codePreview",
3013
+ group: "block",
3014
+ wire: "mdc-container",
3015
+ mdcTag: "code-preview"
3016
+ },
3017
+ {
3018
+ name: "codeTree",
3019
+ group: "block",
3020
+ wire: "mdc-atom-block",
3021
+ mdcTag: "code-tree",
3022
+ attrs: [{
3023
+ key: "files",
3024
+ type: "json"
3025
+ }]
3026
+ },
3027
+ {
3028
+ name: "figure",
3029
+ group: "block",
3030
+ wire: "mdc-container",
3031
+ attrs: [
3032
+ str("src"),
3033
+ str("alt", ""),
3034
+ str("caption")
3035
+ ]
3036
+ },
3037
+ {
3038
+ name: "video",
3039
+ group: "block",
3040
+ wire: "mdc-atom-block",
3041
+ attrs: [
3042
+ str("src"),
3043
+ str("poster"),
3044
+ bool("autoplay"),
3045
+ bool("loop"),
3046
+ bool("controls")
3047
+ ]
3048
+ },
3049
+ {
3050
+ name: "embed",
3051
+ group: "block",
3052
+ wire: "mdc-atom-block",
3053
+ attrs: [str("src"), str("title")]
3054
+ },
3055
+ {
3056
+ name: "svgEmbed",
3057
+ group: "block",
3058
+ wire: "fence",
3059
+ attrs: [str("title")],
3060
+ mdcTag: "svg",
3061
+ doc: "Serialised as a ```svg fenced block; the SVG markup is the body."
3062
+ },
3063
+ {
3064
+ name: "divider",
3065
+ group: "block",
3066
+ wire: "mdc-atom-block",
3067
+ attrs: [str("label"), str("icon")]
3068
+ },
3069
+ {
3070
+ name: "quote",
3071
+ group: "block",
3072
+ wire: "mdc-container",
3073
+ attrs: [str("cite")]
3074
+ },
3075
+ {
3076
+ name: "progress",
3077
+ group: "block",
3078
+ wire: "mdc-atom-block",
3079
+ attrs: [
3080
+ num("value"),
3081
+ num("max"),
3082
+ str("label")
3083
+ ]
3084
+ },
3085
+ {
3086
+ name: "spoiler",
3087
+ group: "block",
3088
+ wire: "mdc-container",
3089
+ attrs: [str("label")]
3090
+ },
3091
+ {
3092
+ name: "colorSwatch",
3093
+ group: "block",
3094
+ wire: "mdc-atom-block",
3095
+ mdcTag: "color-swatch",
3096
+ attrs: [str("color"), str("label")]
3097
+ },
3098
+ {
3099
+ name: "stat",
3100
+ group: "block",
3101
+ wire: "mdc-container",
3102
+ attrs: [
3103
+ str("label"),
3104
+ str("value"),
3105
+ str("icon")
3106
+ ]
3107
+ },
3108
+ {
3109
+ name: "statGroup",
3110
+ group: "block",
3111
+ wire: "mdc-slotted",
3112
+ mdcTag: "stat-group",
3113
+ slotChild: "stat"
3114
+ },
3115
+ {
3116
+ name: "button",
3117
+ group: "block",
3118
+ wire: "mdc-atom-block",
3119
+ attrs: [
3120
+ str("label"),
3121
+ str("to"),
3122
+ str("icon"),
3123
+ str("variant")
3124
+ ]
3125
+ },
3126
+ {
3127
+ name: "buttonGroup",
3128
+ group: "block",
3129
+ wire: "mdc-slotted",
3130
+ mdcTag: "button-group",
3131
+ slotChild: "button"
3132
+ },
3133
+ {
3134
+ name: "timeline",
3135
+ group: "block",
3136
+ wire: "mdc-slotted",
3137
+ slotChild: "timelineItem"
3138
+ },
3139
+ {
3140
+ name: "timelineItem",
3141
+ group: "block",
3142
+ wire: "mdc-container",
3143
+ mdcTag: "timeline-item",
3144
+ attrs: [
3145
+ str("label"),
3146
+ str("icon"),
3147
+ str("date")
3148
+ ]
3149
+ },
3150
+ {
3151
+ name: "diff",
3152
+ group: "block",
3153
+ wire: "mdc-atom-block",
3154
+ attrs: [str("language", ""), {
3155
+ key: "value",
3156
+ type: "string"
3157
+ }]
3158
+ }
3159
+ ];
3160
+ const INLINE_AND_SPECIAL = [
3161
+ {
3162
+ name: "docLink",
3163
+ group: "inline",
3164
+ wire: "special",
3165
+ attrs: [str("docId")],
3166
+ doc: "Wire form `[[uuid|label]]`; label regenerated on export."
3167
+ },
3168
+ {
3169
+ name: "docEmbed",
3170
+ group: "block",
3171
+ wire: "special",
3172
+ attrs: [
3173
+ str("docId"),
3174
+ bool("collapsed"),
3175
+ bool("tall"),
3176
+ bool("seamless")
3177
+ ],
3178
+ doc: "Wire form `![[uuid|label]]{collapsed tall seamless}`."
3179
+ },
3180
+ {
3181
+ name: "mention",
3182
+ group: "inline",
3183
+ wire: "special",
3184
+ attrs: [str("userId")],
3185
+ doc: "Wire form `@[label](user:uuid)`; label regenerated on export."
3186
+ },
3187
+ {
3188
+ name: "mathInline",
3189
+ group: "inline",
3190
+ wire: "special",
3191
+ attrs: [{
3192
+ key: "expression",
3193
+ type: "string"
3194
+ }],
3195
+ doc: "Wire form `$expression$`."
3196
+ },
3197
+ {
3198
+ name: "mathBlock",
3199
+ group: "block",
3200
+ wire: "fence",
3201
+ attrs: [{
3202
+ key: "expression",
3203
+ type: "string"
3204
+ }],
3205
+ mdcTag: "math",
3206
+ doc: "Wire form ``` ```math\\nexpression\\n``` ```."
3207
+ },
3208
+ {
3209
+ name: "fileBlock",
3210
+ group: "block",
3211
+ wire: "mdc-atom-block",
3212
+ mdcTag: "file",
3213
+ attrs: [
3214
+ str("src"),
3215
+ str("mime"),
3216
+ str("uploadId"),
3217
+ str("filename")
3218
+ ],
3219
+ doc: "Wire form `:file{src=… mime=… upload-id=… filename=…}`; binary in sidecar."
3220
+ },
3221
+ {
3222
+ name: "badge",
3223
+ group: "inline",
3224
+ wire: "mdc-atom-inl",
3225
+ attrs: [
3226
+ str("label"),
3227
+ str("color"),
3228
+ str("variant", "subtle")
3229
+ ],
3230
+ doc: "Wire form `:badge[Label]{color=… variant=…}`."
3231
+ },
3232
+ {
3233
+ name: "proseIcon",
3234
+ group: "inline",
3235
+ wire: "mdc-atom-inl",
3236
+ mdcTag: "icon",
3237
+ attrs: [str("name")],
3238
+ doc: "Wire form `:icon{name=…}`."
3239
+ },
3240
+ {
3241
+ name: "kbd",
3242
+ group: "inline",
3243
+ wire: "mdc-atom-inl",
3244
+ attrs: [str("value")],
3245
+ doc: "Wire form `:kbd{value=…}`."
3246
+ }
3247
+ ];
3248
+ const NODE_SPECS = [
3249
+ ...VANILLA_BLOCKS,
3250
+ ...MDC_CONTAINERS,
3251
+ ...INLINE_AND_SPECIAL
3252
+ ];
3253
+ const NODE_SPEC_BY_NAME = new Map(NODE_SPECS.map((spec) => [spec.name, spec]));
3254
+
3255
+ //#endregion
3256
+ //#region packages/convert/src/spec/marks.ts
3257
+ const MARK_SPECS = [
3258
+ {
3259
+ name: "bold",
3260
+ wire: "delimited",
3261
+ delim: "**"
3262
+ },
3263
+ {
3264
+ name: "italic",
3265
+ wire: "delimited",
3266
+ delim: "*"
3267
+ },
3268
+ {
3269
+ name: "strike",
3270
+ wire: "delimited",
3271
+ delim: "~~"
3272
+ },
3273
+ {
3274
+ name: "code",
3275
+ wire: "delimited",
3276
+ delim: "`"
3277
+ },
3278
+ {
3279
+ name: "link",
3280
+ wire: "link",
3281
+ attrs: [{
3282
+ key: "href",
3283
+ type: "string"
3284
+ }, {
3285
+ key: "title",
3286
+ type: "string",
3287
+ optional: true
3288
+ }]
3289
+ },
3290
+ {
3291
+ name: "underline",
3292
+ wire: "delimited",
3293
+ delim: "__",
3294
+ doc: "Disambiguated from bold by delimiter character. Two underscores = underline; two asterisks = bold."
3295
+ },
3296
+ {
3297
+ name: "highlight",
3298
+ wire: "delimited",
3299
+ delim: "==",
3300
+ doc: "Pandoc-style."
3301
+ },
3302
+ {
3303
+ name: "subscript",
3304
+ wire: "delimited",
3305
+ delim: "~",
3306
+ doc: "Single tilde; double tilde is strike."
3307
+ },
3308
+ {
3309
+ name: "superscript",
3310
+ wire: "delimited",
3311
+ delim: "^"
3312
+ },
3313
+ {
3314
+ name: "textStyle",
3315
+ wire: "mdc-span",
3316
+ attrs: [
3317
+ {
3318
+ key: "color",
3319
+ type: "string",
3320
+ optional: true
3321
+ },
3322
+ {
3323
+ key: "backgroundColor",
3324
+ type: "string",
3325
+ optional: true
3326
+ },
3327
+ {
3328
+ key: "fontSize",
3329
+ type: "string",
3330
+ optional: true
3331
+ },
3332
+ {
3333
+ key: "fontFamily",
3334
+ type: "string",
3335
+ optional: true
3336
+ }
3337
+ ],
3338
+ doc: "Wire form `:span[text]{color=\"…\" font-size=\"…\"}`. Any of the attrs may be set."
3339
+ }
3340
+ ];
3341
+ const MARK_SPEC_BY_NAME = new Map(MARK_SPECS.map((spec) => [spec.name, spec]));
3342
+ const MARK_SPEC_BY_DELIM = new Map(MARK_SPECS.filter((spec) => spec.wire === "delimited" && !!spec.delim).map((spec) => [spec.delim, spec]));
3343
+
3344
+ //#endregion
3345
+ //#region packages/convert/src/spec/universal-meta.ts
3346
+ const UNIVERSAL_META_KEYS = [
3347
+ {
3348
+ key: "title",
3349
+ type: "string",
3350
+ doc: "Display title; the first H1 is hoisted into this field on import."
3351
+ },
3352
+ {
3353
+ key: "type",
3354
+ type: "string",
3355
+ doc: "Page type (doc, kanban, table, …). Omitted on serialise when \"doc\"."
3356
+ },
3357
+ {
3358
+ key: "color",
3359
+ type: "string",
3360
+ doc: "Hex or CSS color name."
3361
+ },
3362
+ {
3363
+ key: "icon",
3364
+ type: "string",
3365
+ doc: "Lucide icon name in kebab-case."
3366
+ },
3367
+ {
3368
+ key: "datetimeStart",
3369
+ type: "iso-datetime"
3370
+ },
3371
+ {
3372
+ key: "datetimeEnd",
3373
+ type: "iso-datetime"
3374
+ },
3375
+ {
3376
+ key: "allDay",
3377
+ type: "boolean"
3378
+ },
3379
+ {
3380
+ key: "dateTaken",
3381
+ type: "iso-datetime"
3382
+ },
3383
+ {
3384
+ key: "dateStart",
3385
+ type: "iso-date",
3386
+ parseAliases: ["date", "created"]
3387
+ },
3388
+ {
3389
+ key: "dateEnd",
3390
+ type: "iso-date",
3391
+ parseAliases: ["due"]
3392
+ },
3393
+ {
3394
+ key: "timeStart",
3395
+ type: "hh-mm"
3396
+ },
3397
+ {
3398
+ key: "timeEnd",
3399
+ type: "hh-mm"
3400
+ },
3401
+ {
3402
+ key: "tags",
3403
+ type: "string[]"
3404
+ },
3405
+ {
3406
+ key: "checked",
3407
+ type: "boolean",
3408
+ parseAliases: ["done"]
3409
+ },
3410
+ {
3411
+ key: "priority",
3412
+ type: "integer",
3413
+ min: 0,
3414
+ max: 4,
3415
+ doc: "Numeric or named (low/medium/high/urgent → 1/2/3/4)."
3416
+ },
3417
+ {
3418
+ key: "status",
3419
+ type: "string"
3420
+ },
3421
+ {
3422
+ key: "rating",
3423
+ type: "number",
3424
+ min: 0,
3425
+ max: 5
3426
+ },
3427
+ {
3428
+ key: "url",
3429
+ type: "string"
3430
+ },
3431
+ {
3432
+ key: "email",
3433
+ type: "string"
3434
+ },
3435
+ {
3436
+ key: "phone",
3437
+ type: "string"
3438
+ },
3439
+ {
3440
+ key: "number",
3441
+ type: "number"
3442
+ },
3443
+ {
3444
+ key: "unit",
3445
+ type: "string"
3446
+ },
3447
+ {
3448
+ key: "subtitle",
3449
+ type: "string",
3450
+ parseAliases: ["description"]
3451
+ },
3452
+ {
3453
+ key: "note",
3454
+ type: "string"
3455
+ },
3456
+ {
3457
+ key: "taskProgress",
3458
+ type: "integer",
3459
+ min: 0,
3460
+ max: 100
3461
+ },
3462
+ {
3463
+ key: "members",
3464
+ type: "members"
3465
+ },
3466
+ {
3467
+ key: "coverUploadId",
3468
+ type: "string"
3469
+ },
3470
+ {
3471
+ key: "coverDocId",
3472
+ type: "string"
3473
+ },
3474
+ {
3475
+ key: "coverMimeType",
3476
+ type: "string"
3477
+ },
3478
+ {
3479
+ key: "geoType",
3480
+ type: "string-enum",
3481
+ values: [
3482
+ "marker",
3483
+ "line",
3484
+ "measure"
3485
+ ]
3486
+ },
3487
+ {
3488
+ key: "geoLat",
3489
+ type: "number"
3490
+ },
3491
+ {
3492
+ key: "geoLng",
3493
+ type: "number"
3494
+ },
3495
+ {
3496
+ key: "geoDescription",
3497
+ type: "string"
3498
+ },
3499
+ {
3500
+ key: "deskX",
3501
+ type: "number"
3502
+ },
3503
+ {
3504
+ key: "deskY",
3505
+ type: "number"
3506
+ },
3507
+ {
3508
+ key: "deskZ",
3509
+ type: "number"
3510
+ },
3511
+ {
3512
+ key: "deskMode",
3513
+ type: "string-enum",
3514
+ values: [
3515
+ "icon",
3516
+ "widget-sm",
3517
+ "widget-lg"
3518
+ ]
3519
+ },
3520
+ {
3521
+ key: "mmX",
3522
+ type: "number"
3523
+ },
3524
+ {
3525
+ key: "mmY",
3526
+ type: "number"
3527
+ },
3528
+ {
3529
+ key: "graphX",
3530
+ type: "number"
3531
+ },
3532
+ {
3533
+ key: "graphY",
3534
+ type: "number"
3535
+ },
3536
+ {
3537
+ key: "graphPinned",
3538
+ type: "boolean"
3539
+ },
3540
+ {
3541
+ key: "spX",
3542
+ type: "number"
3543
+ },
3544
+ {
3545
+ key: "spY",
3546
+ type: "number"
3547
+ },
3548
+ {
3549
+ key: "spZ",
3550
+ type: "number"
3551
+ },
3552
+ {
3553
+ key: "spRX",
3554
+ type: "number"
3555
+ },
3556
+ {
3557
+ key: "spRY",
3558
+ type: "number"
3559
+ },
3560
+ {
3561
+ key: "spRZ",
3562
+ type: "number"
3563
+ },
3564
+ {
3565
+ key: "spSX",
3566
+ type: "number"
3567
+ },
3568
+ {
3569
+ key: "spSY",
3570
+ type: "number"
3571
+ },
3572
+ {
3573
+ key: "spSZ",
3574
+ type: "number"
3575
+ },
3576
+ {
3577
+ key: "spShape",
3578
+ type: "string-enum",
3579
+ values: [
3580
+ "box",
3581
+ "sphere",
3582
+ "cylinder",
3583
+ "cone",
3584
+ "plane",
3585
+ "torus",
3586
+ "glb"
3587
+ ]
3588
+ },
3589
+ {
3590
+ key: "spOpacity",
3591
+ type: "integer",
3592
+ min: 0,
3593
+ max: 100
3594
+ },
3595
+ {
3596
+ key: "spModelUploadId",
3597
+ type: "string"
3598
+ },
3599
+ {
3600
+ key: "spModelDocId",
3601
+ type: "string"
3602
+ },
3603
+ {
3604
+ key: "slidesTransition",
3605
+ type: "string-enum",
3606
+ values: [
3607
+ "none",
3608
+ "fade",
3609
+ "slide"
3610
+ ]
3611
+ },
3612
+ {
3613
+ key: "slidesTheme",
3614
+ type: "string-enum",
3615
+ values: ["dark", "light"]
3616
+ },
3617
+ {
3618
+ key: "__schemaVersion",
3619
+ type: "integer",
3620
+ min: 0
3621
+ }
3622
+ ];
3623
+ const UNIVERSAL_META_KEY_NAMES = new Set(UNIVERSAL_META_KEYS.map((k) => k.key));
3624
+
3625
+ //#endregion
3626
+ //#region packages/cli/src/commands/documents.ts
3627
+ /**
3628
+ * Document CRUD commands: read, create, rename, move, delete, type, doc.
3629
+ */
3630
+ /** Safely read a tree map value, converting Y.Map to plain object if needed. */
3631
+ function toPlain$1(val) {
3632
+ return val instanceof Y.Map ? val.toJSON() : val;
3633
+ }
3634
+ registerCommand({
3635
+ name: "doc",
3636
+ aliases: ["info:doc"],
3637
+ description: "Show document metadata (label, type, meta, dates).",
3638
+ usage: "doc id=<docId> | name=<label> | path=<a/b/c>",
3639
+ async run(conn, args) {
3640
+ if (!conn) return "Not connected";
3641
+ const docId = resolveDocument(conn, args.params, args.positional);
3642
+ if (!docId) return "Document not found. Specify id=, name=, or path= to identify the document.";
3643
+ const treeMap = conn.getTreeMap();
3644
+ if (!treeMap) return "Not connected";
3645
+ const raw = treeMap.get(docId);
3646
+ if (!raw) return `Document ${docId} not found in tree.`;
3647
+ const entry = toPlain$1(raw);
3648
+ const lines = [
3649
+ `id: ${docId}`,
3650
+ `label: ${entry.label || "Untitled"}`,
3651
+ `type: ${entry.type ?? "—"}`,
3652
+ `parent: ${entry.parentId ?? "(root)"}`,
3653
+ `order: ${entry.order ?? 0}`,
3654
+ `created: ${entry.createdAt ? new Date(entry.createdAt).toISOString() : "—"}`,
3655
+ `updated: ${entry.updatedAt ? new Date(entry.updatedAt).toISOString() : "—"} (${relativeTime(entry.updatedAt)})`
3656
+ ];
3657
+ if (entry.meta && Object.keys(entry.meta).length > 0) {
3658
+ lines.push(`meta:`);
3659
+ for (const [k, v] of Object.entries(entry.meta)) lines.push(` ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
3660
+ }
3661
+ const children = childrenOf(readEntries(treeMap), docId);
3662
+ lines.push(`children: ${children.length}`);
3663
+ return lines.join("\n");
3664
+ }
3665
+ });
3666
+ registerCommand({
3667
+ name: "read",
3668
+ aliases: ["cat", "r"],
3669
+ description: "Read document content as markdown.",
3670
+ usage: "read id=<docId> | name=<label> | path=<a/b/c> [--format=json|md]",
3671
+ async run(conn, args) {
3672
+ if (!conn) return "Not connected";
2416
3673
  const docId = resolveDocument(conn, args.params, args.positional);
2417
3674
  if (!docId) return "Document not found. Specify id=, name=, or path= to identify the document.";
2418
3675
  try {
@@ -3094,23 +4351,24 @@ registerCommand({
3094
4351
  registerCommand({
3095
4352
  name: "chat",
3096
4353
  aliases: ["send"],
3097
- description: "Send a chat message to a channel.",
3098
- usage: "chat channel=<group:docId> text=<message>",
4354
+ description: "Send a chat message to a channel doc.",
4355
+ usage: "chat channel_doc_id=<docId> text=<message>",
3099
4356
  async run(conn, args) {
3100
4357
  if (!conn) return "Not connected";
3101
4358
  const rootProvider = conn.rootProvider;
3102
4359
  if (!rootProvider) return "Not connected";
3103
- const channel = args.params["channel"];
3104
- if (!channel) return "Missing required parameter: channel=<group:docId>";
4360
+ const raw = args.params["channel_doc_id"] || args.params["channel"];
4361
+ if (!raw) return "Missing required parameter: channel_doc_id=<docId>";
4362
+ const channel_doc_id = raw.startsWith("group:") ? raw.slice(6) : raw;
3105
4363
  const text = args.params["text"] || args.params["content"] || args.positional[0];
3106
4364
  if (!text) return "Missing required parameter: text=<message>";
3107
4365
  rootProvider.sendStateless(JSON.stringify({
3108
- type: "chat:send",
3109
- channel,
4366
+ type: "messages:send",
4367
+ channel_doc_id,
3110
4368
  content: text,
3111
- sender_name: conn.displayName
4369
+ mentions: []
3112
4370
  }));
3113
- return `Sent to ${channel}`;
4371
+ return `Sent to ${channel_doc_id}`;
3114
4372
  }
3115
4373
  });
3116
4374
 
@@ -3280,6 +4538,16 @@ const PAGE_TYPES = {
3280
4538
  core: true,
3281
4539
  supportsChildren: true
3282
4540
  },
4541
+ prose: {
4542
+ key: "prose",
4543
+ label: "Prose",
4544
+ icon: "pen-tool",
4545
+ description: "Long-form prose with serif typography and a narrow readable measure",
4546
+ core: true,
4547
+ supportsChildren: true,
4548
+ childLabel: "Item",
4549
+ defaultDepth: -1
4550
+ },
3283
4551
  kanban: {
3284
4552
  key: "kanban",
3285
4553
  label: "Kanban",
@@ -4064,6 +5332,695 @@ registerCommand({
4064
5332
  }
4065
5333
  });
4066
5334
 
5335
+ //#endregion
5336
+ //#region packages/cli/src/commands/wiki/wikipedia.ts
5337
+ /**
5338
+ * Rate-limited wrapper around wtf_wikipedia + wtf-plugin-api.
5339
+ *
5340
+ * Responsibilities:
5341
+ * - Throttle requests to respect Wikimedia API etiquette
5342
+ * - Cache parsed Documents by canonical title
5343
+ * - Resolve redirects so callers always see the redirect target
5344
+ * - Expose getCategoryPages via wtf-plugin-api
5345
+ */
5346
+ let pluginExtended = false;
5347
+ function ensurePlugin() {
5348
+ if (pluginExtended) return;
5349
+ wtf.extend(wtfApiPlugin);
5350
+ pluginExtended = true;
5351
+ }
5352
+ /** A token-bucket-ish throttle: at most `rate` calls per second, FIFO. */
5353
+ var RateLimiter = class {
5354
+ lastTickMs = 0;
5355
+ constructor(intervalMs) {
5356
+ this.intervalMs = intervalMs;
5357
+ }
5358
+ async wait() {
5359
+ const now = Date.now();
5360
+ const earliest = this.lastTickMs + this.intervalMs;
5361
+ if (now < earliest) await new Promise((r) => setTimeout(r, earliest - now));
5362
+ this.lastTickMs = Math.max(now, earliest);
5363
+ }
5364
+ };
5365
+ var WikipediaClient = class {
5366
+ cache = /* @__PURE__ */ new Map();
5367
+ redirects = /* @__PURE__ */ new Map();
5368
+ limiter;
5369
+ fetchOpts;
5370
+ constructor(config) {
5371
+ this.config = config;
5372
+ ensurePlugin();
5373
+ this.limiter = new RateLimiter(Math.max(50, Math.floor(1e3 / Math.max(.1, config.rate))));
5374
+ this.fetchOpts = {
5375
+ lang: config.lang,
5376
+ "Api-User-Agent": config.userAgent,
5377
+ follow_redirects: true
5378
+ };
5379
+ if (config.domain) this.fetchOpts.domain = config.domain;
5380
+ }
5381
+ /**
5382
+ * Fetch and parse a Wikipedia article.
5383
+ * - Returns the cached Document if we've seen this title before.
5384
+ * - Follows redirects and caches under both source and target titles.
5385
+ * - Returns null when the page does not exist.
5386
+ */
5387
+ async fetchArticle(rawTitle) {
5388
+ const title = canonicalTitle(rawTitle);
5389
+ if (this.cache.has(title)) return this.cache.get(title);
5390
+ if (this.redirects.has(title)) {
5391
+ const target = this.redirects.get(title);
5392
+ return this.cache.get(target) ?? null;
5393
+ }
5394
+ await this.limiter.wait();
5395
+ let doc;
5396
+ try {
5397
+ doc = await wtf.fetch(title, this.fetchOpts);
5398
+ } catch (err) {
5399
+ throw new Error(`Wikipedia fetch failed for "${title}": ${err?.message ?? err}`);
5400
+ }
5401
+ if (!doc) return null;
5402
+ if (typeof doc.isRedirect === "function" && doc.isRedirect()) {
5403
+ const target = doc.redirectTo?.()?.page;
5404
+ if (typeof target === "string") {
5405
+ this.redirects.set(title, canonicalTitle(target));
5406
+ return await this.fetchArticle(target);
5407
+ }
5408
+ }
5409
+ const resolvedTitle = canonicalTitle(doc.title?.() ?? title);
5410
+ this.cache.set(resolvedTitle, doc);
5411
+ if (resolvedTitle !== title) this.redirects.set(title, resolvedTitle);
5412
+ return doc;
5413
+ }
5414
+ /**
5415
+ * Fetch the member pages of a category (and optionally sub-categories).
5416
+ * @param category Category title (with or without "Category:" prefix).
5417
+ * @param recursive Whether to traverse sub-categories.
5418
+ * @param maxDepth Recursion depth when recursive=true.
5419
+ */
5420
+ async fetchCategoryPages(category, recursive, maxDepth) {
5421
+ await this.limiter.wait();
5422
+ const opts = {
5423
+ lang: this.config.lang,
5424
+ "Api-User-Agent": this.config.userAgent,
5425
+ recursive,
5426
+ maxDepth
5427
+ };
5428
+ if (this.config.domain) opts.domain = this.config.domain;
5429
+ return (await wtf.getCategoryPages(category, opts) ?? []).map((m) => ({
5430
+ title: canonicalTitle(m.title),
5431
+ type: m.type === "subcat" ? "subcat" : "page"
5432
+ }));
5433
+ }
5434
+ };
5435
+ /** Normalize a Wikipedia title — trim, collapse spaces, strip leading/trailing colons. */
5436
+ function canonicalTitle(s) {
5437
+ return (s ?? "").toString().replace(/_/g, " ").replace(/\s+/g, " ").trim();
5438
+ }
5439
+ /** Detect a category-namespaced title. */
5440
+ const CATEGORY_PREFIX = /^(Category|Catégorie|Kategorie|Categoría|Categoria|Categorie|Kategoria):/i;
5441
+ function isCategoryTitle(title) {
5442
+ return CATEGORY_PREFIX.test(title);
5443
+ }
5444
+ /** Strip the "Category:" prefix for display. */
5445
+ function stripCategoryPrefix(title) {
5446
+ return title.replace(CATEGORY_PREFIX, "").trim();
5447
+ }
5448
+
5449
+ //#endregion
5450
+ //#region packages/cli/src/commands/wiki/snapshot.ts
5451
+ function snapshotArticle(doc, title) {
5452
+ return {
5453
+ title,
5454
+ linkTitles: collectLinkTitles(doc),
5455
+ categories: collectCategories(doc),
5456
+ sections: snapshotSections(doc.sections?.() ?? []),
5457
+ infobox: snapshotInfobox(doc.infobox?.()),
5458
+ lead: leadParagraph(doc),
5459
+ url: typeof doc.url === "function" ? doc.url() : null
5460
+ };
5461
+ }
5462
+ function prettyCategoryLabel(catTitle) {
5463
+ return stripCategoryPrefix(catTitle);
5464
+ }
5465
+ function collectLinkTitles(doc) {
5466
+ const links = doc.links?.() ?? [];
5467
+ const out = /* @__PURE__ */ new Set();
5468
+ for (const l of links) {
5469
+ if (!l) continue;
5470
+ const page = typeof l.page === "function" ? l.page() : null;
5471
+ if (typeof page !== "string" || page.length === 0) continue;
5472
+ if (isCategoryTitle(page)) continue;
5473
+ out.add(canonicalTitle(page));
5474
+ }
5475
+ return [...out];
5476
+ }
5477
+ function collectCategories(doc) {
5478
+ const out = [];
5479
+ for (const c of doc.categories?.() ?? []) {
5480
+ const norm = canonicalTitle(c);
5481
+ if (norm) out.push(norm);
5482
+ }
5483
+ return out;
5484
+ }
5485
+ function snapshotSections(rawSections) {
5486
+ const all = rawSections.map((s) => ({
5487
+ raw: s,
5488
+ title: s.title?.() || "",
5489
+ parentRef: typeof s.parent === "function" ? s.parent() : null,
5490
+ children: []
5491
+ }));
5492
+ const byRaw = /* @__PURE__ */ new Map();
5493
+ for (const s of all) byRaw.set(s.raw, s);
5494
+ const roots = [];
5495
+ for (const s of all) if (s.parentRef && byRaw.has(s.parentRef)) byRaw.get(s.parentRef).children.push(materialize(s));
5496
+ else roots.push(s);
5497
+ return roots.map(materialize);
5498
+ }
5499
+ function materialize(node) {
5500
+ const lists = node.raw.lists?.() ?? [];
5501
+ const paragraphs = node.raw.paragraphs?.() ?? [];
5502
+ let listLength = 0;
5503
+ for (const l of lists) {
5504
+ const lines = l.lines?.() ?? [];
5505
+ listLength += lines.length;
5506
+ }
5507
+ const isList = lists.length > 0 && (paragraphs.length === 0 || listLength >= paragraphs.length * 2);
5508
+ const bodyParts = [];
5509
+ for (const p of paragraphs) {
5510
+ const md = paragraphMarkdown(p);
5511
+ if (md) bodyParts.push(md);
5512
+ }
5513
+ for (const l of lists) {
5514
+ const lines = l.lines?.() ?? [];
5515
+ for (const line of lines) {
5516
+ const text = lineText(line);
5517
+ if (text) bodyParts.push(`- ${text}`);
5518
+ }
5519
+ }
5520
+ return {
5521
+ title: node.title,
5522
+ body: bodyParts.join("\n\n"),
5523
+ isList,
5524
+ listLength,
5525
+ children: node.children
5526
+ };
5527
+ }
5528
+ function snapshotInfobox(box) {
5529
+ if (!box) return void 0;
5530
+ const data = typeof box.json === "function" ? box.json() : null;
5531
+ if (!data || typeof data !== "object") return void 0;
5532
+ const rows = [];
5533
+ for (const [key, val] of Object.entries(data)) {
5534
+ const value = stringifyInfoboxValue(val);
5535
+ if (!value) continue;
5536
+ rows.push({
5537
+ key: humanKey(key),
5538
+ value
5539
+ });
5540
+ }
5541
+ return rows.length > 0 ? rows : void 0;
5542
+ }
5543
+ function stringifyInfoboxValue(val) {
5544
+ if (val == null) return "";
5545
+ if (typeof val === "string") return val;
5546
+ if (typeof val === "number" || typeof val === "boolean") return String(val);
5547
+ if (Array.isArray(val)) return val.map(stringifyInfoboxValue).filter(Boolean).join(", ");
5548
+ if (typeof val === "object") {
5549
+ const o = val;
5550
+ if (typeof o.text === "string") return o.text;
5551
+ if (typeof o.number === "number") return String(o.number);
5552
+ }
5553
+ return "";
5554
+ }
5555
+ function humanKey(k) {
5556
+ return k.replace(/_/g, " ").replace(/^./, (m) => m.toUpperCase());
5557
+ }
5558
+ function leadParagraph(doc) {
5559
+ const first = (doc.paragraphs?.() ?? [])[0];
5560
+ if (!first) return "";
5561
+ return paragraphMarkdown(first);
5562
+ }
5563
+ /**
5564
+ * Render a paragraph as markdown, replacing internal links with `[[Title]]`.
5565
+ * The streaming orchestrator's link rewriter later swaps `[[Title]]` →
5566
+ * `[[docId|label]]` once IDs are known.
5567
+ */
5568
+ function paragraphMarkdown(paragraph) {
5569
+ const sentences = paragraph.sentences?.() ?? [];
5570
+ const out = [];
5571
+ for (const s of sentences) out.push(sentenceWithWikilinks(s));
5572
+ return out.join(" ").trim();
5573
+ }
5574
+ function sentenceWithWikilinks(sentence) {
5575
+ const text = (sentence.text?.() ?? "").toString();
5576
+ const links = sentence.links?.() ?? [];
5577
+ if (links.length === 0) return text;
5578
+ let result = text;
5579
+ const replacements = links.map((l) => {
5580
+ const page = typeof l.page === "function" ? l.page() : null;
5581
+ const display = typeof l.text === "function" ? l.text() : null;
5582
+ if (typeof page !== "string" || page.length === 0) return null;
5583
+ if (isCategoryTitle(page)) return null;
5584
+ const shown = display && display.length > 0 ? display : page;
5585
+ return {
5586
+ page: canonicalTitle(page),
5587
+ shown
5588
+ };
5589
+ }).filter((x) => x !== null).sort((a, b) => b.shown.length - a.shown.length);
5590
+ for (const { page, shown } of replacements) {
5591
+ if (!result.includes(shown)) continue;
5592
+ const replacement = shown === page ? `[[${page}]]` : `[[${page}|${shown}]]`;
5593
+ result = result.replace(shown, replacement);
5594
+ }
5595
+ return result;
5596
+ }
5597
+ function lineText(line) {
5598
+ if (!line) return "";
5599
+ if (typeof line === "string") return line;
5600
+ if (typeof line.text === "string") return line.text;
5601
+ if (typeof line.text === "function") return line.text();
5602
+ return "";
5603
+ }
5604
+
5605
+ //#endregion
5606
+ //#region packages/cli/src/commands/wiki/render.ts
5607
+ const ICONS = {
5608
+ graph: "git-fork",
5609
+ article: "book-open",
5610
+ category: "tag",
5611
+ infobox: "info",
5612
+ outline: "list",
5613
+ gallery: "images",
5614
+ section: "pilcrow",
5615
+ categories: "tags"
5616
+ };
5617
+ /** Decide a page type for a section based on its shape. */
5618
+ function pickSectionType(section) {
5619
+ if (section.children.length > 0) return {
5620
+ type: "outline",
5621
+ icon: ICONS.outline
5622
+ };
5623
+ if (section.isList && section.listLength >= 5) return {
5624
+ type: "outline",
5625
+ icon: ICONS.outline
5626
+ };
5627
+ return {
5628
+ type: "doc",
5629
+ icon: ICONS.section
5630
+ };
5631
+ }
5632
+ /** Render the lead paragraph as the article-doc body. */
5633
+ function renderArticleLead(article) {
5634
+ return article.lead ?? "";
5635
+ }
5636
+ /** Render the article as a single doc, sections + infobox inlined. */
5637
+ function renderArticleSingleDoc(article) {
5638
+ const parts = [];
5639
+ if (article.lead) parts.push(article.lead);
5640
+ if (article.infobox && article.infobox.length > 0) parts.push("## Infobox", renderInfoboxBody(article.infobox));
5641
+ for (const section of article.sections) parts.push(...renderSectionInline(section, 2));
5642
+ return parts.join("\n\n");
5643
+ }
5644
+ function renderSectionInline(section, level) {
5645
+ const out = [];
5646
+ const prefix = "#".repeat(Math.min(6, level));
5647
+ if (section.title) out.push(`${prefix} ${section.title}`);
5648
+ if (section.body.trim()) out.push(section.body);
5649
+ for (const child of section.children) out.push(...renderSectionInline(child, level + 1));
5650
+ return out;
5651
+ }
5652
+ function renderInfoboxBody(rows) {
5653
+ return rows.map((r) => `- **${r.key}:** ${r.value}`).join("\n");
5654
+ }
5655
+ function renderCategoryBody(members, subcategories) {
5656
+ const parts = [];
5657
+ if (members.length > 0) {
5658
+ parts.push("## Pages");
5659
+ parts.push(members.map((m) => `- [[${m}]]`).join("\n"));
5660
+ }
5661
+ if (subcategories.length > 0) {
5662
+ parts.push("## Sub-categories");
5663
+ parts.push(subcategories.map((s) => `- ${s}`).join("\n"));
5664
+ }
5665
+ return parts.join("\n\n");
5666
+ }
5667
+ /**
5668
+ * Replace `[[Title]]` / `[[Title|Alias]]` in markdown with
5669
+ * `[[docId|label]]` using the title→docId map. Unresolved titles fall
5670
+ * back to plain text (their alias or original title).
5671
+ */
5672
+ function rewriteLinks(markdown, titleToDocId) {
5673
+ return markdown.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (_match, target, alias) => {
5674
+ const title = target.trim();
5675
+ const docId = titleToDocId.get(title);
5676
+ const display = (alias && alias.trim().length > 0 ? alias : title).trim();
5677
+ if (!docId) return display;
5678
+ return `[[${docId}|${display}]]`;
5679
+ });
5680
+ }
5681
+
5682
+ //#endregion
5683
+ //#region packages/cli/src/commands/wiki/connect.ts
5684
+ /**
5685
+ * Open a DocumentManager session for the wiki command, mirroring the
5686
+ * auth/register flow that CLIConnection uses but using the modern public API.
5687
+ *
5688
+ * Reuses the CLI's Ed25519 keypair handling (loadOrCreateKeypair, signChallenge)
5689
+ * so the wiki command authenticates with the same identity as every other
5690
+ * subcommand.
5691
+ */
5692
+ async function openSession(config) {
5693
+ const keypair = await loadOrCreateKeypair(config.keyFile);
5694
+ const sign = (challenge) => Promise.resolve(signChallenge(challenge, keypair.privateKey));
5695
+ const dm = new DocumentManager({
5696
+ url: config.url,
5697
+ name: config.name ?? "Wiki Extractor",
5698
+ color: config.color,
5699
+ quiet: config.quiet
5700
+ });
5701
+ try {
5702
+ await dm.client.loginWithKey(keypair.publicKeyB64, sign);
5703
+ } catch (err) {
5704
+ const status = err?.status ?? err?.response?.status;
5705
+ if (status === 404 || status === 422) {
5706
+ if (!config.quiet) console.error("[abracadabra] Key not registered, creating new account...");
5707
+ await dm.client.registerWithKey({
5708
+ publicKey: keypair.publicKeyB64,
5709
+ username: (config.name ?? "wiki-extractor").replace(/\s+/g, "-").toLowerCase(),
5710
+ displayName: config.name ?? "Wiki Extractor",
5711
+ deviceName: "CLI Wiki",
5712
+ inviteCode: config.inviteCode
5713
+ });
5714
+ await dm.client.loginWithKey(keypair.publicKeyB64, sign);
5715
+ } else throw err;
5716
+ }
5717
+ await dm.connect();
5718
+ const rootDocId = dm.rootDocId;
5719
+ if (!rootDocId) throw new Error("Connected but no rootDocId — server has no spaces.");
5720
+ return {
5721
+ dm,
5722
+ rootDocId
5723
+ };
5724
+ }
5725
+
5726
+ //#endregion
5727
+ //#region packages/cli/src/commands/wiki/index.ts
5728
+ registerCommand({
5729
+ name: "wiki",
5730
+ aliases: ["wikipedia"],
5731
+ description: "Fetch Wikipedia articles into a graph of docs (streaming).",
5732
+ usage: [
5733
+ "wiki \"<Article Title>\"",
5734
+ " mode=single|split single doc per article OR split into sections+infobox [split]",
5735
+ " depth=<n> follow internal links to depth N [1]",
5736
+ " category-depth=<n> recurse into sub-categories [1]",
5737
+ " lang=<code> wiki language [en]",
5738
+ " domain=<host> 3rd-party MediaWiki host (overrides lang)",
5739
+ " parent=<docId> parent doc for the new graph [active space root]",
5740
+ " user-agent=<str> Api-User-Agent header (REQUIRED by Wikimedia etiquette)",
5741
+ " rate=<rps> max wikipedia requests per second [3]",
5742
+ " --include-categories expand each article's categories into nested graphs",
5743
+ " --dry-run fetch only the entry article, print outline, no writes"
5744
+ ].join("\n"),
5745
+ async run(_conn, args) {
5746
+ const opts = parseOptions(args);
5747
+ if (typeof opts === "string") return opts;
5748
+ const log = (msg) => {
5749
+ if (!args.flags.has("quiet") && !args.flags.has("q")) console.error(`[wiki] ${msg}`);
5750
+ };
5751
+ const wp = new WikipediaClient({
5752
+ lang: opts.lang,
5753
+ domain: opts.domain,
5754
+ userAgent: opts.userAgent,
5755
+ rate: opts.rate
5756
+ });
5757
+ if (opts.dryRun) {
5758
+ log(`fetch ${opts.title}`);
5759
+ const doc = await wp.fetchArticle(opts.title);
5760
+ if (!doc) return `Article not found: "${opts.title}"`;
5761
+ const snap = snapshotArticle(doc, canonicalTitle(doc.title?.() ?? opts.title));
5762
+ return [
5763
+ `Entry: ${snap.title}`,
5764
+ `URL: ${snap.url ?? "(none)"}`,
5765
+ `Internal links: ${snap.linkTitles.length}`,
5766
+ `Categories: ${snap.categories.length}`,
5767
+ `Sections: ${snap.sections.length}`,
5768
+ `Has infobox: ${snap.infobox && snap.infobox.length > 0 ? "yes" : "no"}`,
5769
+ "",
5770
+ "── Sections ──",
5771
+ printSections(snap.sections, "")
5772
+ ].join("\n");
5773
+ }
5774
+ const env = globalThis.process?.env ?? {};
5775
+ const url = env["ABRA_URL"];
5776
+ if (!url) return "ABRA_URL is required to write to the server. Set it or pass --dry-run.";
5777
+ const { dm } = await openSession({
5778
+ url,
5779
+ name: env["ABRA_NAME"],
5780
+ color: env["ABRA_COLOR"],
5781
+ inviteCode: env["ABRA_INVITE_CODE"],
5782
+ keyFile: env["ABRA_KEY_FILE"],
5783
+ quiet: args.flags.has("quiet") || args.flags.has("q")
5784
+ });
5785
+ try {
5786
+ const result = await runStreaming(dm, wp, opts, log);
5787
+ return [`Done. Created ${result.articleCount} articles${result.categoryCount > 0 ? ` + ${result.categoryCount} categories` : ""}.`, `Root: ${result.rootDocId}`].join("\n");
5788
+ } finally {
5789
+ await dm.destroy().catch(() => {});
5790
+ }
5791
+ }
5792
+ });
5793
+ async function runStreaming(dm, wp, opts, log) {
5794
+ const titleToDocId = /* @__PURE__ */ new Map();
5795
+ const fetched = /* @__PURE__ */ new Map();
5796
+ const childrenCreated = /* @__PURE__ */ new Set();
5797
+ const categoryToDocId = /* @__PURE__ */ new Map();
5798
+ let categoriesContainerId = null;
5799
+ log(`fetch ${opts.title}`);
5800
+ const entryDoc = await wp.fetchArticle(opts.title);
5801
+ if (!entryDoc) throw new Error(`Article not found: "${opts.title}"`);
5802
+ const entryTitle = canonicalTitle(entryDoc.title?.() ?? opts.title);
5803
+ const entrySnap = snapshotArticle(entryDoc, entryTitle);
5804
+ fetched.set(entryTitle, entrySnap);
5805
+ const rootEntry = dm.tree.create({
5806
+ parentId: opts.parentDocId ?? null,
5807
+ label: entryTitle,
5808
+ type: "graph",
5809
+ meta: { icon: ICONS.graph }
5810
+ });
5811
+ log(`+ ${rootEntry.id.slice(0, 8)}… ${entryTitle} (graph)`);
5812
+ const entryArticleId = createArticleShell(dm, entrySnap, rootEntry.id, log);
5813
+ titleToDocId.set(entryTitle, entryArticleId);
5814
+ const queue = [{
5815
+ title: entryTitle,
5816
+ depth: 0
5817
+ }];
5818
+ let articleCount = 0;
5819
+ while (queue.length > 0) {
5820
+ const { title, depth } = queue.shift();
5821
+ const articleDocId = titleToDocId.get(title);
5822
+ let snap = fetched.get(title);
5823
+ if (!snap) {
5824
+ log(`fetch [d${depth}] ${title}`);
5825
+ try {
5826
+ const doc = await wp.fetchArticle(title);
5827
+ if (!doc) {
5828
+ log(` not found — leaving stub`);
5829
+ continue;
5830
+ }
5831
+ snap = snapshotArticle(doc, canonicalTitle(doc.title?.() ?? title));
5832
+ fetched.set(title, snap);
5833
+ } catch (err) {
5834
+ log(`! fetch failed: ${err?.message ?? err}`);
5835
+ continue;
5836
+ }
5837
+ }
5838
+ if (opts.mode === "split" && !childrenCreated.has(title)) {
5839
+ createArticleChildren(dm, snap, articleDocId, log);
5840
+ childrenCreated.add(title);
5841
+ }
5842
+ if (depth < opts.depth) for (const linkTitle of snap.linkTitles) {
5843
+ if (titleToDocId.has(linkTitle)) continue;
5844
+ const shell = dm.tree.create({
5845
+ parentId: rootEntry.id,
5846
+ label: linkTitle,
5847
+ type: "doc",
5848
+ meta: { icon: ICONS.article }
5849
+ });
5850
+ titleToDocId.set(linkTitle, shell.id);
5851
+ queue.push({
5852
+ title: linkTitle,
5853
+ depth: depth + 1
5854
+ });
5855
+ log(`+ ${shell.id.slice(0, 8)}… ${linkTitle} (doc, shell)`);
5856
+ }
5857
+ if (opts.includeCategories && snap.categories.length > 0) {
5858
+ if (!categoriesContainerId) {
5859
+ const c = dm.tree.create({
5860
+ parentId: rootEntry.id,
5861
+ label: "Categories",
5862
+ type: "graph",
5863
+ meta: { icon: ICONS.categories }
5864
+ });
5865
+ categoriesContainerId = c.id;
5866
+ log(`+ ${c.id.slice(0, 8)}… Categories (graph)`);
5867
+ }
5868
+ for (const catTitle of snap.categories) {
5869
+ if (categoryToDocId.has(catTitle)) continue;
5870
+ const cat = dm.tree.create({
5871
+ parentId: categoriesContainerId,
5872
+ label: prettyCategoryLabel(catTitle),
5873
+ type: "graph",
5874
+ meta: { icon: ICONS.category }
5875
+ });
5876
+ categoryToDocId.set(catTitle, cat.id);
5877
+ log(`+ ${cat.id.slice(0, 8)}… ${prettyCategoryLabel(catTitle)} (graph, cat)`);
5878
+ }
5879
+ }
5880
+ const body = opts.mode === "split" ? renderArticleLead(snap) : renderArticleSingleDoc(snap);
5881
+ if (body.trim().length > 0) {
5882
+ const rewritten = rewriteLinks(body, titleToDocId);
5883
+ try {
5884
+ await dm.content.write(articleDocId, rewritten);
5885
+ log(`✓ body ${title}`);
5886
+ } catch (err) {
5887
+ log(`! body write failed for ${title}: ${err?.message ?? err}`);
5888
+ }
5889
+ }
5890
+ if (opts.mode === "split") await writeChildrenBodies(dm, snap, articleDocId, titleToDocId, log);
5891
+ articleCount++;
5892
+ }
5893
+ let categoryCount = 0;
5894
+ if (opts.includeCategories && categoryToDocId.size > 0) for (const [catTitle, catDocId] of categoryToDocId) {
5895
+ log(`category ${catTitle}`);
5896
+ try {
5897
+ const members = await wp.fetchCategoryPages(catTitle, opts.categoryDepth > 0, Math.max(0, opts.categoryDepth));
5898
+ const memberArticles = [];
5899
+ const subcats = [];
5900
+ for (const m of members) if (m.type === "subcat") subcats.push(prettyCategoryLabel(m.title));
5901
+ else memberArticles.push(m.title);
5902
+ const rewritten = rewriteLinks(renderCategoryBody(memberArticles, subcats), titleToDocId);
5903
+ if (rewritten.trim().length > 0) {
5904
+ await dm.content.write(catDocId, rewritten);
5905
+ log(`✓ body category ${catTitle}`);
5906
+ }
5907
+ categoryCount++;
5908
+ } catch (err) {
5909
+ log(`! category ${catTitle}: ${err?.message ?? err}`);
5910
+ }
5911
+ }
5912
+ return {
5913
+ rootDocId: rootEntry.id,
5914
+ articleCount,
5915
+ categoryCount
5916
+ };
5917
+ }
5918
+ function createArticleShell(dm, article, parentId, log) {
5919
+ const meta = { icon: ICONS.article };
5920
+ if (article.url) meta.url = article.url;
5921
+ const entry = dm.tree.create({
5922
+ parentId,
5923
+ label: article.title,
5924
+ type: "doc",
5925
+ meta
5926
+ });
5927
+ log(`+ ${entry.id.slice(0, 8)}… ${article.title} (doc)`);
5928
+ return entry.id;
5929
+ }
5930
+ /**
5931
+ * Create section + infobox child docs for a split-mode article. Returns nothing
5932
+ * — children get bodies written later in writeChildrenBodies.
5933
+ */
5934
+ function createArticleChildren(dm, article, articleDocId, log) {
5935
+ if (article.infobox && article.infobox.length > 0) {
5936
+ const ib = dm.tree.create({
5937
+ parentId: articleDocId,
5938
+ label: "Infobox",
5939
+ type: "outline",
5940
+ meta: { icon: ICONS.infobox }
5941
+ });
5942
+ log(` + ${ib.id.slice(0, 8)}… Infobox (outline)`);
5943
+ article._infoboxDocId = ib.id;
5944
+ }
5945
+ for (const section of article.sections) createSectionShell(dm, section, articleDocId, log);
5946
+ }
5947
+ function createSectionShell(dm, section, parentDocId, log) {
5948
+ const hasChildren = section.children.length > 0;
5949
+ if (!section.body.trim() && !hasChildren) return;
5950
+ const { type, icon } = pickSectionType(section);
5951
+ const entry = dm.tree.create({
5952
+ parentId: parentDocId,
5953
+ label: section.title || "Untitled section",
5954
+ type,
5955
+ meta: { icon }
5956
+ });
5957
+ log(` + ${entry.id.slice(0, 8)}… ${entry.label} (${type})`);
5958
+ section._docId = entry.id;
5959
+ for (const child of section.children) createSectionShell(dm, child, entry.id, log);
5960
+ }
5961
+ async function writeChildrenBodies(dm, article, _articleDocId, titleToDocId, log) {
5962
+ const infoboxDocId = article._infoboxDocId;
5963
+ if (infoboxDocId && article.infobox && article.infobox.length > 0) try {
5964
+ await dm.content.write(infoboxDocId, renderInfoboxBody(article.infobox));
5965
+ } catch (err) {
5966
+ log(`! infobox body write failed: ${err?.message ?? err}`);
5967
+ }
5968
+ for (const section of article.sections) await writeSectionBody(dm, section, titleToDocId, log);
5969
+ }
5970
+ async function writeSectionBody(dm, section, titleToDocId, log) {
5971
+ const docId = section._docId;
5972
+ if (docId && section.body.trim().length > 0) try {
5973
+ await dm.content.write(docId, rewriteLinks(section.body, titleToDocId));
5974
+ } catch (err) {
5975
+ log(`! section body write failed for ${section.title}: ${err?.message ?? err}`);
5976
+ }
5977
+ for (const child of section.children) await writeSectionBody(dm, child, titleToDocId, log);
5978
+ }
5979
+ function parseOptions(args) {
5980
+ const title = args.positional[0]?.trim() || args.params["title"];
5981
+ if (!title) return "Missing required positional argument: <title>. Example: abracadabra wiki \"Toronto Raptors\"";
5982
+ const env = globalThis.process?.env ?? {};
5983
+ const userAgent = args.params["user-agent"] || args.params["userAgent"] || env["ABRA_WIKI_USER_AGENT"];
5984
+ if (!userAgent) return ["Missing required parameter: user-agent=\"your-name (you@example.com)\"", "(Wikimedia etiquette requires an Api-User-Agent header. Pass user-agent=... or set ABRA_WIKI_USER_AGENT.)"].join("\n");
5985
+ const mode = args.params["mode"] ?? "split";
5986
+ if (mode !== "single" && mode !== "split") return `Invalid mode "${mode}". Use mode=single or mode=split.`;
5987
+ const depth = parseIntOr(args.params["depth"], 1);
5988
+ const categoryDepth = parseIntOr(args.params["category-depth"] ?? args.params["categoryDepth"], 1);
5989
+ const rate = parseFloatOr(args.params["rate"], 3);
5990
+ return {
5991
+ title,
5992
+ mode,
5993
+ depth,
5994
+ categoryDepth,
5995
+ includeCategories: args.flags.has("include-categories") || args.flags.has("includeCategories"),
5996
+ lang: args.params["lang"] ?? "en",
5997
+ domain: args.params["domain"],
5998
+ parentDocId: args.params["parent"],
5999
+ userAgent,
6000
+ rate,
6001
+ dryRun: args.flags.has("dry-run") || args.flags.has("dryRun")
6002
+ };
6003
+ }
6004
+ function parseIntOr(s, fallback) {
6005
+ if (!s) return fallback;
6006
+ const n = Number.parseInt(s, 10);
6007
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
6008
+ }
6009
+ function parseFloatOr(s, fallback) {
6010
+ if (!s) return fallback;
6011
+ const n = Number.parseFloat(s);
6012
+ return Number.isFinite(n) && n > 0 ? n : fallback;
6013
+ }
6014
+ function printSections(sections, indent) {
6015
+ const lines = [];
6016
+ for (const s of sections) {
6017
+ const hint = s.body ? ` (${s.body.length}b)` : "";
6018
+ lines.push(`${indent}- ${s.title}${hint}${s.children.length > 0 ? ` [${s.children.length} sub]` : ""}`);
6019
+ if (s.children.length > 0) lines.push(printSections(s.children, indent + " "));
6020
+ }
6021
+ return lines.join("\n");
6022
+ }
6023
+
4067
6024
  //#endregion
4068
6025
  //#region packages/cli/src/index.ts
4069
6026
  /**
@@ -4087,7 +6044,9 @@ const NO_CONNECT_COMMANDS = new Set([
4087
6044
  "v",
4088
6045
  "page-types",
4089
6046
  "types",
4090
- "doctypes"
6047
+ "doctypes",
6048
+ "wiki",
6049
+ "wikipedia"
4091
6050
  ]);
4092
6051
  async function main() {
4093
6052
  const args = parseArgs(process.argv);