docblocks-core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # docblocks-core
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - bf92634: The README's snapshot shape stands the actual JSON form: the document
8
+ fields (`id`, `title`, the optional `avatar`, `createdAt`) stand at the
9
+ top level of the snapshot next to the `blocks` map — the former `meta` /
10
+ `children` top-level description was wrong (the `children` order lives
11
+ inside the blocks, the `meta` object does not exist in the JSON).
12
+ - b66e45e: The cross-minor as-is stand reaches the KNOWN block types: an additive
13
+ top-level field (a future minor's data) on a known type no longer stands
14
+ dropped on `fromJSON` — the layout writer keeps it with the generic
15
+ storage semantics (the opaque writer's parity) and the snapshot reader
16
+ passes it through (sorted keys, the `Y.Text` field reads as runs), so a
17
+ load → save cycle never loses unknown data.
18
+
19
+ The mark value's as-is stand completes too: an unknown mark with a
20
+ non-boolean primitive value (string / number) is preserved on read —
21
+ the reader previously kept only the `true` and the object value shapes,
22
+ silently dropping the primitive ones on save.
23
+ - c5a38d2: The link href's gate mirrors the WHATWG URL parser's full input
24
+ normalization: ASCII tab / LF / CR (U+0009–U+000D) are removed from ANY
25
+ position (plus the edge C0 / space trim) before the scheme detection. A
26
+ mid-string control could not hide the scheme before — `java\tscript:…`
27
+ carried no scheme per the `^[a-z][a-z0-9+.-]*:` test, while the browser's
28
+ URL parser strips the control and resolves the `javascript:` scheme, so a
29
+ documented link executed as script. The gate is the single stand of the
30
+ validator's link check, the renderer's href gate, and the paste's
31
+ markdown link re-gate.
32
+ - 769c12a: The reactivity's handler isolation: a subscription handler's throw (the
33
+ handlers fire inside the Yjs transaction, the afterTransaction hook) no
34
+ longer rolls back the caller's committed batch (the `transact`'s rollback
35
+ guard would catch the listener's throw otherwise) and no longer aborts the
36
+ remaining handlers of the emission — the throw stands the logged error,
37
+ the emission continues, and the batch stays committed and undoable.
38
+
39
+ The export's stable key order: `toJSON` assembles the blocks in sorted id
40
+ order — the Y.Map's iteration order depends on the creation history, so
41
+ converged peers stood the same data in different key orders before. The
42
+ converged peers' exports are byte-identical now (the `JSON.stringify`'s
43
+ bytes stand the convergence's check, not just the deep equality); the JSON's
44
+ object order is semantically irrelevant — the children's arrays carry the
45
+ document order.
46
+ - 72fdfd3: The block and mark registry lookups stand the own-property stand over the
47
+ prototype-key values (`constructor`, `toString`, ...): the unknown block
48
+ type named by a prototype key keeps its data over the `fromJSON` /
49
+ `toJSON` round-trip (the opaque stand — the reader's lenient contract),
50
+ the strict commands refuse the unknown type and mark, and the export's
51
+ attr assembly writes own properties only (no prototype swap over a
52
+ hostile attr key).
53
+
54
+ The same stand over the remaining lookup points: the `changeBlockType`'s
55
+ attrs merge (the target spec's whitelist membership over the own
56
+ properties — the hostile key stands reset like the type-specific attrs,
57
+ the patch's hostile key stands the strict rejection, the own writes keep
58
+ the prototype's setters out), the strict mark-field validation (an
59
+ unknown field named like an `Object.prototype` member stands the
60
+ rejection — the strict posture rejects rather than preserves it), and the
61
+ export's
62
+ id-keyed assemblies (the own writes over the `blocks` / `rows`
63
+ dictionaries — the wire's defense in depth).
64
+
3
65
  ## 0.1.1
4
66
 
5
67
  ### Patch Changes
package/README.md CHANGED
@@ -31,7 +31,7 @@ const para = doc.insertBlock("paragraph");
31
31
  doc.insertText(para, 0, "hello world");
32
32
  doc.setMark(para, 0, 5, { type: "bold" });
33
33
 
34
- const json = doc.toJSON(); // { v, meta, blocks, children }
34
+ const json = doc.toJSON(); // { v, id, title, avatar?, createdAt, blocks }
35
35
  // …persist or send `json` …
36
36
  const restored = fromJSON(json); // a fresh Doc over the same data
37
37
  ```
@@ -63,7 +63,7 @@ editable paragraph). The other entry points:
63
63
  - `fromJSON(json)` — build a `Doc` from a JSON snapshot.
64
64
  - `loadDoc(bytes)` — build a `Doc` from an encoded full state (a base
65
65
  snapshot; apply diffs with `applyUpdate`).
66
- - `doc.toJSON()` — the JSON snapshot (`{ v, meta, blocks, children }`).
66
+ - `doc.toJSON()` — the JSON snapshot (`{ v, id, title, avatar?, createdAt, blocks }`).
67
67
  - `doc.encode()` / `doc.encodeUpdate(stateVector?)` — the encoded full
68
68
  state / the incremental update.
69
69
  - `doc.applyUpdate(bytes)` — apply an encoded update (the remote-apply path).
@@ -128,8 +128,9 @@ projects a selection into the presence payload shape.
128
128
 
129
129
  ## Data format
130
130
 
131
- The JSON format is `v: 1`: `meta` (the doc `id`, `title`, the optional
132
- `avatar`) plus a **flat block map** (`id → block`) with explicit order via
131
+ The JSON format is `v: 1`: the document fields (`id`, `title`, the
132
+ optional `avatar`, `createdAt`) at the top level, plus a **flat block map**
133
+ (`id → block`) with explicit order via
133
134
  `children: [blockId]`; nesting (lists, table cells) is `children`. The text
134
135
  of a text block is `content: [{ text, marks }]` runs. JSON ↔ Yjs is
135
136
  isomorphic — every JSON field has exactly one Yjs image.
package/dist/index.d.mts CHANGED
@@ -757,7 +757,7 @@ interface ValidationError {
757
757
  //#region src/schema/validate/utils/common.d.ts
758
758
  declare const isHexColor: (value: unknown) => boolean;
759
759
  /** The public link-href contract (the validator's and the consumer
760
- * renderer's gate): the trimmed form is an http/https URL or a
760
+ * renderer's gate): the normalized form is an http/https URL or a
761
761
  * scheme-less (relative) path; any other scheme is rejected. */
762
762
  declare const isSafeLinkHref: (value: string) => boolean;
763
763
  /**
@@ -1132,6 +1132,9 @@ declare class Doc {
1132
1132
  * propagates. A failed batch still emits a transient `onChange` emission
1133
1133
  * and a partial wire update before the rollback (CRDT property: the
1134
1134
  * rollback is best-effort on the wire; observable state converges).
1135
+ * A subscription handler's throw (the handlers fire inside the
1136
+ * transaction) is isolated — it stands the logged error and never rolls
1137
+ * back a committed batch.
1135
1138
  */
1136
1139
  transact(fn: () => void): void;
1137
1140
  /** Undo the last local edit; a safe no-op when there is nothing to undo. */
package/dist/index.mjs CHANGED
@@ -875,6 +875,20 @@ const MARK_REGISTRY = {
875
875
  attrOptional: true
876
876
  }
877
877
  };
878
+ /**
879
+ * The registry's membership checks: the `in` operator is prototype-aware,
880
+ * so an Object.prototype-reserved key (`constructor`, `toString`, …) would
881
+ * pass it as a registered entry (the inherited function, not a spec).
882
+ * The data-key stand is the own-property lookup: the reserved key stands
883
+ * unknown (the lenient reader's pass-through / the strict writer's
884
+ * rejection), never a spec.
885
+ */
886
+ function isRegisteredBlockType(type) {
887
+ return typeof type === "string" && Object.hasOwn(BLOCK_REGISTRY, type);
888
+ }
889
+ function isRegisteredMarkName(name) {
890
+ return typeof name === "string" && Object.hasOwn(MARK_REGISTRY, name);
891
+ }
878
892
  //#endregion
879
893
  //#region src/schema/utils.ts
880
894
  /** The schema's shared helpers: the plain-object check + the block's
@@ -899,7 +913,7 @@ function indentOf(block) {
899
913
  function legacyChildrenOf(block) {
900
914
  if (!Array.isArray(block.children)) return null;
901
915
  const type = block.type;
902
- if (typeof type !== "string" || !(type in BLOCK_REGISTRY)) return null;
916
+ if (!isRegisteredBlockType(type)) return null;
903
917
  return BLOCK_REGISTRY[type].legacyChildrenTypes !== void 0 ? block.children : null;
904
918
  }
905
919
  /**
@@ -956,7 +970,7 @@ function projectBlock(block, id, input, state) {
956
970
  const ordered = block.children;
957
971
  if (!Array.isArray(ordered)) return withIndent(block, id, state);
958
972
  const type = block.type;
959
- const spec = typeof type === "string" && type in BLOCK_REGISTRY ? BLOCK_REGISTRY[type] : void 0;
973
+ const spec = isRegisteredBlockType(type) ? BLOCK_REGISTRY[type] : void 0;
960
974
  if (spec === void 0) return withIndent(block, id, state);
961
975
  if (!spec.children) {
962
976
  const withoutChildren = { ...block };
@@ -1131,16 +1145,19 @@ const show = (value) => {
1131
1145
  const isIso8601 = (value) => typeof value === "string" && ISO_8601.test(value) && !Number.isNaN(Date.parse(value));
1132
1146
  const isHexColor = (value) => typeof value === "string" && HEX_COLOR.test(value);
1133
1147
  /** `http://` / `https://` URLs and relative paths; other schemes are
1134
- * rejected. The check runs over the trimmed form — the browser trims the
1135
- * leading / trailing C0 controls and spaces (U+0000–U+0020) before the
1136
- * scheme detection, so an untrimmed check stands the `"\tjavascript:…"`
1137
- * bypass (the raw string carries no scheme, the browser's does). */
1148
+ * rejected. The check runs over the browser's normalization of the input:
1149
+ * the WHATWG URL parser removes ASCII tab / LF / CR (U+0009–U+000D) from
1150
+ * ANY position and trims the leading / trailing C0 controls and spaces
1151
+ * (U+0000–U+0020) before the scheme detection — so an unnormalized check
1152
+ * stands the `"java\tscript:…"` bypass (the raw string carries no scheme
1153
+ * per the `^[a-z][a-z0-9+.-]*:` test, the browser's does). */
1138
1154
  const CONTROL_TRIM = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g;
1155
+ const MID_STRING_CONTROLS = /[\u0009\u000A\u000D]/g;
1139
1156
  /** The public link-href contract (the validator's and the consumer
1140
- * renderer's gate): the trimmed form is an http/https URL or a
1157
+ * renderer's gate): the normalized form is an http/https URL or a
1141
1158
  * scheme-less (relative) path; any other scheme is rejected. */
1142
1159
  const isSafeLinkHref = (value) => {
1143
- const trimmed = value.replace(CONTROL_TRIM, "");
1160
+ const trimmed = value.replace(MID_STRING_CONTROLS, "").replace(CONTROL_TRIM, "");
1144
1161
  return trimmed.length > 0 && (WEB_URL.test(trimmed) || !SCHEME_PREFIX.test(trimmed));
1145
1162
  };
1146
1163
  const isLinkHref = (value) => isSafeLinkHref(value);
@@ -1733,7 +1750,7 @@ function validateMarkFields(name, fields, value, markPath, errors, lenient = fal
1733
1750
  message: `Mark "${name}" field "${key}" must be a non-empty string.`
1734
1751
  });
1735
1752
  }
1736
- for (const key of Object.keys(value)) if (!(key in fields)) {
1753
+ for (const key of Object.keys(value)) if (!Object.hasOwn(fields, key)) {
1737
1754
  if (!lenient) errors.push({
1738
1755
  path: `${markPath}.${key}`,
1739
1756
  message: `Unknown attribute "${key}" for mark "${name}".`
@@ -1757,20 +1774,20 @@ function validateMarks(blockType, marks, path, errors, lenient = false) {
1757
1774
  }
1758
1775
  const names = Object.keys(marks);
1759
1776
  if (blockType === "code" && names.length > 0) {
1760
- if (!lenient || names.some((name) => name in MARK_REGISTRY)) errors.push({
1777
+ if (!lenient || names.some((name) => isRegisteredMarkName(name))) errors.push({
1761
1778
  path,
1762
1779
  message: "Runs in a code block must not carry marks."
1763
1780
  });
1764
1781
  }
1765
1782
  for (const name of names) {
1766
- const spec = MARK_REGISTRY[name];
1767
- if (!spec) {
1783
+ if (!isRegisteredMarkName(name)) {
1768
1784
  if (!lenient) errors.push({
1769
1785
  path: `${path}.${name}`,
1770
1786
  message: `Unknown mark "${name}".`
1771
1787
  });
1772
1788
  continue;
1773
1789
  }
1790
+ const spec = MARK_REGISTRY[name];
1774
1791
  const markPath = `${path}.${name}`;
1775
1792
  const value = marks[name];
1776
1793
  if (spec.kind === "toggle") {
@@ -1819,7 +1836,7 @@ function validateMarks(blockType, marks, path, errors, lenient = false) {
1819
1836
  });
1820
1837
  }
1821
1838
  }
1822
- const exclusive = names.find((name) => MARK_REGISTRY[name]?.exclusive);
1839
+ const exclusive = names.find((name) => isRegisteredMarkName(name) && MARK_REGISTRY[name].exclusive);
1823
1840
  if (exclusive && names.length > 1) errors.push({
1824
1841
  path,
1825
1842
  message: `A run with exclusive mark "${exclusive}" must not carry other marks.`
@@ -2439,13 +2456,14 @@ function markSetToAttrs(marks) {
2439
2456
  * A Y.Text attribute object as a `MarkSet` (mirrors the snapshot reader:
2440
2457
  * `true` toggles kept, object values reduced to their string fields). The
2441
2458
  * cross-minor stand: an unknown mark name is preserved (its fields pass
2442
- * through as-is, not only the string ones) — the data is not lost on save,
2443
- * the reader/renderer stand the text without it, the strict validator
2444
- * rejects it. A known mark's unknown fields stand the same pass-through
2445
- * (a future minor's additive field on a known mark, any value shape);
2446
- * a known field keeps the string stand (a non-string value on a known
2447
- * field is corrupt data — a future minor cannot change a known field's
2448
- * contract — and is dropped protectively).
2459
+ * through as-is, not only the string ones), and a non-boolean primitive
2460
+ * value (a future minor's additive value shape) is kept as-is — the data
2461
+ * is not lost on save, the reader/renderer stand the text without it, the
2462
+ * strict validator rejects it. A known mark's unknown fields stand the
2463
+ * same pass-through (a future minor's additive field on a known mark, any
2464
+ * value shape); a known field keeps the string stand (a non-string value
2465
+ * on a known field is corrupt data — a future minor cannot change a known
2466
+ * field's contract — and is dropped protectively).
2449
2467
  */
2450
2468
  function attrsToMarkSet(attributes) {
2451
2469
  const marks = {};
@@ -2461,7 +2479,7 @@ function attrsToMarkSet(attributes) {
2461
2479
  setOwn(attrs, key, attrValue);
2462
2480
  }
2463
2481
  setOwn(marks, name, attrs);
2464
- }
2482
+ } else setOwn(marks, name, value);
2465
2483
  return marks;
2466
2484
  }
2467
2485
  /**
@@ -2491,6 +2509,20 @@ function exclusiveFreeSpans(content, from, to) {
2491
2509
  * registry's fields), the parents' write, the full-document write, and the
2492
2510
  * opaque block write (the cross-minor stand). */
2493
2511
  const isPlainObject$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2512
+ /** The layout's own top-level keys (the spec-gated writers + the CRDT
2513
+ * owner field): everything else on a block entry is additive data of a
2514
+ * future minor and stands the generic as-is write (the cross-minor stand). */
2515
+ const LAYOUT_BLOCK_KEYS = /* @__PURE__ */ new Set([
2516
+ "type",
2517
+ "attrs",
2518
+ "geometry",
2519
+ "content",
2520
+ "children",
2521
+ "rows",
2522
+ "rowOrder",
2523
+ "columnOrder",
2524
+ "parent"
2525
+ ]);
2494
2526
  /** Primitive attr values are stored directly; plain objects become nested Y.Maps, and arrays become Y.Arrays (array elements are stored as-is — the schema keeps them plain). */
2495
2527
  function writeAttrValue(target, key, value) {
2496
2528
  if (value === void 0) return;
@@ -2584,6 +2616,10 @@ function writeBlock(blocks, id, block) {
2584
2616
  }
2585
2617
  }
2586
2618
  }
2619
+ for (const [key, value] of Object.entries(block)) {
2620
+ if (LAYOUT_BLOCK_KEYS.has(key)) continue;
2621
+ writeAttrValue(yblock, key, value);
2622
+ }
2587
2623
  blocks.set(id, yblock);
2588
2624
  }
2589
2625
  /**
@@ -2617,7 +2653,7 @@ function writeDocument(ydoc, json) {
2617
2653
  for (const [id, block] of Object.entries(json.blocks)) {
2618
2654
  const writable = block;
2619
2655
  if (writable.type === "root") for (const childId of writable.children ?? []) rootOrder.push([childId]);
2620
- if (writable.type in BLOCK_REGISTRY) writeBlock(blocks, id, writable);
2656
+ if (isRegisteredBlockType(writable.type)) writeBlock(blocks, id, writable);
2621
2657
  else writeOpaqueBlock(blocks, id, block);
2622
2658
  }
2623
2659
  setParents(json, blocks);
@@ -2736,7 +2772,7 @@ function normalizeRuns(runs) {
2736
2772
  /** The read unit's value helpers: the known-type check, the order dedup, and
2737
2773
  * the plain-value read. */
2738
2774
  function isKnownBlockType(value) {
2739
- return typeof value === "string" && Object.hasOwn(BLOCK_REGISTRY, value);
2775
+ return isRegisteredBlockType(value);
2740
2776
  }
2741
2777
  /**
2742
2778
  * Read-side dedupe of an order array: duplicate entries can only arise from
@@ -2859,7 +2895,7 @@ function readOpaqueBlock(yblock, type) {
2859
2895
  attrs.forEach((value, key) => entries.push([key, readPlainValue(value)]));
2860
2896
  entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2861
2897
  const plain = {};
2862
- for (const [key, value] of entries) plain[key] = value;
2898
+ for (const [key, value] of entries) setOwn(plain, key, value);
2863
2899
  block.attrs = plain;
2864
2900
  }
2865
2901
  const geometry = yblock.get("geometry");
@@ -2964,7 +3000,7 @@ function readBlock(ydoc, id) {
2964
3000
  const rows = {};
2965
3001
  const rowsMap = yblock.get("rows");
2966
3002
  if (rowsMap instanceof Y.Map) rowsMap.forEach((row, rowId) => {
2967
- rows[rowId] = row instanceof Y.Map ? readPlainValue(row) : {};
3003
+ setOwn(rows, rowId, row instanceof Y.Map ? readPlainValue(row) : {});
2968
3004
  });
2969
3005
  const rowOrder = yblock.get("rowOrder");
2970
3006
  const hasCells = (rowId) => rowsMap instanceof Y.Map && rowsMap.has(rowId);
@@ -2985,6 +3021,27 @@ function readBlock(ydoc, id) {
2985
3021
  columnWidths: derived
2986
3022
  };
2987
3023
  }
3024
+ const extra = [];
3025
+ yblock.forEach((value, key) => {
3026
+ if (typeof key === "string" && !LAYOUT_BLOCK_KEYS.has(key)) extra.push([key, value]);
3027
+ });
3028
+ extra.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
3029
+ for (const [key, value] of extra) {
3030
+ if (value instanceof Y.Text) {
3031
+ const raw = [];
3032
+ for (const op of value.toDelta()) {
3033
+ if (typeof op.insert !== "string") continue;
3034
+ raw.push({
3035
+ text: op.insert,
3036
+ marks: attrsToMarkSet(op.attributes)
3037
+ });
3038
+ }
3039
+ const runs = normalizeRuns(raw);
3040
+ if (runs.length > 0) setOwn(block, key, runs);
3041
+ continue;
3042
+ }
3043
+ setOwn(block, key, readPlainValue(value));
3044
+ }
2988
3045
  return block;
2989
3046
  }
2990
3047
  /**
@@ -3005,10 +3062,11 @@ function readBlockSnapshot(ydoc, id) {
3005
3062
  function toDocJSON(ydoc) {
3006
3063
  const meta = readMeta(ydoc);
3007
3064
  const blocks = {};
3008
- ydoc.getMap("blocks").forEach((_, id) => {
3065
+ const ids = [...ydoc.getMap("blocks").keys()].sort();
3066
+ for (const id of ids) {
3009
3067
  const block = readBlockSnapshot(ydoc, id);
3010
- if (block) blocks[id] = block;
3011
- });
3068
+ if (block) setOwn(blocks, id, block);
3069
+ }
3012
3070
  return {
3013
3071
  ...meta,
3014
3072
  blocks
@@ -3037,7 +3095,7 @@ function blockTypeOf(ydoc, id) {
3037
3095
  const block = getBlockMap(ydoc, id);
3038
3096
  if (!block) return null;
3039
3097
  const type = block.get("type");
3040
- return typeof type === "string" && type in BLOCK_REGISTRY ? type : null;
3098
+ return isRegisteredBlockType(type) ? type : null;
3041
3099
  }
3042
3100
  /**
3043
3101
  * The block's raw children array; null when the block has none. The root
@@ -3100,7 +3158,7 @@ function findParentInArrays(ydoc, id) {
3100
3158
  blocks.forEach((block, blockId) => {
3101
3159
  if (parent !== null || !(block instanceof Y.Map)) return;
3102
3160
  const type = block.get("type");
3103
- if (typeof type !== "string" || !(type in BLOCK_REGISTRY) || !BLOCK_REGISTRY[type].children) return;
3161
+ if (!isRegisteredBlockType(type) || !BLOCK_REGISTRY[type].children) return;
3104
3162
  const children = block.get("children");
3105
3163
  if (children instanceof Y.Array && children.toArray().includes(id)) parent = blockId;
3106
3164
  });
@@ -3511,7 +3569,7 @@ function changeBlockType(ydoc, generateId, id, type, attrs) {
3511
3569
  const currentType = blockTypeOf(ydoc, id);
3512
3570
  if (currentType === null) throw new UnknownBlockError(`Unknown block "${id}".`);
3513
3571
  if (currentType === "root") throw new InvalidOperationError("The root block's type cannot be changed.");
3514
- if (!(type in BLOCK_REGISTRY)) throw new UnknownBlockTypeError(`Unknown block type "${String(type)}".`);
3572
+ if (!isRegisteredBlockType(type)) throw new UnknownBlockTypeError(`Unknown block type "${String(type)}".`);
3515
3573
  if (type === currentType) throw new InvalidOperationError(`Block "${id}" is already of type "${type}".`);
3516
3574
  const from = BLOCK_REGISTRY[currentType];
3517
3575
  const to = BLOCK_REGISTRY[type];
@@ -3538,8 +3596,8 @@ function changeBlockType(ydoc, generateId, id, type, attrs) {
3538
3596
  const snapshot = readBlock(ydoc, id);
3539
3597
  const currentAttrs = snapshot !== null && "attrs" in snapshot ? snapshot.attrs : void 0;
3540
3598
  const merged = {};
3541
- for (const [key, value] of Object.entries(currentAttrs ?? {})) if (to.attrs !== void 0 && key in to.attrs) merged[key] = value;
3542
- for (const [key, value] of Object.entries(isPlainObject$1(attrs) ? attrs : {})) if (value !== void 0) merged[key] = value;
3599
+ for (const [key, value] of Object.entries(currentAttrs ?? {})) if (to.attrs !== void 0 && Object.hasOwn(to.attrs, key)) setOwn(merged, key, value);
3600
+ for (const [key, value] of Object.entries(isPlainObject$1(attrs) ? attrs : {})) if (value !== void 0) setOwn(merged, key, value);
3543
3601
  if (to.attrs !== void 0) {
3544
3602
  const errors = validateBlockAttrs(type, merged);
3545
3603
  if (errors.length > 0) throw new InvalidAttrsError(`Invalid attrs for type "${type}" on block "${id}": ${formatValidationErrors(errors)}`);
@@ -3948,7 +4006,7 @@ function writeNewBlock(blocks, id, type, attrs) {
3948
4006
  }
3949
4007
  /** Inserts a new block under `at.parent` (default: root) after `at.after` (default: appended). */
3950
4008
  function insertBlock(ydoc, generateId, type, attrs, at) {
3951
- if (!(type in BLOCK_REGISTRY)) throw new UnknownBlockTypeError(`Unknown block type "${String(type)}".`);
4009
+ if (!isRegisteredBlockType(type)) throw new UnknownBlockTypeError(`Unknown block type "${String(type)}".`);
3952
4010
  if (type === "root") throw new InvalidOperationError("The root block already exists and cannot be inserted.");
3953
4011
  const explicitId = at?.id;
3954
4012
  if (explicitId !== void 0) {
@@ -4406,7 +4464,7 @@ function setMark(ydoc, blockId, from, to, mark) {
4406
4464
  }
4407
4465
  /** Removes `name` from the marks of the characters in [from, to); other marks stay. */
4408
4466
  function unsetMark(ydoc, blockId, from, to, name) {
4409
- if (!(name in MARK_REGISTRY)) throw new InvalidMarkError(`Unknown mark "${String(name)}".`);
4467
+ if (!isRegisteredMarkName(name)) throw new InvalidMarkError(`Unknown mark "${String(name)}".`);
4410
4468
  const { content } = requireTextBlock(ydoc, blockId);
4411
4469
  const total = contentTextLength(content);
4412
4470
  if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || from > to || to > total) throw new InvalidRangeError(`Range must satisfy 0 <= from < to <= ${total} (got from=${from}, to=${to}).`);
@@ -4876,20 +4934,35 @@ function emitPending(ydoc, hub, isRemote) {
4876
4934
  metaChanged: pending.metaChanged
4877
4935
  }, isRemote);
4878
4936
  }
4937
+ /**
4938
+ * Run the handler with its throw isolated: a listener fires inside the
4939
+ * Yjs transaction (the afterTransaction hook), so a listener's throw must
4940
+ * not abort the remaining handlers of the emission nor roll back the
4941
+ * caller's committed batch (the `transact`'s rollback guard would catch
4942
+ * it otherwise). The throw stands the logged error; the emission
4943
+ * continues with the next handler.
4944
+ */
4945
+ function safeCall(label, handler) {
4946
+ try {
4947
+ handler();
4948
+ } catch (error) {
4949
+ console.error(`docblocks: a ${label} handler threw — the emission continues`, error);
4950
+ }
4951
+ }
4879
4952
  function emitChange(ydoc, change, isRemote) {
4880
4953
  const hub = getHub(ydoc);
4881
- for (const handler of [...hub.changeHandlers]) handler(change);
4882
- if (isRemote) for (const handler of [...hub.remoteChangeHandlers]) handler(change);
4954
+ for (const handler of [...hub.changeHandlers]) safeCall("change", () => handler(change));
4955
+ if (isRemote) for (const handler of [...hub.remoteChangeHandlers]) safeCall("remote-change", () => handler(change));
4883
4956
  for (const blockId of change.blockIds) {
4884
4957
  const blockHandlers = hub.blockHandlers.get(blockId);
4885
4958
  if (blockHandlers !== void 0 && blockHandlers.size > 0) {
4886
4959
  const block = readBlock(ydoc, blockId);
4887
- if (block !== null) for (const handler of [...blockHandlers]) handler(block);
4960
+ if (block !== null) for (const handler of [...blockHandlers]) safeCall("block", () => handler(block));
4888
4961
  }
4889
4962
  const textHandlers = hub.textHandlers.get(blockId);
4890
4963
  if (textHandlers !== void 0 && textHandlers.size > 0) {
4891
4964
  const text = readText(ydoc, blockId);
4892
- for (const handler of [...textHandlers]) handler(text);
4965
+ for (const handler of [...textHandlers]) safeCall("text", () => handler(text));
4893
4966
  }
4894
4967
  }
4895
4968
  }
@@ -5949,6 +6022,9 @@ var Doc = class Doc {
5949
6022
  * propagates. A failed batch still emits a transient `onChange` emission
5950
6023
  * and a partial wire update before the rollback (CRDT property: the
5951
6024
  * rollback is best-effort on the wire; observable state converges).
6025
+ * A subscription handler's throw (the handlers fire inside the
6026
+ * transaction) is isolated — it stands the logged error and never rolls
6027
+ * back a committed batch.
5952
6028
  */
5953
6029
  transact(fn) {
5954
6030
  const ydoc = this.#ydoc;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docblocks-core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Headless CRDT document engine: schema v1, Doc (commands/transactions/undo), sync providers. Yjs-based, zero UI dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",