@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./errors.js";
2
+ export declare function createCMSPreviewClient(): never;
@@ -0,0 +1,9 @@
1
+ // Browser-condition replacement for the server-only entry point.
2
+ // A bundler resolving the package with the browser condition loads this module
3
+ // instead of server.ts. Calling its factory fails immediately, before any fetch.
4
+ // Keep real secret keys in server configuration regardless of this guard.
5
+ import { CMSConfigurationError } from "./errors.js";
6
+ export * from "./errors.js";
7
+ export function createCMSPreviewClient() {
8
+ throw new CMSConfigurationError("The preview client must run on a server. Import @lemonadecms/client for published content.");
9
+ }
@@ -0,0 +1,10 @@
1
+ import type { CMSClient, CMSPreviewOptions, ModelMap, PreviewModels, UntypedModels } from "./types.js";
2
+ export * from "./errors.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";
5
+ /** Import only from server-owned code. Draft fields may be missing or null. */
6
+ export declare function createCMSPreviewClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSPreviewOptions): CMSClient<PreviewModels<Models>>;
7
+ export { imageSrcSet } from "./images.js";
8
+ export type { RemoteValue } from "./types.js";
9
+ export type { ContentRendition } from "./images.js";
10
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
package/dist/server.js ADDED
@@ -0,0 +1,17 @@
1
+ // Server-only preview factory, exported as @lemonadecms/client/server.
2
+ // Preview reads the latest draft with a secret key. The package browser condition
3
+ // and the runtime browser-global check both help catch accidental client imports;
4
+ // the backend's authorization remains the actual security boundary.
5
+ // PreviewModels expresses that unpublished values can be absent or null.
6
+ import { createTransport } from "./transport.js";
7
+ import { CMSConfigurationError } from "./errors.js";
8
+ export * from "./errors.js";
9
+ /** Import only from server-owned code. Draft fields may be missing or null. */
10
+ export function createCMSPreviewClient(options) {
11
+ if (typeof window !== "undefined" || typeof document !== "undefined") {
12
+ throw new CMSConfigurationError("The preview client must run on a server.");
13
+ }
14
+ return createTransport(options, options?.secretKey, "preview");
15
+ }
16
+ export { imageSrcSet } from "./images.js";
17
+ export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
@@ -0,0 +1,3 @@
1
+ import type { CMSBaseOptions, CMSClient, ModelMap } from "./types.js";
2
+ /** Internal shared transport; public exports choose the credential and route. */
3
+ export declare function createTransport<Models extends ModelMap<Models>>(options: CMSBaseOptions, key: unknown, mode: "content" | "preview"): CMSClient<Models>;