@lemonadecms/client 0.1.1 → 0.2.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 +50 -1
- package/README.md +60 -13
- package/bin/cli/config.mjs +1 -1
- package/bin/cli/create-migration.mjs +10 -3
- package/bin/cli/network.mjs +2 -1
- package/bin/cli/schema.mjs +24 -7
- package/bin/codegen/csharp.mjs +6 -1
- package/bin/codegen/python.mjs +6 -1
- package/bin/generate-types.mjs +25 -10
- package/bin/relationships.mjs +87 -0
- package/bin/rich-text-schema.mjs +54 -0
- package/bin/schema-extensions.mjs +6 -0
- package/bin/unique-identifiers.mjs +50 -0
- package/dist/composer.d.ts +27 -0
- package/dist/composer.js +240 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +1 -1
- package/dist/rich-text-v2.d.ts +20 -0
- package/dist/rich-text-v2.js +242 -0
- package/dist/rich-text.d.ts +3 -1
- package/dist/rich-text.js +60 -25
- package/dist/server.d.ts +5 -4
- package/dist/server.js +1 -1
- package/dist/transport.d.ts +5 -2
- package/dist/transport.js +99 -11
- package/dist/types.d.ts +57 -8
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
## 0.
|
|
5
|
+
## 0.2.1
|
|
6
|
+
|
|
7
|
+
- Filter top-level Reference fields by linked entry UUID with eq/ne/in/not_in,
|
|
8
|
+
and References fields with contains_any/contains_all, including typed global
|
|
9
|
+
and per-collection defaults. Requires the matching CMS deployment.
|
|
10
|
+
|
|
11
|
+
- Add optional typed global and collection query defaults, immutable configuration,
|
|
12
|
+
AND-composed filters and bounded default traversal. ID reads inherit locale/remote
|
|
13
|
+
options only; related reads never inherit the parent collection's defaults.
|
|
14
|
+
- Keep fixed API URLs, explicit credentials, existing method contracts and zero
|
|
15
|
+
runtime dependencies.
|
|
16
|
+
|
|
17
|
+
- Negotiate `unique-identifiers.v1` schema metadata for declared shared-text
|
|
18
|
+
identifiers. Capture, drift and compatible migration additions retain the
|
|
19
|
+
declaration; generated delivery fields stay strings and legacy exports retain
|
|
20
|
+
their shape. Requires an updated backend.
|
|
21
|
+
- Add explicit `richTextVersion: 2` delivery negotiation and opt-in rich-text v2
|
|
22
|
+
quotations, escaped code blocks and accessible rectangular tables. V1 fields
|
|
23
|
+
retain their existing types and values; malformed/unknown v2 nodes and future
|
|
24
|
+
versions raise `CMSResponseError` instead of silently dropping content.
|
|
25
|
+
- Add pure `externalMediaDescriptor` and safe static rich-text cards for supported
|
|
26
|
+
public YouTube/Vimeo videos, Spotify episodes and SoundCloud tracks. Rendering
|
|
27
|
+
does not fetch metadata or start a player; playback consent belongs to the site.
|
|
28
|
+
- Request `relationships.v1,rich-text.v2` schema extensions together, validate and
|
|
29
|
+
round-trip field capabilities, and generate opt-in TypeScript/Python/C# types.
|
|
30
|
+
These capabilities require the matching backend deployment.
|
|
31
|
+
- Add on-demand `related(parentId, relationship, options)` reads for explicitly
|
|
32
|
+
configured reverse collections, including server previews. Optional generated
|
|
33
|
+
`Relationships` types check relation names and source-field selections without
|
|
34
|
+
changing existing `createCMSClient<Models>` calls or entry fields.
|
|
35
|
+
- Negotiate the versioned relationship schema extension for type generation and
|
|
36
|
+
drift checks, retaining legacy schemas. Requires the updated backend. No runtime dependencies.
|
|
37
|
+
|
|
38
|
+
## 0.2.0
|
|
39
|
+
|
|
40
|
+
- Use `https://api.lemonadecms.com` for new SDK/CLI defaults. The server's strict
|
|
41
|
+
hostname mode intentionally returns 404 for old main-domain API URLs; upgrade
|
|
42
|
+
installed clients and configured targets before using that deployment.
|
|
43
|
+
Explicit CLI flags and environment precedence remain unchanged.
|
|
44
|
+
Studio/preview message origins stay separate; no redirect, fallback retry or
|
|
45
|
+
runtime dependency. Package publication and server deployment are separate.
|
|
46
|
+
|
|
47
|
+
- Add the optional zero-dependency `@lemonadecms/client/composer` browser bridge:
|
|
48
|
+
versioned website capabilities, stable section/slot locators, selection and
|
|
49
|
+
arrangement commands, cancellable serial rendering and latest-only commits.
|
|
50
|
+
Requires an explicitly opted-in private CMS destination and website renderer.
|
|
51
|
+
- Generate three-level nested component unions and localized text leaves in
|
|
52
|
+
TypeScript, Python and C#, retaining legacy flat schemas and CLI behavior.
|
|
53
|
+
|
|
54
|
+
## 0.1.1 — published package
|
|
6
55
|
|
|
7
56
|
- Add a native terminal environment picker for online `types generate` using
|
|
8
57
|
validated configured targets and their credential variables. Explicit settings,
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ runtime library or framework dependency is required.
|
|
|
30
30
|
|
|
31
31
|
## Configure and read content
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Version 0.2.0 connects to `https://api.lemonadecms.com` automatically. Strict hostname routing returns 404 for API URLs on `https://lemonadecms.com`. Upgrade earlier clients to 0.2.0 and change explicit CLI/configured-target URLs to `https://api.lemonadecms.com`. Explicit CLI settings still take precedence; no redirect or fallback is attempted. Configure your
|
|
34
34
|
workspace and delivery key; there is no URL or local CMS option. Your website
|
|
35
35
|
can run locally while reading content from Lemonade production. Installing the
|
|
36
36
|
package does not create an account, workspace or delivery key.
|
|
@@ -63,7 +63,7 @@ if (first) {
|
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Entry values are under `fields`; metadata includes `id` and `publishedAt`.
|
|
66
|
-
|
|
66
|
+
The client also exposes `contentType` (ID/key/name), `projectId`,
|
|
67
67
|
`environment` (ID/key) and resolved `locale`. Field projections retain metadata.
|
|
68
68
|
For older CMS responses, the SDK derives only the request's model key/workspace
|
|
69
69
|
and explicit locale; unavailable metadata stays absent. Metadata properties are
|
|
@@ -104,7 +104,7 @@ console.log(entry?.fields.title ?? 'No matching content');
|
|
|
104
104
|
|
|
105
105
|
## Counts, existence and bulk reads
|
|
106
106
|
|
|
107
|
-
These
|
|
107
|
+
These methods require the current SDK and CMS backend. They are
|
|
108
108
|
available in both published and server preview clients:
|
|
109
109
|
|
|
110
110
|
```js
|
|
@@ -195,18 +195,35 @@ const html = richTextToHtml(richEntry?.fields.rich_body);
|
|
|
195
195
|
const plainText = richTextToPlainText(richEntry?.fields.rich_body);
|
|
196
196
|
```
|
|
197
197
|
|
|
198
|
-
Both accept nullable/unknown
|
|
199
|
-
|
|
198
|
+
Both accept nullable/unknown rich-text JSON and work in browsers or servers
|
|
199
|
+
without a framework or DOM. HTML escapes text/attributes and renders only
|
|
200
200
|
supported nodes/marks, safe links and matching expanded image/entry-caption
|
|
201
201
|
resources. Unavailable embeds are omitted; entry routes are never invented.
|
|
202
|
-
Plain text retains authored text with block/list/hard-break newlines
|
|
203
|
-
|
|
202
|
+
Plain text retains authored text with block/list/hard-break newlines. Plain text
|
|
203
|
+
is not escaped HTML: use text interpolation.
|
|
204
|
+
|
|
205
|
+
The source SDK also supports opt-in v2 quotations, escaped code blocks, accessible
|
|
206
|
+
rectangular tables and external-media cards. Configure the field's
|
|
207
|
+
`document_version: 2` and enabled features, then set `richTextVersion: 2` on the
|
|
208
|
+
published or server preview client. This explicitly sends
|
|
209
|
+
`Lemonade-Accept-Content: rich-text.v2`; default reads remain v1. The updated type
|
|
210
|
+
generator requests `relationships.v1,rich-text.v2,unique-identifiers.v1` schema extensions.
|
|
211
|
+
These additions require the updated backend, SDK and website renderer; published
|
|
212
|
+
0.2.0 does not contain them.
|
|
213
|
+
|
|
214
|
+
External-media HTML stays static: no iframe, autoplay or provider request is
|
|
215
|
+
created by these helpers. `externalMediaDescriptor(node)` returns validated,
|
|
216
|
+
fixed provider URLs without I/O so a website can add an explicit click-to-load
|
|
217
|
+
player. The Astro source reference demonstrates disclosure, constrained frames,
|
|
218
|
+
timeout recovery and a transcript that remains available.
|
|
204
219
|
|
|
205
220
|
Traversal is bounded to 500 nodes, depth 8, 200 children per node, 20 embeds,
|
|
206
221
|
20,000 UTF-8 bytes per text and 64 KiB of rendered source strings. Unsupported
|
|
207
|
-
|
|
208
|
-
unsupported parts are omitted.
|
|
209
|
-
|
|
222
|
+
legacy roots/containers or exhausted legacy budgets return empty strings;
|
|
223
|
+
unsupported legacy parts are omitted. Unknown/future versions and malformed or
|
|
224
|
+
unsupported v2 documents throw `CMSResponseError`; handle the error explicitly.
|
|
225
|
+
No raw HTML, resource-field recursion, I/O or automatic Markdown conversion is
|
|
226
|
+
performed. HTML URL/alt bounds and exact
|
|
210
227
|
preview-image conventions are documented in the SDK guide. The helpers are also
|
|
211
228
|
exported from the server entry; preview authorization remains your responsibility.
|
|
212
229
|
|
|
@@ -240,13 +257,13 @@ placeholders with your workspace UUID; the target labels distinguish environment
|
|
|
240
257
|
"version": 1,
|
|
241
258
|
"targets": {
|
|
242
259
|
"production": {
|
|
243
|
-
"url": "https://lemonadecms.com",
|
|
260
|
+
"url": "https://api.lemonadecms.com",
|
|
244
261
|
"workspace": "YOUR_WORKSPACE_UUID",
|
|
245
262
|
"environment": "production",
|
|
246
263
|
"credentialEnv": "CMS_PUBLIC_KEY"
|
|
247
264
|
},
|
|
248
265
|
"dev": {
|
|
249
|
-
"url": "https://lemonadecms.com",
|
|
266
|
+
"url": "https://api.lemonadecms.com",
|
|
250
267
|
"workspace": "YOUR_WORKSPACE_UUID",
|
|
251
268
|
"environment": "dev",
|
|
252
269
|
"credentialEnv": "CMS_DEV_PUBLIC_KEY"
|
|
@@ -275,7 +292,7 @@ Without configured targets, existing defaults remain unchanged. For automation:
|
|
|
275
292
|
npx lemonade types generate --target dev --check
|
|
276
293
|
```
|
|
277
294
|
|
|
278
|
-
|
|
295
|
+
Version 0.2.0 includes the picker; historical 0.1.0 does not.
|
|
279
296
|
It uses native Node APIs and retains zero runtime, peer or optional dependencies.
|
|
280
297
|
|
|
281
298
|
## Private previews
|
|
@@ -303,3 +320,33 @@ fetch client.
|
|
|
303
320
|
|
|
304
321
|
This SDK package is licensed under the [MIT License](LICENSE). Its license does
|
|
305
322
|
not grant a license to the entire CMS repository or the separate native clients.
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
## Visual Composer
|
|
326
|
+
|
|
327
|
+
The optional Visual Composer adds approved nested sections, inspector editing and
|
|
328
|
+
private live updates. A developer connects the website registry and explicitly
|
|
329
|
+
enables unsaved projection on its trusted preview destination. Save draft and
|
|
330
|
+
Publish remain separate. Component text can be translated by stable block ID;
|
|
331
|
+
each language keeps its live tree until explicitly published. Existing form and
|
|
332
|
+
saved-draft previews remain supported. The browser helper is the dependency-free
|
|
333
|
+
`@lemonadecms/client/composer` export.
|
|
334
|
+
|
|
335
|
+
See [setup, editor workflow, Astro reference and protocol](https://github.com/ilara-booqself/lemonadecms/blob/main/docs/visual-composer.md).
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
## Source additions awaiting a release
|
|
339
|
+
|
|
340
|
+
The source checkout adds opt-in rich-text v2 (described above) and on-demand
|
|
341
|
+
reverse collections through
|
|
342
|
+
`cms.content('author').related(authorId, 'articles', {limit: 10})`. Updated
|
|
343
|
+
`lemonade types generate` negotiates the relationship extension and generates a
|
|
344
|
+
separate `Relationships` map for `createCMSClient<Models, Relationships>`.
|
|
345
|
+
Ordinary entries and `fields` retain their shape. This requires the updated
|
|
346
|
+
backend and a separately published package beyond 0.2.0; deployment and npm
|
|
347
|
+
publication are separate gates. The source SDK retains zero runtime dependencies.
|
|
348
|
+
|
|
349
|
+
Management migrations configuring a reverse list need both source and target
|
|
350
|
+
model keys and actions in the management credential, independently of the
|
|
351
|
+
actor's grants. Existing targets require `model.update`; targets created in the
|
|
352
|
+
same migration require `model.create`. See the [reviewed migration guide](https://github.com/ilara-booqself/lemonadecms/blob/main/docs/schema-migrations.md).
|
package/bin/cli/config.mjs
CHANGED
|
@@ -229,7 +229,7 @@ export async function connection(
|
|
|
229
229
|
fail(2, 'Selected target is not configured.');
|
|
230
230
|
const target = name ? targets[name] : {};
|
|
231
231
|
const selected = {
|
|
232
|
-
url: opts.url || target.url || env.LEMONADE_URL || 'https://lemonadecms.com',
|
|
232
|
+
url: opts.url || target.url || env.LEMONADE_URL || 'https://api.lemonadecms.com',
|
|
233
233
|
workspace:
|
|
234
234
|
opts.workspace ||
|
|
235
235
|
target.workspace ||
|
|
@@ -3,6 +3,8 @@ import { validateSchema, canonical } from './schema.mjs';
|
|
|
3
3
|
import { parseJSON, readText, writeOutput } from './io.mjs';
|
|
4
4
|
import { record } from './config.mjs';
|
|
5
5
|
import { fail } from './errors.mjs';
|
|
6
|
+
import { modelsWithRelationships } from '../relationships.mjs';
|
|
7
|
+
import { modelsWithUniqueIdentifiers } from '../unique-identifiers.mjs';
|
|
6
8
|
|
|
7
9
|
const equal = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
8
10
|
const rejected = () =>
|
|
@@ -58,8 +60,13 @@ export async function createMigration(opts, cwd) {
|
|
|
58
60
|
const paths = [opts.baseline, opts.file, opts.status].map((name) =>
|
|
59
61
|
resolve(cwd, name),
|
|
60
62
|
);
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
+
const baselineSchema = validateSchema(parseJSON(await readText(paths[0])));
|
|
64
|
+
const desiredSchema = validateSchema(parseJSON(await readText(paths[1])));
|
|
65
|
+
// Materialize opted-in definitions only in management operations. Published
|
|
66
|
+
// schema fields retain their legacy shape; unsupported edits still reject.
|
|
67
|
+
const managementModels = schema => modelsWithRelationships({...schema, models: modelsWithUniqueIdentifiers(schema)});
|
|
68
|
+
const baseline = {...baselineSchema, models: managementModels(baselineSchema)};
|
|
69
|
+
const desired = {...desiredSchema, models: managementModels(desiredSchema)};
|
|
63
70
|
const status = parseJSON(await readText(paths[2]));
|
|
64
71
|
const environment = baseline.environment?.id || baseline.project_id;
|
|
65
72
|
const modelSummary = (schema) =>
|
|
@@ -94,7 +101,7 @@ export async function createMigration(opts, cwd) {
|
|
|
94
101
|
);
|
|
95
102
|
// Require an exact portable editing baseline. Never turn unsupported edits into
|
|
96
103
|
// omissions, removals, or a blind desired-state replacement.
|
|
97
|
-
const withoutModels = ({ models, ...rest }) => rest;
|
|
104
|
+
const withoutModels = ({ models, extensions, ...rest }) => rest;
|
|
98
105
|
if (!equal(withoutModels(baseline), withoutModels(desired))) rejected();
|
|
99
106
|
const operations = [];
|
|
100
107
|
const prior = new Map(baseline.models.map((model) => [model.key, model]));
|
package/bin/cli/network.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export async function fetchSchema(target, fetcher = fetch) {
|
|
|
33
33
|
const schema = validateSchema(
|
|
34
34
|
await requestJSON(
|
|
35
35
|
target,
|
|
36
|
-
`/api/v1/projects/${target.workspace}/schema`,
|
|
36
|
+
`/api/v1/projects/${target.workspace}/schema?extensions=${schemaExtensions}`,
|
|
37
37
|
{},
|
|
38
38
|
fetcher,
|
|
39
39
|
),
|
|
@@ -132,3 +132,4 @@ export async function requestJSON(
|
|
|
132
132
|
} catch {}
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
import { schemaExtensions } from '../rich-text-schema.mjs';
|
package/bin/cli/schema.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { generateTypes } from '../generate-types.mjs';
|
|
2
|
+
import { relationshipDefinitions } from '../relationships.mjs';
|
|
3
|
+
import { uniqueIdentifierDefinitions } from '../unique-identifiers.mjs';
|
|
4
|
+
import { validateRichTextExtension } from '../rich-text-schema.mjs';
|
|
2
5
|
import { record, uuid } from './config.mjs';
|
|
3
6
|
import { fail } from './errors.mjs';
|
|
4
7
|
const identifier = /^[a-z][a-z0-9_]{0,47}$/;
|
|
@@ -73,7 +76,7 @@ function bounded(value) {
|
|
|
73
76
|
let nodes = 0;
|
|
74
77
|
while (stack.length) {
|
|
75
78
|
const [item, depth] = stack.pop();
|
|
76
|
-
if (++nodes > 150000 || depth >
|
|
79
|
+
if (++nodes > 150000 || depth > 24) invalid();
|
|
77
80
|
if (
|
|
78
81
|
typeof item === 'string' &&
|
|
79
82
|
/(?:pk|sk)_[A-Za-z0-9_-]{43}|lmd_(?:pk|sk|mgmt)_[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}/.test(
|
|
@@ -85,7 +88,7 @@ function bounded(value) {
|
|
|
85
88
|
for (const child of Object.values(item)) stack.push([child, depth + 1]);
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
|
-
function fields(values, keys, component = false) {
|
|
91
|
+
function fields(values, keys, component = false, ancestors = []) {
|
|
89
92
|
if (
|
|
90
93
|
!Array.isArray(values) ||
|
|
91
94
|
values.length > 24 ||
|
|
@@ -107,7 +110,7 @@ function fields(values, keys, component = false) {
|
|
|
107
110
|
: ['reference', 'references'].includes(field.type)
|
|
108
111
|
? ['target']
|
|
109
112
|
: field.type === 'rich_text'
|
|
110
|
-
? ['images', 'targets']
|
|
113
|
+
? ['images', 'targets', 'document_version', 'features']
|
|
111
114
|
: composed.includes(field.type)
|
|
112
115
|
? ['components']
|
|
113
116
|
: [];
|
|
@@ -135,6 +138,7 @@ function fields(values, keys, component = false) {
|
|
|
135
138
|
'help_text',
|
|
136
139
|
'validation',
|
|
137
140
|
...(!component ? ['group'] : []),
|
|
141
|
+
...(!component && field.type === 'blocks' ? ['composer'] : []),
|
|
138
142
|
...extra,
|
|
139
143
|
...seo,
|
|
140
144
|
...(field.type === 'text' && !component ? ['slug'] : []),
|
|
@@ -155,8 +159,7 @@ function fields(values, keys, component = false) {
|
|
|
155
159
|
!['text', 'long_text', 'markdown', 'rich_text'].includes(field.type)))
|
|
156
160
|
)
|
|
157
161
|
invalid();
|
|
158
|
-
if (
|
|
159
|
-
invalid();
|
|
162
|
+
if (Object.hasOwn(field, 'composer') && typeof field.composer !== 'boolean') invalid();
|
|
160
163
|
if (Object.hasOwn(field, 'default')) {
|
|
161
164
|
const value = field.default;
|
|
162
165
|
if (field.type === 'choices' && value === null) invalid();
|
|
@@ -207,12 +210,12 @@ function fields(values, keys, component = false) {
|
|
|
207
210
|
for (const definition of field.components) {
|
|
208
211
|
if (
|
|
209
212
|
!own(definition, ['key', 'name', 'fields']) ||
|
|
210
|
-
!apiKey(definition.key) ||
|
|
213
|
+
!apiKey(definition.key) || ancestors.includes(definition.key) || ancestors.length >= 3 ||
|
|
211
214
|
!string(definition.name, 80) ||
|
|
212
215
|
!definition.fields?.length
|
|
213
216
|
)
|
|
214
217
|
invalid();
|
|
215
|
-
fields(definition.fields, keys, true);
|
|
218
|
+
fields(definition.fields, keys, true, [...ancestors, definition.key]);
|
|
216
219
|
}
|
|
217
220
|
}
|
|
218
221
|
}
|
|
@@ -259,6 +262,7 @@ export function validateSchema(schema) {
|
|
|
259
262
|
'default_locale',
|
|
260
263
|
'models',
|
|
261
264
|
'environment',
|
|
265
|
+
'extensions',
|
|
262
266
|
]) ||
|
|
263
267
|
schema.format_version !== 1 ||
|
|
264
268
|
!uuid.test(schema.project_id || '') ||
|
|
@@ -318,6 +322,7 @@ export function validateSchema(schema) {
|
|
|
318
322
|
invalid();
|
|
319
323
|
}
|
|
320
324
|
}
|
|
325
|
+
try { relationshipDefinitions(schema); validateRichTextExtension(schema); uniqueIdentifierDefinitions(schema); } catch { invalid(); }
|
|
321
326
|
// One type generator remains authoritative for delivery types, references and SEO roles.
|
|
322
327
|
try {
|
|
323
328
|
generateTypes(schema);
|
|
@@ -341,6 +346,12 @@ export function serialize(schema) {
|
|
|
341
346
|
JSON.stringify(
|
|
342
347
|
canonical({
|
|
343
348
|
...schema,
|
|
349
|
+
...(schema.extensions ? {extensions: {...schema.extensions, ...(schema.extensions.relationships ? {relationships: {...schema.extensions.relationships,
|
|
350
|
+
definitions: [...schema.extensions.relationships.definitions].sort((a, b) =>
|
|
351
|
+
`${a.model}.${a.key}`.localeCompare(`${b.model}.${b.key}`))}} : {}),
|
|
352
|
+
...(schema.extensions.unique_identifiers ? {unique_identifiers: {...schema.extensions.unique_identifiers,
|
|
353
|
+
definitions: [...schema.extensions.unique_identifiers.definitions].sort((a, b) =>
|
|
354
|
+
`${a.model}.${a.field}`.localeCompare(`${b.model}.${b.field}`))}} : {})}} : {}),
|
|
344
355
|
models: [...schema.models].sort((a, b) =>
|
|
345
356
|
a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
|
|
346
357
|
),
|
|
@@ -393,5 +404,11 @@ export function compareSchemas(baseline, current) {
|
|
|
393
404
|
]),
|
|
394
405
|
);
|
|
395
406
|
compare(modelMap(baseline), modelMap(current), 'models');
|
|
407
|
+
const relationshipMap = schema => Object.fromEntries(relationshipDefinitions(schema).map(definition =>
|
|
408
|
+
[`${definition.model}.${definition.key}`, definition]));
|
|
409
|
+
compare(relationshipMap(baseline), relationshipMap(current), 'relationships');
|
|
410
|
+
const identifierMap = schema => Object.fromEntries(uniqueIdentifierDefinitions(schema).map(definition =>
|
|
411
|
+
[`${definition.model}.${definition.field}`, definition]));
|
|
412
|
+
compare(identifierMap(baseline), identifierMap(current), 'unique_identifiers');
|
|
396
413
|
return changes;
|
|
397
414
|
}
|
package/bin/codegen/csharp.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export function generateCSharp(schema) {
|
|
|
27
27
|
case 'file':
|
|
28
28
|
return 'ContentAsset';
|
|
29
29
|
case 'rich_text':
|
|
30
|
-
return 'ContentRichText';
|
|
30
|
+
return field.document_version === 2 ? 'ContentRichTextV2' : 'ContentRichText';
|
|
31
31
|
case 'component':
|
|
32
32
|
return field.variants[0].name;
|
|
33
33
|
case 'components':
|
|
@@ -147,6 +147,11 @@ ${property('id', 'string', 'Id', ' = "";')}${property('url', 'string', 'Url', '
|
|
|
147
147
|
public sealed record ContentRichText
|
|
148
148
|
{
|
|
149
149
|
${property('version', 'int', 'Version')}${property('document', 'JsonElement', 'Document')}}
|
|
150
|
+
${schema.extensions?.rich_text ? `// This opt-in field can retain historical v1 or authored v2 documents.
|
|
151
|
+
public sealed record ContentRichTextV2
|
|
152
|
+
{
|
|
153
|
+
${property('version', 'int', 'Version')}${property('document', 'JsonElement', 'Document')}}
|
|
154
|
+
` : ''}
|
|
150
155
|
public sealed record ContentEntry<TFields>
|
|
151
156
|
{
|
|
152
157
|
${property('id', 'string', 'Id', ' = "";')}${property('fields', 'TFields?', 'Fields')}${property('published_at', 'string?', 'PublishedAt')}${property('remote', 'JsonElement?', 'Remote')}}
|
package/bin/codegen/python.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export function generatePython(schema) {
|
|
|
21
21
|
case 'file':
|
|
22
22
|
return 'ContentAsset';
|
|
23
23
|
case 'rich_text':
|
|
24
|
-
return 'ContentRichText';
|
|
24
|
+
return field.document_version === 2 ? 'ContentRichTextV2' : 'ContentRichText';
|
|
25
25
|
case 'component':
|
|
26
26
|
return field.variants[0].name;
|
|
27
27
|
case 'components':
|
|
@@ -98,6 +98,11 @@ class RichTextDocument(TypedDict):
|
|
|
98
98
|
class ContentRichText(TypedDict):
|
|
99
99
|
version: Literal[1]
|
|
100
100
|
document: RichTextDocument
|
|
101
|
+
${schema.extensions?.rich_text ? `
|
|
102
|
+
class ContentRichTextV2(TypedDict):
|
|
103
|
+
version: Literal[1, 2]
|
|
104
|
+
document: RichTextDocument
|
|
105
|
+
` : ''}
|
|
101
106
|
|
|
102
107
|
class ContentEntry(TypedDict, Generic[TFields]):
|
|
103
108
|
id: str
|
package/bin/generate-types.mjs
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { writeFile, mkdir, realpath } from 'node:fs/promises';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { relationshipDefinitions } from './relationships.mjs';
|
|
6
|
+
import { uniqueIdentifierDefinitions } from './unique-identifiers.mjs';
|
|
7
|
+
import { richTextSettings, richTextV2Types, validateRichTextExtension, schemaExtensions } from './rich-text-schema.mjs';
|
|
5
8
|
const identifier = /^[a-z][a-z0-9_]{0,47}$/;
|
|
6
9
|
export function generateTypes(schema) {
|
|
7
10
|
if (
|
|
@@ -17,14 +20,19 @@ export function generateTypes(schema) {
|
|
|
17
20
|
)
|
|
18
21
|
)
|
|
19
22
|
throw new Error('Invalid schema export.');
|
|
23
|
+
const relationships = relationshipDefinitions(schema);
|
|
24
|
+
uniqueIdentifierDefinitions(schema);
|
|
25
|
+
validateRichTextExtension(schema);
|
|
20
26
|
const keys = new Set(schema.models.map((model) => model.key));
|
|
21
|
-
|
|
27
|
+
let fieldNodes = 0;
|
|
28
|
+
function type(field, ancestors = []) {
|
|
29
|
+
if (++fieldNodes > 150000) throw new Error('Schema exceeds generation budget.');
|
|
22
30
|
switch (field.type) {
|
|
23
31
|
case 'component':
|
|
24
32
|
case 'components':
|
|
25
33
|
case 'blocks': {
|
|
26
34
|
if (
|
|
27
|
-
!Array.isArray(field.components) ||
|
|
35
|
+
ancestors.length >= 3 || !Array.isArray(field.components) ||
|
|
28
36
|
field.components.length < 1 ||
|
|
29
37
|
field.components.length > 12 ||
|
|
30
38
|
(field.type !== 'blocks' && field.components.length !== 1)
|
|
@@ -33,19 +41,18 @@ export function generateTypes(schema) {
|
|
|
33
41
|
const variants = field.components
|
|
34
42
|
.map((component) => {
|
|
35
43
|
if (
|
|
36
|
-
!identifier.test(component.key) ||
|
|
44
|
+
!identifier.test(component.key) || ancestors.includes(component.key) ||
|
|
37
45
|
!Array.isArray(component.fields) ||
|
|
38
46
|
component.fields.length < 1 ||
|
|
39
47
|
component.fields.length > 24 ||
|
|
40
48
|
component.fields.some(
|
|
41
49
|
(child) =>
|
|
42
|
-
!identifier.test(child.key)
|
|
43
|
-
['component', 'components', 'blocks'].includes(child.type),
|
|
50
|
+
!identifier.test(child.key),
|
|
44
51
|
)
|
|
45
52
|
)
|
|
46
53
|
throw new Error('Invalid component fields.');
|
|
47
54
|
const fields = component.fields
|
|
48
|
-
.map((child) => `${JSON.stringify(child.key)}?: ${type(child)} | null`)
|
|
55
|
+
.map((child) => `${JSON.stringify(child.key)}?: ${type(child, [...ancestors, component.key])} | null`)
|
|
49
56
|
.join('; ');
|
|
50
57
|
return `{ id: string; type: ${JSON.stringify(component.key)}; fields: { ${fields} } }`;
|
|
51
58
|
})
|
|
@@ -62,7 +69,10 @@ export function generateTypes(schema) {
|
|
|
62
69
|
const targets =
|
|
63
70
|
(field.targets || []).map((target) => JSON.stringify(target)).join(' | ') ||
|
|
64
71
|
'never';
|
|
65
|
-
|
|
72
|
+
const settings = richTextSettings(field);
|
|
73
|
+
return settings.version === 2
|
|
74
|
+
? `ContentRichTextV2<${targets}, ${settings.features.map(JSON.stringify).join(' | ') || 'never'}>`
|
|
75
|
+
: `ContentRichText<${targets}>`;
|
|
66
76
|
}
|
|
67
77
|
case 'number':
|
|
68
78
|
return 'number';
|
|
@@ -105,6 +115,11 @@ export function generateTypes(schema) {
|
|
|
105
115
|
` ${JSON.stringify(model.key)}: {\n${model.fields.map((field) => ` ${JSON.stringify(field.key)}?: ${type(field)} | null;`).join('\n')}\n };`,
|
|
106
116
|
)
|
|
107
117
|
.join('\n');
|
|
118
|
+
const relationshipMap = [...schema.models].sort((a, b) => a.key.localeCompare(b.key)).map(model => {
|
|
119
|
+
const fields = relationships.filter(relation => relation.model === model.key).sort((a, b) => a.key.localeCompare(b.key))
|
|
120
|
+
.map(relation => ` ${JSON.stringify(relation.key)}: ${JSON.stringify(relation.source_model)};`).join('\n');
|
|
121
|
+
return ` ${JSON.stringify(model.key)}: {${fields ? `\n${fields}\n ` : ''}};`;
|
|
122
|
+
}).join('\n');
|
|
108
123
|
const seoModels = Object.fromEntries(
|
|
109
124
|
schema.models.map((model) => {
|
|
110
125
|
const mappings = {};
|
|
@@ -130,7 +145,7 @@ export function generateTypes(schema) {
|
|
|
130
145
|
return [model.key, mappings];
|
|
131
146
|
}),
|
|
132
147
|
);
|
|
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>[] } }\
|
|
148
|
+
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>[] } }\n${richTextV2Types(schema)}export interface Models {\n${models}\n}\n\n// Opt-in read-only reverse collections: parent model -> relation -> source model.\nexport interface Relationships {\n${relationshipMap}\n}\n\n// Presentation roles, refreshed by codegen after schema changes.\nexport const seoModels = ${JSON.stringify(seoModels, null, 2)} as const;\n`;
|
|
134
149
|
}
|
|
135
150
|
async function main() {
|
|
136
151
|
const args = process.argv.slice(2);
|
|
@@ -146,7 +161,7 @@ async function main() {
|
|
|
146
161
|
throw new Error('Use --help for codegen options.');
|
|
147
162
|
options[args[i].slice(2)] = args[i + 1];
|
|
148
163
|
}
|
|
149
|
-
const url = new URL(options.url || process.env.CMS_URL || 'https://lemonadecms.com');
|
|
164
|
+
const url = new URL(options.url || process.env.CMS_URL || 'https://api.lemonadecms.com');
|
|
150
165
|
if (
|
|
151
166
|
!['http:', 'https:'].includes(url.protocol) ||
|
|
152
167
|
url.username ||
|
|
@@ -172,7 +187,7 @@ async function main() {
|
|
|
172
187
|
throw new Error('Set CMS_PUBLIC_KEY or CMS_SECRET_KEY.');
|
|
173
188
|
if (!options.out) throw new Error('--out is required.');
|
|
174
189
|
const response = await fetch(
|
|
175
|
-
`${url.href.replace(/\/+$/, '')}/api/v1/projects/${project}/schema`,
|
|
190
|
+
`${url.href.replace(/\/+$/, '')}/api/v1/projects/${project}/schema?extensions=${schemaExtensions}`,
|
|
176
191
|
{
|
|
177
192
|
headers: { Accept: 'application/json', Authorization: `Bearer ${key}` },
|
|
178
193
|
credentials: 'omit',
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Versioned, explicitly requested delivery metadata. It never adds stored fields
|
|
2
|
+
// or forward-reference edges and is validated before generating or persisting it.
|
|
3
|
+
import {schemaExtensionKeys} from './schema-extensions.mjs';
|
|
4
|
+
const identifier = /^[a-z][a-z0-9_]{0,47}$/;
|
|
5
|
+
const record = (value) =>
|
|
6
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
const exact = (value, keys) =>
|
|
8
|
+
record(value) &&
|
|
9
|
+
Object.keys(value).length === keys.length &&
|
|
10
|
+
keys.every((key) => Object.hasOwn(value, key));
|
|
11
|
+
const invalid = () => {
|
|
12
|
+
throw new Error('Invalid relationship extension.');
|
|
13
|
+
};
|
|
14
|
+
function validLabel(value) {
|
|
15
|
+
return (
|
|
16
|
+
typeof value === 'string' &&
|
|
17
|
+
!/^\p{White_Space}|\p{White_Space}$/u.test(value) &&
|
|
18
|
+
Buffer.byteLength(value) >= 1 &&
|
|
19
|
+
Buffer.byteLength(value) <= 80 &&
|
|
20
|
+
!/[\u0000\r\n\ud800-\udfff]/u.test(value)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
export function relationshipDefinitions(schema) {
|
|
24
|
+
if (!Object.hasOwn(schema, 'extensions')) return [];
|
|
25
|
+
if (!record(schema.extensions) || Object.keys(schema.extensions).length === 0 ||
|
|
26
|
+
Object.keys(schema.extensions).some(key => !schemaExtensionKeys.includes(key))) invalid();
|
|
27
|
+
if (!Object.hasOwn(schema.extensions, 'relationships')) return [];
|
|
28
|
+
const extension = schema.extensions.relationships;
|
|
29
|
+
if (
|
|
30
|
+
!exact(extension, ['version', 'definitions']) ||
|
|
31
|
+
extension.version !== 1 ||
|
|
32
|
+
!Array.isArray(extension.definitions) ||
|
|
33
|
+
extension.definitions.length > 6144
|
|
34
|
+
)
|
|
35
|
+
invalid();
|
|
36
|
+
const models = new Map(schema.models.map((model) => [model.key, model]));
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const sourceFields = new Set();
|
|
39
|
+
for (const definition of extension.definitions) {
|
|
40
|
+
if (
|
|
41
|
+
!exact(definition, ['model', 'key', 'label', 'source_model', 'source_field']) ||
|
|
42
|
+
!['model', 'key', 'source_model', 'source_field'].every(
|
|
43
|
+
(key) =>
|
|
44
|
+
typeof definition[key] === 'string' && identifier.test(definition[key]),
|
|
45
|
+
) ||
|
|
46
|
+
!validLabel(definition.label)
|
|
47
|
+
)
|
|
48
|
+
invalid();
|
|
49
|
+
const parent = models.get(definition.model);
|
|
50
|
+
const source = models.get(definition.source_model);
|
|
51
|
+
const field = source?.fields.find((field) => field.key === definition.source_field);
|
|
52
|
+
const identity = `${definition.model}:${definition.key}`;
|
|
53
|
+
const origin = `${definition.source_model}:${definition.source_field}`;
|
|
54
|
+
if (
|
|
55
|
+
!parent ||
|
|
56
|
+
!field ||
|
|
57
|
+
!['reference', 'references'].includes(field.type) ||
|
|
58
|
+
field.target !== definition.model ||
|
|
59
|
+
parent.fields.some((field) => field.key === definition.key) ||
|
|
60
|
+
seen.has(identity) ||
|
|
61
|
+
sourceFields.has(origin)
|
|
62
|
+
)
|
|
63
|
+
invalid();
|
|
64
|
+
seen.add(identity);
|
|
65
|
+
sourceFields.add(origin);
|
|
66
|
+
}
|
|
67
|
+
return extension.definitions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function modelsWithRelationships(schema) {
|
|
71
|
+
const definitions = relationshipDefinitions(schema);
|
|
72
|
+
const bySource = new Map(
|
|
73
|
+
definitions.map((definition) => [
|
|
74
|
+
`${definition.source_model}:${definition.source_field}`,
|
|
75
|
+
definition,
|
|
76
|
+
]),
|
|
77
|
+
);
|
|
78
|
+
return schema.models.map((model) => ({
|
|
79
|
+
...model,
|
|
80
|
+
fields: model.fields.map((field) => {
|
|
81
|
+
const reverse = bySource.get(`${model.key}:${field.key}`);
|
|
82
|
+
return reverse
|
|
83
|
+
? { ...field, reverse: { key: reverse.key, label: reverse.label } }
|
|
84
|
+
: field;
|
|
85
|
+
}),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Explicit schema capability metadata, separate from ordinary delivery fields.
|
|
2
|
+
import {schemaExtensionKeys} from './schema-extensions.mjs';
|
|
3
|
+
export {schemaExtensions} from './schema-extensions.mjs';
|
|
4
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
const features = ['blockquote', 'code_block', 'table', 'external_media'];
|
|
6
|
+
const invalid = () => { throw new Error('Invalid rich-text schema extension.'); };
|
|
7
|
+
export function richTextSettings(field) {
|
|
8
|
+
if ((Object.hasOwn(field, 'document_version') || Object.hasOwn(field, 'features')) && field.type !== 'rich_text') invalid();
|
|
9
|
+
if (!Object.hasOwn(field, 'document_version') || field.document_version === 1) {
|
|
10
|
+
if (Object.hasOwn(field, 'features')) invalid();
|
|
11
|
+
return {version: 1, features: []};
|
|
12
|
+
}
|
|
13
|
+
const enabled = Object.hasOwn(field, 'features') ? field.features : [];
|
|
14
|
+
if (field.type !== 'rich_text' || field.document_version !== 2 || !Array.isArray(enabled) ||
|
|
15
|
+
enabled.length > features.length || new Set(enabled).size !== enabled.length ||
|
|
16
|
+
!enabled.every(feature => features.includes(feature))) invalid();
|
|
17
|
+
return {version: 2, features: enabled};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateRichTextExtension(schema) {
|
|
21
|
+
if (Object.hasOwn(schema, 'extensions')) {
|
|
22
|
+
if (!record(schema.extensions) || Object.keys(schema.extensions).length === 0 ||
|
|
23
|
+
Object.keys(schema.extensions).some(key => !schemaExtensionKeys.includes(key))) invalid();
|
|
24
|
+
if (Object.hasOwn(schema.extensions, 'rich_text')) {
|
|
25
|
+
const extension = schema.extensions.rich_text;
|
|
26
|
+
if (!record(extension) || Object.keys(extension).length !== 1 || extension.version !== 2) invalid();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function fields(values) {
|
|
30
|
+
for (const field of values) {
|
|
31
|
+
const settings = richTextSettings(field);
|
|
32
|
+
if (settings.version === 2 && schema.extensions?.rich_text?.version !== 2) invalid();
|
|
33
|
+
for (const definition of field.components || []) fields(definition.fields);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const model of schema.models) fields(model.fields);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function richTextV2Types(schema) {
|
|
40
|
+
if (!schema.extensions?.rich_text) return '';
|
|
41
|
+
return `\n// V2 is explicitly enabled per field; historical v1 values remain valid.
|
|
42
|
+
export type RichTextFeature = 'blockquote' | 'code_block' | 'table' | 'external_media';
|
|
43
|
+
export type RichTextParagraph = { type: 'paragraph'; content?: RichTextInline[] };
|
|
44
|
+
export type RichTextCodeBlock = { type: 'codeBlock'; attrs: { language: string }; content?: { type: 'text'; text: string }[] };
|
|
45
|
+
export type RichTextTable = { type: 'table'; attrs: { caption: string; header: boolean }; content: { type: 'tableRow'; content: { type: 'tableCell' | 'tableHeader'; content: [RichTextParagraph] }[] }[] };
|
|
46
|
+
export type RichTextExternalMedia = { type: 'externalMedia'; attrs: { provider: 'youtube' | 'vimeo' | 'spotify' | 'soundcloud'; url: string; title: string; caption: string; transcript: string; poster?: string }; resource?: ContentAsset | null };
|
|
47
|
+
export type RichTextAdvanced<Features extends RichTextFeature> =
|
|
48
|
+
('blockquote' extends Features ? { type: 'blockquote'; content: RichTextParagraph[] } : never) |
|
|
49
|
+
('code_block' extends Features ? RichTextCodeBlock : never) |
|
|
50
|
+
('table' extends Features ? RichTextTable : never) |
|
|
51
|
+
('external_media' extends Features ? RichTextExternalMedia : never);
|
|
52
|
+
export type RichTextBlockV2<Target extends keyof Models, Features extends RichTextFeature> = RichTextBlock<Target> | RichTextAdvanced<Features>;
|
|
53
|
+
export type ContentRichTextV2<Target extends keyof Models = keyof Models, Features extends RichTextFeature = RichTextFeature> = ContentRichText<Target> | { version: 2; document: { type: 'doc'; content?: RichTextBlockV2<Target, Features>[] } };\n`;
|
|
54
|
+
}
|