@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/CHANGELOG.md +85 -4
- package/README.md +206 -5
- package/bin/cli/config.mjs +63 -8
- package/bin/cli/network.mjs +50 -15
- package/bin/cli/picker.mjs +118 -0
- package/bin/cli/run.mjs +55 -5
- package/bin/cli/schema.mjs +6 -6
- package/bin/generate-types.mjs +9 -8
- package/dist/composer.d.ts +27 -0
- package/dist/composer.js +240 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/rich-text.d.ts +4 -0
- package/dist/rich-text.js +228 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1 -0
- package/dist/transport.js +198 -30
- package/dist/types.d.ts +79 -16
- package/package.json +5 -1
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 {
|
|
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,
|
|
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
|
|
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}).`;
|
package/bin/cli/schema.mjs
CHANGED
|
@@ -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 >
|
|
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 (
|
|
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
|
}
|
package/bin/generate-types.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/composer.js
ADDED
|
@@ -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 {
|
|
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
|
@@ -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;
|