1import {
2 COMPOSER_CONTEXT_CLIPBOARD_MIME,
3 ComposerContextClipboardFragment,
4} from "@t3tools/contracts";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7
8export { COMPOSER_CONTEXT_CLIPBOARD_MIME };
9
10const MAX_FRAGMENT_CHARS = 16_000_000;
11const decodeFragment = Schema.decodeUnknownOption(ComposerContextClipboardFragment);
12
13export function encodeComposerContextFragment(
14 fragment: ComposerContextClipboardFragment,
15): string | null {
16 const encoded = JSON.stringify(fragment);
17 return encoded.length <= MAX_FRAGMENT_CHARS ? encoded : null;
18}
19
20/** HTML is the portable flavor shared by browsers and native system clipboards. */
21/** Room for the wrapper element, its attribute name, and the copied HTML itself. */
22const HTML_WRAPPER_SLACK_CHARS = 4096;
23
24export function encodeComposerContextClipboardHtml(
25 text: string,
26 fragment: string,
27 html?: string,
28): string {
29 if (html !== undefined)
30 return `<div data-t3-context-fragment="${encodeURIComponent(fragment)}">${html}</div>`;
31 const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32 return `<pre data-t3-context-fragment="${encodeURIComponent(fragment)}">${escaped}</pre>`;
33}
34
35export function decodeComposerContextClipboardHtml(
36 html: string | null | undefined,
37): ComposerContextClipboardFragment | null {
38 // `encodeURIComponent` expands one non-ASCII code unit to up to nine characters, so a
39 // fragment just under the limit must still survive the round trip through the attribute.
40 if (!html || html.length > MAX_FRAGMENT_CHARS * 9 + HTML_WRAPPER_SLACK_CHARS) return null;
41 const encoded = /data-t3-context-fragment=["']([^"']+)["']/.exec(html)?.[1];
42 if (!encoded) return null;
43 try {
44 return decodeComposerContextFragment(decodeURIComponent(encoded));
45 } catch {
46 return null;
47 }
48}
49
50/** Clipboard data is untrusted: anything that is not a valid version-1 fragment is ignored. */
51export function decodeComposerContextFragment(
52 raw: string | null | undefined,
53): ComposerContextClipboardFragment | null {
54 if (!raw || raw.length > MAX_FRAGMENT_CHARS) return null;
55 try {
56 return Option.getOrNull(decodeFragment(JSON.parse(raw)));
57 } catch {
58 return null;
59 }
60}