@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,513 @@
1
+ import type { AIDocumentFieldTarget } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
2
+ import {
3
+ formFieldCheckboxLabelTarget,
4
+ formFieldDescriptionTarget,
5
+ formFieldLabelTarget,
6
+ formFieldPlaceholderTarget,
7
+ formOptionLabelTarget,
8
+ formRootTitleTarget,
9
+ formStepDescriptionTarget,
10
+ formStepTitleTarget,
11
+ formValidatorMessageTarget,
12
+ } from "@echovisionlab/geul-proto/intra/form_locale_catalog.ts";
13
+ import * as Y from "yjs";
14
+ import { z } from "zod";
15
+
16
+ export const FORM_FIELDS_MAP_NAME = "form-fields";
17
+ export const FORM_LOCALE_PRESENCE_MAP_NAME = "form-locale-presence";
18
+ export const FORM_CANONICAL_CONTEXT_MAP_NAME = "form-canonical-context";
19
+
20
+ export const formCollabFieldsSchema = z
21
+ .object({
22
+ title: z.string().optional(),
23
+ schema: z.unknown().optional(),
24
+ })
25
+ .strict();
26
+
27
+ export const FORM_JSON_KEYS: ReadonlySet<
28
+ keyof z.infer<typeof formCollabFieldsSchema>
29
+ > = new Set(["schema"]);
30
+
31
+ export type FormCollabFields = z.infer<typeof formCollabFieldsSchema>;
32
+ export type FormFieldValue = unknown;
33
+
34
+ export interface FormCanonicalRoomInput {
35
+ sourceLocale: string;
36
+ locale: string;
37
+ source: FormCollabFields;
38
+ requested: FormCollabFields;
39
+ requestedExists: boolean;
40
+ presentLocaleValues: readonly AIDocumentFieldTarget[];
41
+ }
42
+
43
+ export interface FormCanonicalRoomOutput {
44
+ fields: FormCollabFields;
45
+ presentLocaleValues: AIDocumentFieldTarget[];
46
+ }
47
+
48
+ type JsonObject = Record<string, unknown>;
49
+ type FormObjectKind = "step" | "field" | "option" | "validator";
50
+
51
+ interface FormLocaleSlot {
52
+ field: string;
53
+ key: string;
54
+ kind: FormObjectKind;
55
+ object: JsonObject;
56
+ stableId: string;
57
+ target: AIDocumentFieldTarget;
58
+ }
59
+
60
+ interface FormObjectIndexes {
61
+ step: Map<string, JsonObject>;
62
+ field: Map<string, JsonObject>;
63
+ option: Map<string, JsonObject>;
64
+ validator: Map<string, JsonObject>;
65
+ }
66
+
67
+ function fail(reason: string): never {
68
+ throw new Error(`form_collaboration:${reason}`);
69
+ }
70
+
71
+ function isObject(value: unknown): value is JsonObject {
72
+ return typeof value === "object" && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ function object(value: unknown, reason: string): JsonObject {
76
+ return isObject(value) ? value : fail(reason);
77
+ }
78
+
79
+ function array(value: unknown, reason: string): unknown[] {
80
+ return Array.isArray(value) ? value : fail(reason);
81
+ }
82
+
83
+ function string(value: unknown, reason: string): string {
84
+ return typeof value === "string" ? value : fail(reason);
85
+ }
86
+
87
+ function own(objectValue: JsonObject, key: string): boolean {
88
+ return Object.prototype.hasOwnProperty.call(objectValue, key);
89
+ }
90
+
91
+ function clone<T>(value: T): T {
92
+ return structuredClone(value);
93
+ }
94
+
95
+ function stableJson(value: unknown): string {
96
+ if (Array.isArray(value)) {
97
+ return `[${value.map(stableJson).join(",")}]`;
98
+ }
99
+ if (isObject(value)) {
100
+ return `{${Object.keys(value)
101
+ .sort()
102
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
103
+ .join(",")}}`;
104
+ }
105
+ return JSON.stringify(value);
106
+ }
107
+
108
+ function targetKey(target: AIDocumentFieldTarget): string {
109
+ if (
110
+ target.owner.case !== "blockHandle" ||
111
+ target.owner.value === "" ||
112
+ target.fieldHandle === "" ||
113
+ target.path.length !== 0
114
+ ) {
115
+ return fail("locale_presence_target");
116
+ }
117
+ return `${target.owner.value}\u0000${target.fieldHandle}`;
118
+ }
119
+
120
+ function canonicalTargets(
121
+ values: readonly AIDocumentFieldTarget[],
122
+ ): AIDocumentFieldTarget[] {
123
+ const targets = new Map<string, AIDocumentFieldTarget>();
124
+ for (const value of values) {
125
+ const key = targetKey(value);
126
+ if (targets.has(key)) return fail("locale_presence_duplicate");
127
+ targets.set(key, value);
128
+ }
129
+ return [...targets.entries()]
130
+ .sort(([left], [right]) => left.localeCompare(right))
131
+ .map(([, target]) => target);
132
+ }
133
+
134
+ function schemaObject(value: unknown): JsonObject {
135
+ const schema = object(value, "schema");
136
+ string(schema.id, "schema_id");
137
+ array(schema.steps, "schema_steps");
138
+ return schema;
139
+ }
140
+
141
+ function stableId(value: JsonObject, reason: string): string {
142
+ return string(value.id, reason);
143
+ }
144
+
145
+ function addIndex(
146
+ index: Map<string, JsonObject>,
147
+ value: JsonObject,
148
+ reason: string,
149
+ ): string {
150
+ const id = stableId(value, reason);
151
+ if (index.has(id)) return fail(`${reason}_duplicate`);
152
+ index.set(id, value);
153
+ return id;
154
+ }
155
+
156
+ function formObjectIndexes(schemaValue: JsonObject): FormObjectIndexes {
157
+ const indexes: FormObjectIndexes = {
158
+ step: new Map(),
159
+ field: new Map(),
160
+ option: new Map(),
161
+ validator: new Map(),
162
+ };
163
+ for (const rawStep of array(schemaValue.steps, "schema_steps")) {
164
+ const step = object(rawStep, "step");
165
+ addIndex(indexes.step, step, "step_id");
166
+ for (const rawField of Array.isArray(step.fields) ? step.fields : []) {
167
+ const field = object(rawField, "field");
168
+ addIndex(indexes.field, field, "field_id");
169
+ for (const rawOption of Array.isArray(field.options)
170
+ ? field.options
171
+ : []) {
172
+ addIndex(indexes.option, object(rawOption, "option"), "option_id");
173
+ }
174
+ const validation = isObject(field.validation) ? field.validation : {};
175
+ for (const rawValidator of Array.isArray(validation.validators)
176
+ ? validation.validators
177
+ : []) {
178
+ addIndex(
179
+ indexes.validator,
180
+ object(rawValidator, "validator"),
181
+ "validator_id",
182
+ );
183
+ }
184
+ }
185
+ }
186
+ return indexes;
187
+ }
188
+
189
+ function visitLocaleSlots(
190
+ schemaValue: JsonObject,
191
+ visit: (slot: FormLocaleSlot) => void,
192
+ ): void {
193
+ const indexes = formObjectIndexes(schemaValue);
194
+ for (const [stepId, step] of indexes.step) {
195
+ for (const [field, target] of [
196
+ ["title", formStepTitleTarget(stepId)],
197
+ ["description", formStepDescriptionTarget(stepId)],
198
+ ] as const) {
199
+ visit({
200
+ field,
201
+ key: targetKey(target),
202
+ kind: "step",
203
+ object: step,
204
+ stableId: stepId,
205
+ target,
206
+ });
207
+ }
208
+ }
209
+ for (const [fieldId, fieldValue] of indexes.field) {
210
+ for (const [field, target] of [
211
+ ["label", formFieldLabelTarget(fieldId)],
212
+ ["description", formFieldDescriptionTarget(fieldId)],
213
+ ["placeholder", formFieldPlaceholderTarget(fieldId)],
214
+ ["checkboxLabel", formFieldCheckboxLabelTarget(fieldId)],
215
+ ] as const) {
216
+ visit({
217
+ field,
218
+ key: targetKey(target),
219
+ kind: "field",
220
+ object: fieldValue,
221
+ stableId: fieldId,
222
+ target,
223
+ });
224
+ }
225
+ }
226
+ for (const [optionId, option] of indexes.option) {
227
+ const target = formOptionLabelTarget(optionId);
228
+ visit({
229
+ field: "label",
230
+ key: targetKey(target),
231
+ kind: "option",
232
+ object: option,
233
+ stableId: optionId,
234
+ target,
235
+ });
236
+ }
237
+ for (const [validatorId, validator] of indexes.validator) {
238
+ const target = formValidatorMessageTarget(validatorId);
239
+ visit({
240
+ field: "message",
241
+ key: targetKey(target),
242
+ kind: "validator",
243
+ object: validator,
244
+ stableId: validatorId,
245
+ target,
246
+ });
247
+ }
248
+ }
249
+
250
+ function localeTargets(fields: FormCollabFields): AIDocumentFieldTarget[] {
251
+ const values: AIDocumentFieldTarget[] = [];
252
+ if (fields.title !== undefined) values.push(formRootTitleTarget());
253
+ if (fields.schema !== undefined) {
254
+ visitLocaleSlots(schemaObject(fields.schema), (slot) => {
255
+ if (own(slot.object, slot.field)) {
256
+ string(slot.object[slot.field], `${slot.kind}_${slot.field}`);
257
+ values.push(slot.target);
258
+ }
259
+ });
260
+ }
261
+ return canonicalTargets(values);
262
+ }
263
+
264
+ function stripLocaleFields(schemaValue: JsonObject): JsonObject {
265
+ const stripped = clone(schemaValue);
266
+ visitLocaleSlots(stripped, (slot) => {
267
+ delete slot.object[slot.field];
268
+ });
269
+ return stripped;
270
+ }
271
+
272
+ function requestedIndexes(
273
+ source: JsonObject,
274
+ requested: FormCollabFields,
275
+ ): FormObjectIndexes {
276
+ if (requested.schema === undefined) {
277
+ return formObjectIndexes(stripLocaleFields(source));
278
+ }
279
+ const requestedSchema = schemaObject(requested.schema);
280
+ if (
281
+ stableJson(stripLocaleFields(source)) !==
282
+ stableJson(stripLocaleFields(requestedSchema))
283
+ ) {
284
+ return fail("target_topology");
285
+ }
286
+ return formObjectIndexes(requestedSchema);
287
+ }
288
+
289
+ function indexedObject(
290
+ indexes: FormObjectIndexes,
291
+ slot: FormLocaleSlot,
292
+ ): JsonObject {
293
+ return indexes[slot.kind].get(slot.stableId) ?? fail("target_identity");
294
+ }
295
+
296
+ function assertExactPresence(
297
+ requested: FormCollabFields,
298
+ presentLocaleValues: readonly AIDocumentFieldTarget[],
299
+ ): AIDocumentFieldTarget[] {
300
+ const canonical = canonicalTargets(presentLocaleValues);
301
+ const derived = localeTargets(requested);
302
+ if (
303
+ canonical.map(targetKey).join("\n") !== derived.map(targetKey).join("\n")
304
+ ) {
305
+ return fail("locale_presence_mismatch");
306
+ }
307
+ return canonical;
308
+ }
309
+
310
+ function materializedFields(input: FormCanonicalRoomInput): FormCollabFields {
311
+ const sourceSchema = schemaObject(input.source.schema);
312
+ if (input.locale === input.sourceLocale) {
313
+ if (!input.requestedExists) return fail("source_missing");
314
+ assertExactPresence(input.source, input.presentLocaleValues);
315
+ return clone(input.source);
316
+ }
317
+ if (!input.requestedExists) return fail("target_missing");
318
+ const present = new Set(
319
+ assertExactPresence(input.requested, input.presentLocaleValues).map(
320
+ targetKey,
321
+ ),
322
+ );
323
+ const materialized = clone(sourceSchema);
324
+ const requested = requestedIndexes(sourceSchema, input.requested);
325
+ visitLocaleSlots(materialized, (slot) => {
326
+ if (!present.has(slot.key)) return;
327
+ const requestedObject = indexedObject(requested, slot);
328
+ if (!own(requestedObject, slot.field)) return fail("target_value_missing");
329
+ slot.object[slot.field] = string(
330
+ requestedObject[slot.field],
331
+ "target_value",
332
+ );
333
+ });
334
+ return {
335
+ ...(present.has(targetKey(formRootTitleTarget()))
336
+ ? { title: input.requested.title ?? fail("target_title_missing") }
337
+ : input.source.title === undefined
338
+ ? {}
339
+ : { title: input.source.title }),
340
+ schema: materialized,
341
+ };
342
+ }
343
+
344
+ function presenceMap(document: Y.Doc): Y.Map<boolean> {
345
+ return document.getMap<boolean>(FORM_LOCALE_PRESENCE_MAP_NAME);
346
+ }
347
+
348
+ function contextMap(document: Y.Doc): Y.Map<string> {
349
+ return document.getMap<string>(FORM_CANONICAL_CONTEXT_MAP_NAME);
350
+ }
351
+
352
+ function setPresence(
353
+ document: Y.Doc,
354
+ values: readonly AIDocumentFieldTarget[],
355
+ ): void {
356
+ const map = presenceMap(document);
357
+ for (const key of [...map.keys()]) map.delete(key);
358
+ for (const target of canonicalTargets(values))
359
+ map.set(targetKey(target), true);
360
+ }
361
+
362
+ function readPresenceKeys(document: Y.Doc): Set<string> {
363
+ const keys = new Set<string>();
364
+ for (const [key, value] of presenceMap(document).entries()) {
365
+ if (value !== true) fail("locale_presence_value");
366
+ keys.add(key);
367
+ }
368
+ return keys;
369
+ }
370
+
371
+ function targetByKey(
372
+ source: FormCollabFields,
373
+ ): Map<string, AIDocumentFieldTarget> {
374
+ const values = new Map<string, AIDocumentFieldTarget>();
375
+ values.set(targetKey(formRootTitleTarget()), formRootTitleTarget());
376
+ visitLocaleSlots(schemaObject(source.schema), (slot) => {
377
+ values.set(slot.key, slot.target);
378
+ });
379
+ return values;
380
+ }
381
+
382
+ export function hydrateFormCanonicalRoom(input: FormCanonicalRoomInput): Y.Doc {
383
+ if (input.sourceLocale === "" || input.locale === "") {
384
+ return fail("locale");
385
+ }
386
+ const fields = materializedFields(input);
387
+ const document = new Y.Doc();
388
+ document.transact(() => {
389
+ const map = document.getMap<unknown>(FORM_FIELDS_MAP_NAME);
390
+ if (fields.title !== undefined) map.set("title", fields.title);
391
+ if (fields.schema !== undefined)
392
+ map.set("schema", JSON.stringify(fields.schema));
393
+ setPresence(document, input.presentLocaleValues);
394
+ const context = contextMap(document);
395
+ context.set("sourceLocale", input.sourceLocale);
396
+ context.set("locale", input.locale);
397
+ });
398
+ return document;
399
+ }
400
+
401
+ export function extractFormCanonicalRoom(
402
+ document: Y.Doc,
403
+ source: FormCollabFields,
404
+ ): FormCanonicalRoomOutput {
405
+ const sourceLocale = contextMap(document).get("sourceLocale");
406
+ const locale = contextMap(document).get("locale");
407
+ if (!sourceLocale || !locale) return fail("context");
408
+ const materialized = extractFormFields(
409
+ document.getMap<FormFieldValue>(FORM_FIELDS_MAP_NAME),
410
+ );
411
+ schemaObject(materialized.schema);
412
+ if (locale === sourceLocale) {
413
+ return {
414
+ fields: materialized,
415
+ presentLocaleValues: localeTargets(materialized),
416
+ };
417
+ }
418
+
419
+ const sourceSchema = schemaObject(source.schema);
420
+ const materializedSchema = schemaObject(materialized.schema);
421
+ if (
422
+ stableJson(stripLocaleFields(sourceSchema)) !==
423
+ stableJson(stripLocaleFields(materializedSchema))
424
+ ) {
425
+ return fail("target_topology");
426
+ }
427
+ const allowed = targetByKey(source);
428
+ const present = readPresenceKeys(document);
429
+ for (const key of present) {
430
+ if (!allowed.has(key)) return fail("locale_presence_unknown");
431
+ }
432
+ const sparseSchema = stripLocaleFields(sourceSchema);
433
+ const materializedIndexes = formObjectIndexes(materializedSchema);
434
+ visitLocaleSlots(sparseSchema, (slot) => {
435
+ if (!present.has(slot.key)) return;
436
+ const sourceObject = indexedObject(materializedIndexes, slot);
437
+ if (!own(sourceObject, slot.field)) return fail("target_value_missing");
438
+ slot.object[slot.field] = string(sourceObject[slot.field], "target_value");
439
+ });
440
+ const presentLocaleValues = [...present]
441
+ .sort((left, right) => left.localeCompare(right))
442
+ .map((key) => allowed.get(key) ?? fail("locale_presence_unknown"));
443
+ return {
444
+ fields: {
445
+ ...(present.has(targetKey(formRootTitleTarget()))
446
+ ? { title: materialized.title ?? fail("target_title_missing") }
447
+ : {}),
448
+ schema: sparseSchema,
449
+ },
450
+ presentLocaleValues,
451
+ };
452
+ }
453
+
454
+ function localeValuesByKey(fields: FormCollabFields): Map<string, string> {
455
+ const values = new Map<string, string>();
456
+ if (fields.title !== undefined)
457
+ values.set(targetKey(formRootTitleTarget()), fields.title);
458
+ if (fields.schema !== undefined) {
459
+ visitLocaleSlots(schemaObject(fields.schema), (slot) => {
460
+ if (own(slot.object, slot.field)) {
461
+ values.set(slot.key, string(slot.object[slot.field], "locale_value"));
462
+ }
463
+ });
464
+ }
465
+ return values;
466
+ }
467
+
468
+ export function recordFormLocaleFieldChange(
469
+ document: Y.Doc,
470
+ previous: FormCollabFields,
471
+ next: FormCollabFields,
472
+ ): void {
473
+ const context = contextMap(document);
474
+ if (context.get("locale") === context.get("sourceLocale")) return;
475
+ const before = localeValuesByKey(previous);
476
+ const after = localeValuesByKey(next);
477
+ const presence = presenceMap(document);
478
+ const keys = new Set([...before.keys(), ...after.keys()]);
479
+ document.transact(() => {
480
+ for (const key of keys) {
481
+ if (before.get(key) === after.get(key)) continue;
482
+ if (after.has(key)) presence.set(key, true);
483
+ else presence.delete(key);
484
+ }
485
+ });
486
+ }
487
+
488
+ export function extractFormFields(fieldsMap: {
489
+ get(key: string): FormFieldValue | undefined;
490
+ }): FormCollabFields {
491
+ const raw: Record<string, unknown> = {};
492
+
493
+ for (const key of Object.keys(formCollabFieldsSchema.shape)) {
494
+ let value = fieldsMap.get(key);
495
+
496
+ if (
497
+ FORM_JSON_KEYS.has(key as keyof FormCollabFields) &&
498
+ typeof value === "string"
499
+ ) {
500
+ try {
501
+ value = JSON.parse(value) as FormFieldValue;
502
+ } catch {
503
+ throw new Error(`Failed to parse JSON for form field "${key}"`);
504
+ }
505
+ }
506
+
507
+ if (value !== undefined) {
508
+ raw[key] = value;
509
+ }
510
+ }
511
+
512
+ return formCollabFieldsSchema.parse(raw);
513
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+
3
+ export const labelCollabFieldsSchema = z
4
+ .object({
5
+ slug: z.string().optional(),
6
+ countryCode: z.string().optional(),
7
+ website: z.string().optional(),
8
+ socialLinks: z.record(z.string(), z.string()).optional(),
9
+ parentLabelId: z.string().nullable().optional(),
10
+ })
11
+ .strict();
12
+
13
+ export const LABEL_SHARED_FIELD_KEYS = Object.keys(
14
+ labelCollabFieldsSchema.shape,
15
+ ) as (keyof z.infer<typeof labelCollabFieldsSchema>)[];
16
+
17
+ export const LABEL_JSON_KEYS: ReadonlySet<
18
+ keyof z.infer<typeof labelCollabFieldsSchema>
19
+ > = new Set(["socialLinks"]);
20
+
21
+ export type LabelCollabFields = z.infer<typeof labelCollabFieldsSchema>;
22
+ export type LabelFieldValue = string | Record<string, string> | null;
23
+
24
+ export function extractLabelFields(fieldsMap: {
25
+ get(key: string): LabelFieldValue | undefined;
26
+ }): LabelCollabFields {
27
+ const raw: Record<string, unknown> = {};
28
+
29
+ for (const key of Object.keys(labelCollabFieldsSchema.shape)) {
30
+ let value = fieldsMap.get(key);
31
+
32
+ if (
33
+ LABEL_JSON_KEYS.has(key as keyof LabelCollabFields) &&
34
+ typeof value === "string"
35
+ ) {
36
+ try {
37
+ value = JSON.parse(value) as LabelFieldValue;
38
+ } catch {
39
+ throw new Error(`Failed to parse JSON for label field "${key}"`);
40
+ }
41
+ }
42
+
43
+ if (value !== undefined) {
44
+ raw[key] = value;
45
+ }
46
+ }
47
+
48
+ return labelCollabFieldsSchema.parse(raw);
49
+ }
@@ -0,0 +1,157 @@
1
+ import { z } from "zod";
2
+
3
+ export const MAP_THEME_META_MAP_NAME = "map-theme-meta";
4
+ export const MAP_THEME_SETTINGS_MAP_NAME = "map-theme-settings";
5
+ export const MAP_THEME_NAME_MAX_LENGTH = 255;
6
+
7
+ export function mapThemeNameCodePointLength(value: string): number {
8
+ return Array.from(value).length;
9
+ }
10
+
11
+ function isValidMapThemeName(value: string): boolean {
12
+ const normalized = value.trim();
13
+ return (
14
+ normalized.length > 0 &&
15
+ mapThemeNameCodePointLength(normalized) <= MAP_THEME_NAME_MAX_LENGTH
16
+ );
17
+ }
18
+
19
+ export function getMapThemeVariantMapName(scheme: "light" | "dark"): string {
20
+ return `map-theme-${scheme}-variant`;
21
+ }
22
+
23
+ const calloutFieldSchema = z.enum([
24
+ "name",
25
+ "address",
26
+ "coordinates",
27
+ "street",
28
+ "city",
29
+ "region",
30
+ "country",
31
+ "postalCode",
32
+ ]);
33
+
34
+ const MapThemeEditingNameSchema = z.string().refine(isValidMapThemeName, {
35
+ message: `Map Theme name must contain 1-${MAP_THEME_NAME_MAX_LENGTH} Unicode code points`,
36
+ });
37
+
38
+ const MapThemeCanonicalNameSchema = MapThemeEditingNameSchema.transform(
39
+ (value) => value.trim(),
40
+ );
41
+
42
+ export const MapThemeDocumentEditingMetaSchema = z
43
+ .object({
44
+ name: MapThemeEditingNameSchema,
45
+ })
46
+ .strict();
47
+
48
+ export const MapThemeDocumentMetaSchema = z
49
+ .object({
50
+ name: MapThemeCanonicalNameSchema,
51
+ })
52
+ .strict();
53
+
54
+ export type MapThemeDocumentMeta = z.infer<typeof MapThemeDocumentMetaSchema>;
55
+
56
+ export const MAP_THEME_META_JSON_KEYS: ReadonlySet<keyof MapThemeDocumentMeta> =
57
+ new Set([]);
58
+
59
+ export const MapThemeDocumentSettingsSchema = z
60
+ .object({
61
+ calloutScale: z.number().min(0.5).max(2),
62
+ calloutOffsetX: z.number().int().min(-50).max(50),
63
+ calloutOffsetY: z.number().int().min(-50).max(50),
64
+ calloutFields: z.array(calloutFieldSchema).min(1).max(8),
65
+ showAreaLabels: z.boolean(),
66
+ showPoiLabels: z.boolean(),
67
+ attributionFontSize: z.number().int().min(9).max(14),
68
+ })
69
+ .strict();
70
+
71
+ export type MapThemeDocumentSettings = z.infer<
72
+ typeof MapThemeDocumentSettingsSchema
73
+ >;
74
+
75
+ export const MAP_THEME_SETTINGS_JSON_KEYS: ReadonlySet<
76
+ keyof MapThemeDocumentSettings
77
+ > = new Set(["calloutFields"]);
78
+
79
+ const HEX_COLOR_PATTERN =
80
+ /^#[\da-f]{3}(?:[\da-f]{1}|[\da-f]{3}(?:[\da-f]{2})?)?$/i;
81
+ const RGB_COLOR_PATTERN =
82
+ /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;
83
+ const RGBA_COLOR_PATTERN =
84
+ /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*((?:0(?:\.\d+)?)|(?:1(?:\.0+)?))\s*\)$/i;
85
+
86
+ export function isMapThemeColor(value: string): boolean {
87
+ if (value === "transparent" || HEX_COLOR_PATTERN.test(value)) {
88
+ return true;
89
+ }
90
+
91
+ const rgb = RGB_COLOR_PATTERN.exec(value);
92
+ if (rgb) {
93
+ return rgb.slice(1).every((channel) => Number(channel) <= 255);
94
+ }
95
+
96
+ const rgba = RGBA_COLOR_PATTERN.exec(value);
97
+ if (!rgba) {
98
+ return false;
99
+ }
100
+
101
+ return rgba.slice(1, 4).every((channel) => Number(channel) <= 255);
102
+ }
103
+
104
+ export const MapThemeColorSchema = z
105
+ .string()
106
+ .trim()
107
+ .max(50)
108
+ .refine(isMapThemeColor, {
109
+ message: "Invalid map theme color",
110
+ });
111
+
112
+ export const MapThemeDocumentVariantSchema = z
113
+ .object({
114
+ backgroundColor: MapThemeColorSchema,
115
+ waterColor: MapThemeColorSchema,
116
+ landColor: MapThemeColorSchema,
117
+ roadColor: MapThemeColorSchema,
118
+ buildingFillColor: MapThemeColorSchema,
119
+ buildingStrokeEnabled: z.boolean(),
120
+ buildingStrokeColor: MapThemeColorSchema,
121
+ calloutLineColor: MapThemeColorSchema,
122
+ calloutHoverLineColor: MapThemeColorSchema,
123
+ calloutTextColor: MapThemeColorSchema,
124
+ calloutHoverTextColor: MapThemeColorSchema,
125
+ calloutDescriptionColor: MapThemeColorSchema,
126
+ calloutHoverDescriptionColor: MapThemeColorSchema,
127
+ calloutBackgroundColor: MapThemeColorSchema,
128
+ calloutHoverBackgroundColor: MapThemeColorSchema,
129
+ attributionColor: MapThemeColorSchema,
130
+ labelTextColor: MapThemeColorSchema,
131
+ clusterColor: MapThemeColorSchema,
132
+ clusterHoverColor: MapThemeColorSchema,
133
+ clusterTextColor: MapThemeColorSchema,
134
+ clusterTextHoverColor: MapThemeColorSchema,
135
+ })
136
+ .strict();
137
+
138
+ export type MapThemeDocumentVariant = z.infer<
139
+ typeof MapThemeDocumentVariantSchema
140
+ >;
141
+
142
+ export const MAP_THEME_VARIANT_JSON_KEYS: ReadonlySet<
143
+ keyof MapThemeDocumentVariant
144
+ > = new Set([]);
145
+
146
+ export const MapThemeDocumentSnapshotSchema = z
147
+ .object({
148
+ name: MapThemeDocumentMetaSchema.shape.name,
149
+ settings: MapThemeDocumentSettingsSchema,
150
+ lightVariant: MapThemeDocumentVariantSchema,
151
+ darkVariant: MapThemeDocumentVariantSchema,
152
+ })
153
+ .strict();
154
+
155
+ export type MapThemeDocumentSnapshot = z.infer<
156
+ typeof MapThemeDocumentSnapshotSchema
157
+ >;