@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,185 @@
1
+ /**
2
+ * Resident collaboration document identity.
3
+ *
4
+ * Every room uses `{type}:{entity UUID}:{canonical locale}`. Source and target
5
+ * are current roles derived by the owning API, never document-name segments.
6
+ */
7
+
8
+ import { CollaborativeDocumentType } from "@echovisionlab/geul-proto/secure/collaboration_pb.ts";
9
+ import type * as Y from "yjs";
10
+ import { z } from "zod";
11
+ import { memberIdSchema } from "./member-id.ts";
12
+ import type { BlockRoomDocumentType } from "./block-room-codec.ts";
13
+
14
+ export { CollaborativeDocumentType };
15
+
16
+ const CANONICAL_UUID_PATTERN =
17
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
18
+
19
+ type SupportedCollaborativeDocumentType = Exclude<
20
+ CollaborativeDocumentType,
21
+ CollaborativeDocumentType.UNSPECIFIED
22
+ >;
23
+
24
+ const documentPrefixes = {
25
+ [CollaborativeDocumentType.POST]: "post",
26
+ [CollaborativeDocumentType.PAGE]: "page",
27
+ [CollaborativeDocumentType.WORK]: "work",
28
+ [CollaborativeDocumentType.CAMPAIGN]: "campaign",
29
+ [CollaborativeDocumentType.EMAIL_TEMPLATE]: "email-template",
30
+ [CollaborativeDocumentType.EMAIL_LAYOUT]: "email-layout",
31
+ [CollaborativeDocumentType.TERMS_HISTORY]: "terms-history",
32
+ [CollaborativeDocumentType.PRIVACY_HISTORY]: "privacy-history",
33
+ [CollaborativeDocumentType.ARTIST]: "artist",
34
+ [CollaborativeDocumentType.RELEASE]: "release",
35
+ [CollaborativeDocumentType.LABEL]: "label",
36
+ [CollaborativeDocumentType.FORM]: "form",
37
+ [CollaborativeDocumentType.MAP_THEME]: "map-theme",
38
+ [CollaborativeDocumentType.PROGRAM_EVENT]: "program-event",
39
+ [CollaborativeDocumentType.MENU]: "menu",
40
+ [CollaborativeDocumentType.POST_SERIES]: "post-series",
41
+ } as const satisfies Record<SupportedCollaborativeDocumentType, string>;
42
+
43
+ const documentTypesByPrefix = new Map<
44
+ string,
45
+ SupportedCollaborativeDocumentType
46
+ >(
47
+ Object.entries(documentPrefixes).map(([type, prefix]) => [
48
+ prefix,
49
+ Number(type) as SupportedCollaborativeDocumentType,
50
+ ]),
51
+ );
52
+
53
+ function assertEntityId(entityId: string): void {
54
+ if (!CANONICAL_UUID_PATTERN.test(entityId)) {
55
+ throw new Error(`Invalid collaboration entity UUID: ${entityId}`);
56
+ }
57
+ }
58
+
59
+ function assertCanonicalLocale(locale: string): void {
60
+ let canonical: string | undefined;
61
+ try {
62
+ [canonical] = Intl.getCanonicalLocales(locale);
63
+ } catch {
64
+ throw new Error(`Invalid collaboration locale: ${locale}`);
65
+ }
66
+ if (canonical !== locale || locale === "source" || locale === "target") {
67
+ throw new Error(`Invalid collaboration locale: ${locale}`);
68
+ }
69
+ }
70
+
71
+ function assertDocumentLocale(
72
+ type: SupportedCollaborativeDocumentType,
73
+ locale: string,
74
+ ): void {
75
+ assertCanonicalLocale(locale);
76
+ if (type === CollaborativeDocumentType.MAP_THEME && locale !== "und") {
77
+ throw new Error("Map Theme collaboration uses the locale-neutral und room");
78
+ }
79
+ }
80
+
81
+ export function residentBlockDocumentType(
82
+ type: CollaborativeDocumentType,
83
+ ): BlockRoomDocumentType | undefined {
84
+ switch (type) {
85
+ case CollaborativeDocumentType.POST:
86
+ return "post";
87
+ case CollaborativeDocumentType.PAGE:
88
+ return "page";
89
+ case CollaborativeDocumentType.WORK:
90
+ return "work";
91
+ case CollaborativeDocumentType.PROGRAM_EVENT:
92
+ return "program-event";
93
+ case CollaborativeDocumentType.ARTIST:
94
+ return "artist";
95
+ case CollaborativeDocumentType.LABEL:
96
+ return "label";
97
+ case CollaborativeDocumentType.RELEASE:
98
+ return "release";
99
+ case CollaborativeDocumentType.CAMPAIGN:
100
+ return "campaign";
101
+ case CollaborativeDocumentType.EMAIL_TEMPLATE:
102
+ return "email-template";
103
+ case CollaborativeDocumentType.TERMS_HISTORY:
104
+ return "terms-history";
105
+ case CollaborativeDocumentType.PRIVACY_HISTORY:
106
+ return "privacy-history";
107
+ default:
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ export interface ParsedDocument {
113
+ type: CollaborativeDocumentType;
114
+ entityId: string;
115
+ locale: string;
116
+ }
117
+
118
+ export const documentSaveOptionsSchema = z
119
+ .object({
120
+ /** Distinct authenticated Members whose edits are included in this persisted state. */
121
+ contributorMemberIds: z.array(memberIdSchema).optional(),
122
+ /** Requests a source version checkpoint after ordinary durable persistence. */
123
+ versionCheckpoint: z.boolean().optional(),
124
+ })
125
+ .strict();
126
+
127
+ export type DocumentSaveOptions = z.infer<typeof documentSaveOptionsSchema>;
128
+
129
+ export function parseDocumentSaveOptions(value: unknown): DocumentSaveOptions {
130
+ return documentSaveOptionsSchema.parse(value);
131
+ }
132
+
133
+ export interface DocumentHandler {
134
+ /** Declares that this domain adapter supports generic collaboration version checkpoints. */
135
+ supportsVersionCheckpoints?: boolean;
136
+ store(id: string, doc: Y.Doc, options?: DocumentSaveOptions): Promise<void>;
137
+ load(id: string): Promise<Buffer | null>;
138
+ }
139
+
140
+ function getDocumentPrefix(type: CollaborativeDocumentType): string {
141
+ const prefix = documentPrefixes[type as SupportedCollaborativeDocumentType];
142
+ if (!prefix) throw new Error(`Unknown document type: ${type}`);
143
+ return prefix;
144
+ }
145
+
146
+ /**
147
+ * Create a document name from type, canonical entity UUID, and canonical locale.
148
+ * Always use this function instead of manual string concatenation.
149
+ *
150
+ * @example
151
+ * createDocumentName(CollaborativeDocumentType.POST, entityId, 'ko')
152
+ */
153
+ export function createDocumentName(
154
+ type: CollaborativeDocumentType,
155
+ entityId: string,
156
+ locale: string,
157
+ ): string {
158
+ assertEntityId(entityId);
159
+ const prefix = getDocumentPrefix(type);
160
+ assertDocumentLocale(type as SupportedCollaborativeDocumentType, locale);
161
+ return `${prefix}:${entityId}:${locale}`;
162
+ }
163
+
164
+ /**
165
+ * Parse and validate a canonical resident document name.
166
+ *
167
+ * @example
168
+ * parseDocumentName(`post:${entityId}:ko`)
169
+ *
170
+ * @throws Error if document name format is invalid
171
+ */
172
+ export function parseDocumentName(documentName: string): ParsedDocument {
173
+ const segments = documentName.split(":");
174
+ if (segments.length !== 3) {
175
+ throw new Error(`Invalid document name format: ${documentName}`);
176
+ }
177
+ const [prefix, entityId, locale] = segments;
178
+ const type = documentTypesByPrefix.get(prefix!);
179
+ if (type === undefined || !entityId || !locale) {
180
+ throw new Error(`Invalid document name format: ${documentName}`);
181
+ }
182
+ assertEntityId(entityId);
183
+ assertDocumentLocale(type, locale);
184
+ return { type, entityId, locale };
185
+ }
@@ -0,0 +1,150 @@
1
+ import * as Y from "yjs";
2
+
3
+ export const EMAIL_LAYOUT_CONTEXT_MAP_NAME = "email-layout-context";
4
+ export const EMAIL_LAYOUT_HTML_TEXT_NAME = "html-content";
5
+ export const EMAIL_LAYOUT_UNITS_ARRAY_NAME = "email-layout-units";
6
+ export const EMAIL_LAYOUT_LOCALE_VALUES_MAP_NAME = "email-layout-locale-values";
7
+
8
+ export type EmailLayoutUnitKind = "text" | "attribute";
9
+
10
+ export interface EmailLayoutUnit {
11
+ handle: string;
12
+ kind: EmailLayoutUnitKind;
13
+ element: string;
14
+ attribute: string;
15
+ order: number;
16
+ sourceValue: string;
17
+ }
18
+
19
+ export interface MaterializedEmailLayoutUnit extends EmailLayoutUnit {
20
+ value: string;
21
+ localeValuePresent: boolean;
22
+ }
23
+
24
+ export interface EmailLayoutCanonicalRoomInput {
25
+ sourceLocale: string;
26
+ locale: string;
27
+ localeExists: boolean;
28
+ contentHtml: string;
29
+ units?: readonly EmailLayoutUnit[];
30
+ localeValues?: Readonly<Record<string, string>>;
31
+ }
32
+
33
+ export function emailLayoutLocaleValuesMap(document: Y.Doc): Y.Map<string> {
34
+ return document.getMap<string>(EMAIL_LAYOUT_LOCALE_VALUES_MAP_NAME);
35
+ }
36
+
37
+ export function hydrateEmailLayoutCanonicalRoom(
38
+ input: EmailLayoutCanonicalRoomInput,
39
+ ): Y.Doc {
40
+ const document = new Y.Doc();
41
+ document.transact(() => {
42
+ const context = document.getMap<string | boolean>(
43
+ EMAIL_LAYOUT_CONTEXT_MAP_NAME,
44
+ );
45
+ context.set("sourceLocale", input.sourceLocale);
46
+ context.set("locale", input.locale);
47
+ context.set("localeExists", input.localeExists);
48
+ if (input.locale === input.sourceLocale) {
49
+ if (input.contentHtml.length > 0) {
50
+ document
51
+ .getText(EMAIL_LAYOUT_HTML_TEXT_NAME)
52
+ .insert(0, input.contentHtml);
53
+ }
54
+ return;
55
+ }
56
+ const units = [...(input.units ?? [])].sort(
57
+ (left, right) => left.order - right.order,
58
+ );
59
+ assertEmailLayoutUnits(units);
60
+ document
61
+ .getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
62
+ .insert(0, units);
63
+ const values = emailLayoutLocaleValuesMap(document);
64
+ for (const [handle, value] of Object.entries(input.localeValues ?? {})) {
65
+ if (units.some((unit) => unit.handle === handle)) {
66
+ values.set(handle, value);
67
+ }
68
+ }
69
+ });
70
+ return document;
71
+ }
72
+
73
+ export function materializeEmailLayoutUnits(
74
+ document: Y.Doc,
75
+ ): MaterializedEmailLayoutUnit[] {
76
+ const values = emailLayoutLocaleValuesMap(document);
77
+ return document
78
+ .getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
79
+ .toArray()
80
+ .sort((left, right) => left.order - right.order)
81
+ .map((unit) => ({
82
+ ...unit,
83
+ value: values.has(unit.handle)
84
+ ? (values.get(unit.handle) ?? "")
85
+ : unit.sourceValue,
86
+ localeValuePresent: values.has(unit.handle),
87
+ }));
88
+ }
89
+
90
+ export function setEmailLayoutLocaleValue(
91
+ document: Y.Doc,
92
+ handle: string,
93
+ value: string,
94
+ ): void {
95
+ requireEmailLayoutHandle(document, handle);
96
+ emailLayoutLocaleValuesMap(document).set(handle, value);
97
+ }
98
+
99
+ export function unsetEmailLayoutLocaleValue(
100
+ document: Y.Doc,
101
+ handle: string,
102
+ ): void {
103
+ requireEmailLayoutHandle(document, handle);
104
+ emailLayoutLocaleValuesMap(document).delete(handle);
105
+ }
106
+
107
+ export function extractEmailLayoutLocaleValues(
108
+ document: Y.Doc,
109
+ ): Record<string, string> {
110
+ const allowed = new Set(
111
+ document
112
+ .getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
113
+ .toArray()
114
+ .map((unit) => unit.handle),
115
+ );
116
+ const output: Record<string, string> = {};
117
+ for (const [handle, value] of emailLayoutLocaleValuesMap(document)) {
118
+ if (!allowed.has(handle)) {
119
+ throw new Error(`Unknown Email Layout unit handle: ${handle}`);
120
+ }
121
+ output[handle] = value;
122
+ }
123
+ return output;
124
+ }
125
+
126
+ function requireEmailLayoutHandle(document: Y.Doc, handle: string): void {
127
+ if (
128
+ !document
129
+ .getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
130
+ .toArray()
131
+ .some((unit) => unit.handle === handle)
132
+ ) {
133
+ throw new Error(`Unknown Email Layout unit handle: ${handle}`);
134
+ }
135
+ }
136
+
137
+ function assertEmailLayoutUnits(units: readonly EmailLayoutUnit[]): void {
138
+ const handles = new Set<string>();
139
+ for (const unit of units) {
140
+ if (
141
+ !unit.handle ||
142
+ handles.has(unit.handle) ||
143
+ !Number.isSafeInteger(unit.order) ||
144
+ unit.order < 0
145
+ ) {
146
+ throw new Error("Invalid Email Layout unit catalog");
147
+ }
148
+ handles.add(unit.handle);
149
+ }
150
+ }