@echovisionlab/geul-common 0.3.0 → 0.3.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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@echovisionlab/geul-common",
3
3
  "description": "Shared TypeScript contracts and helpers for Geul editors, collaboration, localized content, media blocks, pages, posts, and runtime events.",
4
4
  "author": "state303 <state303@dsub.io>",
5
- "version": "0.3.0",
5
+ "version": "0.3.2",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "repository": {
@@ -10,9 +10,9 @@
10
10
  "url": "git+https://github.com/echovisionlab/geul-common.git"
11
11
  },
12
12
  "engines": {
13
- "node": "24.19.0"
13
+ "node": "24.21.0"
14
14
  },
15
- "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
15
+ "packageManager": "pnpm@12.9.1",
16
16
  "files": [
17
17
  "README.md",
18
18
  "src",
@@ -62,24 +62,25 @@
62
62
  "./types": "./src/types.ts"
63
63
  },
64
64
  "dependencies": {
65
- "@bufbuild/protobuf": "^2.14.0",
66
- "@echovisionlab/geul-proto": "^0.3.0",
67
- "zod": "^4.4.3"
65
+ "@bufbuild/protobuf": "^2.16.0",
66
+ "@echovisionlab/geul-proto": "^0.4.1",
67
+ "diff": "9.0.0",
68
+ "zod": "^4.6.5"
68
69
  },
69
70
  "peerDependencies": {
70
- "yjs": "^13.6.32"
71
+ "yjs": "^13.6.33"
71
72
  },
72
73
  "devDependencies": {
73
- "@types/node": "^24.13.3",
74
+ "@types/node": "^24.19.1",
74
75
  "@typescript/native": "npm:typescript@7.0.2",
75
- "@vitest/coverage-v8": "^4.1.10",
76
- "eslint": "^10.8.1",
77
- "knip": "6.32.2",
78
- "prettier": "3.9.6",
76
+ "@vitest/coverage-v8": "^5.0.3",
77
+ "eslint": "^10.12.0",
78
+ "knip": "6.39.0",
79
+ "prettier": "3.9.9",
79
80
  "typescript": "npm:@typescript/typescript6@6.0.2",
80
- "typescript-eslint": "^8.67.0",
81
- "vitest": "^4.1.10",
82
- "yjs": "^13.6.32"
81
+ "typescript-eslint": "^8.71.1",
82
+ "vitest": "^5.0.3",
83
+ "yjs": "^13.6.33"
83
84
  },
84
85
  "license": "PolyForm-Noncommercial-1.0.0",
85
86
  "homepage": "https://github.com/echovisionlab/geul-common#readme",
@@ -0,0 +1,341 @@
1
+ import type { JsonValue } from "@bufbuild/protobuf";
2
+ import { diffArrays } from "diff";
3
+ import { desiredGroups, type Style } from "./inline-content-model.ts";
4
+
5
+ type Attributes = Record<string, string | boolean>;
6
+ type Token =
7
+ | { kind: "text"; value: string; attributes: Attributes }
8
+ | { kind: "mathInline"; attributes: Attributes }
9
+ | { kind: "hardBreak"; attributes: Attributes }
10
+ | { kind: "emptyLink"; attributes: Attributes }
11
+ | { kind: "linkBoundary"; attributes: Attributes };
12
+
13
+ const MAX_EXACT_EDIT_DISTANCE = 128;
14
+
15
+ interface Projection<T> {
16
+ readonly retained: Map<number, T>;
17
+ readonly inserted: Map<number, T[]>;
18
+ }
19
+
20
+ function tokenIdentity(token: Token): string {
21
+ return token.kind === "text" ? `text:${token.value}` : token.kind;
22
+ }
23
+
24
+ /** Align text by Unicode code point; style/link changes remain attribute edits. */
25
+ function project<T>(
26
+ before: readonly T[],
27
+ after: readonly T[],
28
+ identity: (token: T) => string,
29
+ ): Projection<T> {
30
+ const retained = new Map<number, T>();
31
+ const inserted = new Map<number, T[]>();
32
+ let position = 0;
33
+ const changes = diffArrays([...before], [...after], {
34
+ comparator: (left, right) => identity(left) === identity(right),
35
+ maxEditLength: MAX_EXACT_EDIT_DISTANCE,
36
+ });
37
+ if (!changes) return projectByForwardAnchors(before, after, identity);
38
+ for (const change of changes) {
39
+ if (change.added) {
40
+ inserted.set(position, change.value);
41
+ } else if (change.removed) {
42
+ position += change.count;
43
+ } else {
44
+ for (const token of change.value) retained.set(position++, token);
45
+ }
46
+ }
47
+ return { retained, inserted };
48
+ }
49
+
50
+ /**
51
+ * Large replacements do not need an optimal edit script. Forward occurrence
52
+ * indexes retain a monotonic common subsequence in linear time, so recovery
53
+ * remains bounded while still replaying insertions/deletions as author intent.
54
+ * Exact Myers alignment remains the normal path for ordinary editing.
55
+ */
56
+ function projectByForwardAnchors<T>(
57
+ before: readonly T[],
58
+ after: readonly T[],
59
+ identity: (token: T) => string,
60
+ ): Projection<T> {
61
+ const occurrences = new Map<string, { positions: number[]; next: number }>();
62
+ before.forEach((token, index) => {
63
+ const key = identity(token);
64
+ let entry = occurrences.get(key);
65
+ if (!entry) {
66
+ entry = { positions: [], next: 0 };
67
+ occurrences.set(key, entry);
68
+ }
69
+ entry.positions.push(index);
70
+ });
71
+ const retained = new Map<number, T>();
72
+ const inserted = new Map<number, T[]>();
73
+ let cursor = 0;
74
+ let pending: T[] = [];
75
+ for (const token of after) {
76
+ const entry = occurrences.get(identity(token));
77
+ if (entry) {
78
+ while (
79
+ entry.next < entry.positions.length &&
80
+ entry.positions[entry.next]! < cursor
81
+ )
82
+ entry.next++;
83
+ }
84
+ const position = entry?.positions[entry.next];
85
+ if (position === undefined) {
86
+ pending.push(token);
87
+ continue;
88
+ }
89
+ if (pending.length) {
90
+ inserted.set(position, pending);
91
+ pending = [];
92
+ }
93
+ retained.set(position, token);
94
+ cursor = position + 1;
95
+ entry!.next++;
96
+ }
97
+ if (pending.length) inserted.set(before.length, pending);
98
+ return { retained, inserted };
99
+ }
100
+
101
+ function mergeAttributes(
102
+ before: Attributes,
103
+ local: Attributes,
104
+ peer: Attributes,
105
+ ): Attributes {
106
+ const result = { ...peer };
107
+ for (const key of new Set([...Object.keys(before), ...Object.keys(local)])) {
108
+ if (before[key] === local[key]) continue;
109
+ if (local[key] === undefined) delete result[key];
110
+ else result[key] = local[key];
111
+ }
112
+ return result;
113
+ }
114
+
115
+ function withAttributes(token: Token, attributes: Attributes): Token {
116
+ return { ...token, attributes };
117
+ }
118
+
119
+ function insertionIdentity(token: Token): string {
120
+ if (token.kind === "mathInline") return `math:${token.attributes.source}`;
121
+ if (token.kind === "emptyLink") return `emptyLink:${token.attributes.href}`;
122
+ return tokenIdentity(token);
123
+ }
124
+
125
+ /** Preserve each newly inserted adjacent link as a semantic group. */
126
+ function linkedInsertionGroups(tokens: readonly Token[]): Token[][] | null {
127
+ if (!tokens.some((token) => token.kind === "linkBoundary")) return null;
128
+ if (
129
+ tokens.some(
130
+ (token) =>
131
+ token.kind !== "linkBoundary" &&
132
+ token.kind !== "emptyLink" &&
133
+ (token.kind !== "text" || token.attributes.href === undefined),
134
+ )
135
+ )
136
+ return null;
137
+ const groups: Token[][] = [[]];
138
+ for (const token of tokens) {
139
+ if (token.kind === "linkBoundary") groups.push([]);
140
+ else groups[groups.length - 1]!.push(token);
141
+ }
142
+ return groups;
143
+ }
144
+
145
+ function linkGroupIdentity(group: readonly Token[]): string {
146
+ return JSON.stringify(
147
+ group.map((token) => [insertionIdentity(token), token.attributes.href]),
148
+ );
149
+ }
150
+
151
+ /** An ordered union retains accepted inserts and appends only missing intent. */
152
+ function mergeInsertionSequences<T>(
153
+ peer: readonly T[],
154
+ local: readonly T[],
155
+ identity: (value: T) => string,
156
+ merge: (peerValue: T, localValue: T) => T,
157
+ ): T[] {
158
+ if (local.length === 0) return [...peer];
159
+ if (peer.length === 0) return [...local];
160
+ const localProjection = project(peer, local, identity);
161
+ const result: T[] = [];
162
+ for (let position = 0; position <= peer.length; position++) {
163
+ for (const value of localProjection.inserted.get(position) ?? [])
164
+ result.push(value);
165
+ const peerValue = peer[position];
166
+ if (peerValue === undefined) continue;
167
+ const localValue = localProjection.retained.get(position);
168
+ result.push(
169
+ localValue === undefined ? peerValue : merge(peerValue, localValue),
170
+ );
171
+ }
172
+ return result;
173
+ }
174
+
175
+ function mergeInsertions(
176
+ peer: readonly Token[],
177
+ local: readonly Token[],
178
+ ): Token[] {
179
+ const peerGroups = linkedInsertionGroups(peer);
180
+ const localGroups = linkedInsertionGroups(local);
181
+ if (peerGroups && localGroups) {
182
+ // Matching separator tokens alone would join unrelated links from two
183
+ // insertion streams. Align whole link groups, then merge their run intent.
184
+ const groups = mergeInsertionSequences(
185
+ peerGroups,
186
+ localGroups,
187
+ linkGroupIdentity,
188
+ (peerGroup, localGroup) => mergeInsertions(peerGroup, localGroup),
189
+ );
190
+ const result: Token[] = [];
191
+ for (const group of groups) {
192
+ if (result.length) result.push({ kind: "linkBoundary", attributes: {} });
193
+ for (const token of group) result.push(token);
194
+ }
195
+ return result;
196
+ }
197
+ return mergeInsertionSequences(
198
+ peer,
199
+ local,
200
+ insertionIdentity,
201
+ (peerToken, localToken) =>
202
+ withAttributes(
203
+ peerToken,
204
+ mergeAttributes({}, localToken.attributes, peerToken.attributes),
205
+ ),
206
+ );
207
+ }
208
+
209
+ function mergeTokens(
210
+ before: readonly Token[],
211
+ local: readonly Token[],
212
+ peer: readonly Token[],
213
+ ): Token[] {
214
+ const localProjection = project(before, local, tokenIdentity);
215
+ const peerProjection = project(before, peer, tokenIdentity);
216
+ const result: Token[] = [];
217
+ for (let position = 0; position <= before.length; position++) {
218
+ for (const token of mergeInsertions(
219
+ peerProjection.inserted.get(position) ?? [],
220
+ localProjection.inserted.get(position) ?? [],
221
+ ))
222
+ result.push(token);
223
+ const localToken = localProjection.retained.get(position);
224
+ const peerToken = peerProjection.retained.get(position);
225
+ // An observed character removed on either side is never resurrected by
226
+ // formatting or another edit to the old projection.
227
+ if (localToken && peerToken) {
228
+ result.push(
229
+ withAttributes(
230
+ peerToken,
231
+ mergeAttributes(
232
+ before[position]!.attributes,
233
+ localToken.attributes,
234
+ peerToken.attributes,
235
+ ),
236
+ ),
237
+ );
238
+ }
239
+ }
240
+ return result;
241
+ }
242
+
243
+ function normalizedStyles(styles: Style): Attributes {
244
+ return Object.fromEntries(
245
+ Object.entries(styles).filter(
246
+ ([, value]) => value !== false && value !== "",
247
+ ),
248
+ );
249
+ }
250
+
251
+ function inlineTokens(content: readonly JsonValue[]): Token[] {
252
+ const tokens: Token[] = [];
253
+ let previousKind: string | undefined;
254
+ for (const group of desiredGroups(content)) {
255
+ if (group.kind === "link" && previousKind === "link")
256
+ tokens.push({ kind: "linkBoundary", attributes: {} });
257
+ previousKind = group.kind;
258
+ if (group.kind === "text" || group.kind === "link") {
259
+ if (group.kind === "link" && group.runs.length === 0)
260
+ tokens.push({ kind: "emptyLink", attributes: { href: group.href } });
261
+ for (const run of group.runs) {
262
+ const attributes = {
263
+ ...normalizedStyles(run.styles),
264
+ ...(group.kind === "link" ? { href: group.href } : {}),
265
+ };
266
+ for (const value of run.text)
267
+ tokens.push({ kind: "text", value, attributes });
268
+ }
269
+ } else if (group.kind === "mathInline") {
270
+ tokens.push({ kind: "mathInline", attributes: { source: group.source } });
271
+ } else {
272
+ tokens.push({ kind: "hardBreak", attributes: {} });
273
+ }
274
+ }
275
+ return tokens;
276
+ }
277
+
278
+ function inlineContent(tokens: readonly Token[]): JsonValue[] {
279
+ const result: JsonValue[] = [];
280
+ let runs: Array<{ text: string; styles?: Attributes }> | undefined;
281
+ let previousHref: string | boolean | undefined;
282
+ for (const token of tokens) {
283
+ if (token.kind !== "text") {
284
+ runs = undefined;
285
+ if (token.kind === "linkBoundary") continue;
286
+ result.push(
287
+ token.kind === "emptyLink"
288
+ ? { link: { href: token.attributes.href as string, content: [] } }
289
+ : token.kind === "mathInline"
290
+ ? { mathInline: { source: token.attributes.source as string } }
291
+ : { hardBreak: {} },
292
+ );
293
+ continue;
294
+ }
295
+ const { href, ...styles } = token.attributes;
296
+ if (!runs || previousHref !== href) {
297
+ runs = [];
298
+ if (href !== undefined) result.push({ link: { href, content: runs } });
299
+ previousHref = href;
300
+ }
301
+ const last = runs[runs.length - 1];
302
+ if (last && JSON.stringify(last.styles ?? {}) === JSON.stringify(styles)) {
303
+ last.text += token.value;
304
+ } else {
305
+ const run = {
306
+ text: token.value,
307
+ ...(Object.keys(styles).length ? { styles } : {}),
308
+ };
309
+ runs.push(run);
310
+ if (href === undefined) result.push({ text: run });
311
+ }
312
+ }
313
+ return result;
314
+ }
315
+
316
+ /** Replay only local character/attribute intent over the canonical projection. */
317
+ export function mergeInlineIntent(
318
+ before: readonly JsonValue[],
319
+ local: readonly JsonValue[],
320
+ peer: readonly JsonValue[],
321
+ ): JsonValue[] {
322
+ return inlineContent(
323
+ mergeTokens(inlineTokens(before), inlineTokens(local), inlineTokens(peer)),
324
+ );
325
+ }
326
+
327
+ export function mergeTextIntent(
328
+ before: string,
329
+ local: string,
330
+ peer: string,
331
+ ): string {
332
+ const tokens = (value: string): Token[] =>
333
+ Array.from(value, (character) => ({
334
+ kind: "text",
335
+ value: character,
336
+ attributes: {},
337
+ }));
338
+ return mergeTokens(tokens(before), tokens(local), tokens(peer))
339
+ .map((token) => (token as Extract<Token, { kind: "text" }>).value)
340
+ .join("");
341
+ }
@@ -18,6 +18,12 @@ import {
18
18
  type BlockRoomPayloadRef,
19
19
  } from "./room-access.ts";
20
20
  import { longestCommonSubsequence } from "./replay-structure.ts";
21
+ import {
22
+ isBlockRoomInlineContentRef,
23
+ reconcileBlockRoomInlineContent,
24
+ } from "./inline-content-mutations.ts";
25
+ import { fromYInlineContent } from "./inline-content-projection.ts";
26
+ import { mergeInlineIntent, mergeTextIntent } from "./replay-inline-merge.ts";
21
27
 
22
28
  const STABLE_ARRAY_ID_FIELDS = ["id", "rowId", "cellId", "unitId"] as const;
23
29
 
@@ -239,12 +245,7 @@ function writePayloadValue(
239
245
  replaceBlockRoomPayloadArray(document, ref, value);
240
246
  return;
241
247
  }
242
- const node = roomNode(document, ref);
243
- if (typeof value === "string" && nodeTextPredicate(node, ref)(ref.path)) {
244
- replaceBlockRoomCollaborativeText(document, ref, value);
245
- } else {
246
- setBlockRoomAtomicValue(document, ref, value);
247
- }
248
+ setBlockRoomAtomicValue(document, ref, value);
248
249
  }
249
250
 
250
251
  function applyPayloadDiff(
@@ -253,6 +254,36 @@ function applyPayloadDiff(
253
254
  before: JsonValue | undefined,
254
255
  after: JsonValue | undefined,
255
256
  ): void {
257
+ if (
258
+ isBlockRoomInlineContentRef(ref) &&
259
+ (before === undefined || isJsonArray(before)) &&
260
+ (after === undefined || isJsonArray(after))
261
+ ) {
262
+ const current = readPayload(document, ref);
263
+ const peer = current instanceof Y.Array ? fromYInlineContent(current) : [];
264
+ reconcileBlockRoomInlineContent(
265
+ document,
266
+ ref,
267
+ peer,
268
+ mergeInlineIntent(before ?? [], after ?? [], peer),
269
+ );
270
+ return;
271
+ }
272
+ if (
273
+ (before === undefined || typeof before === "string") &&
274
+ (after === undefined || typeof after === "string") &&
275
+ nodeTextPredicate(roomNode(document, ref), ref)(ref.path)
276
+ ) {
277
+ const current = readPayload(document, ref);
278
+ if (current !== undefined && !(current instanceof Y.Text))
279
+ fail(`replay:payload_shape:${ref.id}:${ref.path}`);
280
+ replaceBlockRoomCollaborativeText(
281
+ document,
282
+ ref,
283
+ mergeTextIntent(before ?? "", after ?? "", current?.toString() ?? ""),
284
+ );
285
+ return;
286
+ }
256
287
  if (after === undefined) {
257
288
  deletePayloadValue(document, ref);
258
289
  return;
@@ -4,8 +4,7 @@ export type FormSchemaPatchErrorReason =
4
4
  | "invalid_schema"
5
5
  | "schema_identity_changed"
6
6
  | "target_topology_changed"
7
- | "schema_identity_collision"
8
- | "schema_add_parent_missing";
7
+ | "schema_identity_collision";
9
8
 
10
9
  export class FormSchemaPatchError extends Error {
11
10
  constructor(readonly reason: FormSchemaPatchErrorReason) {
@@ -335,6 +334,7 @@ export function applyFormSchemaPatch(
335
334
  }
336
335
 
337
336
  const added = new Set<string>();
337
+ const skippedAdditions = new Set<string>();
338
338
  for (const [key, node] of next.nodes) {
339
339
  if (previous.nodes.has(key)) continue;
340
340
  if (result.nodes.has(key)) {
@@ -344,8 +344,15 @@ export function applyFormSchemaPatch(
344
344
  }
345
345
  continue;
346
346
  }
347
- if (node.parent !== ROOT_PARENT && !result.nodes.has(node.parent)) {
348
- throw new FormSchemaPatchError("schema_add_parent_missing");
347
+ // Schemas are indexed parent-first. A missing parent here is an observed
348
+ // peer deletion or a new parent already skipped beneath one.
349
+ if (
350
+ node.parent !== ROOT_PARENT &&
351
+ (skippedAdditions.has(node.parent) ||
352
+ (previous.nodes.has(node.parent) && !current.nodes.has(node.parent)))
353
+ ) {
354
+ skippedAdditions.add(key);
355
+ continue;
349
356
  }
350
357
  result.nodes.set(key, structuredClone(node));
351
358
  added.add(key);
@@ -158,6 +158,7 @@ export function replaceMenuCanonicalSource(
158
158
  sourceLabels.delete(id);
159
159
  }
160
160
  for (const [id, item] of nextById) {
161
+ if (!document.getMap<string>(MENU_ITEMS_MAP_NAME).has(id)) continue;
161
162
  const before = previousById.get(id);
162
163
  const previouslyOwned = before
163
164
  ? menuItemOwnsLocaleLabel(before, locale)
@@ -242,6 +243,10 @@ function replaceMenuStructure(
242
243
  const items = document.getMap<string>(MENU_ITEMS_MAP_NAME);
243
244
  const parents = document.getMap<string>(MENU_PARENTS_MAP_NAME);
244
245
  const orders = document.getMap<number>(MENU_ORDERS_MAP_NAME);
246
+ const liveIDs = new Set(items.keys());
247
+ const peerDeletedObservedIDs = new Set(
248
+ [...previousIDs].filter((id) => !liveIDs.has(id)),
249
+ );
245
250
  const childrenByParent = new Map<string, string[]>();
246
251
  for (const id of items.keys()) {
247
252
  const parent = parents.get(id) ?? MENU_ROOT_PARENT;
@@ -251,11 +256,25 @@ function replaceMenuStructure(
251
256
  }
252
257
 
253
258
  const deletedIDs = new Set([...previousIDs].filter((id) => !nextIDs.has(id)));
259
+ for (const id of peerDeletedObservedIDs) deletedIDs.add(id);
260
+ const skippedNextIDs = new Set<string>();
261
+ const blockedNextParents = new Set(peerDeletedObservedIDs);
262
+ for (const { item, parent } of flattened) {
263
+ if (blockedNextParents.has(parent) || peerDeletedObservedIDs.has(item.id)) {
264
+ skippedNextIDs.add(item.id);
265
+ blockedNextParents.add(item.id);
266
+ }
267
+ }
254
268
  const pendingDeletedIDs = [...deletedIDs];
255
269
  for (let index = 0; index < pendingDeletedIDs.length; index += 1) {
256
270
  const deletedParent = pendingDeletedIDs[index]!;
257
271
  for (const childId of childrenByParent.get(deletedParent) ?? []) {
258
- if (nextIDs.has(childId) || deletedIDs.has(childId)) continue;
272
+ if (
273
+ (nextIDs.has(childId) && !skippedNextIDs.has(childId)) ||
274
+ deletedIDs.has(childId)
275
+ ) {
276
+ continue;
277
+ }
259
278
  deletedIDs.add(childId);
260
279
  pendingDeletedIDs.push(childId);
261
280
  }
@@ -266,6 +285,7 @@ function replaceMenuStructure(
266
285
  orders.delete(id);
267
286
  }
268
287
  for (const { item, parent, order } of flattened) {
288
+ if (skippedNextIDs.has(item.id)) continue;
269
289
  const before = previousById.get(item.id);
270
290
  const stored = menuItemStructure(item);
271
291
  if (