@lemonadecms/client 0.0.0-stage → 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.
@@ -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 }\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,18 @@
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
+ /** The configured request deadline expired, including response-body reading. */
14
+ export declare class CMSTimeoutError extends Error {
15
+ readonly timeoutMs: number;
16
+ readonly name = "CMSTimeoutError";
17
+ constructor(timeoutMs: number);
18
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,30 @@
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
+ /** The configured request deadline expired, including response-body reading. */
23
+ export class CMSTimeoutError extends Error {
24
+ timeoutMs;
25
+ name = "CMSTimeoutError";
26
+ constructor(timeoutMs) {
27
+ super("CMS request exceeded its configured deadline.");
28
+ this.timeoutMs = timeoutMs;
29
+ }
30
+ }
@@ -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,8 @@
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 { CMSClient, CMSClientOptions, ContentCollection, ContentEntry, ContentPage, ListOptions, PaginationOptions, ContentFilter, ContentSort } from "./types.js";
5
+ /** Published content only. Framework environment variables must be passed explicitly. */
6
+ export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSClientOptions): CMSClient<Models>;
7
+ export { imageSrcSet } from "./images.js";
8
+ export type { ContentRendition } from "./images.js";
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
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";
@@ -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,2 @@
1
+ export * from "./errors.js";
2
+ export declare function createCMSPreviewClient(): never;
@@ -0,0 +1,9 @@
1
+ // Browser-condition replacement for the server-only entry point.
2
+ // A bundler resolving the package with the browser condition loads this module
3
+ // instead of server.ts. Calling its factory fails immediately, before any fetch.
4
+ // Keep real secret keys in server configuration regardless of this guard.
5
+ import { CMSConfigurationError } from "./errors.js";
6
+ export * from "./errors.js";
7
+ export function createCMSPreviewClient() {
8
+ throw new CMSConfigurationError("The preview client must run on a server. Import @lemonadecms/client for published content.");
9
+ }
@@ -0,0 +1,8 @@
1
+ import type { CMSClient, CMSPreviewOptions, ModelMap, PreviewModels, UntypedModels } from "./types.js";
2
+ export * from "./errors.js";
3
+ export type { CMSPreviewOptions, PreviewModels } from "./types.js";
4
+ /** Import only from server-owned code. Draft fields may be missing or null. */
5
+ export declare function createCMSPreviewClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSPreviewOptions): CMSClient<PreviewModels<Models>>;
6
+ export { imageSrcSet } from "./images.js";
7
+ export type { RemoteValue } from "./types.js";
8
+ export type { ContentRendition } from "./images.js";
package/dist/server.js ADDED
@@ -0,0 +1,16 @@
1
+ // Server-only preview factory, exported as @lemonadecms/client/server.
2
+ // Preview reads the latest draft with a secret key. The package browser condition
3
+ // and the runtime browser-global check both help catch accidental client imports;
4
+ // the backend's authorization remains the actual security boundary.
5
+ // PreviewModels expresses that unpublished values can be absent or null.
6
+ import { createTransport } from "./transport.js";
7
+ import { CMSConfigurationError } from "./errors.js";
8
+ export * from "./errors.js";
9
+ /** Import only from server-owned code. Draft fields may be missing or null. */
10
+ export function createCMSPreviewClient(options) {
11
+ if (typeof window !== "undefined" || typeof document !== "undefined") {
12
+ throw new CMSConfigurationError("The preview client must run on a server.");
13
+ }
14
+ return createTransport(options, options?.secretKey, "preview");
15
+ }
16
+ export { imageSrcSet } from "./images.js";
@@ -0,0 +1,3 @@
1
+ import type { CMSBaseOptions, CMSClient, ModelMap } from "./types.js";
2
+ /** Internal shared transport; public exports choose the credential and route. */
3
+ export declare function createTransport<Models extends ModelMap<Models>>(options: CMSBaseOptions, key: unknown, mode: "content" | "preview"): CMSClient<Models>;