@echovisionlab/geul-common 0.6.1 → 0.7.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.
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.6.1",
5
+ "version": "0.7.0",
6
6
  "private": false,
7
7
  "type": "module",
8
8
  "repository": {
@@ -59,13 +59,14 @@
59
59
  "./media/block-schemas": "./src/media/block-schemas.ts",
60
60
  "./media/hydration": "./src/media/hydration.ts",
61
61
  "./post": "./src/post/index.ts",
62
+ "./site-variables": "./src/site-variables.ts",
62
63
  "./test/random-id": "./src/test/random-id.ts",
63
64
  "./translation/release": "./src/translation/release.ts",
64
65
  "./types": "./src/types.ts"
65
66
  },
66
67
  "dependencies": {
67
68
  "@bufbuild/protobuf": "^2.16.0",
68
- "@echovisionlab/geul-proto": "^0.6.0",
69
+ "@echovisionlab/geul-proto": "^0.7.0",
69
70
  "diff": "9.0.0",
70
71
  "zod": "^4.6.5"
71
72
  },
@@ -1,4 +1,8 @@
1
1
  import type { JsonValue } from "@bufbuild/protobuf";
2
+ import {
3
+ isSiteVariableKey,
4
+ type SiteVariableKey,
5
+ } from "@echovisionlab/geul-proto/content/block_catalog.ts";
2
6
  import { fail } from "./internal.ts";
3
7
 
4
8
  export const RICH_TEXT_STYLE_KEYS = [
@@ -34,6 +38,13 @@ interface DesiredMathGroup {
34
38
  readonly source: string;
35
39
  }
36
40
 
41
+ interface DesiredSiteVariableGroup {
42
+ readonly kind: "siteVariable";
43
+ readonly key: SiteVariableKey;
44
+ readonly linked: boolean;
45
+ readonly styles: Style;
46
+ }
47
+
37
48
  interface DesiredHardBreakGroup {
38
49
  readonly kind: "hardBreak";
39
50
  }
@@ -42,6 +53,7 @@ export type DesiredGroup =
42
53
  | DesiredTextGroup
43
54
  | DesiredLinkGroup
44
55
  | DesiredMathGroup
56
+ | DesiredSiteVariableGroup
45
57
  | DesiredHardBreakGroup;
46
58
 
47
59
  function object(value: unknown, reason: string): Record<string, unknown> {
@@ -119,15 +131,47 @@ export function styledInput(value: unknown, reason: string): StyledRun {
119
131
  };
120
132
  }
121
133
 
134
+ export function siteVariableInput(
135
+ value: unknown,
136
+ reason: string,
137
+ ): DesiredSiteVariableGroup {
138
+ const variable = object(value, reason);
139
+ if (typeof variable.key !== "string" || !isSiteVariableKey(variable.key)) {
140
+ return fail(`${reason}:key`);
141
+ }
142
+ if (variable.linked !== undefined && typeof variable.linked !== "boolean") {
143
+ return fail(`${reason}:linked`);
144
+ }
145
+ if (
146
+ variable.linked &&
147
+ (variable.key === "site.title" || variable.key === "site.companyName")
148
+ ) {
149
+ return fail(`${reason}:linked_key`);
150
+ }
151
+ return {
152
+ kind: "siteVariable",
153
+ key: variable.key,
154
+ linked: variable.linked === true,
155
+ styles: checkedStyles(variable.styles, `${reason}:styles`),
156
+ };
157
+ }
158
+
122
159
  function inputItem(value: JsonValue, reason: string): DesiredGroup {
123
160
  const item = object(value, reason);
124
- const cases = ["text", "link", "mathInline", "hardBreak"].filter(
125
- (key) => item[key] !== undefined,
126
- );
161
+ const cases = [
162
+ "text",
163
+ "link",
164
+ "mathInline",
165
+ "hardBreak",
166
+ "siteVariable",
167
+ ].filter((key) => item[key] !== undefined);
127
168
  if (cases.length !== 1) {
128
169
  return fail(`${reason}:oneof`);
129
170
  }
130
171
  const selected = cases[0]!;
172
+ if (selected === "siteVariable") {
173
+ return siteVariableInput(item.siteVariable, `${reason}:siteVariable`);
174
+ }
131
175
  if (selected === "text") {
132
176
  const run = styledInput(item.text, `${reason}:text`);
133
177
  return { kind: "text", runs: run.text ? [run] : [] };
@@ -207,6 +251,15 @@ export function normalizeInlineContent(values: readonly JsonValue[]): string {
207
251
  },
208
252
  };
209
253
  }
254
+ if (group.kind === "siteVariable") {
255
+ return {
256
+ siteVariable: {
257
+ key: group.key,
258
+ linked: group.linked,
259
+ styles: normalizeStyles(group.styles),
260
+ },
261
+ };
262
+ }
210
263
  if (group.kind === "mathInline") {
211
264
  return { mathInline: { source: group.source } };
212
265
  }
@@ -16,6 +16,7 @@ import {
16
16
  projectedStyledTextRuns,
17
17
  } from "./inline-content-projection.ts";
18
18
  import {
19
+ RICH_TEXT_STYLE_KEYS,
19
20
  changedStyleAttributes,
20
21
  desiredGroups,
21
22
  minimalDiff,
@@ -70,13 +71,25 @@ interface RawMathGroup {
70
71
  readonly text: Y.Text;
71
72
  }
72
73
 
74
+ interface RawSiteVariableGroup {
75
+ readonly kind: "siteVariable";
76
+ readonly rawStart: number;
77
+ readonly rawEnd: number;
78
+ readonly variable: Y.Map<unknown>;
79
+ }
80
+
73
81
  interface RawHardBreakGroup {
74
82
  readonly kind: "hardBreak";
75
83
  readonly rawStart: number;
76
84
  readonly rawEnd: number;
77
85
  }
78
86
 
79
- type RawGroup = RawTextGroup | RawLinkGroup | RawMathGroup | RawHardBreakGroup;
87
+ type RawGroup =
88
+ | RawTextGroup
89
+ | RawLinkGroup
90
+ | RawMathGroup
91
+ | RawHardBreakGroup
92
+ | RawSiteVariableGroup;
80
93
 
81
94
  function yMap(value: unknown, reason: string): Y.Map<unknown> {
82
95
  if (!(value instanceof Y.Map)) {
@@ -110,9 +123,21 @@ function rawInlineItem(
110
123
  // reconcileArray first runs fromYInlineContent, which validates this map and
111
124
  // its oneof before the raw handles are resolved.
112
125
  const item = array.get(index) as Y.Map<unknown>;
113
- const selected = ["text", "link", "mathInline", "hardBreak"].find((key) =>
114
- item.has(key),
115
- )!;
126
+ const selected = [
127
+ "text",
128
+ "link",
129
+ "mathInline",
130
+ "hardBreak",
131
+ "siteVariable",
132
+ ].find((key) => item.has(key))!;
133
+ if (selected === "siteVariable") {
134
+ return {
135
+ kind: "siteVariable",
136
+ rawStart: index,
137
+ rawEnd: index + 1,
138
+ variable: yMap(item.get("siteVariable"), `${reason}:siteVariable`),
139
+ };
140
+ }
116
141
  if (selected === "text") {
117
142
  const segment = rawStyledText(item.get("text"), index, `${reason}:text`);
118
143
  return {
@@ -376,6 +401,25 @@ function reconcileRawGroup(
376
401
  );
377
402
  return;
378
403
  }
404
+ if (raw.kind === "siteVariable" && desired.kind === "siteVariable") {
405
+ if (raw.variable.get("key") !== desired.key)
406
+ raw.variable.set("key", desired.key);
407
+ if ((raw.variable.get("linked") === true) !== desired.linked)
408
+ raw.variable.set("linked", desired.linked);
409
+ let styles = raw.variable.get("styles") as Y.Map<unknown> | undefined;
410
+ if (!styles && Object.keys(desired.styles).length > 0) {
411
+ styles = new Y.Map<unknown>();
412
+ raw.variable.set("styles", styles);
413
+ }
414
+ if (styles)
415
+ for (const key of RICH_TEXT_STYLE_KEYS) {
416
+ const value = desired.styles[key];
417
+ if (value === undefined || value === false || value === "")
418
+ styles.delete(key);
419
+ else if (styles.get(key) !== value) styles.set(key, value);
420
+ }
421
+ return;
422
+ }
379
423
  if (raw.kind === "mathInline" && desired.kind === "mathInline") {
380
424
  const diff = minimalDiff(raw.text.toString(), desired.source);
381
425
  if (diff.oldTo > diff.from) {
@@ -402,6 +446,15 @@ function createRawValue(group: DesiredGroup): JsonValue {
402
446
  },
403
447
  };
404
448
  }
449
+ if (group.kind === "siteVariable") {
450
+ return {
451
+ siteVariable: {
452
+ key: group.key,
453
+ linked: group.linked,
454
+ styles: group.styles,
455
+ },
456
+ };
457
+ }
405
458
  if (group.kind === "mathInline") {
406
459
  return { mathInline: { source: group.source } };
407
460
  }
@@ -8,12 +8,14 @@ import * as Y from "yjs";
8
8
  import {
9
9
  fail,
10
10
  fromYValue,
11
+ yMap,
11
12
  type BlockRoomNodeFamily,
12
13
  type JsonObject,
13
14
  } from "./internal.ts";
14
15
 
15
16
  import {
16
17
  RICH_TEXT_STYLE_KEYS,
18
+ siteVariableInput,
17
19
  type RichTextStyleKey,
18
20
  } from "./inline-content-model.ts";
19
21
  type RichTextStyleValue = boolean | string;
@@ -205,13 +207,52 @@ function projectedInlineItem(value: unknown, reason: string): JsonValue[] {
205
207
  if (!(value instanceof Y.Map)) {
206
208
  return fail(reason);
207
209
  }
208
- const cases = ["text", "link", "mathInline", "hardBreak"].filter((key) =>
209
- value.has(key),
210
- );
210
+ const cases = [
211
+ "text",
212
+ "link",
213
+ "mathInline",
214
+ "hardBreak",
215
+ "siteVariable",
216
+ ].filter((key) => value.has(key));
211
217
  if (cases.length !== 1) {
212
218
  return fail(`${reason}:oneof`);
213
219
  }
214
220
  const selected = cases[0]!;
221
+ if (selected === "siteVariable") {
222
+ const raw = yMap(value.get("siteVariable"), `${reason}:siteVariable`);
223
+ const rawStyles = raw.get("styles");
224
+ const key = raw.get("key");
225
+ const linked = raw.get("linked");
226
+ const variable = siteVariableInput(
227
+ {
228
+ key,
229
+ // Independently valid key/link writes can merge into a forbidden pair.
230
+ // Normalize only that pair here; authored inputs stay strictly checked.
231
+ linked:
232
+ linked === true &&
233
+ (key === "site.title" || key === "site.companyName")
234
+ ? false
235
+ : linked,
236
+ styles:
237
+ rawStyles === undefined
238
+ ? undefined
239
+ : Object.fromEntries(
240
+ yMap(rawStyles, `${reason}:siteVariable:styles`).entries(),
241
+ ),
242
+ },
243
+ `${reason}:siteVariable`,
244
+ );
245
+ const styles = jsonStyle(variable.styles);
246
+ return [
247
+ {
248
+ siteVariable: {
249
+ key: variable.key,
250
+ ...(variable.linked ? { linked: true } : {}),
251
+ ...(styles ? { styles } : {}),
252
+ },
253
+ },
254
+ ];
255
+ }
215
256
  if (selected === "text") {
216
257
  return projectedStyledText(value.get("text"), `${reason}:text`).map(
217
258
  (styledText) => ({ text: styledText }),
@@ -170,6 +170,16 @@ export function toYValue(
170
170
  }
171
171
  if (value && typeof value === "object") {
172
172
  const map = new Y.Map<unknown>();
173
+ if (
174
+ /^content(?:\.rows\[\d+\]\.cells\[\d+\]\.content)?\[\d+\]\.siteVariable$/.test(
175
+ path,
176
+ ) &&
177
+ value.styles === undefined
178
+ ) {
179
+ // Establish one shared map before replicas fork, even for unstyled atoms.
180
+ // Creating it during each client's first style edit loses one whole map.
181
+ map.set("styles", new Y.Map<unknown>());
182
+ }
173
183
  for (const [key, child] of Object.entries(value).sort(([left], [right]) =>
174
184
  left.localeCompare(right),
175
185
  )) {
@@ -5,6 +5,7 @@ import { desiredGroups, type Style } from "./inline-content-model.ts";
5
5
  type Attributes = Record<string, string | boolean>;
6
6
  type Token =
7
7
  | { kind: "text"; value: string; attributes: Attributes }
8
+ | { kind: "siteVariable"; attributes: Attributes }
8
9
  | { kind: "mathInline"; attributes: Attributes }
9
10
  | { kind: "hardBreak"; attributes: Attributes }
10
11
  | { kind: "emptyLink"; attributes: Attributes }
@@ -117,11 +118,96 @@ function withAttributes(token: Token, attributes: Attributes): Token {
117
118
  }
118
119
 
119
120
  function insertionIdentity(token: Token): string {
121
+ if (token.kind === "siteVariable")
122
+ return `siteVariable:${token.attributes.key}`;
120
123
  if (token.kind === "mathInline") return `math:${token.attributes.source}`;
121
124
  if (token.kind === "emptyLink") return `emptyLink:${token.attributes.href}`;
122
125
  return tokenIdentity(token);
123
126
  }
124
127
 
128
+ /** Align surviving keys with text anchors; equal occurrence counts retain edits. */
129
+ function siteVariableAnchors(
130
+ before: readonly Token[],
131
+ after: readonly Token[],
132
+ ): Map<number, Token> {
133
+ const base = before.filter((token) => token.kind === "siteVariable");
134
+ const variables = after.filter((token) => token.kind === "siteVariable");
135
+ if (base.length === variables.length)
136
+ return new Map(variables.map((token, index) => [index, token]));
137
+ // Filtering first discards the text separating equal keys and can identify
138
+ // a deleted occurrence as the survivor. Diff the complete inline sequence.
139
+ const retained = project(before, after, insertionIdentity).retained;
140
+ const anchors = new Map<number, Token>();
141
+ let occurrence = 0;
142
+ before.forEach((token, position) => {
143
+ if (token.kind !== "siteVariable") return;
144
+ const survivor = retained.get(position);
145
+ if (survivor) anchors.set(occurrence, survivor);
146
+ occurrence++;
147
+ });
148
+ return anchors;
149
+ }
150
+
151
+ function occurrenceIdentity(
152
+ before: readonly Token[],
153
+ local: readonly Token[],
154
+ peer: readonly Token[],
155
+ ): (token: Token) => string {
156
+ const variables = (tokens: readonly Token[]) =>
157
+ tokens.filter((token) => token.kind === "siteVariable");
158
+ const base = variables(before);
159
+ const localVariables = variables(local);
160
+ const peerVariables = variables(peer);
161
+ const localAnchors = siteVariableAnchors(before, local);
162
+ const peerAnchors = siteVariableAnchors(before, peer);
163
+ const identities = new Map<Token, string>();
164
+ base.forEach((token, index) =>
165
+ identities.set(token, `siteVariable:${index}`),
166
+ );
167
+
168
+ const align = (
169
+ tokens: readonly Token[],
170
+ anchors: Map<number, Token>,
171
+ otherAnchors: Map<number, Token>,
172
+ ) => {
173
+ const tokenPositions = new Map(
174
+ tokens.map((token, index) => [token, index]),
175
+ );
176
+ let baseStart = 0;
177
+ let start = 0;
178
+ for (const [position, token] of [
179
+ ...anchors,
180
+ [base.length, undefined] as const,
181
+ ]) {
182
+ const end =
183
+ token === undefined ? tokens.length : tokenPositions.get(token)!;
184
+ // If this deletion was already replayed, the other side's retained
185
+ // occurrences also anchor peer keys that have since changed.
186
+ const candidates = Array.from(
187
+ { length: position - baseStart },
188
+ (_, index) => baseStart + index,
189
+ );
190
+ const hinted = candidates.filter((index) => otherAnchors.has(index));
191
+ const positions = hinted.length === end - start ? hinted : candidates;
192
+ for (let index = start; index < end; index++) {
193
+ const baseIndex = positions[index - start];
194
+ if (baseIndex !== undefined)
195
+ identities.set(tokens[index]!, `siteVariable:${baseIndex}`);
196
+ }
197
+ if (token !== undefined)
198
+ identities.set(token, `siteVariable:${position}`);
199
+ baseStart = position + 1;
200
+ start = end + 1;
201
+ }
202
+ };
203
+ align(localVariables, localAnchors, peerAnchors);
204
+ align(peerVariables, peerAnchors, localAnchors);
205
+ return (token) =>
206
+ token.kind === "siteVariable"
207
+ ? (identities.get(token) ?? insertionIdentity(token))
208
+ : tokenIdentity(token);
209
+ }
210
+
125
211
  /** Preserve each newly inserted adjacent link as a semantic group. */
126
212
  function linkedInsertionGroups(tokens: readonly Token[]): Token[][] | null {
127
213
  if (!tokens.some((token) => token.kind === "linkBoundary")) return null;
@@ -211,8 +297,9 @@ function mergeTokens(
211
297
  local: readonly Token[],
212
298
  peer: readonly Token[],
213
299
  ): Token[] {
214
- const localProjection = project(before, local, tokenIdentity);
215
- const peerProjection = project(before, peer, tokenIdentity);
300
+ const identity = occurrenceIdentity(before, local, peer);
301
+ const localProjection = project(before, local, identity);
302
+ const peerProjection = project(before, peer, identity);
216
303
  const result: Token[] = [];
217
304
  for (let position = 0; position <= before.length; position++) {
218
305
  for (const token of mergeInsertions(
@@ -266,6 +353,15 @@ function inlineTokens(content: readonly JsonValue[]): Token[] {
266
353
  for (const value of run.text)
267
354
  tokens.push({ kind: "text", value, attributes });
268
355
  }
356
+ } else if (group.kind === "siteVariable") {
357
+ tokens.push({
358
+ kind: "siteVariable",
359
+ attributes: {
360
+ ...normalizedStyles(group.styles),
361
+ key: group.key,
362
+ linked: group.linked,
363
+ },
364
+ });
269
365
  } else if (group.kind === "mathInline") {
270
366
  tokens.push({ kind: "mathInline", attributes: { source: group.source } });
271
367
  } else {
@@ -283,6 +379,19 @@ function inlineContent(tokens: readonly Token[]): JsonValue[] {
283
379
  if (token.kind !== "text") {
284
380
  runs = undefined;
285
381
  if (token.kind === "linkBoundary") continue;
382
+ if (token.kind === "siteVariable") {
383
+ const { key, linked, ...styles } = token.attributes;
384
+ result.push({
385
+ siteVariable: {
386
+ key,
387
+ ...(linked && key !== "site.title" && key !== "site.companyName"
388
+ ? { linked: true }
389
+ : {}),
390
+ ...(Object.keys(styles).length ? { styles } : {}),
391
+ },
392
+ });
393
+ continue;
394
+ }
286
395
  result.push(
287
396
  token.kind === "emptyLink"
288
397
  ? { link: { href: token.attributes.href as string, content: [] } }
@@ -0,0 +1,26 @@
1
+ import type { SiteVariableKey } from "@echovisionlab/geul-proto/content/block_catalog.ts";
2
+
3
+ export interface SiteVariableContext {
4
+ readonly siteTitle: string;
5
+ readonly siteUrl: string;
6
+ readonly companyName: string;
7
+ readonly legalEmail: string;
8
+ readonly supportEmail: string;
9
+ readonly privacyEmail: string;
10
+ }
11
+
12
+ const contextFields = {
13
+ "site.title": "siteTitle",
14
+ "site.url": "siteUrl",
15
+ "site.companyName": "companyName",
16
+ "contact.legalEmail": "legalEmail",
17
+ "contact.supportEmail": "supportEmail",
18
+ "contact.privacyEmail": "privacyEmail",
19
+ } as const satisfies Record<SiteVariableKey, keyof SiteVariableContext>;
20
+
21
+ export function resolveSiteVariable(
22
+ key: SiteVariableKey,
23
+ context: SiteVariableContext,
24
+ ): string {
25
+ return context[contextFields[key]];
26
+ }