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