@lemonadecms/client 0.1.0 → 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/bin/cli/run.mjs CHANGED
@@ -2,7 +2,13 @@ import { resolve } from 'node:path';
2
2
  import { createRequire } from 'node:module';
3
3
  const { version } = createRequire(import.meta.url)('../../package.json');
4
4
  import { access } from 'node:fs/promises';
5
- import { parseArgs, connection } from './config.mjs';
5
+ import {
6
+ parseArgs,
7
+ connection,
8
+ typeGenerationEnv,
9
+ readConfiguration,
10
+ } from './config.mjs';
11
+ import { canPickTarget, pickTarget } from './picker.mjs';
6
12
  import { CLIError, fail } from './errors.mjs';
7
13
  import { parseJSON, readText, writeOutput, MAX_SCHEMA_BYTES } from './io.mjs';
8
14
  import {
@@ -23,7 +29,7 @@ lemonade schema pull --out schema.json [connection options]
23
29
  lemonade schema export --out schema.json [connection options]
24
30
  lemonade schema validate --file schema.json
25
31
  lemonade schema diff --file schema.json [connection options]
26
- lemonade types generate [--file schema.json | connection options] --out FILE [--language typescript|csharp|python] [--check]
32
+ lemonade types generate [--file schema.json | connection options] [--out FILE] [--language typescript|csharp|python] [--check]
27
33
 
28
34
  lemonade schema migrate status [--before MIGRATION_ID] [connection options]
29
35
  lemonade schema migrate create --baseline current.json --file desired.json --status status.json --id YYYYMMDDHHMMSS_description --out migration.json
@@ -36,9 +42,17 @@ Migration commands require an environment UUID and a separate management credent
36
42
  Connection: --config FILE --target NAME, or --url URL --workspace UUID --environment KEY_OR_UUID
37
43
  Credential: --credential-env ENV_NAME (default LEMONADE_TOKEN). Never pass a token value.
38
44
  Targets live in lemonade.config.json. URL/workspace/environment also accept LEMONADE_* environment variables.
45
+ Types generate: loads cwd/.env (process values win); workspace falls back to CMS_PROJECT_ID,
46
+ environment to CMS_ENVIRONMENT or production, credential to CMS_PUBLIC_KEY when LEMONADE_TOKEN is unset.
47
+ Default output: src/types/cms-models.ts (csharp: cms-models.cs; python: cms_models.py).
48
+ Explicit options, named targets and existing LEMONADE_* settings take precedence. --out is a file path.
49
+ Offline generation and other commands do not load .env. Use the selected environment's credential.
50
+ Interactive types generate offers configured targets (production first) when no connection selection is explicit.
51
+ Use arrows/Enter to choose; Escape/Ctrl+C/EOF cancels without network or output-file writes.
52
+ CI, --json, --check, piped input/output, --file and explicit connection/environment settings never prompt.
39
53
  All commands: --json. Informational: --help, --version.
40
- Exit codes: 0 success, 2 usage/config, 3 drift, 4 connection/auth/context, 5 invalid schema/input, 6 output failure.
41
- Only schema migrate apply writes compatible schema additions or reviewed missing-draft backfills. No content publication, prompts, automatic retries or credential persistence. Node.js 22+ required.
54
+ Exit codes: 0 success, 2 usage/config, 3 drift, 4 connection/auth/context, 5 invalid schema/input, 6 output failure, 130 selection cancelled.
55
+ Only schema migrate apply writes compatible schema additions or reviewed missing-draft backfills. No content publication, automatic retries or credential persistence. Node.js 22+ required.
42
56
  `;
43
57
  export async function run(
44
58
  args,
@@ -48,6 +62,11 @@ export async function run(
48
62
  fetcher = fetch,
49
63
  stdout = (text) => process.stdout.write(text),
50
64
  stderr = (text) => process.stderr.write(text),
65
+ terminal = {
66
+ input: process.stdin,
67
+ output: process.stderr,
68
+ stdoutIsTTY: process.stdout.isTTY,
69
+ },
51
70
  } = {},
52
71
  ) {
53
72
  let opts = {},
@@ -94,7 +113,37 @@ export async function run(
94
113
  const inputPath = opts.file && resolve(cwd, opts.file);
95
114
  const readSchema = async () => validateSchema(parseJSON(await readText(inputPath)));
96
115
  const remote = async () => {
97
- const target = await connection(opts, env, cwd);
116
+ const generation = command === 'types generate';
117
+ const selectedEnv = generation ? await typeGenerationEnv(env, cwd) : env;
118
+ const defaults = generation
119
+ ? {
120
+ workspace: selectedEnv.CMS_PROJECT_ID,
121
+ environment: selectedEnv.CMS_ENVIRONMENT || 'production',
122
+ credentialEnv: Object.hasOwn(selectedEnv, 'LEMONADE_TOKEN')
123
+ ? 'LEMONADE_TOKEN'
124
+ : 'CMS_PUBLIC_KEY',
125
+ }
126
+ : {};
127
+ const configuration = await readConfiguration(opts, cwd);
128
+ let selected = opts;
129
+ if (
130
+ generation &&
131
+ canPickTarget(opts, selectedEnv, terminal) &&
132
+ Object.keys(configuration.targets).length
133
+ ) {
134
+ selected = {
135
+ ...opts,
136
+ target: await pickTarget(configuration.targets, terminal),
137
+ };
138
+ }
139
+ const target = await connection(
140
+ selected,
141
+ selectedEnv,
142
+ cwd,
143
+ false,
144
+ defaults,
145
+ configuration,
146
+ );
98
147
  key = target.key;
99
148
  return fetchSchema(target, fetcher);
100
149
  };
@@ -167,6 +216,7 @@ export async function run(
167
216
  await writeOutput(path, output, [
168
217
  inputPath,
169
218
  resolve(cwd, opts.config || 'lemonade.config.json'),
219
+ resolve(cwd, '.env'),
170
220
  ]);
171
221
  result.written = true;
172
222
  human = `Types generated successfully (${language}).`;
@@ -130,7 +130,7 @@ export function generateTypes(schema) {
130
130
  return [model.key, mappings];
131
131
  }),
132
132
  );
133
- return `// Generated by @lemonadecms/client codegen. Do not edit.\n// Every field allows historical absence/null; previews may be incomplete.\nexport interface ContentReference<Fields> { id: string; fields?: Fields; published_at?: string | null }\nexport interface ContentRendition { key: string; preset: "hero" | "card" | "social"; width: number; height: number; content_type: string; url: string }\nexport interface ContentAsset { id: string; url: string; content_type: string; byte_size: number; metadata: { title?: string; alt?: string; description?: string; focal_point?: { x: number; y: number } }; renditions?: ContentRendition[] }\nexport type RichTextMark = { type: 'bold' | 'italic' | 'underline' | 'strike' | 'code' } | { type: 'link'; attrs: { href: string } };\nexport type RichTextInline = { type: 'text'; text: string; marks?: RichTextMark[] } | { type: 'hardBreak' };\nexport type RichTextEntry<Target extends keyof Models> = { [K in Target]: { type: 'entry'; attrs: { id: string; target: K }; resource?: ContentReference<Models[K]> | null } }[Target];\nexport type RichTextBlock<Target extends keyof Models = keyof Models> = { type: 'paragraph'; content?: RichTextInline[] } | { type: 'heading'; attrs: { level: 2 | 3 | 4 }; content?: RichTextInline[] } | { type: 'bulletList' | 'orderedList'; attrs?: { start: 1 }; content: { type: 'listItem'; content: RichTextBlock<Target>[] }[] } | { type: 'image'; attrs: { id: string; alt: string }; resource?: ContentAsset | null } | RichTextEntry<Target>;\nexport interface ContentRichText<Target extends keyof Models = keyof Models> { version: 1; document: { type: 'doc'; content?: RichTextBlock<Target>[] } }\nexport interface Models {\n${models}\n}\n\n// Presentation roles, refreshed by codegen after schema changes.\nexport const seoModels = ${JSON.stringify(seoModels, null, 2)} as const;\n`;
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
134
  }
135
135
  async function main() {
136
136
  const args = process.argv.slice(2);
package/dist/errors.d.ts CHANGED
@@ -10,6 +10,12 @@ export declare class CMSRequestError extends Error {
10
10
  export declare class CMSResponseError extends Error {
11
11
  readonly name = "CMSResponseError";
12
12
  }
13
+ /** A successful findOneOrThrow query had no matching entry. */
14
+ export declare class CMSNotFoundError extends Error {
15
+ readonly model: string;
16
+ readonly name = "CMSNotFoundError";
17
+ constructor(model: string);
18
+ }
13
19
  /** The configured request deadline expired, including response-body reading. */
14
20
  export declare class CMSTimeoutError extends Error {
15
21
  readonly timeoutMs: number;
package/dist/errors.js CHANGED
@@ -19,6 +19,15 @@ export class CMSRequestError extends Error {
19
19
  export class CMSResponseError extends Error {
20
20
  name = "CMSResponseError";
21
21
  }
22
+ /** A successful findOneOrThrow query had no matching entry. */
23
+ export class CMSNotFoundError extends Error {
24
+ model;
25
+ name = "CMSNotFoundError";
26
+ constructor(model) {
27
+ super("No content entry matched the query.");
28
+ this.model = model;
29
+ }
30
+ }
22
31
  /** The configured request deadline expired, including response-body reading. */
23
32
  export class CMSTimeoutError extends Error {
24
33
  timeoutMs;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { CMSClient, CMSClientOptions, ModelMap, UntypedModels } from "./types.js";
2
2
  export * from "./errors.js";
3
3
  export type { RemoteValue } from "./types.js";
4
- export type { CMSClient, CMSClientOptions, ContentCollection, ContentEntry, ContentPage, ListOptions, PaginationOptions, ContentFilter, ContentSort } from "./types.js";
4
+ export type { CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
5
+ export type { CMSClient, CMSClientOptions, ContentCollection, ContentEntry, ContentPage, ContentTypeIdentity, ContentEnvironment, ListOptions, FindOneOptions, PaginationOptions, ContentFilter, ContentSort } from "./types.js";
5
6
  /** Published content only. Framework environment variables must be passed explicitly. */
6
7
  export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSClientOptions): CMSClient<Models>;
7
8
  export { imageSrcSet } from "./images.js";
8
9
  export type { ContentRendition } from "./images.js";
10
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
package/dist/index.js CHANGED
@@ -14,3 +14,4 @@ export function createCMSClient(options) {
14
14
  return createTransport(options, options?.publicKey, "content");
15
15
  }
16
16
  export { imageSrcSet } from "./images.js";
17
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
@@ -0,0 +1,4 @@
1
+ /** Escaped HTML for rich-text v1, including safe expanded images/entry captions. */
2
+ export declare function richTextToHtml(value: unknown): string;
3
+ /** Authored text with block/hard-break newlines; excludes embeds and link URLs. */
4
+ export declare function richTextToPlainText(value: unknown): string;
@@ -0,0 +1,228 @@
1
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
2
+ const model = /^[a-z][a-z0-9_]{0,47}$/;
3
+ const encoder = new TextEncoder();
4
+ function record(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function escape(value) {
8
+ return value.replace(/[&<>"']/g, (character) => {
9
+ switch (character) {
10
+ case '&':
11
+ return '&amp;';
12
+ case '<':
13
+ return '&lt;';
14
+ case '>':
15
+ return '&gt;';
16
+ case '"':
17
+ return '&quot;';
18
+ default:
19
+ return '&#39;';
20
+ }
21
+ });
22
+ }
23
+ function safeUrl(value, image) {
24
+ if (typeof value !== 'string' ||
25
+ value.length > (image ? 4096 : 2048) ||
26
+ /[\s\\\u0000-\u0020\u007f-\u009f]/u.test(value))
27
+ return null;
28
+ // Only the consuming-site preview proxy's fixed same-origin route is relative.
29
+ if (image &&
30
+ /^\/lemonade\/preview\/assets\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value))
31
+ return value;
32
+ if (!/^https?:\/\//i.test(value) && (image || !/^mailto:/i.test(value)))
33
+ return null;
34
+ try {
35
+ const url = new URL(value);
36
+ if (url.username || url.password)
37
+ return null;
38
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && url.hostname)
39
+ return value;
40
+ return !image && url.protocol === 'mailto:' && url.pathname ? value : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ function matchingResource(node) {
47
+ const attrs = node.attrs, resource = node.resource;
48
+ if (!record(attrs) ||
49
+ !record(resource) ||
50
+ typeof attrs.id !== 'string' ||
51
+ attrs.id.length !== 36 ||
52
+ !uuid.test(attrs.id) ||
53
+ typeof resource.id !== 'string' ||
54
+ resource.id.length !== 36 ||
55
+ attrs.id.toLowerCase() !== resource.id.toLowerCase())
56
+ return null;
57
+ return resource;
58
+ }
59
+ function convert(value, format) {
60
+ if (!record(value) ||
61
+ value.version !== 1 ||
62
+ !record(value.document) ||
63
+ value.document.type !== 'doc')
64
+ return '';
65
+ // The CMS bounds the authored document. Apply independent bounds to unknown
66
+ // consumer input and expanded strings as well; exhaustion discards all output.
67
+ let remaining = 500, embeds = 20, bytes = 65_536, exhausted = false;
68
+ function string(value, maximum = 20_000) {
69
+ if (typeof value !== 'string')
70
+ return '';
71
+ if (value.length > maximum) {
72
+ exhausted = true;
73
+ return '';
74
+ }
75
+ const size = encoder.encode(value).byteLength;
76
+ bytes -= size;
77
+ if (size > maximum || bytes < 0) {
78
+ exhausted = true;
79
+ return '';
80
+ }
81
+ return value;
82
+ }
83
+ function visit(value, context, depth) {
84
+ if (exhausted)
85
+ return '';
86
+ if (depth > 8 || --remaining < 0) {
87
+ exhausted = true;
88
+ return '';
89
+ }
90
+ if (!record(value))
91
+ return '';
92
+ const children = (next) => {
93
+ if (value.content === undefined)
94
+ return '';
95
+ if (!Array.isArray(value.content) || value.content.length > 200) {
96
+ exhausted = true;
97
+ return '';
98
+ }
99
+ return value.content.map((child) => visit(child, next, depth + 1)).join('');
100
+ };
101
+ if (context === 'inline') {
102
+ if (value.type === 'hardBreak')
103
+ return format === 'html' ? '<br>' : '\n';
104
+ if (value.type !== 'text')
105
+ return '';
106
+ const source = string(value.text);
107
+ if (format === 'text')
108
+ return source;
109
+ let text = escape(source);
110
+ const marks = value.marks;
111
+ if (marks !== undefined && (!Array.isArray(marks) || marks.length > 6)) {
112
+ exhausted = true;
113
+ return '';
114
+ }
115
+ const seen = new Set();
116
+ for (const mark of Array.isArray(marks) ? marks : []) {
117
+ if (!record(mark) ||
118
+ typeof mark.type !== 'string' ||
119
+ mark.type.length > 9 ||
120
+ seen.has(mark.type))
121
+ continue;
122
+ seen.add(mark.type);
123
+ switch (mark.type) {
124
+ case 'bold':
125
+ text = `<strong>${text}</strong>`;
126
+ break;
127
+ case 'italic':
128
+ text = `<em>${text}</em>`;
129
+ break;
130
+ case 'underline':
131
+ text = `<u>${text}</u>`;
132
+ break;
133
+ case 'strike':
134
+ text = `<s>${text}</s>`;
135
+ break;
136
+ case 'code':
137
+ text = `<code>${text}</code>`;
138
+ break;
139
+ case 'link': {
140
+ const href = safeUrl(record(mark.attrs) ? mark.attrs.href : null, false);
141
+ if (href)
142
+ text = `<a href="${escape(string(href, 2048))}" rel="noopener noreferrer">${text}</a>`;
143
+ }
144
+ }
145
+ }
146
+ return text;
147
+ }
148
+ if (context === 'root')
149
+ return value.type === 'doc' ? children('block') : '';
150
+ if (context === 'list') {
151
+ if (value.type !== 'listItem')
152
+ return '';
153
+ const content = children('item');
154
+ return format === 'html' ? `<li>${content}</li>` : content.trimEnd() + '\n';
155
+ }
156
+ if (value.type === 'paragraph') {
157
+ const content = children('inline');
158
+ return format === 'html'
159
+ ? `<p>${content}</p>`
160
+ : content + (context === 'item' ? '\n' : '\n\n');
161
+ }
162
+ if (value.type === 'bulletList' || value.type === 'orderedList') {
163
+ const content = children('list');
164
+ const tag = value.type === 'bulletList' ? 'ul' : 'ol';
165
+ return format === 'html'
166
+ ? `<${tag}>${content}</${tag}>`
167
+ : content + (context === 'item' ? '' : '\n');
168
+ }
169
+ if (context !== 'block')
170
+ return '';
171
+ if (value.type === 'heading') {
172
+ if (!record(value.attrs) ||
173
+ typeof value.attrs.level !== 'number' ||
174
+ ![2, 3, 4].includes(value.attrs.level))
175
+ return '';
176
+ const content = children('inline');
177
+ return format === 'html'
178
+ ? `<h${value.attrs.level}>${content}</h${value.attrs.level}>`
179
+ : content + '\n\n';
180
+ }
181
+ if (value.type !== 'image' && value.type !== 'entry')
182
+ return '';
183
+ if (--embeds < 0) {
184
+ exhausted = true;
185
+ return '';
186
+ }
187
+ if (format === 'text')
188
+ return '';
189
+ const resource = matchingResource(value);
190
+ if (!resource || !record(value.attrs))
191
+ return '';
192
+ if (value.type === 'image') {
193
+ if (typeof resource.content_type !== 'string' ||
194
+ !['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(resource.content_type))
195
+ return '';
196
+ const src = safeUrl(resource.url, true);
197
+ if (!src)
198
+ return '';
199
+ const metadata = record(resource.metadata) ? resource.metadata : {};
200
+ const alt = typeof value.attrs.alt === 'string' ? value.attrs.alt : metadata.alt;
201
+ return `<figure><img src="${escape(string(src, 4096))}" alt="${escape(string(alt, 2000))}" loading="lazy" decoding="async"></figure>`;
202
+ }
203
+ if (typeof value.attrs.target !== 'string' ||
204
+ value.attrs.target.length > 48 ||
205
+ model.exec(value.attrs.target)?.[0] !== value.attrs.target ||
206
+ (resource.content_type !== undefined &&
207
+ (!record(resource.content_type) ||
208
+ resource.content_type.key !== value.attrs.target)) ||
209
+ !record(resource.fields))
210
+ return '';
211
+ const caption = typeof resource.fields.title === 'string'
212
+ ? resource.fields.title
213
+ : resource.fields.name;
214
+ return typeof caption === 'string'
215
+ ? `<aside class="rich-text-reference"><p>${escape(string(caption))}</p></aside>`
216
+ : '';
217
+ }
218
+ const output = visit(value.document, 'root', 0);
219
+ return exhausted ? '' : format === 'text' ? output.trim() : output;
220
+ }
221
+ /** Escaped HTML for rich-text v1, including safe expanded images/entry captions. */
222
+ export function richTextToHtml(value) {
223
+ return convert(value, 'html');
224
+ }
225
+ /** Authored text with block/hard-break newlines; excludes embeds and link URLs. */
226
+ export function richTextToPlainText(value) {
227
+ return convert(value, 'text');
228
+ }
package/dist/server.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { CMSClient, CMSPreviewOptions, ModelMap, PreviewModels, UntypedModels } from "./types.js";
2
2
  export * from "./errors.js";
3
- export type { CMSPreviewOptions, PreviewModels } from "./types.js";
3
+ export type { CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
4
+ export type { CMSPreviewOptions, PreviewModels, ContentEntry, ContentPage, ContentTypeIdentity, ContentEnvironment, FindOneOptions } from "./types.js";
4
5
  /** Import only from server-owned code. Draft fields may be missing or null. */
5
6
  export declare function createCMSPreviewClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSPreviewOptions): CMSClient<PreviewModels<Models>>;
6
7
  export { imageSrcSet } from "./images.js";
7
8
  export type { RemoteValue } from "./types.js";
8
9
  export type { ContentRendition } from "./images.js";
10
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
package/dist/server.js CHANGED
@@ -14,3 +14,4 @@ export function createCMSPreviewClient(options) {
14
14
  return createTransport(options, options?.secretKey, "preview");
15
15
  }
16
16
  export { imageSrcSet } from "./images.js";
17
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";