@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
package/LICENSE.md ADDED
@@ -0,0 +1,6 @@
1
+ Required Notice: Copyright 2026 Echo Vision Lab. All rights reserved.
2
+
3
+ Licensed under the PolyForm Noncommercial License 1.0.0:
4
+ https://polyformproject.org/licenses/noncommercial/1.0.0
5
+
6
+ Commercial use requires a separate license from Echo Vision Lab.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # Geul common
2
+
3
+ Shared TypeScript contracts and helpers for Geul editors, collaboration,
4
+ localized content, media blocks, pages, posts, and runtime events.
5
+
6
+ ```sh
7
+ pnpm add @echovisionlab/geul-common
8
+ ```
9
+
10
+ The package exports TypeScript source. Consumers must support `.ts` package
11
+ exports. `yjs` is a peer dependency.
12
+
13
+ ## Development
14
+
15
+ ```sh
16
+ corepack enable
17
+ pnpm install --frozen-lockfile
18
+ pnpm format:check
19
+ pnpm lint
20
+ pnpm typecheck
21
+ pnpm test:coverage
22
+ pnpm package:smoke
23
+ ```
24
+
25
+ `@echovisionlab/geul-proto` is the contract source of truth. For coordinated
26
+ local changes, `typecheck:local-contracts` and `test:local-contracts` resolve it
27
+ from the sibling `geul-event-contracts` checkout.
28
+
29
+ ## Release
30
+
31
+ Release Please creates releases from `main`. npm publication uses GitHub
32
+ Actions trusted publishing without a repository npm token.
33
+
34
+ ## License
35
+
36
+ PolyForm Noncommercial 1.0.0. Commercial use requires a separate license from
37
+ Echo Vision Lab. See [LICENSE.md](LICENSE.md).
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@echovisionlab/geul-common",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/echovisionlab/geul-common.git"
9
+ },
10
+ "engines": {
11
+ "node": "24.19.0"
12
+ },
13
+ "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
14
+ "files": [
15
+ "README.md",
16
+ "src",
17
+ "!src/**/*.test.ts"
18
+ ],
19
+ "scripts": {
20
+ "format": "prettier --write .",
21
+ "format:check": "prettier --check .",
22
+ "lint": "eslint --cache .",
23
+ "lint-fix": "eslint --cache . --fix",
24
+ "deadcode": "knip --include files,dependencies,unlisted,exports,types",
25
+ "typecheck": "tsc --noEmit",
26
+ "typecheck:local-contracts": "tsc --noEmit -p tsconfig.local-contracts.json",
27
+ "test": "vitest run",
28
+ "test:local-contracts": "vitest run --config vitest.local-contracts.config.mjs",
29
+ "test:coverage": "vitest run --coverage",
30
+ "package:smoke": "npm pack --dry-run"
31
+ },
32
+ "exports": {
33
+ ".": "./src/index.ts",
34
+ "./collaboration/artist": "./src/collaboration/artist.ts",
35
+ "./collaboration/block-room-codec": "./src/collaboration/block-room-codec.ts",
36
+ "./collaboration/campaign": "./src/collaboration/campaign.ts",
37
+ "./collaboration/document-layout": "./src/collaboration/document-layout.ts",
38
+ "./collaboration/document": "./src/collaboration/document.ts",
39
+ "./collaboration/email-layout": "./src/collaboration/email-layout.ts",
40
+ "./collaboration/form": "./src/collaboration/form.ts",
41
+ "./collaboration/label": "./src/collaboration/label.ts",
42
+ "./collaboration/map-theme": "./src/collaboration/map-theme.ts",
43
+ "./collaboration/menu": "./src/collaboration/menu.ts",
44
+ "./collaboration/metadata-ai": "./src/collaboration/metadata-ai.ts",
45
+ "./collaboration/page": "./src/collaboration/page.ts",
46
+ "./collaboration/post": "./src/collaboration/post.ts",
47
+ "./collaboration/post-series": "./src/collaboration/post-series.ts",
48
+ "./collaboration/release": "./src/collaboration/release.ts",
49
+ "./collaboration/runtime-events": "./src/collaboration/runtime-events.ts",
50
+ "./collaboration/work": "./src/collaboration/work.ts",
51
+ "./editor/materialized-blocks": "./src/editor/materialized-blocks.ts",
52
+ "./editor/link-normalization": "./src/editor/link-normalization.ts",
53
+ "./page": "./src/page/index.ts",
54
+ "./media/block-schemas": "./src/media/block-schemas.ts",
55
+ "./media/hydration": "./src/media/hydration.ts",
56
+ "./post": "./src/post/index.ts",
57
+ "./test/random-id": "./src/test/random-id.ts",
58
+ "./translation/release": "./src/translation/release.ts",
59
+ "./types": "./src/types.ts"
60
+ },
61
+ "dependencies": {
62
+ "@bufbuild/protobuf": "^2.14.0",
63
+ "@echovisionlab/geul-proto": "^0.1.0",
64
+ "zod": "^4.4.3"
65
+ },
66
+ "peerDependencies": {
67
+ "yjs": "^13.6.32"
68
+ },
69
+ "devDependencies": {
70
+ "@types/node": "^24.13.3",
71
+ "@typescript/native": "npm:typescript@7.0.2",
72
+ "@vitest/coverage-v8": "^4.1.10",
73
+ "eslint": "^10.8.1",
74
+ "knip": "6.32.2",
75
+ "prettier": "3.9.6",
76
+ "typescript": "npm:@typescript/typescript6@6.0.2",
77
+ "typescript-eslint": "^8.67.0",
78
+ "vitest": "^4.1.10",
79
+ "yjs": "^13.6.32"
80
+ },
81
+ "license": "PolyForm-Noncommercial-1.0.0",
82
+ "homepage": "https://github.com/echovisionlab/geul-common#readme",
83
+ "bugs": {
84
+ "url": "https://github.com/echovisionlab/geul-common/issues"
85
+ },
86
+ "publishConfig": {
87
+ "access": "public"
88
+ }
89
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+
3
+ export const artistCollabFieldsSchema = z
4
+ .object({
5
+ status: z.enum(["draft", "published"]).optional(),
6
+ realName: z.string().optional(),
7
+ countryCode: z.string().optional(),
8
+ website: z.string().optional(),
9
+ socialLinks: z.record(z.string(), z.string()).optional(),
10
+ slug: z.string().optional(),
11
+ labelIds: z.array(z.string()).optional(),
12
+ parentArtistId: z.string().nullable().optional(),
13
+ })
14
+ .strict();
15
+
16
+ export const ARTIST_JSON_KEYS: ReadonlySet<
17
+ keyof z.infer<typeof artistCollabFieldsSchema>
18
+ > = new Set(["socialLinks", "labelIds"]);
19
+
20
+ export const ARTIST_SOURCE_OWNED_FIELD_KEYS = ["name"] as const;
21
+ export const ARTIST_SHARED_FIELD_KEYS = [
22
+ "status",
23
+ "realName",
24
+ "countryCode",
25
+ "website",
26
+ "socialLinks",
27
+ "slug",
28
+ "labelIds",
29
+ "parentArtistId",
30
+ ] as const;
31
+
32
+ export type ArtistCollabFields = z.infer<typeof artistCollabFieldsSchema>;
33
+ export type ArtistSourceOwnedFieldKey =
34
+ (typeof ARTIST_SOURCE_OWNED_FIELD_KEYS)[number];
35
+ export type ArtistFieldValue =
36
+ string | string[] | Record<string, string> | null;
37
+
38
+ export function extractArtistFields(fieldsMap: {
39
+ get(key: string): ArtistFieldValue | undefined;
40
+ }): ArtistCollabFields {
41
+ const raw: Record<string, unknown> = {};
42
+
43
+ for (const key of Object.keys(artistCollabFieldsSchema.shape)) {
44
+ let value = fieldsMap.get(key);
45
+
46
+ if (
47
+ ARTIST_JSON_KEYS.has(key as keyof ArtistCollabFields) &&
48
+ typeof value === "string"
49
+ ) {
50
+ try {
51
+ value = JSON.parse(value) as ArtistFieldValue;
52
+ } catch {
53
+ throw new Error(`Failed to parse JSON for artist field "${key}"`);
54
+ }
55
+ }
56
+
57
+ if (value !== undefined) {
58
+ raw[key] = value;
59
+ }
60
+ }
61
+
62
+ return artistCollabFieldsSchema.parse(raw);
63
+ }
@@ -0,0 +1,319 @@
1
+ import { create, fromJson } from "@bufbuild/protobuf";
2
+ import {
3
+ richTextBlockCatalog,
4
+ richTextBlockKindByProtoCase,
5
+ type RichTextBlockKind,
6
+ } from "@echovisionlab/geul-proto/content/block_catalog.ts";
7
+ import {
8
+ RichTextBlockDataSchema,
9
+ RichTextBlockLocaleDataSchema,
10
+ RichTextBlockLocaleSchema,
11
+ RichTextBlockNodeSchema,
12
+ type RichTextBlockData,
13
+ type RichTextBlockLocale,
14
+ type RichTextBlockLocaleData,
15
+ type RichTextBlockNode,
16
+ } from "@echovisionlab/geul-proto/content/block_content_pb.ts";
17
+ import {
18
+ AIDocumentFieldTargetSchema,
19
+ type AIDocumentOperation,
20
+ } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
21
+ import * as Y from "yjs";
22
+ import {
23
+ attachAIDocumentFile,
24
+ detachAIDocumentFile,
25
+ setAIDocumentField,
26
+ unsetAIDocumentField,
27
+ } from "./ai-document-field-mutations.ts";
28
+ import { applyPageStructureAIDocumentOperation } from "./ai-document-page-structure-mutations.ts";
29
+ import { operationFail, requiredHandle } from "./ai-document-values.ts";
30
+ import { yMap, type BlockRoomDocumentType } from "./internal.ts";
31
+ import { assertBlockRoomLocaleChangeAllowed } from "./locale-change-validation.ts";
32
+ import {
33
+ canonicalBlockRoomLocaleValueTarget,
34
+ markBlockRoomLocaleValuePresent,
35
+ } from "./locale-presence.ts";
36
+ import {
37
+ decodeCanonicalBlockRoom,
38
+ materializeCanonicalBlockRoom,
39
+ } from "./materialization.ts";
40
+ import { replaceRichTextBlockData } from "./payload-mutations.ts";
41
+ import {
42
+ roomDocumentType,
43
+ roomLocale,
44
+ roomLocaleRole,
45
+ roomNode,
46
+ } from "./room-access.ts";
47
+ import {
48
+ deleteBlockRoomBaseNode,
49
+ insertRichTextBlockNode,
50
+ insertRichTextBlockLocale,
51
+ moveRichTextBlockNode,
52
+ } from "./structure-mutations.ts";
53
+
54
+ export interface ApplyAIDocumentOperationsOptions {
55
+ expectedRoomLocale: string;
56
+ origin: object;
57
+ }
58
+
59
+ const richTextProtoCaseByKind = new Map<string, string>(
60
+ Object.entries(richTextBlockKindByProtoCase).map(([protoCase, kind]) => [
61
+ kind,
62
+ protoCase,
63
+ ]),
64
+ );
65
+
66
+ function siblingPosition(
67
+ yDocument: Y.Doc,
68
+ parentId: string | null,
69
+ afterId: string | undefined,
70
+ movingId?: string,
71
+ ): number {
72
+ const snapshot = decodeCanonicalBlockRoom(
73
+ yDocument,
74
+ roomDocumentType(yDocument),
75
+ );
76
+ const siblings = snapshot.baseNodes
77
+ .filter((node) => node.parentId === parentId && node.id !== movingId)
78
+ .sort((left, right) => left.position - right.position);
79
+ if (afterId === undefined) return 0;
80
+ const after = siblings.findIndex(({ id }) => id === afterId);
81
+ return after < 0
82
+ ? operationFail(`after_block:${afterId}:not_sibling`)
83
+ : after + 1;
84
+ }
85
+
86
+ function richTextNode(
87
+ blockId: string,
88
+ kind: string,
89
+ parentId: string | undefined,
90
+ index: number,
91
+ ): { node: RichTextBlockNode; locale: RichTextBlockLocale } {
92
+ const protoCase = richTextProtoCaseByKind.get(kind);
93
+ if (!protoCase) return operationFail(`block_kind:${kind}`);
94
+ return {
95
+ node: fromJson(RichTextBlockNodeSchema, {
96
+ block: { id: blockId, [protoCase]: {} },
97
+ placement: {
98
+ index,
99
+ ...(parentId ? { parentBlockId: parentId } : {}),
100
+ },
101
+ }) as RichTextBlockNode,
102
+ locale: fromJson(RichTextBlockLocaleSchema, {
103
+ blockId,
104
+ [protoCase]: {},
105
+ }) as RichTextBlockLocale,
106
+ };
107
+ }
108
+
109
+ function insertBlock(
110
+ yDocument: Y.Doc,
111
+ value: Extract<
112
+ AIDocumentOperation["operation"],
113
+ { case: "insertBlock" }
114
+ >["value"],
115
+ ): void {
116
+ const id = requiredHandle(value.blockHandle, "insert:block");
117
+ const kind = requiredHandle(value.kind, "insert:kind");
118
+ const parentId = value.parentBlockHandle ?? null;
119
+ const index = siblingPosition(yDocument, parentId, value.afterBlockHandle);
120
+ const created = richTextNode(id, kind, value.parentBlockHandle, index);
121
+ insertRichTextBlockNode(yDocument, created.node);
122
+ insertRichTextBlockLocale(yDocument, created.locale);
123
+ const content = (
124
+ richTextBlockCatalog as Readonly<
125
+ Record<string, { readonly content: string }>
126
+ >
127
+ )[kind]?.content;
128
+ if (
129
+ roomLocaleRole(yDocument) === "source" &&
130
+ roomDocumentType(yDocument) === "page" &&
131
+ (content === "inline" || content === "locale_text")
132
+ ) {
133
+ const localePayload = yMap(
134
+ roomNode(yDocument, {
135
+ id,
136
+ family: "rich_text",
137
+ locale: true,
138
+ }).get("payload"),
139
+ "insert:locale",
140
+ );
141
+ localePayload.set(
142
+ "content",
143
+ content === "inline" ? new Y.Array<unknown>() : new Y.Text(),
144
+ );
145
+ markBlockRoomLocaleValuePresent(
146
+ yDocument,
147
+ create(AIDocumentFieldTargetSchema, {
148
+ owner: { case: "blockHandle", value: id },
149
+ fieldHandle: "content",
150
+ }),
151
+ );
152
+ }
153
+ }
154
+
155
+ function moveBlock(
156
+ yDocument: Y.Doc,
157
+ blockId: string,
158
+ parentId: string | undefined,
159
+ afterId: string | undefined,
160
+ ): void {
161
+ const snapshot = decodeCanonicalBlockRoom(
162
+ yDocument,
163
+ roomDocumentType(yDocument),
164
+ );
165
+ const node = snapshot.baseNodes.find(({ id }) => id === blockId);
166
+ if (!node) return operationFail(`move:block:${blockId}`);
167
+ const index = siblingPosition(yDocument, parentId ?? null, afterId, blockId);
168
+ moveRichTextBlockNode(yDocument, blockId, {
169
+ ...(parentId ? { parentBlockId: parentId } : {}),
170
+ index,
171
+ });
172
+ }
173
+
174
+ function replaceBlockKind(
175
+ yDocument: Y.Doc,
176
+ blockId: string,
177
+ kind: string,
178
+ ): void {
179
+ const snapshot = decodeCanonicalBlockRoom(
180
+ yDocument,
181
+ roomDocumentType(yDocument),
182
+ );
183
+ const current = snapshot.baseNodes.find(({ id }) => id === blockId);
184
+ if (!current) return operationFail(`replace:block:${blockId}`);
185
+ const protoCase = richTextProtoCaseByKind.get(kind);
186
+ const currentCatalogKind =
187
+ richTextBlockKindByProtoCase[
188
+ current.kind as keyof typeof richTextBlockKindByProtoCase
189
+ ];
190
+ if (!protoCase) return operationFail(`replace:kind:${kind}`);
191
+ const data = fromJson(RichTextBlockDataSchema, {
192
+ [protoCase]: {},
193
+ }) as RichTextBlockData;
194
+ const locale = fromJson(RichTextBlockLocaleDataSchema, {
195
+ [protoCase]: {},
196
+ }) as RichTextBlockLocaleData;
197
+ replaceRichTextBlockData(yDocument, blockId, data, {
198
+ expectedKind: currentCatalogKind as RichTextBlockKind,
199
+ localeData: locale,
200
+ });
201
+ }
202
+
203
+ function applyOperation(
204
+ yDocument: Y.Doc,
205
+ documentType: BlockRoomDocumentType,
206
+ operation: AIDocumentOperation,
207
+ ): void {
208
+ if (
209
+ documentType === "page" &&
210
+ applyPageStructureAIDocumentOperation(yDocument, operation)
211
+ ) {
212
+ return;
213
+ }
214
+ switch (operation.operation.case) {
215
+ case "setField":
216
+ setAIDocumentField(
217
+ yDocument,
218
+ operation.operation.value.target,
219
+ operation.operation.value.value,
220
+ );
221
+ return;
222
+ case "unsetField":
223
+ unsetAIDocumentField(yDocument, operation.operation.value.target);
224
+ return;
225
+ case "insertBlock":
226
+ insertBlock(yDocument, operation.operation.value);
227
+ return;
228
+ case "deleteBlock":
229
+ deleteBlockRoomBaseNode(
230
+ yDocument,
231
+ requiredHandle(operation.operation.value.blockHandle, "delete:block"),
232
+ );
233
+ return;
234
+ case "moveBlock":
235
+ moveBlock(
236
+ yDocument,
237
+ requiredHandle(operation.operation.value.blockHandle, "move:block"),
238
+ operation.operation.value.parentBlockHandle,
239
+ operation.operation.value.afterBlockHandle,
240
+ );
241
+ return;
242
+ case "replaceBlockKind":
243
+ replaceBlockKind(
244
+ yDocument,
245
+ requiredHandle(operation.operation.value.blockHandle, "replace:block"),
246
+ requiredHandle(operation.operation.value.kind, "replace:kind"),
247
+ );
248
+ return;
249
+ case "attachFile":
250
+ attachAIDocumentFile(
251
+ yDocument,
252
+ operation.operation.value.target,
253
+ operation.operation.value.fileHandle,
254
+ );
255
+ return;
256
+ case "detachFile":
257
+ detachAIDocumentFile(yDocument, operation.operation.value.target);
258
+ return;
259
+ case "insertRelationItem":
260
+ case "deleteRelationItem":
261
+ case "moveRelationItem":
262
+ case "createTranslation":
263
+ case "deleteTranslation":
264
+ operationFail(`unsupported:${operation.operation.case}`);
265
+ case undefined:
266
+ operationFail("missing_kind");
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Applies one already-authorized and normalized DCDP batch to a Block Room.
272
+ * The resident document stays untouched unless the complete result validates.
273
+ */
274
+ export function applyAIDocumentOperationsToBlockRoom(
275
+ yDocument: Y.Doc,
276
+ documentType: BlockRoomDocumentType,
277
+ operations: readonly AIDocumentOperation[],
278
+ options: ApplyAIDocumentOperationsOptions,
279
+ ): boolean {
280
+ if (roomDocumentType(yDocument) !== documentType)
281
+ operationFail("document_type");
282
+ if (
283
+ !options.expectedRoomLocale ||
284
+ roomLocale(yDocument) !== options.expectedRoomLocale
285
+ )
286
+ operationFail("room_locale");
287
+ if (!options.origin || typeof options.origin !== "object")
288
+ operationFail("origin");
289
+ if (operations.length === 0) return false;
290
+ if (
291
+ operations.some(({ operation }) => {
292
+ if (operation.case !== "unsetField") return false;
293
+ if (roomLocaleRole(yDocument) === "target") return true;
294
+ try {
295
+ canonicalBlockRoomLocaleValueTarget(yDocument, operation.value.target);
296
+ return true;
297
+ } catch {
298
+ return operation.value.target?.fieldHandle === "tableContent";
299
+ }
300
+ })
301
+ ) {
302
+ operationFail("locale_clear");
303
+ }
304
+ const before = Y.encodeStateVector(yDocument);
305
+ const working = new Y.Doc();
306
+ let update: Uint8Array;
307
+ try {
308
+ Y.applyUpdate(working, Y.encodeStateAsUpdate(yDocument));
309
+ for (const operation of operations)
310
+ applyOperation(working, documentType, operation);
311
+ assertBlockRoomLocaleChangeAllowed(yDocument, working, documentType);
312
+ materializeCanonicalBlockRoom(working, documentType);
313
+ update = Y.encodeStateAsUpdate(working, before);
314
+ } finally {
315
+ working.destroy();
316
+ }
317
+ Y.applyUpdate(yDocument, update, options.origin);
318
+ return true;
319
+ }