@wocart/theme 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Rich text: a closed JSON format, checked on read and on write, rendered to
3
+ * escaped HTML or to plain text here — a theme never walks the nodes itself.
4
+ *
5
+ * The format has no way to carry markup: text is always text, links pass the
6
+ * `url` field's rule, and every node and key outside the format makes the
7
+ * value invalid. Rendering re-checks what it emits, so a value that skipped
8
+ * validation still cannot produce markup.
9
+ */
10
+ import { isSafeLinkTarget } from "./schemas.js";
11
+ /** Every format a rich-text field can allow, in the order an editor lists them. */
12
+ export const RICH_TEXT_FORMATS = [
13
+ "bold",
14
+ "italic",
15
+ "underline",
16
+ "strike",
17
+ "link",
18
+ "heading",
19
+ "list",
20
+ ];
21
+ const MARKS = ["bold", "italic", "underline", "strike"];
22
+ const MARK_TAGS = { bold: "strong", italic: "em", underline: "u", strike: "s" };
23
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
24
+ function unexpectedKey(value, allowed) {
25
+ return Object.keys(value).find((key) => !allowed.includes(key));
26
+ }
27
+ /** Whether a stored value is meant as one document (rather than a per-language map). */
28
+ export function isRichTextDocumentShape(value) {
29
+ return isRecord(value) && Object.hasOwn(value, "type");
30
+ }
31
+ /**
32
+ * Why `value` is not a valid rich-text document under `formats`, or `null`
33
+ * when it is. Absent `formats` allows every format.
34
+ */
35
+ export function richTextProblem(value, formats) {
36
+ const allowed = new Set(formats ?? RICH_TEXT_FORMATS);
37
+ if (!isRecord(value))
38
+ return "must be a rich-text document";
39
+ const extra = unexpectedKey(value, ["type", "version", "content"]);
40
+ if (extra !== undefined)
41
+ return `has an unknown key "${extra}"`;
42
+ if (value.type !== "doc")
43
+ return 'must have type "doc"';
44
+ if (value.version !== 1)
45
+ return `has unsupported version ${JSON.stringify(value.version ?? null)}; this SDK reads version 1`;
46
+ if (!Array.isArray(value.content))
47
+ return "must have a `content` array";
48
+ for (const [index, block] of value.content.entries()) {
49
+ const problem = blockProblem(block, allowed);
50
+ if (problem !== null)
51
+ return `content[${index}] ${problem}`;
52
+ }
53
+ return null;
54
+ }
55
+ function blockProblem(block, allowed) {
56
+ if (!isRecord(block))
57
+ return "must be a block";
58
+ switch (block.type) {
59
+ case "paragraph": {
60
+ const extra = unexpectedKey(block, ["type", "content"]);
61
+ if (extra !== undefined)
62
+ return `has an unknown key "${extra}"`;
63
+ return inlinesProblem(block.content, allowed);
64
+ }
65
+ case "heading": {
66
+ if (!allowed.has("heading"))
67
+ return "is a heading, which this field does not allow";
68
+ const extra = unexpectedKey(block, ["type", "level", "content"]);
69
+ if (extra !== undefined)
70
+ return `has an unknown key "${extra}"`;
71
+ if (block.level !== 2 && block.level !== 3 && block.level !== 4)
72
+ return "must have level 2, 3 or 4";
73
+ return inlinesProblem(block.content, allowed);
74
+ }
75
+ case "list": {
76
+ if (!allowed.has("list"))
77
+ return "is a list, which this field does not allow";
78
+ const extra = unexpectedKey(block, ["type", "style", "items"]);
79
+ if (extra !== undefined)
80
+ return `has an unknown key "${extra}"`;
81
+ if (block.style !== "bullet" && block.style !== "ordered")
82
+ return 'must have style "bullet" or "ordered"';
83
+ if (!Array.isArray(block.items) || block.items.length === 0)
84
+ return "must have at least one item";
85
+ for (const [index, item] of block.items.entries()) {
86
+ if (!isRecord(item))
87
+ return `items[${index}] must be an object`;
88
+ const itemExtra = unexpectedKey(item, ["content"]);
89
+ if (itemExtra !== undefined)
90
+ return `items[${index}] has an unknown key "${itemExtra}"`;
91
+ const problem = inlinesProblem(item.content, allowed);
92
+ if (problem !== null)
93
+ return `items[${index}] ${problem}`;
94
+ }
95
+ return null;
96
+ }
97
+ default:
98
+ return `has unknown block type ${JSON.stringify(block.type ?? null)}`;
99
+ }
100
+ }
101
+ function inlinesProblem(content, allowed) {
102
+ if (content === undefined)
103
+ return null;
104
+ if (!Array.isArray(content))
105
+ return "must have a `content` array";
106
+ for (const [index, inline] of content.entries()) {
107
+ const problem = inlineProblem(inline, allowed);
108
+ if (problem !== null)
109
+ return `content[${index}] ${problem}`;
110
+ }
111
+ return null;
112
+ }
113
+ function inlineProblem(inline, allowed) {
114
+ if (!isRecord(inline))
115
+ return "must be a text or break node";
116
+ if (inline.type === "break") {
117
+ const extra = unexpectedKey(inline, ["type"]);
118
+ return extra === undefined ? null : `has an unknown key "${extra}"`;
119
+ }
120
+ if (inline.type !== "text")
121
+ return `has unknown inline type ${JSON.stringify(inline.type ?? null)}`;
122
+ const extra = unexpectedKey(inline, ["type", "text", "marks", "link"]);
123
+ if (extra !== undefined)
124
+ return `has an unknown key "${extra}"`;
125
+ if (typeof inline.text !== "string" || inline.text.length === 0)
126
+ return "must have non-empty `text`";
127
+ if (inline.marks !== undefined) {
128
+ if (!Array.isArray(inline.marks))
129
+ return "`marks` must be an array";
130
+ const seen = new Set();
131
+ for (const mark of inline.marks) {
132
+ if (!MARKS.includes(mark))
133
+ return `has unknown mark ${JSON.stringify(mark)}`;
134
+ if (!allowed.has(mark))
135
+ return `uses "${String(mark)}", which this field does not allow`;
136
+ if (seen.has(mark))
137
+ return `repeats mark "${String(mark)}"`;
138
+ seen.add(mark);
139
+ }
140
+ }
141
+ if (inline.link !== undefined) {
142
+ if (!allowed.has("link"))
143
+ return "has a link, which this field does not allow";
144
+ if (typeof inline.link !== "string" || !isSafeLinkTarget(inline.link)) {
145
+ return "`link` must be a site path, #fragment, ?query, or an http(s), mailto or tel URL";
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+ const escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
151
+ /**
152
+ * The document as HTML: `<p>`, `<h2>`–`<h4>`, `<ul>`/`<ol>`, `<strong>`, `<em>`,
153
+ * `<u>`, `<s>`, `<a href>` and `<br>`, every text and attribute escaped. Nodes
154
+ * outside the format and unsafe links are left out rather than trusted, so
155
+ * the output is safe for Astro's `set:html` even for an unchecked value.
156
+ */
157
+ export function richTextToHtml(document, options = {}) {
158
+ if (!isRecord(document) || !Array.isArray(document.content))
159
+ return "";
160
+ return document.content.map((block) => blockHtml(block, options)).join("");
161
+ }
162
+ function blockHtml(block, options) {
163
+ if (!isRecord(block))
164
+ return "";
165
+ switch (block.type) {
166
+ case "paragraph":
167
+ return `<p>${inlinesHtml(block.content, options)}</p>`;
168
+ case "heading": {
169
+ const level = block.level === 3 || block.level === 4 ? block.level : 2;
170
+ return `<h${level}>${inlinesHtml(block.content, options)}</h${level}>`;
171
+ }
172
+ case "list": {
173
+ const tag = block.style === "ordered" ? "ol" : "ul";
174
+ const items = Array.isArray(block.items) ? block.items : [];
175
+ return `<${tag}>${items.map((item) => `<li>${inlinesHtml(isRecord(item) ? item.content : undefined, options)}</li>`).join("")}</${tag}>`;
176
+ }
177
+ default:
178
+ return "";
179
+ }
180
+ }
181
+ function inlinesHtml(content, options) {
182
+ if (!Array.isArray(content))
183
+ return "";
184
+ return content.map((inline) => inlineHtml(inline, options)).join("");
185
+ }
186
+ function inlineHtml(inline, options) {
187
+ if (!isRecord(inline))
188
+ return "";
189
+ if (inline.type === "break")
190
+ return "<br>";
191
+ if (inline.type !== "text" || typeof inline.text !== "string")
192
+ return "";
193
+ let html = escapeHtml(inline.text);
194
+ const marks = Array.isArray(inline.marks) ? inline.marks : [];
195
+ for (const mark of [...MARKS].reverse()) {
196
+ if (marks.includes(mark))
197
+ html = `<${MARK_TAGS[mark]}>${html}</${MARK_TAGS[mark]}>`;
198
+ }
199
+ if (typeof inline.link === "string" && isSafeLinkTarget(inline.link)) {
200
+ const href = options.href ? options.href(inline.link) : inline.link;
201
+ html = `<a href="${escapeHtml(href)}">${html}</a>`;
202
+ }
203
+ return html;
204
+ }
205
+ /**
206
+ * The document's text without formatting: blocks and list items on their own
207
+ * lines, line breaks kept — for meta descriptions, `aria-label`s and previews.
208
+ */
209
+ export function richTextToPlainText(document) {
210
+ if (!isRecord(document) || !Array.isArray(document.content))
211
+ return "";
212
+ const lines = [];
213
+ for (const block of document.content) {
214
+ if (!isRecord(block))
215
+ continue;
216
+ if (block.type === "list") {
217
+ for (const item of Array.isArray(block.items) ? block.items : []) {
218
+ lines.push(inlinesText(isRecord(item) ? item.content : undefined));
219
+ }
220
+ }
221
+ else if (block.type === "paragraph" || block.type === "heading") {
222
+ lines.push(inlinesText(block.content));
223
+ }
224
+ }
225
+ return lines.join("\n");
226
+ }
227
+ function inlinesText(content) {
228
+ if (!Array.isArray(content))
229
+ return "";
230
+ return content
231
+ .map((inline) => (!isRecord(inline) ? "" : inline.type === "break" ? "\n" : inline.type === "text" && typeof inline.text === "string" ? inline.text : ""))
232
+ .join("");
233
+ }
package/dist/schemas.d.ts CHANGED
@@ -21,6 +21,8 @@ export declare const TYPE_KEY_PATTERN: RegExp;
21
21
  * not of the package's curated public surface (`src/index.ts`).
22
22
  */
23
23
  export declare const SURFACE_KEY_PATTERN: RegExp;
24
+ /** The platform page types a surface can render (`SurfacePageType`), mirrored from the storefront's page types. */
25
+ export declare const PAGE_TYPES: readonly ["home", "catalog", "category", "collection", "product", "page", "landing", "checkout", "thankYou"];
24
26
  /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
25
27
  export declare const COLOR_PATTERN: RegExp;
26
28
  /**
@@ -30,12 +32,31 @@ export declare const COLOR_PATTERN: RegExp;
30
32
  */
31
33
  export declare const MENU_HANDLE_PATTERN: RegExp;
32
34
  export declare const COLLECTION_HANDLE_PATTERN: RegExp;
35
+ /**
36
+ * A `product` or `category` value: a handle or slug as the store's URLs use
37
+ * it. Merchants may type their own, so the rule only keeps out what cannot be
38
+ * one URL path segment: whitespace, control characters, `/`, `\`, `?`, `#`
39
+ * and `%`, and anything over 255 characters.
40
+ */
41
+ export declare const RESOURCE_HANDLE_PATTERN: RegExp;
42
+ /**
43
+ * A media object key in the store's bucket: a namespace and a name
44
+ * (`products/3f2a9c.webp`), segments of letters, digits, `.`, `_` and `-`
45
+ * that never start with a dot — so no `..`, no leading slash, no empty
46
+ * segment — and at most 512 characters.
47
+ */
48
+ export declare const MEDIA_KEY_PATTERN: RegExp;
33
49
  export declare function isLocaleTag(value: string): boolean;
34
50
  /**
35
51
  * Whether `value` is a per-language map (`{ ar: "…", fr: "…" }`): a plain
36
52
  * object with at least one entry, every key a locale tag, every value a string.
37
53
  */
38
54
  export declare function isLocaleMap(value: unknown): value is Readonly<Record<string, string>>;
55
+ /**
56
+ * Whether `value` is a per-language map whose entries pass `isEntry`: a plain
57
+ * object with at least one entry, every key a locale tag.
58
+ */
59
+ export declare function isLocaleMapOf<T>(value: unknown, isEntry: (entry: unknown) => entry is T): value is Readonly<Record<string, T>>;
39
60
  /**
40
61
  * Whether a `url` field value is safe to render as an `href`: a site path,
41
62
  * fragment or query, or an `http(s)`, `mailto` or `tel` URL. Rejects
package/dist/schemas.js CHANGED
@@ -20,6 +20,18 @@ export const TYPE_KEY_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
20
20
  * not of the package's curated public surface (`src/index.ts`).
21
21
  */
22
22
  export const SURFACE_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
23
+ /** The platform page types a surface can render (`SurfacePageType`), mirrored from the storefront's page types. */
24
+ export const PAGE_TYPES = [
25
+ "home",
26
+ "catalog",
27
+ "category",
28
+ "collection",
29
+ "product",
30
+ "page",
31
+ "landing",
32
+ "checkout",
33
+ "thankYou",
34
+ ];
23
35
  /** A `color` field's value: a hex color, `#rgb` or `#rrggbb`. */
24
36
  export const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
25
37
  /**
@@ -29,6 +41,20 @@ export const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
29
41
  */
30
42
  export const MENU_HANDLE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
31
43
  export const COLLECTION_HANDLE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
44
+ /**
45
+ * A `product` or `category` value: a handle or slug as the store's URLs use
46
+ * it. Merchants may type their own, so the rule only keeps out what cannot be
47
+ * one URL path segment: whitespace, control characters, `/`, `\`, `?`, `#`
48
+ * and `%`, and anything over 255 characters.
49
+ */
50
+ export const RESOURCE_HANDLE_PATTERN = /^[^\s/\\?#%\u0000-\u001f\u007f]{1,255}$/u;
51
+ /**
52
+ * A media object key in the store's bucket: a namespace and a name
53
+ * (`products/3f2a9c.webp`), segments of letters, digits, `.`, `_` and `-`
54
+ * that never start with a dot — so no `..`, no leading slash, no empty
55
+ * segment — and at most 512 characters.
56
+ */
57
+ export const MEDIA_KEY_PATTERN = /^(?=.{3,512}$)[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)+$/;
32
58
  /**
33
59
  * A BCP 47 language tag in canonical case: a 2–3 letter language, an optional
34
60
  * 4-letter script, an optional 2-letter or 3-digit region (`ar`, `zgh`,
@@ -44,11 +70,17 @@ export function isLocaleTag(value) {
44
70
  * object with at least one entry, every key a locale tag, every value a string.
45
71
  */
46
72
  export function isLocaleMap(value) {
73
+ return isLocaleMapOf(value, (entry) => typeof entry === "string");
74
+ }
75
+ /**
76
+ * Whether `value` is a per-language map whose entries pass `isEntry`: a plain
77
+ * object with at least one entry, every key a locale tag.
78
+ */
79
+ export function isLocaleMapOf(value, isEntry) {
47
80
  if (typeof value !== "object" || value === null || Array.isArray(value))
48
81
  return false;
49
82
  const entries = Object.entries(value);
50
- return (entries.length > 0 &&
51
- entries.every(([key, text]) => isLocaleTag(key) && typeof text === "string"));
83
+ return entries.length > 0 && entries.every(([key, entry]) => isLocaleTag(key) && isEntry(entry));
52
84
  }
53
85
  /**
54
86
  * Whether a `url` field value is safe to render as an `href`: a site path,
@@ -94,6 +126,7 @@ export const ComponentInstanceSchema = z.lazy(() => z.object({
94
126
  props: z.exactOptional(z.record(z.string(), JsonValueSchema)),
95
127
  children: z.exactOptional(z.array(ComponentInstanceSchema)),
96
128
  slots: z.exactOptional(z.record(z.string(), z.array(ComponentInstanceSchema))),
129
+ hidden: z.exactOptional(z.boolean()),
97
130
  }));
98
131
  /**
99
132
  * The envelope schema. **Loose on purpose**: unknown top-level keys