@lemonadecms/client 0.1.0 → 0.2.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/bin/cli/run.mjs CHANGED
@@ -2,7 +2,13 @@ import { resolve } from 'node:path';
2
2
  import { createRequire } from 'node:module';
3
3
  const { version } = createRequire(import.meta.url)('../../package.json');
4
4
  import { access } from 'node:fs/promises';
5
- import { parseArgs, connection } from './config.mjs';
5
+ import {
6
+ parseArgs,
7
+ connection,
8
+ typeGenerationEnv,
9
+ readConfiguration,
10
+ } from './config.mjs';
11
+ import { canPickTarget, pickTarget } from './picker.mjs';
6
12
  import { CLIError, fail } from './errors.mjs';
7
13
  import { parseJSON, readText, writeOutput, MAX_SCHEMA_BYTES } from './io.mjs';
8
14
  import {
@@ -23,7 +29,7 @@ lemonade schema pull --out schema.json [connection options]
23
29
  lemonade schema export --out schema.json [connection options]
24
30
  lemonade schema validate --file schema.json
25
31
  lemonade schema diff --file schema.json [connection options]
26
- lemonade types generate [--file schema.json | connection options] --out FILE [--language typescript|csharp|python] [--check]
32
+ lemonade types generate [--file schema.json | connection options] [--out FILE] [--language typescript|csharp|python] [--check]
27
33
 
28
34
  lemonade schema migrate status [--before MIGRATION_ID] [connection options]
29
35
  lemonade schema migrate create --baseline current.json --file desired.json --status status.json --id YYYYMMDDHHMMSS_description --out migration.json
@@ -36,9 +42,17 @@ Migration commands require an environment UUID and a separate management credent
36
42
  Connection: --config FILE --target NAME, or --url URL --workspace UUID --environment KEY_OR_UUID
37
43
  Credential: --credential-env ENV_NAME (default LEMONADE_TOKEN). Never pass a token value.
38
44
  Targets live in lemonade.config.json. URL/workspace/environment also accept LEMONADE_* environment variables.
45
+ Types generate: loads cwd/.env (process values win); workspace falls back to CMS_PROJECT_ID,
46
+ environment to CMS_ENVIRONMENT or production, credential to CMS_PUBLIC_KEY when LEMONADE_TOKEN is unset.
47
+ Default output: src/types/cms-models.ts (csharp: cms-models.cs; python: cms_models.py).
48
+ Explicit options, named targets and existing LEMONADE_* settings take precedence. --out is a file path.
49
+ Offline generation and other commands do not load .env. Use the selected environment's credential.
50
+ Interactive types generate offers configured targets (production first) when no connection selection is explicit.
51
+ Use arrows/Enter to choose; Escape/Ctrl+C/EOF cancels without network or output-file writes.
52
+ CI, --json, --check, piped input/output, --file and explicit connection/environment settings never prompt.
39
53
  All commands: --json. Informational: --help, --version.
40
- Exit codes: 0 success, 2 usage/config, 3 drift, 4 connection/auth/context, 5 invalid schema/input, 6 output failure.
41
- Only schema migrate apply writes compatible schema additions or reviewed missing-draft backfills. No content publication, prompts, automatic retries or credential persistence. Node.js 22+ required.
54
+ Exit codes: 0 success, 2 usage/config, 3 drift, 4 connection/auth/context, 5 invalid schema/input, 6 output failure, 130 selection cancelled.
55
+ Only schema migrate apply writes compatible schema additions or reviewed missing-draft backfills. No content publication, automatic retries or credential persistence. Node.js 22+ required.
42
56
  `;
43
57
  export async function run(
44
58
  args,
@@ -48,6 +62,11 @@ export async function run(
48
62
  fetcher = fetch,
49
63
  stdout = (text) => process.stdout.write(text),
50
64
  stderr = (text) => process.stderr.write(text),
65
+ terminal = {
66
+ input: process.stdin,
67
+ output: process.stderr,
68
+ stdoutIsTTY: process.stdout.isTTY,
69
+ },
51
70
  } = {},
52
71
  ) {
53
72
  let opts = {},
@@ -94,7 +113,37 @@ export async function run(
94
113
  const inputPath = opts.file && resolve(cwd, opts.file);
95
114
  const readSchema = async () => validateSchema(parseJSON(await readText(inputPath)));
96
115
  const remote = async () => {
97
- const target = await connection(opts, env, cwd);
116
+ const generation = command === 'types generate';
117
+ const selectedEnv = generation ? await typeGenerationEnv(env, cwd) : env;
118
+ const defaults = generation
119
+ ? {
120
+ workspace: selectedEnv.CMS_PROJECT_ID,
121
+ environment: selectedEnv.CMS_ENVIRONMENT || 'production',
122
+ credentialEnv: Object.hasOwn(selectedEnv, 'LEMONADE_TOKEN')
123
+ ? 'LEMONADE_TOKEN'
124
+ : 'CMS_PUBLIC_KEY',
125
+ }
126
+ : {};
127
+ const configuration = await readConfiguration(opts, cwd);
128
+ let selected = opts;
129
+ if (
130
+ generation &&
131
+ canPickTarget(opts, selectedEnv, terminal) &&
132
+ Object.keys(configuration.targets).length
133
+ ) {
134
+ selected = {
135
+ ...opts,
136
+ target: await pickTarget(configuration.targets, terminal),
137
+ };
138
+ }
139
+ const target = await connection(
140
+ selected,
141
+ selectedEnv,
142
+ cwd,
143
+ false,
144
+ defaults,
145
+ configuration,
146
+ );
98
147
  key = target.key;
99
148
  return fetchSchema(target, fetcher);
100
149
  };
@@ -167,6 +216,7 @@ export async function run(
167
216
  await writeOutput(path, output, [
168
217
  inputPath,
169
218
  resolve(cwd, opts.config || 'lemonade.config.json'),
219
+ resolve(cwd, '.env'),
170
220
  ]);
171
221
  result.written = true;
172
222
  human = `Types generated successfully (${language}).`;
@@ -73,7 +73,7 @@ function bounded(value) {
73
73
  let nodes = 0;
74
74
  while (stack.length) {
75
75
  const [item, depth] = stack.pop();
76
- if (++nodes > 150000 || depth > 16) invalid();
76
+ if (++nodes > 150000 || depth > 24) invalid();
77
77
  if (
78
78
  typeof item === 'string' &&
79
79
  /(?:pk|sk)_[A-Za-z0-9_-]{43}|lmd_(?:pk|sk|mgmt)_[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}/.test(
@@ -85,7 +85,7 @@ function bounded(value) {
85
85
  for (const child of Object.values(item)) stack.push([child, depth + 1]);
86
86
  }
87
87
  }
88
- function fields(values, keys, component = false) {
88
+ function fields(values, keys, component = false, ancestors = []) {
89
89
  if (
90
90
  !Array.isArray(values) ||
91
91
  values.length > 24 ||
@@ -135,6 +135,7 @@ function fields(values, keys, component = false) {
135
135
  'help_text',
136
136
  'validation',
137
137
  ...(!component ? ['group'] : []),
138
+ ...(!component && field.type === 'blocks' ? ['composer'] : []),
138
139
  ...extra,
139
140
  ...seo,
140
141
  ...(field.type === 'text' && !component ? ['slug'] : []),
@@ -155,8 +156,7 @@ function fields(values, keys, component = false) {
155
156
  !['text', 'long_text', 'markdown', 'rich_text'].includes(field.type)))
156
157
  )
157
158
  invalid();
158
- if (component && (composed.includes(field.type) || field.localized === true))
159
- invalid();
159
+ if (Object.hasOwn(field, 'composer') && typeof field.composer !== 'boolean') invalid();
160
160
  if (Object.hasOwn(field, 'default')) {
161
161
  const value = field.default;
162
162
  if (field.type === 'choices' && value === null) invalid();
@@ -207,12 +207,12 @@ function fields(values, keys, component = false) {
207
207
  for (const definition of field.components) {
208
208
  if (
209
209
  !own(definition, ['key', 'name', 'fields']) ||
210
- !apiKey(definition.key) ||
210
+ !apiKey(definition.key) || ancestors.includes(definition.key) || ancestors.length >= 3 ||
211
211
  !string(definition.name, 80) ||
212
212
  !definition.fields?.length
213
213
  )
214
214
  invalid();
215
- fields(definition.fields, keys, true);
215
+ fields(definition.fields, keys, true, [...ancestors, definition.key]);
216
216
  }
217
217
  }
218
218
  }
@@ -18,13 +18,15 @@ export function generateTypes(schema) {
18
18
  )
19
19
  throw new Error('Invalid schema export.');
20
20
  const keys = new Set(schema.models.map((model) => model.key));
21
- function type(field) {
21
+ let fieldNodes = 0;
22
+ function type(field, ancestors = []) {
23
+ if (++fieldNodes > 150000) throw new Error('Schema exceeds generation budget.');
22
24
  switch (field.type) {
23
25
  case 'component':
24
26
  case 'components':
25
27
  case 'blocks': {
26
28
  if (
27
- !Array.isArray(field.components) ||
29
+ ancestors.length >= 3 || !Array.isArray(field.components) ||
28
30
  field.components.length < 1 ||
29
31
  field.components.length > 12 ||
30
32
  (field.type !== 'blocks' && field.components.length !== 1)
@@ -33,19 +35,18 @@ export function generateTypes(schema) {
33
35
  const variants = field.components
34
36
  .map((component) => {
35
37
  if (
36
- !identifier.test(component.key) ||
38
+ !identifier.test(component.key) || ancestors.includes(component.key) ||
37
39
  !Array.isArray(component.fields) ||
38
40
  component.fields.length < 1 ||
39
41
  component.fields.length > 24 ||
40
42
  component.fields.some(
41
43
  (child) =>
42
- !identifier.test(child.key) ||
43
- ['component', 'components', 'blocks'].includes(child.type),
44
+ !identifier.test(child.key),
44
45
  )
45
46
  )
46
47
  throw new Error('Invalid component fields.');
47
48
  const fields = component.fields
48
- .map((child) => `${JSON.stringify(child.key)}?: ${type(child)} | null`)
49
+ .map((child) => `${JSON.stringify(child.key)}?: ${type(child, [...ancestors, component.key])} | null`)
49
50
  .join('; ');
50
51
  return `{ id: string; type: ${JSON.stringify(component.key)}; fields: { ${fields} } }`;
51
52
  })
@@ -130,7 +131,7 @@ export function generateTypes(schema) {
130
131
  return [model.key, mappings];
131
132
  }),
132
133
  );
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
+ 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
  }
135
136
  async function main() {
136
137
  const args = process.argv.slice(2);
@@ -146,7 +147,7 @@ async function main() {
146
147
  throw new Error('Use --help for codegen options.');
147
148
  options[args[i].slice(2)] = args[i + 1];
148
149
  }
149
- const url = new URL(options.url || process.env.CMS_URL || 'https://lemonadecms.com');
150
+ const url = new URL(options.url || process.env.CMS_URL || 'https://api.lemonadecms.com');
150
151
  if (
151
152
  !['http:', 'https:'].includes(url.protocol) ||
152
153
  url.username ||
@@ -0,0 +1,27 @@
1
+ /** Optional browser-only Composer bridge. Website code owns rendering.
2
+ * Enable only on an authenticated preview. No credentials or framework imports.
3
+ */
4
+ export interface ComposerUpdate {
5
+ channel: string;
6
+ entry: string;
7
+ locale: string;
8
+ root: string;
9
+ fields: Record<string, unknown>;
10
+ model_version: number;
11
+ entry_version: number;
12
+ renderer_revision: string;
13
+ }
14
+ export interface VisualComposerOptions {
15
+ studioOrigin: string;
16
+ channel: string;
17
+ entry: string;
18
+ locale: string;
19
+ roots: readonly string[];
20
+ components: readonly string[];
21
+ rendererRevision: string;
22
+ root?: HTMLElement;
23
+ /** Return a synchronous commit function; the bridge calls it only for the
24
+ * latest successful render. Honor signal and keep DOM changes in commit. */
25
+ render: (update: ComposerUpdate, signal: AbortSignal) => Promise<() => void>;
26
+ }
27
+ export declare function enableVisualComposer(options: VisualComposerOptions): () => void;
@@ -0,0 +1,240 @@
1
+ export function enableVisualComposer(options) {
2
+ const keyPattern = /^[a-z][a-z0-9_]{0,47}$/;
3
+ const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ const { studioOrigin, channel, entry, locale, roots, components, rendererRevision } = options;
5
+ let origin;
6
+ try {
7
+ origin = new URL(studioOrigin);
8
+ }
9
+ catch {
10
+ return () => { };
11
+ }
12
+ if (origin.origin !== studioOrigin || !['http:', 'https:'].includes(origin.protocol) ||
13
+ window.parent === window || !idPattern.test(channel) || !idPattern.test(entry) ||
14
+ !/^[a-zA-Z0-9-]{2,20}$/.test(locale) || !/^[a-f0-9]{64}$/.test(rendererRevision) ||
15
+ roots.length < 1 || roots.length > 24 || components.length < 1 || components.length > 128 ||
16
+ ![...roots, ...components].every(key => keyPattern.test(key)) || typeof options.render !== 'function') {
17
+ return () => { };
18
+ }
19
+ const root = options.root ?? document.body;
20
+ let active = true;
21
+ let enabled = false;
22
+ let selectedRoot = '';
23
+ let generation = '';
24
+ let latest = 0;
25
+ let busy = false;
26
+ let pending;
27
+ let controller;
28
+ const originals = new Map();
29
+ const send = (type, extra = {}) => window.parent.postMessage({ type, version: 1, channel, entry, locale, root: selectedRoot, ...extra }, studioOrigin);
30
+ const announce = (request) => send('lemonade:composer-ready', {
31
+ roots: [...roots], components: [...components], renderer_revision: rendererRevision,
32
+ ...(request ? { request } : {}),
33
+ });
34
+ const restore = () => {
35
+ for (const [element, attrs] of originals)
36
+ for (const [name, value] of attrs) {
37
+ if (value === null)
38
+ element.removeAttribute(name);
39
+ else
40
+ element.setAttribute(name, value);
41
+ }
42
+ originals.clear();
43
+ };
44
+ const enhance = () => {
45
+ restore();
46
+ if (!enabled)
47
+ return;
48
+ for (const element of Array.from(root.querySelectorAll('[data-lemonade-block]')).slice(0, 100)) {
49
+ const id = element.dataset.lemonadeBlock ?? '';
50
+ if (!idPattern.test(id))
51
+ continue;
52
+ const attrs = new Map();
53
+ for (const name of ['tabindex', 'role', 'aria-label', 'draggable', 'style'])
54
+ attrs.set(name, element.getAttribute(name));
55
+ originals.set(element, attrs);
56
+ if (!element.matches('a[href],button,input,textarea,select'))
57
+ element.setAttribute('role', 'button');
58
+ element.tabIndex = 0;
59
+ element.setAttribute('aria-label', `Edit ${element.dataset.lemonadeName ?? 'section'} in Studio`);
60
+ element.draggable = true;
61
+ element.style.outline = '1px dashed currentColor';
62
+ element.style.outlineOffset = '3px';
63
+ }
64
+ };
65
+ const run = async () => {
66
+ if (!active || !enabled || busy || !pending)
67
+ return;
68
+ const work = pending;
69
+ pending = undefined;
70
+ busy = true;
71
+ const abort = new AbortController();
72
+ controller = abort;
73
+ let timeout;
74
+ try {
75
+ const commit = await Promise.race([
76
+ options.render(work.update, abort.signal),
77
+ new Promise((_, reject) => { timeout = setTimeout(() => { abort.abort(); reject(new Error('Composer render timed out')); }, 10000); }),
78
+ ]);
79
+ if (active && enabled && !abort.signal.aborted && work.sequence === latest && work.generation === generation) {
80
+ if (typeof commit !== 'function')
81
+ throw new Error('Composer renderer must return a commit function');
82
+ commit();
83
+ enhance();
84
+ send('lemonade:composer-rendered', { sequence: work.sequence, generation });
85
+ }
86
+ }
87
+ catch {
88
+ if (active && enabled && work.sequence === latest && work.generation === generation) {
89
+ send('lemonade:composer-error', { sequence: work.sequence, generation });
90
+ }
91
+ }
92
+ finally {
93
+ clearTimeout(timeout);
94
+ if (controller === abort)
95
+ controller = undefined;
96
+ busy = false;
97
+ void run();
98
+ }
99
+ };
100
+ const disable = () => {
101
+ enabled = false;
102
+ pending = undefined;
103
+ controller?.abort();
104
+ restore();
105
+ };
106
+ const receive = (event) => {
107
+ if (!active || event.source !== window.parent || event.origin !== studioOrigin || event.data?.channel !== channel)
108
+ return;
109
+ const data = event.data;
110
+ if (data.type === 'lemonade:preview-connect') {
111
+ if (typeof data.request === 'string' && data.request.length <= 64)
112
+ announce(data.request);
113
+ return;
114
+ }
115
+ if (data.version !== 1 || data.entry !== entry || data.locale !== locale)
116
+ return;
117
+ if (data.type === 'lemonade:composer-disable') {
118
+ disable();
119
+ return;
120
+ }
121
+ if (data.type === 'lemonade:composer-enable') {
122
+ if (!roots.includes(data.root) || typeof data.generation !== 'string' || data.generation.length > 64)
123
+ return;
124
+ if (generation !== data.generation) {
125
+ controller?.abort();
126
+ pending = undefined;
127
+ latest = 0;
128
+ }
129
+ enabled = true;
130
+ generation = data.generation;
131
+ selectedRoot = data.root;
132
+ enhance();
133
+ return;
134
+ }
135
+ if (!enabled || data.generation !== generation || data.root !== selectedRoot)
136
+ return;
137
+ if (data.type === 'lemonade:composer-highlight' && idPattern.test(data.id)) {
138
+ for (const element of originals.keys()) {
139
+ element.style.outlineWidth = element.dataset.lemonadeBlock === data.id ? '3px' : '1px';
140
+ }
141
+ return;
142
+ }
143
+ if (data.type === 'lemonade:composer-drop') {
144
+ if (!Number.isFinite(data.x) || !Number.isFinite(data.y) || data.x < 0 || data.y < 0 ||
145
+ data.x >= window.innerWidth || data.y >= window.innerHeight)
146
+ return;
147
+ const element = document.elementFromPoint(data.x, data.y);
148
+ if (element)
149
+ emitDrop(element, data.payload);
150
+ return;
151
+ }
152
+ if (data.type !== 'lemonade:composer-update' || !Number.isSafeInteger(data.sequence) || data.sequence <= latest ||
153
+ !data.fields || typeof data.fields !== 'object' || Array.isArray(data.fields) ||
154
+ data.renderer_revision !== rendererRevision || !Number.isSafeInteger(data.model_version) || !Number.isSafeInteger(data.entry_version))
155
+ return;
156
+ try {
157
+ if (new TextEncoder().encode(JSON.stringify(data.fields)).length > 65536)
158
+ return;
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ latest = data.sequence;
164
+ pending = { update: { channel, entry, locale, root: selectedRoot, fields: data.fields,
165
+ model_version: data.model_version, entry_version: data.entry_version, renderer_revision: rendererRevision }, sequence: latest, generation };
166
+ controller?.abort();
167
+ void run();
168
+ };
169
+ const select = (event) => {
170
+ if (!enabled || !(event.target instanceof Element))
171
+ return;
172
+ if (event instanceof KeyboardEvent && !['Enter', ' '].includes(event.key))
173
+ return;
174
+ if (event instanceof MouseEvent && (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey))
175
+ return;
176
+ const element = event.target.closest('[data-lemonade-block]');
177
+ if (!element || !originals.has(element))
178
+ return;
179
+ event.preventDefault();
180
+ event.stopImmediatePropagation();
181
+ send('lemonade:composer-select', { id: element.dataset.lemonadeBlock, root: selectedRoot, generation });
182
+ };
183
+ const dragstart = (event) => {
184
+ if (!enabled || !(event.target instanceof Element))
185
+ return;
186
+ const element = event.target.closest('[data-lemonade-block]');
187
+ if (!element || !originals.has(element))
188
+ return;
189
+ event.dataTransfer?.setData('application/x-lemonade-composer', JSON.stringify({ id: element.dataset.lemonadeBlock, channel, root: selectedRoot }));
190
+ };
191
+ const dragover = (event) => {
192
+ if (enabled && event.target instanceof Element && event.target.closest('[data-lemonade-slot]'))
193
+ event.preventDefault();
194
+ };
195
+ const emitDrop = (element, data) => {
196
+ const slot = element.closest('[data-lemonade-slot]');
197
+ const path = slot?.dataset.lemonadeSlot;
198
+ if (!slot || !path || path.length > 512 || !root.contains(slot) || !data ||
199
+ !(idPattern.test(data.id ?? '') || keyPattern.test(data.component ?? '')))
200
+ return;
201
+ if (path !== selectedRoot && !path.startsWith(selectedRoot + '.'))
202
+ return;
203
+ const block = element.closest('[data-lemonade-block]');
204
+ send('lemonade:composer-action', { action: data.id ? 'move' : 'add', id: data.id, component: data.component,
205
+ target: path, before: block?.dataset.lemonadeBlock, root: selectedRoot, generation });
206
+ };
207
+ const drop = (event) => {
208
+ if (!enabled || !(event.target instanceof Element))
209
+ return;
210
+ try {
211
+ const raw = event.dataTransfer?.getData('application/x-lemonade-composer') ?? '';
212
+ if (raw.length > 1024)
213
+ return;
214
+ const data = JSON.parse(raw);
215
+ if (data.channel !== channel || data.root !== selectedRoot)
216
+ return;
217
+ event.preventDefault();
218
+ event.stopPropagation();
219
+ emitDrop(event.target, data);
220
+ }
221
+ catch { /* Foreign drag payloads are not composition commands. */ }
222
+ };
223
+ window.addEventListener('message', receive);
224
+ root.addEventListener('click', select, true);
225
+ root.addEventListener('keydown', select, true);
226
+ root.addEventListener('dragstart', dragstart);
227
+ root.addEventListener('dragover', dragover);
228
+ root.addEventListener('drop', drop);
229
+ announce();
230
+ return () => {
231
+ active = false;
232
+ disable();
233
+ window.removeEventListener('message', receive);
234
+ root.removeEventListener('click', select, true);
235
+ root.removeEventListener('keydown', select, true);
236
+ root.removeEventListener('dragstart', dragstart);
237
+ root.removeEventListener('dragover', dragover);
238
+ root.removeEventListener('drop', drop);
239
+ };
240
+ }
package/dist/errors.d.ts CHANGED
@@ -10,6 +10,12 @@ export declare class CMSRequestError extends Error {
10
10
  export declare class CMSResponseError extends Error {
11
11
  readonly name = "CMSResponseError";
12
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
+ }
13
19
  /** The configured request deadline expired, including response-body reading. */
14
20
  export declare class CMSTimeoutError extends Error {
15
21
  readonly timeoutMs: number;
package/dist/errors.js CHANGED
@@ -19,6 +19,15 @@ export class CMSRequestError extends Error {
19
19
  export class CMSResponseError extends Error {
20
20
  name = "CMSResponseError";
21
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
+ }
22
31
  /** The configured request deadline expired, including response-body reading. */
23
32
  export class CMSTimeoutError extends Error {
24
33
  timeoutMs;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { CMSClient, CMSClientOptions, ModelMap, UntypedModels } from "./types.js";
2
2
  export * from "./errors.js";
3
3
  export type { RemoteValue } from "./types.js";
4
- export type { CMSClient, CMSClientOptions, ContentCollection, ContentEntry, ContentPage, ListOptions, PaginationOptions, ContentFilter, ContentSort } 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";
5
6
  /** Published content only. Framework environment variables must be passed explicitly. */
6
7
  export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSClientOptions): CMSClient<Models>;
7
8
  export { imageSrcSet } from "./images.js";
8
9
  export type { ContentRendition } from "./images.js";
10
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export function createCMSClient(options) {
14
14
  return createTransport(options, options?.publicKey, "content");
15
15
  }
16
16
  export { imageSrcSet } from "./images.js";
17
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
@@ -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;