@lemonadecms/client 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,125 @@
1
+ import { definitions } from './definitions.mjs';
2
+ const quote = JSON.stringify;
3
+
4
+ export function generatePython(schema) {
5
+ const { records, names } = definitions(schema);
6
+ function type(field) {
7
+ switch (field.type) {
8
+ case 'number':
9
+ return 'float';
10
+ case 'boolean':
11
+ return 'bool';
12
+ case 'choice':
13
+ return `Literal[${field.options.map(quote).join(', ')}]`;
14
+ case 'choices':
15
+ return `list[Literal[${field.options.map(quote).join(', ')}]]`;
16
+ case 'reference':
17
+ return `ContentReference[${names.get(field.target)}]`;
18
+ case 'references':
19
+ return `list[ContentReference[${names.get(field.target)}]]`;
20
+ case 'image':
21
+ case 'file':
22
+ return 'ContentAsset';
23
+ case 'rich_text':
24
+ return 'ContentRichText';
25
+ case 'component':
26
+ return field.variants[0].name;
27
+ case 'components':
28
+ case 'blocks':
29
+ return `list[${field.variants.map((v) => v.name).join(' | ')}]`;
30
+ default:
31
+ return 'str';
32
+ }
33
+ }
34
+ const models = records
35
+ .map((record) => {
36
+ const entries = record.fields
37
+ .map((field) => ` ${quote(field.key)}: ${quote(`${type(field)} | None`)},`)
38
+ .join('\n');
39
+ const fields = `${record.name}Fields`;
40
+ if (record.component)
41
+ return `${fields} = TypedDict(${quote(fields)}, {\n${entries}\n}, total=False)\n\nclass ${record.name}(TypedDict):\n id: str\n type: Literal[${quote(record.key)}]\n fields: ${fields}\n`;
42
+ return `${record.name} = TypedDict(${quote(record.name)}, {\n${entries}\n}, total=False)\n`;
43
+ })
44
+ .join('\n');
45
+ return `# Generated by Lemonade CMS. Do not edit. Python 3.11+; standard library only.
46
+ # All model fields allow historical absence/null, including publication-required fields.
47
+ # TypedDict describes JSON dictionaries; it does not validate untrusted JSON at runtime.
48
+ from __future__ import annotations
49
+ from typing import Generic, Literal, NotRequired, TypeVar, TypedDict
50
+
51
+ TFields = TypeVar("TFields")
52
+
53
+ class ContentReference(TypedDict, Generic[TFields]):
54
+ id: str
55
+ fields: NotRequired[TFields]
56
+ published_at: NotRequired[str | None]
57
+
58
+ class ContentRendition(TypedDict):
59
+ key: str
60
+ preset: Literal["hero", "card", "social"]
61
+ width: int
62
+ height: int
63
+ content_type: str
64
+ url: str
65
+
66
+ class FocalPoint(TypedDict):
67
+ x: float
68
+ y: float
69
+
70
+ class AssetMetadata(TypedDict, total=False):
71
+ title: str
72
+ alt: str
73
+ description: str
74
+ focal_point: FocalPoint
75
+
76
+ class ContentAsset(TypedDict):
77
+ id: str
78
+ url: str
79
+ content_type: str
80
+ byte_size: int
81
+ metadata: AssetMetadata
82
+ renditions: NotRequired[list[ContentRendition]]
83
+
84
+ # Rich-text documents retain their versioned JSON structure. Node/target validation
85
+ # belongs to the CMS; these transport types deliberately do not claim validation.
86
+ class RichTextNode(TypedDict):
87
+ type: str
88
+ text: NotRequired[str]
89
+ attrs: NotRequired[dict[str, object]]
90
+ marks: NotRequired[list[dict[str, object]]]
91
+ content: NotRequired[list[RichTextNode]]
92
+ resource: NotRequired[ContentAsset | ContentReference[dict[str, object]] | None]
93
+
94
+ class RichTextDocument(TypedDict):
95
+ type: Literal["doc"]
96
+ content: NotRequired[list[RichTextNode]]
97
+
98
+ class ContentRichText(TypedDict):
99
+ version: Literal[1]
100
+ document: RichTextDocument
101
+
102
+ class ContentEntry(TypedDict, Generic[TFields]):
103
+ id: str
104
+ fields: TFields
105
+ published_at: str | None
106
+ remote: NotRequired[dict[str, object] | None]
107
+
108
+ class PageMeta(TypedDict):
109
+ next_cursor: str | None
110
+ limit: int
111
+ page: NotRequired[int]
112
+ page_size: NotRequired[int]
113
+ previous_cursor: NotRequired[str | None]
114
+ has_next_page: NotRequired[bool]
115
+ has_previous_page: NotRequired[bool]
116
+
117
+ class ContentPage(TypedDict, Generic[TFields]):
118
+ data: list[ContentEntry[TFields]]
119
+ meta: PageMeta
120
+
121
+ ${models}
122
+ # Stable API model keys map to the generated field dictionaries above.
123
+ Models = TypedDict("Models", {${[...names].map(([key, name]) => `${quote(key)}: ${quote(name)}`).join(', ')}})
124
+ `;
125
+ }
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ import { writeFile, mkdir, realpath } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ const identifier = /^[a-z][a-z0-9_]{0,47}$/;
6
+ export function generateTypes(schema) {
7
+ if (
8
+ schema?.format_version !== 1 ||
9
+ !Array.isArray(schema.models) ||
10
+ schema.models.length > 256 ||
11
+ !schema.models.every(
12
+ (model) =>
13
+ identifier.test(model.key) &&
14
+ Array.isArray(model.fields) &&
15
+ model.fields.length <= 24 &&
16
+ model.fields.every((field) => identifier.test(field.key)),
17
+ )
18
+ )
19
+ throw new Error('Invalid schema export.');
20
+ const keys = new Set(schema.models.map((model) => model.key));
21
+ function type(field) {
22
+ switch (field.type) {
23
+ case 'component':
24
+ case 'components':
25
+ case 'blocks': {
26
+ if (
27
+ !Array.isArray(field.components) ||
28
+ field.components.length < 1 ||
29
+ field.components.length > 12 ||
30
+ (field.type !== 'blocks' && field.components.length !== 1)
31
+ )
32
+ throw new Error('Invalid component definitions.');
33
+ const variants = field.components
34
+ .map((component) => {
35
+ if (
36
+ !identifier.test(component.key) ||
37
+ !Array.isArray(component.fields) ||
38
+ component.fields.length < 1 ||
39
+ component.fields.length > 24 ||
40
+ component.fields.some(
41
+ (child) =>
42
+ !identifier.test(child.key) ||
43
+ ['component', 'components', 'blocks'].includes(child.type),
44
+ )
45
+ )
46
+ throw new Error('Invalid component fields.');
47
+ const fields = component.fields
48
+ .map((child) => `${JSON.stringify(child.key)}?: ${type(child)} | null`)
49
+ .join('; ');
50
+ return `{ id: string; type: ${JSON.stringify(component.key)}; fields: { ${fields} } }`;
51
+ })
52
+ .join(' | ');
53
+ return field.type === 'component' ? variants : `(${variants})[]`;
54
+ }
55
+ case 'rich_text': {
56
+ if (
57
+ !Array.isArray(field.targets || []) ||
58
+ (field.targets || []).length > 12 ||
59
+ !(field.targets || []).every((target) => keys.has(target))
60
+ )
61
+ throw new Error('Invalid rich-text targets.');
62
+ const targets =
63
+ (field.targets || []).map((target) => JSON.stringify(target)).join(' | ') ||
64
+ 'never';
65
+ return `ContentRichText<${targets}>`;
66
+ }
67
+ case 'number':
68
+ return 'number';
69
+ case 'boolean':
70
+ return 'boolean';
71
+ case 'choice':
72
+ case 'choices':
73
+ if (
74
+ !Array.isArray(field.options) ||
75
+ !field.options.every((v) => typeof v === 'string')
76
+ )
77
+ throw new Error('Invalid choices.');
78
+ {
79
+ const option =
80
+ field.options.map((v) => JSON.stringify(v)).join(' | ') || 'never';
81
+ return field.type === 'choices' ? `(${option})[]` : option;
82
+ }
83
+ case 'reference':
84
+ case 'references':
85
+ if (!keys.has(field.target)) throw new Error('Unknown reference target.');
86
+ return `ContentReference<Models[${JSON.stringify(field.target)}]>${field.type === 'references' ? '[]' : ''}`;
87
+ case 'image':
88
+ case 'file':
89
+ return 'ContentAsset';
90
+ case 'text':
91
+ case 'long_text':
92
+ case 'markdown':
93
+ case 'color':
94
+ case 'date':
95
+ case 'datetime':
96
+ return 'string';
97
+ default:
98
+ throw new Error('Unsupported field type.');
99
+ }
100
+ }
101
+ const models = [...schema.models]
102
+ .sort((a, b) => a.key.localeCompare(b.key))
103
+ .map(
104
+ (model) =>
105
+ ` ${JSON.stringify(model.key)}: {\n${model.fields.map((field) => ` ${JSON.stringify(field.key)}?: ${type(field)} | null;`).join('\n')}\n };`,
106
+ )
107
+ .join('\n');
108
+ const seoModels = Object.fromEntries(
109
+ schema.models.map((model) => {
110
+ const mappings = {};
111
+ function add(field, path) {
112
+ if (!field.seo) return;
113
+ const roles =
114
+ field.type === 'image'
115
+ ? ['image', 'image_fallback']
116
+ : ['text', 'long_text'].includes(field.type)
117
+ ? ['title', 'description', 'title_fallback', 'description_fallback']
118
+ : [];
119
+ if (!roles.includes(field.seo) || Object.hasOwn(mappings, field.seo))
120
+ throw new Error('Invalid or duplicate SEO role.');
121
+ mappings[field.seo] = path;
122
+ }
123
+ for (const field of model.fields) {
124
+ if (field.type === 'component') {
125
+ for (const child of field.components[0].fields)
126
+ add(child, [field.key, child.key]);
127
+ } else if (!['components', 'blocks'].includes(field.type))
128
+ add(field, [field.key]);
129
+ }
130
+ return [model.key, mappings];
131
+ }),
132
+ );
133
+ return `// Generated by @lemonadecms/client codegen. Do not edit.\n// Every field allows historical absence/null; previews may be incomplete.\nexport interface ContentReference<Fields> { id: string; fields?: Fields; published_at?: string | null; content_type?: { id: string; key: string; name: string }; project_id?: string; environment?: { id: string; key: string }; locale?: string }\nexport interface ContentRendition { key: string; preset: "hero" | "card" | "social"; width: number; height: number; content_type: string; url: string }\nexport interface ContentAsset { id: string; url: string; content_type: string; byte_size: number; metadata: { title?: string; alt?: string; description?: string; focal_point?: { x: number; y: number } }; renditions?: ContentRendition[] }\nexport type RichTextMark = { type: 'bold' | 'italic' | 'underline' | 'strike' | 'code' } | { type: 'link'; attrs: { href: string } };\nexport type RichTextInline = { type: 'text'; text: string; marks?: RichTextMark[] } | { type: 'hardBreak' };\nexport type RichTextEntry<Target extends keyof Models> = { [K in Target]: { type: 'entry'; attrs: { id: string; target: K }; resource?: ContentReference<Models[K]> | null } }[Target];\nexport type RichTextBlock<Target extends keyof Models = keyof Models> = { type: 'paragraph'; content?: RichTextInline[] } | { type: 'heading'; attrs: { level: 2 | 3 | 4 }; content?: RichTextInline[] } | { type: 'bulletList' | 'orderedList'; attrs?: { start: 1 }; content: { type: 'listItem'; content: RichTextBlock<Target>[] }[] } | { type: 'image'; attrs: { id: string; alt: string }; resource?: ContentAsset | null } | RichTextEntry<Target>;\nexport interface ContentRichText<Target extends keyof Models = keyof Models> { version: 1; document: { type: 'doc'; content?: RichTextBlock<Target>[] } }\nexport interface Models {\n${models}\n}\n\n// Presentation roles, refreshed by codegen after schema changes.\nexport const seoModels = ${JSON.stringify(seoModels, null, 2)} as const;\n`;
134
+ }
135
+ async function main() {
136
+ const args = process.argv.slice(2);
137
+ if (args.includes('--help')) {
138
+ console.log(
139
+ 'cms-codegen --url URL --project UUID --out FILE\nCredentials: CMS_PUBLIC_KEY or CMS_SECRET_KEY environment variable.',
140
+ );
141
+ return;
142
+ }
143
+ const options = {};
144
+ for (let i = 0; i < args.length; i += 2) {
145
+ if (!['--url', '--project', '--out'].includes(args[i]) || !args[i + 1])
146
+ throw new Error('Use --help for codegen options.');
147
+ options[args[i].slice(2)] = args[i + 1];
148
+ }
149
+ const url = new URL(options.url || process.env.CMS_URL || 'https://lemonadecms.com');
150
+ if (
151
+ !['http:', 'https:'].includes(url.protocol) ||
152
+ url.username ||
153
+ url.password ||
154
+ url.search ||
155
+ url.hash
156
+ )
157
+ throw new Error('Use an HTTP(S) CMS URL without credentials or query.');
158
+ const project = options.project || process.env.CMS_PROJECT_ID;
159
+ if (
160
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
161
+ project || '',
162
+ )
163
+ )
164
+ throw new Error('A workspace UUID is required.');
165
+ const key = process.env.CMS_PUBLIC_KEY || process.env.CMS_SECRET_KEY;
166
+ if (
167
+ !/^(pk|sk)_[A-Za-z0-9_-]{43}$/.test(key || '') &&
168
+ !/^lmd_(pk|sk)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[A-Za-z0-9_-]{43}$/.test(
169
+ key || '',
170
+ )
171
+ )
172
+ throw new Error('Set CMS_PUBLIC_KEY or CMS_SECRET_KEY.');
173
+ if (!options.out) throw new Error('--out is required.');
174
+ const response = await fetch(
175
+ `${url.href.replace(/\/+$/, '')}/api/v1/projects/${project}/schema`,
176
+ {
177
+ headers: { Accept: 'application/json', Authorization: `Bearer ${key}` },
178
+ credentials: 'omit',
179
+ redirect: 'error',
180
+ cache: 'no-store',
181
+ signal: AbortSignal.timeout(15000),
182
+ },
183
+ );
184
+ if (!response.ok) throw new Error(`Schema export failed (${response.status}).`);
185
+ const output = generateTypes(await response.json());
186
+ const path = resolve(options.out);
187
+ await mkdir(dirname(path), { recursive: true });
188
+ await writeFile(path, output);
189
+ console.log(`Generated ${path}`);
190
+ }
191
+ // npm exposes executables through .bin symlinks; compare their real target while
192
+ // keeping generateTypes importable without running the CLI.
193
+ const invokedPath = process.argv[1]
194
+ ? await realpath(resolve(process.argv[1])).catch(() => undefined)
195
+ : undefined;
196
+ if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href)
197
+ main().catch(() => {
198
+ console.error(
199
+ 'Code generation failed. Check schema, URL, workspace, credentials and output path; use --help.',
200
+ );
201
+ process.exitCode = 1;
202
+ });
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ if (Number(process.versions.node.split('.')[0]) < 22) {
3
+ const error = 'The Lemonade CLI requires Node.js 22 or later.';
4
+ if (process.argv.includes('--json'))
5
+ process.stdout.write(JSON.stringify({ ok: false, code: 2, error }) + '\n');
6
+ else process.stderr.write(error + '\n');
7
+ process.exitCode = 2;
8
+ } else {
9
+ const { run } = await import('./cli/run.mjs');
10
+ process.exitCode = await run(process.argv.slice(2));
11
+ }
@@ -0,0 +1,24 @@
1
+ export declare class CMSConfigurationError extends TypeError {
2
+ readonly name = "CMSConfigurationError";
3
+ }
4
+ export declare class CMSRequestError extends Error {
5
+ readonly status: number;
6
+ readonly code: string;
7
+ readonly name = "CMSRequestError";
8
+ constructor(message: string, status: number, code: string);
9
+ }
10
+ export declare class CMSResponseError extends Error {
11
+ readonly name = "CMSResponseError";
12
+ }
13
+ /** A successful findOneOrThrow query had no matching entry. */
14
+ export declare class CMSNotFoundError extends Error {
15
+ readonly model: string;
16
+ readonly name = "CMSNotFoundError";
17
+ constructor(model: string);
18
+ }
19
+ /** The configured request deadline expired, including response-body reading. */
20
+ export declare class CMSTimeoutError extends Error {
21
+ readonly timeoutMs: number;
22
+ readonly name = "CMSTimeoutError";
23
+ constructor(timeoutMs: number);
24
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,39 @@
1
+ // Error categories callers can distinguish with instanceof.
2
+ // Configuration errors happen locally before a request. Request errors contain an
3
+ // HTTP status and stable API code. Response errors mean a successful HTTP response
4
+ // did not match the expected JSON envelope. An opt-in deadline has its own error;
5
+ // network errors and caller cancellation reasons pass through unchanged.
6
+ export class CMSConfigurationError extends TypeError {
7
+ name = "CMSConfigurationError";
8
+ }
9
+ export class CMSRequestError extends Error {
10
+ status;
11
+ code;
12
+ name = "CMSRequestError";
13
+ constructor(message, status, code) {
14
+ super(message);
15
+ this.status = status;
16
+ this.code = code;
17
+ }
18
+ }
19
+ export class CMSResponseError extends Error {
20
+ name = "CMSResponseError";
21
+ }
22
+ /** A successful findOneOrThrow query had no matching entry. */
23
+ export class CMSNotFoundError extends Error {
24
+ model;
25
+ name = "CMSNotFoundError";
26
+ constructor(model) {
27
+ super("No content entry matched the query.");
28
+ this.model = model;
29
+ }
30
+ }
31
+ /** The configured request deadline expired, including response-body reading. */
32
+ export class CMSTimeoutError extends Error {
33
+ timeoutMs;
34
+ name = "CMSTimeoutError";
35
+ constructor(timeoutMs) {
36
+ super("CMS request exceeded its configured deadline.");
37
+ this.timeoutMs = timeoutMs;
38
+ }
39
+ }
@@ -0,0 +1,11 @@
1
+ export interface ContentRendition {
2
+ key: string;
3
+ preset: 'hero' | 'card' | 'social';
4
+ width: number;
5
+ height: number;
6
+ url: string;
7
+ }
8
+ /** Build a width-descriptor srcset from one compatible crop family. */
9
+ export declare function imageSrcSet(asset: {
10
+ renditions?: readonly ContentRendition[];
11
+ } | null | undefined, preset: ContentRendition['preset']): string | undefined;
package/dist/images.js ADDED
@@ -0,0 +1,10 @@
1
+ /** Build a width-descriptor srcset from one compatible crop family. */
2
+ export function imageSrcSet(asset, preset) {
3
+ const images = asset?.renditions?.filter((image) => image.preset === preset);
4
+ if (!images?.length)
5
+ return undefined;
6
+ return [...images]
7
+ .sort((left, right) => left.width - right.width)
8
+ .map((image) => `${image.url} ${image.width}w`)
9
+ .join(', ');
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { CMSClient, CMSClientOptions, ModelMap, UntypedModels } from "./types.js";
2
+ export * from "./errors.js";
3
+ export type { RemoteValue } from "./types.js";
4
+ export type { CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
5
+ export type { CMSClient, CMSClientOptions, ContentCollection, ContentEntry, ContentPage, ContentTypeIdentity, ContentEnvironment, ListOptions, FindOneOptions, PaginationOptions, ContentFilter, ContentSort } from "./types.js";
6
+ /** Published content only. Framework environment variables must be passed explicitly. */
7
+ export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSClientOptions): CMSClient<Models>;
8
+ export { imageSrcSet } from "./images.js";
9
+ export type { ContentRendition } from "./images.js";
10
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // Public package entry point for browser-safe published-content reads.
2
+ // Vue, React, plain JavaScript, and server callers pass configuration explicitly;
3
+ // this module does not know how any framework loads its environment variables.
4
+ // The factory selects the public-key route, while transport.ts handles HTTP.
5
+ // There are intentionally no management/write or preview methods on this client.
6
+ import { createTransport } from "./transport.js";
7
+ import { CMSConfigurationError } from "./errors.js";
8
+ export * from "./errors.js";
9
+ /** Published content only. Framework environment variables must be passed explicitly. */
10
+ export function createCMSClient(options) {
11
+ if (options && typeof options === "object" && "secretKey" in options) {
12
+ throw new CMSConfigurationError("Use the server entry point for secret keys.");
13
+ }
14
+ return createTransport(options, options?.publicKey, "content");
15
+ }
16
+ export { imageSrcSet } from "./images.js";
17
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
@@ -0,0 +1,11 @@
1
+ /** Optional browser editing bridge. Call only inside an authenticated preview.
2
+ * Annotations identify top-level fields on the current entry; they confer no access.
3
+ * This module imports no client runtime, renderer, framework or server credentials.
4
+ */
5
+ export declare function enablePreviewEditing(options: {
6
+ studioOrigin: string;
7
+ channel: string;
8
+ entry: string;
9
+ locale: string;
10
+ root?: HTMLElement;
11
+ }): () => void;
@@ -0,0 +1,92 @@
1
+ /** Optional browser editing bridge. Call only inside an authenticated preview.
2
+ * Annotations identify top-level fields on the current entry; they confer no access.
3
+ * This module imports no client runtime, renderer, framework or server credentials.
4
+ */
5
+ export function enablePreviewEditing(options) {
6
+ const root = options.root ?? document.body;
7
+ const { studioOrigin, channel, entry, locale } = options;
8
+ const origin = new URL(studioOrigin);
9
+ if (origin.origin !== studioOrigin || !['http:', 'https:'].includes(origin.protocol) ||
10
+ window.parent === window || ![channel, entry, locale].every(value => typeof value === 'string' && value.length > 0 && value.length <= 64)) {
11
+ return () => { };
12
+ }
13
+ let enabled = false;
14
+ const originals = new Map();
15
+ const attribute = 'data-lemonade-field';
16
+ const send = (type, extra = {}) => window.parent.postMessage({ type, channel, ...extra }, studioOrigin);
17
+ const restore = () => {
18
+ for (const [element, attrs] of originals) {
19
+ for (const [name, value] of attrs) {
20
+ if (value === null)
21
+ element.removeAttribute(name);
22
+ else
23
+ element.setAttribute(name, value);
24
+ }
25
+ }
26
+ originals.clear();
27
+ };
28
+ const activate = () => {
29
+ restore();
30
+ if (!enabled)
31
+ return;
32
+ // Bound enhancements; delegated activation also works after framework rerenders.
33
+ for (const element of Array.from(root.querySelectorAll(`[${attribute}]`)).slice(0, 500)) {
34
+ const field = element.getAttribute(attribute) ?? '';
35
+ if (!/^[a-z][a-z0-9_]{0,63}$/.test(field))
36
+ continue;
37
+ const attrs = new Map();
38
+ for (const name of ['tabindex', 'role', 'aria-label', 'title', 'style'])
39
+ attrs.set(name, element.getAttribute(name));
40
+ originals.set(element, attrs);
41
+ // Preserve native semantic roles for links/buttons. No contenteditable is added.
42
+ if (!element.matches('a[href],button,input,textarea,select'))
43
+ element.setAttribute('role', 'button');
44
+ element.setAttribute('tabindex', '0');
45
+ element.setAttribute('aria-label', `Edit ${field.replaceAll('_', ' ')} in Studio`);
46
+ element.setAttribute('title', 'Edit in Studio');
47
+ element.style.outline = '2px dashed currentColor';
48
+ element.style.outlineOffset = '4px';
49
+ element.style.cursor = 'pointer';
50
+ }
51
+ };
52
+ const receive = (event) => {
53
+ if (event.source !== window.parent || event.origin !== studioOrigin || event.data?.channel !== channel)
54
+ return;
55
+ if (event.data.type === 'lemonade:preview-connect' && typeof event.data.request === 'string' && event.data.request.length <= 64) {
56
+ send('lemonade:preview-editing-ready', { request: event.data.request });
57
+ return;
58
+ }
59
+ if (event.data.type !== 'lemonade:preview-editing' || typeof event.data.enabled !== 'boolean')
60
+ return;
61
+ enabled = event.data.enabled;
62
+ activate();
63
+ };
64
+ const select = (event) => {
65
+ if (!enabled || !(event.target instanceof Element))
66
+ return;
67
+ if (event instanceof KeyboardEvent && (event.key !== 'Enter' && event.key !== ' '))
68
+ return;
69
+ if (event instanceof MouseEvent && (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey))
70
+ return;
71
+ const element = event.target.closest(`[${attribute}]`);
72
+ if (!element || !originals.has(element))
73
+ return;
74
+ const field = element.getAttribute(attribute);
75
+ if (!field || !/^[a-z][a-z0-9_]{0,63}$/.test(field))
76
+ return;
77
+ event.preventDefault();
78
+ event.stopImmediatePropagation();
79
+ send('lemonade:preview-select', { entry, locale, field });
80
+ };
81
+ window.addEventListener('message', receive);
82
+ root.addEventListener('click', select, true);
83
+ root.addEventListener('keydown', select, true);
84
+ send('lemonade:preview-editing-ready');
85
+ return () => {
86
+ enabled = false;
87
+ window.removeEventListener('message', receive);
88
+ root.removeEventListener('click', select, true);
89
+ root.removeEventListener('keydown', select, true);
90
+ restore();
91
+ };
92
+ }
@@ -0,0 +1,4 @@
1
+ /** Escaped HTML for rich-text v1, including safe expanded images/entry captions. */
2
+ export declare function richTextToHtml(value: unknown): string;
3
+ /** Authored text with block/hard-break newlines; excludes embeds and link URLs. */
4
+ export declare function richTextToPlainText(value: unknown): string;