@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.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +304 -2
- package/bin/cli/config.mjs +309 -0
- package/bin/cli/create-migration.mjs +157 -0
- package/bin/cli/errors.mjs +9 -0
- package/bin/cli/io.mjs +70 -0
- package/bin/cli/migrate.mjs +109 -0
- package/bin/cli/network.mjs +134 -0
- package/bin/cli/picker.mjs +118 -0
- package/bin/cli/run.mjs +240 -0
- package/bin/cli/schema.mjs +397 -0
- package/bin/codegen/csharp.mjs +180 -0
- package/bin/codegen/definitions.mjs +52 -0
- package/bin/codegen/languages.mjs +16 -0
- package/bin/codegen/python.mjs +125 -0
- package/bin/generate-types.mjs +202 -0
- package/bin/lemonade.mjs +11 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +39 -0
- package/dist/images.d.ts +11 -0
- package/dist/images.js +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +17 -0
- package/dist/preview.d.ts +11 -0
- package/dist/preview.js +92 -0
- package/dist/rich-text.d.ts +4 -0
- package/dist/rich-text.js +228 -0
- package/dist/server.browser.d.ts +2 -0
- package/dist/server.browser.js +9 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +17 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.js +519 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.js +6 -0
- package/package.json +52 -4
|
@@ -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
|
+
});
|
package/bin/lemonade.mjs
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/images.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/preview.js
ADDED
|
@@ -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;
|