@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.
- package/LICENSE.md +6 -0
- package/README.md +37 -0
- package/package.json +89 -0
- package/src/collaboration/artist.ts +63 -0
- package/src/collaboration/block-room-codec/ai-document-applicator.ts +319 -0
- package/src/collaboration/block-room-codec/ai-document-field-mutations.ts +557 -0
- package/src/collaboration/block-room-codec/ai-document-page-structure-mutations.ts +449 -0
- package/src/collaboration/block-room-codec/ai-document-values.ts +368 -0
- package/src/collaboration/block-room-codec/hydration.ts +257 -0
- package/src/collaboration/block-room-codec/internal.ts +432 -0
- package/src/collaboration/block-room-codec/locale-change-validation.ts +237 -0
- package/src/collaboration/block-room-codec/locale-presence.ts +827 -0
- package/src/collaboration/block-room-codec/materialization.ts +450 -0
- package/src/collaboration/block-room-codec/observation.ts +518 -0
- package/src/collaboration/block-room-codec/payload-mutations.ts +347 -0
- package/src/collaboration/block-room-codec/room-access.ts +154 -0
- package/src/collaboration/block-room-codec/structure-mutations.ts +456 -0
- package/src/collaboration/block-room-codec.ts +86 -0
- package/src/collaboration/campaign.ts +37 -0
- package/src/collaboration/document-layout.ts +75 -0
- package/src/collaboration/document.ts +185 -0
- package/src/collaboration/email-layout.ts +150 -0
- package/src/collaboration/form.ts +513 -0
- package/src/collaboration/label.ts +49 -0
- package/src/collaboration/map-theme.ts +157 -0
- package/src/collaboration/member-id.ts +9 -0
- package/src/collaboration/menu.ts +258 -0
- package/src/collaboration/metadata-ai.ts +99 -0
- package/src/collaboration/page.ts +483 -0
- package/src/collaboration/post-series.ts +96 -0
- package/src/collaboration/post.ts +59 -0
- package/src/collaboration/release.ts +221 -0
- package/src/collaboration/runtime-events.ts +547 -0
- package/src/collaboration/work.ts +107 -0
- package/src/editor/link-normalization.ts +212 -0
- package/src/editor/materialized-blocks.ts +58 -0
- package/src/index.ts +22 -0
- package/src/media/block-schemas.ts +78 -0
- package/src/media/hydration.ts +226 -0
- package/src/page/block-fixtures.ts +586 -0
- package/src/page/index.ts +3 -0
- package/src/page/types.ts +57 -0
- package/src/post/index.ts +1 -0
- package/src/post/types.ts +13 -0
- package/src/test/random-id.ts +9 -0
- package/src/translation/release.ts +88 -0
- package/src/types.ts +14 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
const PLACEHOLDER_PROTOCOL_RE = /^(https?:\/\/)(\{\{[^{}]+\}\})$/i;
|
|
2
|
+
const DUPLICATE_PROTOCOL_RE = /^(https?:\/\/)(https?:\/\/)(.+)$/i;
|
|
3
|
+
const HREF_ATTR_RE =
|
|
4
|
+
/(^|[\s])href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
|
|
5
|
+
const HREF_ATTR_LOOKUP_RE = /href\s*=/i;
|
|
6
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
7
|
+
const OBFUSCATED_SCHEME_RE = /^([a-z][a-z0-9+.-]*):/i;
|
|
8
|
+
const SCHEME_COMPACT_RE = /[\u0000-\u0020\u007f]+/g;
|
|
9
|
+
const SCHEME_ENTITY_RE =
|
|
10
|
+
/&(?:(?:#(\d+)|#x([0-9a-f]+));?|((?:colon|tab|newline));)/gi;
|
|
11
|
+
const MAX_UNICODE_CODE_POINT = 0x10ffff;
|
|
12
|
+
const ALLOWED_SCHEMES = new Set(["http", "https", "mailto", "tel"]);
|
|
13
|
+
|
|
14
|
+
type LinkableInlineProps = Record<string, unknown> & {
|
|
15
|
+
href?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type LinkableInlineNode<TInline extends LinkableInlineNode<TInline>> = {
|
|
19
|
+
type?: string;
|
|
20
|
+
href?: string;
|
|
21
|
+
props?: LinkableInlineProps;
|
|
22
|
+
content?: readonly TInline[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type LinkableBlockNode<
|
|
26
|
+
TInline extends LinkableInlineNode<TInline>,
|
|
27
|
+
TBlock extends LinkableBlockNode<TInline, TBlock>,
|
|
28
|
+
> = {
|
|
29
|
+
content?: readonly TInline[] | string | object;
|
|
30
|
+
children?: readonly TBlock[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function normalizeRichTextHref(href: string): string {
|
|
34
|
+
const original = href;
|
|
35
|
+
let normalized = href.trim();
|
|
36
|
+
|
|
37
|
+
for (;;) {
|
|
38
|
+
const before = normalized;
|
|
39
|
+
const placeholderMatch = normalized.match(PLACEHOLDER_PROTOCOL_RE);
|
|
40
|
+
if (placeholderMatch) {
|
|
41
|
+
normalized = placeholderMatch[2];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
normalized = normalized.replace(DUPLICATE_PROTOCOL_RE, "$1$3");
|
|
45
|
+
if (normalized === before) {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!isAllowedRichTextHref(normalized)) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return normalized === original ? original : normalized;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isAllowedRichTextHref(href: string): boolean {
|
|
58
|
+
if (href === "") {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const compacted = decodeSchemeEntities(href).replace(SCHEME_COMPACT_RE, "");
|
|
63
|
+
const schemeMatch = compacted.match(OBFUSCATED_SCHEME_RE);
|
|
64
|
+
if (!schemeMatch) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return ALLOWED_SCHEMES.has(schemeMatch[1].toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decodeSchemeEntities(value: string): string {
|
|
72
|
+
return value.replace(
|
|
73
|
+
SCHEME_ENTITY_RE,
|
|
74
|
+
(
|
|
75
|
+
match: string,
|
|
76
|
+
decimal: string | undefined,
|
|
77
|
+
hex: string | undefined,
|
|
78
|
+
named: string | undefined,
|
|
79
|
+
) => {
|
|
80
|
+
if (decimal) {
|
|
81
|
+
return decodeSchemeCodePoint(decimal, 10, match);
|
|
82
|
+
}
|
|
83
|
+
if (hex) {
|
|
84
|
+
return decodeSchemeCodePoint(hex, 16, match);
|
|
85
|
+
}
|
|
86
|
+
const normalizedNamed = named!.toLowerCase();
|
|
87
|
+
return normalizedNamed === "colon"
|
|
88
|
+
? ":"
|
|
89
|
+
: normalizedNamed === "tab"
|
|
90
|
+
? "\t"
|
|
91
|
+
: "\n";
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function decodeSchemeCodePoint(
|
|
97
|
+
value: string,
|
|
98
|
+
radix: number,
|
|
99
|
+
fallback: string,
|
|
100
|
+
): string {
|
|
101
|
+
const codePoint = Number.parseInt(value, radix);
|
|
102
|
+
if (codePoint > MAX_UNICODE_CODE_POINT) {
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return String.fromCodePoint(codePoint);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeRichTextHtmlLinks(html: string): string {
|
|
110
|
+
if (!html || !HREF_ATTR_LOOKUP_RE.test(html)) {
|
|
111
|
+
return html;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return html.replace(
|
|
115
|
+
HREF_ATTR_RE,
|
|
116
|
+
(match, prefix: string, ...groups: string[]) => {
|
|
117
|
+
const { href, quote } = readHrefAttributeGroups(
|
|
118
|
+
groups[0],
|
|
119
|
+
groups[1],
|
|
120
|
+
groups[2],
|
|
121
|
+
);
|
|
122
|
+
const normalizedHref = normalizeRichTextHref(href);
|
|
123
|
+
if (normalizedHref === "" && href.trim() !== "") {
|
|
124
|
+
return prefix;
|
|
125
|
+
}
|
|
126
|
+
if (normalizedHref === href) {
|
|
127
|
+
return match;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `${prefix}href=${quote}${normalizedHref}${quote}`;
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readHrefAttributeGroups(
|
|
136
|
+
doubleQuotedHref: string | undefined,
|
|
137
|
+
singleQuotedHref: string | undefined,
|
|
138
|
+
unquotedHref: string | undefined,
|
|
139
|
+
): { href: string; quote: string } {
|
|
140
|
+
if (doubleQuotedHref !== undefined) {
|
|
141
|
+
return { href: doubleQuotedHref, quote: '"' };
|
|
142
|
+
}
|
|
143
|
+
if (singleQuotedHref !== undefined) {
|
|
144
|
+
return { href: singleQuotedHref, quote: "'" };
|
|
145
|
+
}
|
|
146
|
+
return { href: unquotedHref!, quote: "" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectRestorableLinkHrefPairs<
|
|
150
|
+
TInline extends LinkableInlineNode<TInline>,
|
|
151
|
+
>(content: readonly TInline[], pairs: Map<string, string>): void {
|
|
152
|
+
for (const node of content) {
|
|
153
|
+
const rawHref = node.href ?? node.props?.href;
|
|
154
|
+
const normalizedHref =
|
|
155
|
+
typeof rawHref === "string" ? normalizeRichTextHref(rawHref) : null;
|
|
156
|
+
if (
|
|
157
|
+
normalizedHref &&
|
|
158
|
+
normalizedHref.length > 0 &&
|
|
159
|
+
!SCHEME_RE.test(normalizedHref)
|
|
160
|
+
) {
|
|
161
|
+
pairs.set(`https://${normalizedHref}`, normalizedHref);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (Array.isArray(node.content) && node.content.length > 0) {
|
|
165
|
+
collectRestorableLinkHrefPairs(node.content, pairs);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function collectRestorableBlockHrefPairs<
|
|
171
|
+
TInline extends LinkableInlineNode<TInline>,
|
|
172
|
+
TBlock extends LinkableBlockNode<TInline, TBlock>,
|
|
173
|
+
>(blocks: readonly TBlock[], pairs: Map<string, string>): void {
|
|
174
|
+
for (const block of blocks) {
|
|
175
|
+
if (Array.isArray(block.content) && block.content.length > 0) {
|
|
176
|
+
collectRestorableLinkHrefPairs(block.content, pairs);
|
|
177
|
+
}
|
|
178
|
+
if (Array.isArray(block.children) && block.children.length > 0) {
|
|
179
|
+
collectRestorableBlockHrefPairs(block.children, pairs);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function normalizeRichTextHtmlLinksFromBlocks<
|
|
185
|
+
TInline extends LinkableInlineNode<TInline>,
|
|
186
|
+
TBlock extends LinkableBlockNode<TInline, TBlock>,
|
|
187
|
+
>(blocks: readonly TBlock[], html: string): string {
|
|
188
|
+
if (!html || blocks.length === 0 || !HREF_ATTR_LOOKUP_RE.test(html)) {
|
|
189
|
+
return normalizeRichTextHtmlLinks(html);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const replacementPairs = new Map<string, string>();
|
|
193
|
+
collectRestorableBlockHrefPairs(blocks, replacementPairs);
|
|
194
|
+
|
|
195
|
+
const restored = html.replace(
|
|
196
|
+
HREF_ATTR_RE,
|
|
197
|
+
(match, prefix: string, ...groups: string[]) => {
|
|
198
|
+
const { href, quote } = readHrefAttributeGroups(
|
|
199
|
+
groups[0],
|
|
200
|
+
groups[1],
|
|
201
|
+
groups[2],
|
|
202
|
+
);
|
|
203
|
+
const restoredHref = replacementPairs.get(href);
|
|
204
|
+
if (!restoredHref) {
|
|
205
|
+
return match;
|
|
206
|
+
}
|
|
207
|
+
return `${prefix}href=${quote}${restoredHref}${quote}`;
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
return normalizeRichTextHtmlLinks(restored);
|
|
212
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type MaterializedInlineNode = {
|
|
2
|
+
text?: string;
|
|
3
|
+
content?: readonly MaterializedInlineNode[];
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type MaterializedBlockLike<
|
|
7
|
+
TBlock extends MaterializedBlockLike<TBlock>,
|
|
8
|
+
> = {
|
|
9
|
+
type?: string;
|
|
10
|
+
content?: readonly MaterializedInlineNode[] | string | object | null;
|
|
11
|
+
children?: readonly TBlock[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function inlineContentHasText(
|
|
15
|
+
content: readonly MaterializedInlineNode[],
|
|
16
|
+
): boolean {
|
|
17
|
+
return content.some((node) => {
|
|
18
|
+
if (typeof node.text === "string" && node.text.trim().length > 0) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return Array.isArray(node.content) && inlineContentHasText(node.content);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isEmptyParagraphBlock<
|
|
27
|
+
TBlock extends MaterializedBlockLike<TBlock>,
|
|
28
|
+
>(block: TBlock): boolean {
|
|
29
|
+
if (block.type !== "paragraph") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(block.children) && block.children.length > 0) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof block.content === "string") {
|
|
38
|
+
return block.content.trim().length === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (Array.isArray(block.content)) {
|
|
42
|
+
return !inlineContentHasText(block.content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return block.content == null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function stripTrailingEmptyParagraphBlocks<
|
|
49
|
+
TBlock extends MaterializedBlockLike<TBlock>,
|
|
50
|
+
>(blocks: readonly TBlock[]): TBlock[] {
|
|
51
|
+
let end = blocks.length;
|
|
52
|
+
|
|
53
|
+
while (end > 0 && isEmptyParagraphBlock(blocks[end - 1])) {
|
|
54
|
+
end -= 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return blocks.slice(0, end);
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Collaboration schemas
|
|
2
|
+
export * from "./collaboration/artist.ts";
|
|
3
|
+
export * from "./collaboration/block-room-codec.ts";
|
|
4
|
+
export * from "./collaboration/document-layout.ts";
|
|
5
|
+
export * from "./collaboration/document.ts";
|
|
6
|
+
export * from "./collaboration/form.ts";
|
|
7
|
+
export * from "./collaboration/label.ts";
|
|
8
|
+
export * from "./collaboration/map-theme.ts";
|
|
9
|
+
export * from "./collaboration/metadata-ai.ts";
|
|
10
|
+
export * from "./collaboration/page.ts";
|
|
11
|
+
export * from "./collaboration/post.ts";
|
|
12
|
+
export * from "./collaboration/release.ts";
|
|
13
|
+
export * from "./collaboration/runtime-events.ts";
|
|
14
|
+
export * from "./collaboration/work.ts";
|
|
15
|
+
|
|
16
|
+
// Common types
|
|
17
|
+
export * from "./editor/materialized-blocks.ts";
|
|
18
|
+
export * from "./editor/link-normalization.ts";
|
|
19
|
+
export * from "./page/index.ts";
|
|
20
|
+
export * from "./post/index.ts";
|
|
21
|
+
export * from "./media/block-schemas.ts";
|
|
22
|
+
export * from "./translation/release.ts";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const fileBlockType = "file" as const;
|
|
2
|
+
|
|
3
|
+
export const EXTERNAL_VIDEO_ASPECT_RATIO_VALUES = [
|
|
4
|
+
"auto",
|
|
5
|
+
"16:9",
|
|
6
|
+
"4:3",
|
|
7
|
+
"1:1",
|
|
8
|
+
"9:16",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export type ExternalVideoAspectRatio =
|
|
12
|
+
(typeof EXTERNAL_VIDEO_ASPECT_RATIO_VALUES)[number];
|
|
13
|
+
|
|
14
|
+
export const EXTERNAL_VIDEO_PREVIEW_WIDTH_MIN_PERCENT = 10;
|
|
15
|
+
export const EXTERNAL_VIDEO_PREVIEW_WIDTH_MAX_PERCENT = 100;
|
|
16
|
+
export const EXTERNAL_VIDEO_PREVIEW_WIDTH_DEFAULT = "100";
|
|
17
|
+
|
|
18
|
+
export interface ExternalVideoLinkLayoutProps {
|
|
19
|
+
previewWidth: string;
|
|
20
|
+
aspectRatio: ExternalVideoAspectRatio;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deriveMediaDisplayName(fileName: string): string {
|
|
24
|
+
const trimmed = fileName.trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const basename = trimmed.split(/[\\/]/).pop() || trimmed;
|
|
30
|
+
const dotIndex = basename.lastIndexOf(".");
|
|
31
|
+
if (dotIndex <= 0) {
|
|
32
|
+
return basename;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const displayName = basename.slice(0, dotIndex).trim();
|
|
36
|
+
return displayName || basename;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A rich-text File becomes public content only after the authoritative File
|
|
41
|
+
* projection has attached a non-empty File identity. Upload names, slots and
|
|
42
|
+
* temporary delivery URLs are editor state and never make a placeholder
|
|
43
|
+
* publishable by themselves.
|
|
44
|
+
*/
|
|
45
|
+
export function hasAttachedFileId(value: unknown): value is string {
|
|
46
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Durable layout props composed into rich-text paragraph specs that preview a
|
|
51
|
+
* standalone external-video link. The URL and label remain ordinary inline
|
|
52
|
+
* link content, while the document textAlignment prop owns alignment.
|
|
53
|
+
*/
|
|
54
|
+
export const externalVideoLinkLayoutPropSchema = {
|
|
55
|
+
previewWidth: { default: EXTERNAL_VIDEO_PREVIEW_WIDTH_DEFAULT },
|
|
56
|
+
aspectRatio: {
|
|
57
|
+
default: "auto" as ExternalVideoAspectRatio,
|
|
58
|
+
values: EXTERNAL_VIDEO_ASPECT_RATIO_VALUES,
|
|
59
|
+
},
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Canonical durable rich-text attachment schema. File MIME is resolved from
|
|
64
|
+
* the verified File record at runtime and is intentionally not persisted.
|
|
65
|
+
*/
|
|
66
|
+
export const fileBlockPropSchema = {
|
|
67
|
+
fileId: { default: "" },
|
|
68
|
+
name: { default: "" },
|
|
69
|
+
alt: { default: "" },
|
|
70
|
+
caption: { default: "" },
|
|
71
|
+
width: { default: "0" },
|
|
72
|
+
height: { default: "0" },
|
|
73
|
+
previewWidth: { default: "100" },
|
|
74
|
+
textAlignment: {
|
|
75
|
+
default: "left" as const,
|
|
76
|
+
values: ["left", "center", "right"] as const,
|
|
77
|
+
},
|
|
78
|
+
} as const;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
export const MEDIA_HYDRATION_DOM_ATTRS = {
|
|
2
|
+
mediaKind: "data-media-kind",
|
|
3
|
+
fileId: "data-file-id",
|
|
4
|
+
entityType: "data-entity-type",
|
|
5
|
+
entityId: "data-entity-id",
|
|
6
|
+
playbackUrl: "data-playback-url",
|
|
7
|
+
playbackSource: "data-playback-source",
|
|
8
|
+
originalUrl: "data-original-url",
|
|
9
|
+
waveformUrl: "data-waveform-url",
|
|
10
|
+
spectrogramUrl: "data-spectrogram-url",
|
|
11
|
+
hlsUrl: "data-hls-src",
|
|
12
|
+
posterUrl: "data-poster-url",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type MediaHydrationDomAttrs = Record<string, string | undefined>;
|
|
16
|
+
export type AudioPlaybackSource = "hls";
|
|
17
|
+
|
|
18
|
+
export interface AudioMediaHydrationInput {
|
|
19
|
+
fileId?: string;
|
|
20
|
+
entityType?: string;
|
|
21
|
+
entityId?: string;
|
|
22
|
+
playbackUrl?: string;
|
|
23
|
+
playbackSource?: string | null;
|
|
24
|
+
originalUrl?: string;
|
|
25
|
+
hlsUrl?: string;
|
|
26
|
+
waveformUrl?: string;
|
|
27
|
+
spectrogramUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VideoMediaHydrationInput {
|
|
31
|
+
fileId?: string;
|
|
32
|
+
entityType?: string;
|
|
33
|
+
entityId?: string;
|
|
34
|
+
originalUrl?: string;
|
|
35
|
+
hlsUrl?: string;
|
|
36
|
+
posterUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HydratedAudioMedia {
|
|
40
|
+
fileId: string;
|
|
41
|
+
entityType: string;
|
|
42
|
+
entityId: string;
|
|
43
|
+
playbackUrl: string;
|
|
44
|
+
playbackSource: AudioPlaybackSource | null;
|
|
45
|
+
originalUrl: string;
|
|
46
|
+
hlsUrl: string;
|
|
47
|
+
waveformUrl: string;
|
|
48
|
+
spectrogramUrl: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HydratedVideoMedia {
|
|
52
|
+
fileId: string;
|
|
53
|
+
entityType: string;
|
|
54
|
+
entityId: string;
|
|
55
|
+
originalUrl: string;
|
|
56
|
+
hlsUrl: string;
|
|
57
|
+
posterUrl: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AudioPlaybackHydrationInput {
|
|
61
|
+
originalUrl?: string;
|
|
62
|
+
hlsUrl?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AudioPlaybackHydration {
|
|
66
|
+
originalUrl: string;
|
|
67
|
+
hlsUrl: string;
|
|
68
|
+
playbackUrl: string;
|
|
69
|
+
playbackSource: AudioPlaybackSource | null;
|
|
70
|
+
hasOriginal: boolean;
|
|
71
|
+
canPlayHls: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeHydrationUrl(
|
|
75
|
+
value: string | undefined | null,
|
|
76
|
+
): string {
|
|
77
|
+
const trimmed = (value ?? "").trim();
|
|
78
|
+
return !trimmed || trimmed === "undefined" || trimmed === "null"
|
|
79
|
+
? ""
|
|
80
|
+
: trimmed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getHydrationAttr(
|
|
84
|
+
primary: Element | null | undefined,
|
|
85
|
+
attr: string,
|
|
86
|
+
secondary?: Element | null,
|
|
87
|
+
): string {
|
|
88
|
+
return normalizeHydrationUrl(
|
|
89
|
+
primary?.getAttribute(attr) ?? secondary?.getAttribute(attr) ?? "",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function looksLikeHlsUrl(url: string): boolean {
|
|
94
|
+
return /\.m3u8(?:$|[?#])/i.test(url);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveAudioPlaybackHydration(
|
|
98
|
+
input: AudioPlaybackHydrationInput,
|
|
99
|
+
): AudioPlaybackHydration {
|
|
100
|
+
const originalUrl = normalizeHydrationUrl(input.originalUrl);
|
|
101
|
+
const hlsUrl = normalizeHydrationUrl(input.hlsUrl);
|
|
102
|
+
const hasOriginal = originalUrl.length > 0;
|
|
103
|
+
const canPlayHls = hlsUrl.length > 0;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
originalUrl,
|
|
107
|
+
hlsUrl,
|
|
108
|
+
playbackUrl: canPlayHls ? hlsUrl : "",
|
|
109
|
+
playbackSource: canPlayHls ? "hls" : null,
|
|
110
|
+
hasOriginal,
|
|
111
|
+
canPlayHls,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function applyMediaHydrationDomAttrs(
|
|
116
|
+
element: HTMLElement,
|
|
117
|
+
attrs: MediaHydrationDomAttrs,
|
|
118
|
+
): void {
|
|
119
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
120
|
+
if (value) {
|
|
121
|
+
element.setAttribute(key, value);
|
|
122
|
+
} else {
|
|
123
|
+
element.removeAttribute(key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function buildAudioMediaHydrationAttrs(
|
|
129
|
+
input: AudioMediaHydrationInput,
|
|
130
|
+
): MediaHydrationDomAttrs {
|
|
131
|
+
return {
|
|
132
|
+
[MEDIA_HYDRATION_DOM_ATTRS.mediaKind]: "audio",
|
|
133
|
+
[MEDIA_HYDRATION_DOM_ATTRS.fileId]:
|
|
134
|
+
normalizeHydrationUrl(input.fileId) || undefined,
|
|
135
|
+
[MEDIA_HYDRATION_DOM_ATTRS.entityType]:
|
|
136
|
+
normalizeHydrationUrl(input.entityType) || undefined,
|
|
137
|
+
[MEDIA_HYDRATION_DOM_ATTRS.entityId]:
|
|
138
|
+
normalizeHydrationUrl(input.entityId) || undefined,
|
|
139
|
+
[MEDIA_HYDRATION_DOM_ATTRS.playbackUrl]:
|
|
140
|
+
normalizeHydrationUrl(input.playbackUrl) || undefined,
|
|
141
|
+
[MEDIA_HYDRATION_DOM_ATTRS.playbackSource]:
|
|
142
|
+
input.playbackSource === "hls" ? "hls" : undefined,
|
|
143
|
+
[MEDIA_HYDRATION_DOM_ATTRS.originalUrl]:
|
|
144
|
+
normalizeHydrationUrl(input.originalUrl) || undefined,
|
|
145
|
+
[MEDIA_HYDRATION_DOM_ATTRS.hlsUrl]:
|
|
146
|
+
normalizeHydrationUrl(input.hlsUrl) || undefined,
|
|
147
|
+
[MEDIA_HYDRATION_DOM_ATTRS.waveformUrl]:
|
|
148
|
+
normalizeHydrationUrl(input.waveformUrl) || undefined,
|
|
149
|
+
[MEDIA_HYDRATION_DOM_ATTRS.spectrogramUrl]:
|
|
150
|
+
normalizeHydrationUrl(input.spectrogramUrl) || undefined,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildVideoMediaHydrationAttrs(
|
|
155
|
+
input: VideoMediaHydrationInput,
|
|
156
|
+
): MediaHydrationDomAttrs {
|
|
157
|
+
return {
|
|
158
|
+
[MEDIA_HYDRATION_DOM_ATTRS.mediaKind]: "video",
|
|
159
|
+
[MEDIA_HYDRATION_DOM_ATTRS.fileId]:
|
|
160
|
+
normalizeHydrationUrl(input.fileId) || undefined,
|
|
161
|
+
[MEDIA_HYDRATION_DOM_ATTRS.entityType]:
|
|
162
|
+
normalizeHydrationUrl(input.entityType) || undefined,
|
|
163
|
+
[MEDIA_HYDRATION_DOM_ATTRS.entityId]:
|
|
164
|
+
normalizeHydrationUrl(input.entityId) || undefined,
|
|
165
|
+
[MEDIA_HYDRATION_DOM_ATTRS.originalUrl]:
|
|
166
|
+
normalizeHydrationUrl(input.originalUrl) || undefined,
|
|
167
|
+
[MEDIA_HYDRATION_DOM_ATTRS.hlsUrl]:
|
|
168
|
+
normalizeHydrationUrl(input.hlsUrl) || undefined,
|
|
169
|
+
[MEDIA_HYDRATION_DOM_ATTRS.posterUrl]:
|
|
170
|
+
normalizeHydrationUrl(input.posterUrl) || undefined,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function readAudioMediaHydration(
|
|
175
|
+
root: HTMLElement,
|
|
176
|
+
audio?: HTMLAudioElement | null,
|
|
177
|
+
): HydratedAudioMedia {
|
|
178
|
+
const playbackUrl =
|
|
179
|
+
getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.playbackUrl, audio) ||
|
|
180
|
+
normalizeHydrationUrl(audio?.getAttribute("src"));
|
|
181
|
+
const playbackSource =
|
|
182
|
+
getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.playbackSource) === "hls"
|
|
183
|
+
? "hls"
|
|
184
|
+
: null;
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
fileId: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.fileId, audio),
|
|
188
|
+
entityType: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.entityType),
|
|
189
|
+
entityId: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.entityId),
|
|
190
|
+
playbackUrl,
|
|
191
|
+
playbackSource,
|
|
192
|
+
originalUrl: getHydrationAttr(
|
|
193
|
+
root,
|
|
194
|
+
MEDIA_HYDRATION_DOM_ATTRS.originalUrl,
|
|
195
|
+
audio,
|
|
196
|
+
),
|
|
197
|
+
hlsUrl: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.hlsUrl, audio),
|
|
198
|
+
waveformUrl: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.waveformUrl),
|
|
199
|
+
spectrogramUrl: getHydrationAttr(
|
|
200
|
+
root,
|
|
201
|
+
MEDIA_HYDRATION_DOM_ATTRS.spectrogramUrl,
|
|
202
|
+
),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function readVideoMediaHydration(
|
|
207
|
+
root: HTMLElement,
|
|
208
|
+
video?: HTMLVideoElement | null,
|
|
209
|
+
): HydratedVideoMedia {
|
|
210
|
+
const currentSrc = normalizeHydrationUrl(video?.getAttribute("src"));
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
fileId: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.fileId, video),
|
|
214
|
+
entityType: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.entityType),
|
|
215
|
+
entityId: getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.entityId),
|
|
216
|
+
originalUrl:
|
|
217
|
+
getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.originalUrl, video) ||
|
|
218
|
+
currentSrc,
|
|
219
|
+
hlsUrl:
|
|
220
|
+
getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.hlsUrl, video) ||
|
|
221
|
+
(looksLikeHlsUrl(currentSrc) ? currentSrc : ""),
|
|
222
|
+
posterUrl:
|
|
223
|
+
getHydrationAttr(root, MEDIA_HYDRATION_DOM_ATTRS.posterUrl, video) ||
|
|
224
|
+
normalizeHydrationUrl(video?.getAttribute("poster")),
|
|
225
|
+
};
|
|
226
|
+
}
|