@echovisionlab/geul-common 0.1.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.
Files changed (47) hide show
  1. package/LICENSE.md +6 -0
  2. package/README.md +37 -0
  3. package/package.json +89 -0
  4. package/src/collaboration/artist.ts +63 -0
  5. package/src/collaboration/block-room-codec/ai-document-applicator.ts +319 -0
  6. package/src/collaboration/block-room-codec/ai-document-field-mutations.ts +557 -0
  7. package/src/collaboration/block-room-codec/ai-document-page-structure-mutations.ts +449 -0
  8. package/src/collaboration/block-room-codec/ai-document-values.ts +368 -0
  9. package/src/collaboration/block-room-codec/hydration.ts +257 -0
  10. package/src/collaboration/block-room-codec/internal.ts +432 -0
  11. package/src/collaboration/block-room-codec/locale-change-validation.ts +237 -0
  12. package/src/collaboration/block-room-codec/locale-presence.ts +827 -0
  13. package/src/collaboration/block-room-codec/materialization.ts +450 -0
  14. package/src/collaboration/block-room-codec/observation.ts +518 -0
  15. package/src/collaboration/block-room-codec/payload-mutations.ts +347 -0
  16. package/src/collaboration/block-room-codec/room-access.ts +154 -0
  17. package/src/collaboration/block-room-codec/structure-mutations.ts +456 -0
  18. package/src/collaboration/block-room-codec.ts +86 -0
  19. package/src/collaboration/campaign.ts +37 -0
  20. package/src/collaboration/document-layout.ts +75 -0
  21. package/src/collaboration/document.ts +185 -0
  22. package/src/collaboration/email-layout.ts +150 -0
  23. package/src/collaboration/form.ts +513 -0
  24. package/src/collaboration/label.ts +49 -0
  25. package/src/collaboration/map-theme.ts +157 -0
  26. package/src/collaboration/member-id.ts +9 -0
  27. package/src/collaboration/menu.ts +258 -0
  28. package/src/collaboration/metadata-ai.ts +99 -0
  29. package/src/collaboration/page.ts +483 -0
  30. package/src/collaboration/post-series.ts +96 -0
  31. package/src/collaboration/post.ts +59 -0
  32. package/src/collaboration/release.ts +221 -0
  33. package/src/collaboration/runtime-events.ts +547 -0
  34. package/src/collaboration/work.ts +107 -0
  35. package/src/editor/link-normalization.ts +212 -0
  36. package/src/editor/materialized-blocks.ts +58 -0
  37. package/src/index.ts +22 -0
  38. package/src/media/block-schemas.ts +78 -0
  39. package/src/media/hydration.ts +226 -0
  40. package/src/page/block-fixtures.ts +586 -0
  41. package/src/page/index.ts +3 -0
  42. package/src/page/types.ts +57 -0
  43. package/src/post/index.ts +1 -0
  44. package/src/post/types.ts +13 -0
  45. package/src/test/random-id.ts +9 -0
  46. package/src/translation/release.ts +88 -0
  47. package/src/types.ts +14 -0
@@ -0,0 +1,368 @@
1
+ import type { JsonValue } from "@bufbuild/protobuf";
2
+ import type {
3
+ AIDocumentFieldPathSegment,
4
+ AIDocumentFieldTarget,
5
+ AIDocumentInlineItem,
6
+ AIDocumentValue,
7
+ } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
8
+ import { fail, jsonObject } from "./internal.ts";
9
+
10
+ export type CatalogField = {
11
+ readonly type: string;
12
+ readonly default?: unknown;
13
+ readonly ownership?: string;
14
+ readonly values?: readonly (string | number)[];
15
+ readonly items?: CatalogField;
16
+ readonly fields?: Readonly<Record<string, CatalogField>>;
17
+ readonly item_identity?: {
18
+ readonly strategy: "field" | "fixed" | "value";
19
+ readonly field?: string;
20
+ readonly values?: readonly string[];
21
+ };
22
+ };
23
+
24
+ export type JsonPathPart = string | number;
25
+ type MutableJsonObject = { [key: string]: JsonValue };
26
+
27
+ export function operationFail(reason: string): never {
28
+ return fail(`ai_operation:${reason}`);
29
+ }
30
+
31
+ export function requiredHandle(value: string, reason: string): string {
32
+ return value ? value : operationFail(reason);
33
+ }
34
+
35
+ export function blockTarget(target: AIDocumentFieldTarget | undefined): {
36
+ blockId: string;
37
+ field: string;
38
+ path: readonly AIDocumentFieldPathSegment[];
39
+ } {
40
+ if (!target) return operationFail("field_target:missing");
41
+ if (target.owner.case !== "blockHandle") {
42
+ return operationFail("field_target:relation_item");
43
+ }
44
+ return {
45
+ blockId: requiredHandle(target.owner.value, "field_target:block"),
46
+ field: requiredHandle(target.fieldHandle, "field_target:field"),
47
+ path: target.path,
48
+ };
49
+ }
50
+
51
+ function scalarText(
52
+ value: AIDocumentValue | undefined,
53
+ reason: string,
54
+ ): string {
55
+ if (!value || value.value.case !== "text") return operationFail(reason);
56
+ return value.value.value;
57
+ }
58
+
59
+ type InlineStyle = {
60
+ bold?: true;
61
+ italic?: true;
62
+ underline?: true;
63
+ strike?: true;
64
+ code?: true;
65
+ textColor?: string;
66
+ backgroundColor?: string;
67
+ };
68
+
69
+ function styledText(text: string, style: InlineStyle): JsonValue {
70
+ return Object.keys(style).length === 0
71
+ ? { text: { text } }
72
+ : { text: { text, styles: style } };
73
+ }
74
+
75
+ function inlineItems(
76
+ items: readonly AIDocumentInlineItem[],
77
+ style: InlineStyle = {},
78
+ insideLink = false,
79
+ ): JsonValue[] {
80
+ const result: JsonValue[] = [];
81
+ for (const item of items) {
82
+ switch (item.item.case) {
83
+ case "text":
84
+ result.push(styledText(item.item.value, style));
85
+ break;
86
+ case "mark": {
87
+ const mark = item.item.value;
88
+ const next = { ...style };
89
+ switch (mark.mark) {
90
+ case "bold":
91
+ case "italic":
92
+ case "underline":
93
+ case "strike":
94
+ case "code":
95
+ if (mark.parameter) operationFail(`inline:${mark.mark}:parameter`);
96
+ next[mark.mark] = true;
97
+ break;
98
+ case "textColor":
99
+ case "backgroundColor":
100
+ next[mark.mark] = scalarText(
101
+ mark.parameter,
102
+ `inline:${mark.mark}:parameter`,
103
+ );
104
+ break;
105
+ default:
106
+ operationFail(`inline:mark:${mark.mark || "missing"}`);
107
+ }
108
+ result.push(...inlineItems(mark.children, next, insideLink));
109
+ break;
110
+ }
111
+ case "link": {
112
+ if (insideLink) operationFail("inline:nested_link");
113
+ const content = inlineItems(item.item.value.children, style, true).map(
114
+ (child) => {
115
+ const object = jsonObject(child, "ai_operation:inline:link_child");
116
+ return object.text!;
117
+ },
118
+ );
119
+ result.push({
120
+ link: {
121
+ href: requiredHandle(item.item.value.target, "inline:link_target"),
122
+ content,
123
+ },
124
+ });
125
+ break;
126
+ }
127
+ case "hardBreak":
128
+ if (insideLink || Object.keys(style).length !== 0)
129
+ operationFail("inline:marked_hard_break");
130
+ result.push({ hardBreak: {} });
131
+ break;
132
+ case "math":
133
+ if (insideLink || Object.keys(style).length !== 0)
134
+ operationFail("inline:marked_math");
135
+ result.push({ mathInline: { source: item.item.value } });
136
+ break;
137
+ case "placeholderHandle":
138
+ operationFail("inline:placeholder");
139
+ case undefined:
140
+ operationFail("inline:missing");
141
+ }
142
+ }
143
+ return result;
144
+ }
145
+
146
+ function decimal(value: string): number {
147
+ if (value.trim() !== value || value === "")
148
+ return operationFail("value:number");
149
+ const number = Number(value);
150
+ return Number.isFinite(number) ? number : operationFail("value:number");
151
+ }
152
+
153
+ function upperSnake(value: string): string {
154
+ return value
155
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
156
+ .replace(/[^A-Za-z0-9]+/g, "_")
157
+ .replace(/^_+|_+$/g, "")
158
+ .toUpperCase();
159
+ }
160
+
161
+ function enumName(
162
+ field: string,
163
+ canonical: string,
164
+ type: string,
165
+ item: boolean,
166
+ ): string {
167
+ let token = upperSnake(canonical);
168
+ if (type !== "enum_int" && /^[0-9]/.test(token)) token = `X_${token}`;
169
+ return `${upperSnake(field)}${item ? "_ITEM" : ""}_${token}`;
170
+ }
171
+
172
+ export function plainValue(
173
+ value: AIDocumentValue | undefined,
174
+ descriptor?: CatalogField,
175
+ field = "value",
176
+ item = false,
177
+ ): JsonValue {
178
+ if (!value) return operationFail("value:missing");
179
+ switch (value.value.case) {
180
+ case "text":
181
+ if (descriptor?.type === "enum" || descriptor?.type === "enum_int") {
182
+ const canonical = value.value.value;
183
+ if (
184
+ descriptor.values &&
185
+ !descriptor.values.some(
186
+ (candidate) => String(candidate) === canonical,
187
+ )
188
+ ) {
189
+ return operationFail(`value:enum:${field}`);
190
+ }
191
+ return enumName(field, canonical, descriptor.type, item);
192
+ }
193
+ return value.value.value;
194
+ case "boolean":
195
+ return value.value.value;
196
+ case "number":
197
+ return decimal(value.value.value);
198
+ case "inline":
199
+ return inlineItems(value.value.value.items);
200
+ case "list":
201
+ return value.value.value.items.map((item) =>
202
+ plainValue(item.value, descriptor?.items, field, true),
203
+ );
204
+ case "object": {
205
+ const result: MutableJsonObject = {};
206
+ for (const item of value.value.value.fields) {
207
+ const child = requiredHandle(item.fieldHandle, "value:object_field");
208
+ if (child in result) operationFail(`value:duplicate_field:${child}`);
209
+ result[child] = plainValue(
210
+ item.value,
211
+ descriptor?.fields?.[child],
212
+ child,
213
+ );
214
+ }
215
+ return result;
216
+ }
217
+ case undefined:
218
+ return operationFail("value:missing");
219
+ }
220
+ }
221
+
222
+ function canonicalIdentity(
223
+ descriptor: CatalogField,
224
+ field: string,
225
+ handle: string,
226
+ item: boolean,
227
+ ): JsonValue {
228
+ if (descriptor.type === "enum" || descriptor.type === "enum_int") {
229
+ if (
230
+ descriptor.values &&
231
+ !descriptor.values.some((value) => String(value) === handle)
232
+ ) {
233
+ return operationFail(`field_path:item:${handle}`);
234
+ }
235
+ return enumName(field, handle, descriptor.type, item);
236
+ }
237
+ if (descriptor.type === "integer" || descriptor.type === "number")
238
+ return decimal(handle);
239
+ return handle;
240
+ }
241
+
242
+ function itemIndex(
243
+ array: readonly JsonValue[],
244
+ handle: string,
245
+ descriptor: CatalogField | undefined,
246
+ field: string,
247
+ ): number {
248
+ const identity = descriptor?.item_identity;
249
+ if (!descriptor?.items || !identity)
250
+ return operationFail("field_path:item_identity");
251
+ if (identity.strategy === "fixed") {
252
+ const index = identity.values?.indexOf(handle) ?? -1;
253
+ return index >= 0 && index < array.length
254
+ ? index
255
+ : operationFail(`field_path:item:${handle}`);
256
+ }
257
+ if (identity.strategy === "value") {
258
+ const expected = canonicalIdentity(descriptor.items, field, handle, true);
259
+ const index = array.findIndex((value) => value === expected);
260
+ return index >= 0 ? index : operationFail(`field_path:item:${handle}`);
261
+ }
262
+ const identityField = requiredHandle(
263
+ identity.field ?? "",
264
+ "field_path:item_identity_field",
265
+ );
266
+ const fieldDescriptor =
267
+ descriptor.items.fields?.[identityField] ??
268
+ operationFail("field_path:item_identity_descriptor");
269
+ const expected = canonicalIdentity(
270
+ fieldDescriptor,
271
+ identityField,
272
+ handle,
273
+ false,
274
+ );
275
+ const index = array.findIndex((value) => {
276
+ if (!value || typeof value !== "object" || Array.isArray(value))
277
+ return false;
278
+ return (value as MutableJsonObject)[identityField] === expected;
279
+ });
280
+ return index >= 0 ? index : operationFail(`field_path:item:${handle}`);
281
+ }
282
+
283
+ export function resolvedPath(
284
+ root: JsonValue,
285
+ prefix: readonly JsonPathPart[],
286
+ segments: readonly AIDocumentFieldPathSegment[],
287
+ createMissing: boolean,
288
+ descriptor?: CatalogField,
289
+ field = "value",
290
+ ): JsonPathPart[] {
291
+ const path = [...prefix];
292
+ let current: JsonValue = root;
293
+ for (const part of path) {
294
+ if (typeof part === "string") {
295
+ const object = jsonObject(current, `ai_operation:field_path:${part}`);
296
+ const child = object[part];
297
+ current = child === undefined && createMissing ? {} : child!;
298
+ } else {
299
+ if (!Array.isArray(current) || part < 0 || part >= current.length)
300
+ operationFail("field_path:index");
301
+ current = current[part]!;
302
+ }
303
+ }
304
+ let currentDescriptor = descriptor;
305
+ for (const segment of segments) {
306
+ if (segment.selector.case === "fieldHandle") {
307
+ const key = requiredHandle(
308
+ segment.selector.value,
309
+ "field_path:field_handle",
310
+ );
311
+ const object = jsonObject(current, `ai_operation:field_path:${key}`);
312
+ path.push(key);
313
+ const child = object[key];
314
+ current = child === undefined && createMissing ? {} : child!;
315
+ currentDescriptor = currentDescriptor?.fields?.[key];
316
+ continue;
317
+ }
318
+ if (segment.selector.case === "itemHandle") {
319
+ if (!Array.isArray(current)) operationFail("field_path:not_array");
320
+ const index = itemIndex(
321
+ current,
322
+ requiredHandle(segment.selector.value, "field_path:item_handle"),
323
+ currentDescriptor,
324
+ field,
325
+ );
326
+ path.push(index);
327
+ current = current[index]!;
328
+ currentDescriptor = currentDescriptor?.items;
329
+ continue;
330
+ }
331
+ operationFail("field_path:selector");
332
+ }
333
+ return path;
334
+ }
335
+
336
+ export function resolvedCatalogField(
337
+ descriptor: CatalogField | undefined,
338
+ segments: readonly AIDocumentFieldPathSegment[],
339
+ ): CatalogField | undefined {
340
+ let current = descriptor;
341
+ for (const segment of segments) {
342
+ if (!current) return undefined;
343
+ if (segment.selector.case === "fieldHandle") {
344
+ const field = requiredHandle(
345
+ segment.selector.value,
346
+ "field_path:field_handle",
347
+ );
348
+ current = current.fields?.[field] ?? operationFail(`field:${field}`);
349
+ continue;
350
+ }
351
+ if (segment.selector.case === "itemHandle") {
352
+ current = current.items ?? operationFail("field_path:item_descriptor");
353
+ continue;
354
+ }
355
+ operationFail("field_path:selector");
356
+ }
357
+ return current;
358
+ }
359
+
360
+ export function yPath(path: readonly JsonPathPart[]): string {
361
+ if (path.length === 0) return operationFail("field_path:empty");
362
+ return path
363
+ .map((part, index) =>
364
+ typeof part === "number" ? `[${part}]` : index === 0 ? part : `.${part}`,
365
+ )
366
+ .join("")
367
+ .replace(/\.\[/g, "[");
368
+ }
@@ -0,0 +1,257 @@
1
+ import { toJson } from "@bufbuild/protobuf";
2
+ import {
3
+ PageSectionLocaleSchema,
4
+ PageSectionNodeSchema,
5
+ RichTextBlockLocaleSchema,
6
+ RichTextBlockNodeSchema,
7
+ type LocalizedPageDocument,
8
+ type LocalizedRichTextDocument,
9
+ type PageSectionNode,
10
+ type RichTextBlockNode,
11
+ } from "@echovisionlab/geul-proto/content/block_content_pb.ts";
12
+ import * as Y from "yjs";
13
+ import type { AIDocumentFieldTarget } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
14
+ import {
15
+ BLOCK_ROOM_BASE_NODES,
16
+ BLOCK_ROOM_BASE_ORDER,
17
+ BLOCK_ROOM_LOCALE_OVERLAY,
18
+ BLOCK_ROOM_LOCALE_PRESENCE,
19
+ BLOCK_ROOM_ROOT,
20
+ assertBlockRoomLocaleProjectionParity,
21
+ fail,
22
+ integerValue,
23
+ jsonObject,
24
+ normalizeBlockDocument,
25
+ oneofPayload,
26
+ orderContainerKey,
27
+ pageSectionPayload,
28
+ pageSectionSlot,
29
+ richTextSlot,
30
+ setNodePayload,
31
+ stringValue,
32
+ withoutField,
33
+ type BlockRoomDocumentType,
34
+ type BlockRoomNodeFamily,
35
+ type BlockRoomTypedDocument,
36
+ } from "./internal.ts";
37
+ import { hydrateBlockRoomLocalePresence } from "./locale-presence.ts";
38
+
39
+ interface BaseOrderEntry {
40
+ id: string;
41
+ parentId: string | null;
42
+ containerSlot: string;
43
+ position: number;
44
+ }
45
+
46
+ function hydrateBaseOrder(
47
+ orders: Y.Map<unknown>,
48
+ entries: readonly BaseOrderEntry[],
49
+ ): void {
50
+ const grouped = new Map<string, BaseOrderEntry[]>();
51
+ for (const entry of entries) {
52
+ const key = orderContainerKey(entry.parentId, entry.containerSlot);
53
+ const group = grouped.get(key) ?? [];
54
+ group.push(entry);
55
+ grouped.set(key, group);
56
+ }
57
+ for (const [key, group] of [...grouped.entries()].sort(([left], [right]) =>
58
+ left.localeCompare(right),
59
+ )) {
60
+ group.sort((left, right) => left.position - right.position);
61
+ group.forEach((entry, index) => {
62
+ if (entry.position !== index) fail(`base_order:${key}:not_dense`);
63
+ });
64
+ const order = new Y.Array<string>();
65
+ order.insert(
66
+ 0,
67
+ group.map((entry) => entry.id),
68
+ );
69
+ orders.set(key, order);
70
+ }
71
+ }
72
+
73
+ function hydrateRichTextBase(
74
+ nodes: Y.Map<unknown>,
75
+ orderEntries: BaseOrderEntry[],
76
+ typedNodes: readonly RichTextBlockNode[],
77
+ topLevelParentId?: string,
78
+ ): void {
79
+ for (const typedNode of typedNodes) {
80
+ const node = jsonObject(
81
+ toJson(RichTextBlockNodeSchema, typedNode),
82
+ "rich_node",
83
+ );
84
+ const block = oneofPayload(
85
+ jsonObject(node.block, "rich_node:block"),
86
+ "id",
87
+ "rich_node:block",
88
+ );
89
+ if (!typedNode.placement) fail("rich_node:placement");
90
+ const parentId = typedNode.placement.parentBlockId ?? topLevelParentId;
91
+ const value = new Y.Map<unknown>();
92
+ setNodePayload(value, "rich_text", block.kind, block.payload);
93
+ value.set("parentId", parentId ?? null);
94
+ value.set("containerSlot", richTextSlot());
95
+ orderEntries.push({
96
+ id: block.id,
97
+ parentId: parentId ?? null,
98
+ containerSlot: richTextSlot(),
99
+ position: integerValue(typedNode.placement.index, "rich_node:index"),
100
+ });
101
+ nodes.set(block.id, value);
102
+ }
103
+ }
104
+
105
+ function hydratePageBase(
106
+ nodes: Y.Map<unknown>,
107
+ orderEntries: BaseOrderEntry[],
108
+ typedNodes: readonly PageSectionNode[],
109
+ ): void {
110
+ for (const typedNode of typedNodes) {
111
+ const node = jsonObject(
112
+ toJson(PageSectionNodeSchema, typedNode),
113
+ "page_node",
114
+ );
115
+ const section = pageSectionPayload(
116
+ jsonObject(node.section, "page_node:section"),
117
+ "page_node:section",
118
+ );
119
+ if (!typedNode.placement) fail("page_node:placement");
120
+ const parentId = typedNode.placement.parentSectionId;
121
+ const columnId = typedNode.placement.columnId;
122
+ const value = new Y.Map<unknown>();
123
+ const sectionPayload =
124
+ section.kind === "richText"
125
+ ? withoutField(section.payload, "blocks", "page_node:rich_text")
126
+ : section.payload;
127
+ setNodePayload(value, "page_section", section.kind, sectionPayload);
128
+ value.set("parentId", parentId ?? null);
129
+ const containerSlot = pageSectionSlot(parentId, columnId);
130
+ value.set("containerSlot", containerSlot);
131
+ orderEntries.push({
132
+ id: section.id,
133
+ parentId: parentId ?? null,
134
+ containerSlot,
135
+ position: integerValue(typedNode.placement.index, "page_node:index"),
136
+ });
137
+ if (columnId) value.set("columnId", columnId);
138
+ nodes.set(section.id, value);
139
+ if (typedNode.section?.value.case === "richText") {
140
+ hydrateRichTextBase(
141
+ nodes,
142
+ orderEntries,
143
+ typedNode.section.value.value.blocks?.nodes ?? [],
144
+ section.id,
145
+ );
146
+ }
147
+ }
148
+ }
149
+
150
+ function hydrateLocaleNode(
151
+ target: Y.Map<unknown>,
152
+ json: ReturnType<typeof jsonObject>,
153
+ idField: string,
154
+ family: BlockRoomNodeFamily,
155
+ reason: string,
156
+ ): void {
157
+ const { id, kind, payload } = oneofPayload(json, idField, reason);
158
+ if (target.has(id)) fail(`${reason}:duplicate_id:${id}`);
159
+ const value = new Y.Map<unknown>();
160
+ setNodePayload(value, family, kind, payload);
161
+ target.set(id, value);
162
+ }
163
+
164
+ function hydratePageLocale(
165
+ localeNodes: Y.Map<unknown>,
166
+ document: LocalizedPageDocument,
167
+ ): void {
168
+ const overlay = document.localeOverlay!;
169
+ for (const section of overlay.sections) {
170
+ const json = jsonObject(
171
+ toJson(PageSectionLocaleSchema, section),
172
+ "page_locale",
173
+ );
174
+ const parsed = oneofPayload(json, "sectionId", "page_locale");
175
+ const value = new Y.Map<unknown>();
176
+ const payload =
177
+ parsed.kind === "richText"
178
+ ? withoutField(parsed.payload, "blocks", "page_locale:rich_text")
179
+ : parsed.payload;
180
+ setNodePayload(value, "page_section", parsed.kind, payload);
181
+ if (localeNodes.has(parsed.id))
182
+ fail(`page_locale:duplicate_id:${parsed.id}`);
183
+ localeNodes.set(parsed.id, value);
184
+ if (section.value.case === "richText") {
185
+ for (const block of section.value.value.blocks?.blocks ?? []) {
186
+ hydrateLocaleNode(
187
+ localeNodes,
188
+ jsonObject(
189
+ toJson(RichTextBlockLocaleSchema, block),
190
+ "page_rich_locale",
191
+ ),
192
+ "blockId",
193
+ "rich_text",
194
+ "page_rich_locale",
195
+ );
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ function hydrateRichTextLocale(
202
+ localeNodes: Y.Map<unknown>,
203
+ document: LocalizedRichTextDocument,
204
+ ): void {
205
+ for (const block of document.localeOverlay!.blocks) {
206
+ hydrateLocaleNode(
207
+ localeNodes,
208
+ jsonObject(toJson(RichTextBlockLocaleSchema, block), "rich_locale"),
209
+ "blockId",
210
+ "rich_text",
211
+ "rich_locale",
212
+ );
213
+ }
214
+ }
215
+
216
+ export function hydrateCanonicalBlockRoom(
217
+ yDocument: Y.Doc,
218
+ documentType: BlockRoomDocumentType,
219
+ sourceLocale: string,
220
+ document: BlockRoomTypedDocument,
221
+ presentLocaleValues: readonly AIDocumentFieldTarget[],
222
+ ): void {
223
+ const normalized = normalizeBlockDocument(documentType, document);
224
+ const canonicalSourceLocale = stringValue(sourceLocale, "source_locale");
225
+ const root = yDocument.getMap<unknown>(BLOCK_ROOM_ROOT);
226
+ if (root.size !== 0) fail("room_not_empty");
227
+ const baseNodes = new Y.Map<unknown>();
228
+ const baseOrder = new Y.Map<unknown>();
229
+ const localeOverlay = new Y.Map<unknown>();
230
+ const localePresence = new Y.Map<unknown>();
231
+ const orderEntries: BaseOrderEntry[] = [];
232
+ yDocument.transact(() => {
233
+ root.set("documentType", documentType);
234
+ root.set("blockCatalogFingerprint", normalized.blockCatalogFingerprint);
235
+ root.set("sourceLocale", canonicalSourceLocale);
236
+ root.set("roomLocale", normalized.locale);
237
+ root.set(BLOCK_ROOM_BASE_NODES, baseNodes);
238
+ root.set(BLOCK_ROOM_BASE_ORDER, baseOrder);
239
+ root.set(BLOCK_ROOM_LOCALE_OVERLAY, localeOverlay);
240
+ root.set(BLOCK_ROOM_LOCALE_PRESENCE, localePresence);
241
+ if (normalized.$typeName === "api.content.v1.LocalizedRichTextDocument") {
242
+ root.set("profile", normalized.profile);
243
+ hydrateRichTextBase(
244
+ baseNodes,
245
+ orderEntries,
246
+ normalized.base?.nodes ?? [],
247
+ );
248
+ hydrateRichTextLocale(localeOverlay, normalized);
249
+ } else {
250
+ hydratePageBase(baseNodes, orderEntries, normalized.base?.nodes ?? []);
251
+ hydratePageLocale(localeOverlay, normalized);
252
+ }
253
+ hydrateBaseOrder(baseOrder, orderEntries);
254
+ assertBlockRoomLocaleProjectionParity(yDocument);
255
+ hydrateBlockRoomLocalePresence(yDocument, presentLocaleValues);
256
+ }, "canonical-bootstrap");
257
+ }