@avocadostudio-ai/shared 0.3.1 → 0.3.3
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/dist/api-responses.d.ts +5 -0
- package/dist/api-responses.js +12 -0
- package/dist/block-manifest.d.ts +9 -0
- package/dist/block-manifest.js +10 -1
- package/dist/blocks/_helpers.d.ts +9 -0
- package/dist/blocks/_helpers.js +9 -0
- package/dist/blocks/_registry.d.ts +27 -2
- package/dist/blocks/_registry.js +49 -1
- package/dist/blocks/banner.js +3 -1
- package/dist/blocks/card-grid.js +3 -1
- package/dist/blocks/card.js +3 -1
- package/dist/blocks/carousel.js +3 -1
- package/dist/blocks/cta.js +7 -3
- package/dist/blocks/hero.js +7 -3
- package/dist/blocks/site-header.js +3 -1
- package/dist/blocks/table.js +5 -2
- package/dist/blocks/two-column.js +3 -1
- package/dist/editable-path.d.ts +11 -0
- package/dist/editable-path.js +17 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/links.d.ts +122 -0
- package/dist/links.js +258 -0
- package/package.json +2 -2
package/dist/api-responses.d.ts
CHANGED
|
@@ -52,6 +52,11 @@ export declare const chatStartResponseSchema: z.ZodObject<{
|
|
|
52
52
|
}, z.core.$loose>;
|
|
53
53
|
export declare const slugsResponseSchema: z.ZodObject<{
|
|
54
54
|
slugs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
55
|
+
pages: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
56
|
+
slug: z.ZodString;
|
|
57
|
+
path: z.ZodOptional<z.ZodString>;
|
|
58
|
+
title: z.ZodOptional<z.ZodString>;
|
|
59
|
+
}, z.core.$loose>>>;
|
|
55
60
|
}, z.core.$loose>;
|
|
56
61
|
export declare const bootstrapResponseSchema: z.ZodObject<{
|
|
57
62
|
slugs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
package/dist/api-responses.js
CHANGED
|
@@ -58,6 +58,18 @@ export const chatStartResponseSchema = z.object({
|
|
|
58
58
|
}).passthrough();
|
|
59
59
|
export const slugsResponseSchema = z.object({
|
|
60
60
|
slugs: z.array(z.string()).optional(),
|
|
61
|
+
/*
|
|
62
|
+
* The same pages the `slugs` array names, with the title and the URL that
|
|
63
|
+
* `/draft/slugs` has always returned beside them. The editor parsed this
|
|
64
|
+
* response and kept only the bare slugs, so the link picker had nothing to
|
|
65
|
+
* label a page with; `path` is what a locale-prefixed site navigates to when
|
|
66
|
+
* it differs from the slug (see docs/ideas/page-identity-punch-list.md).
|
|
67
|
+
*/
|
|
68
|
+
pages: z.array(z.object({
|
|
69
|
+
slug: z.string(),
|
|
70
|
+
path: z.string().optional(),
|
|
71
|
+
title: z.string().optional(),
|
|
72
|
+
}).passthrough()).optional(),
|
|
61
73
|
}).passthrough();
|
|
62
74
|
export const bootstrapResponseSchema = z.object({
|
|
63
75
|
slugs: z.array(z.string()).optional(),
|
package/dist/block-manifest.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare const fieldMetaSchema: z.ZodObject<{
|
|
|
19
19
|
text: "text";
|
|
20
20
|
richtext: "richtext";
|
|
21
21
|
url: "url";
|
|
22
|
+
link: "link";
|
|
22
23
|
image: "image";
|
|
23
24
|
imageAlt: "imageAlt";
|
|
24
25
|
color: "color";
|
|
@@ -59,6 +60,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
|
|
|
59
60
|
text: "text";
|
|
60
61
|
richtext: "richtext";
|
|
61
62
|
url: "url";
|
|
63
|
+
link: "link";
|
|
62
64
|
image: "image";
|
|
63
65
|
imageAlt: "imageAlt";
|
|
64
66
|
color: "color";
|
|
@@ -98,6 +100,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
|
|
|
98
100
|
text: "text";
|
|
99
101
|
richtext: "richtext";
|
|
100
102
|
url: "url";
|
|
103
|
+
link: "link";
|
|
101
104
|
image: "image";
|
|
102
105
|
imageAlt: "imageAlt";
|
|
103
106
|
color: "color";
|
|
@@ -143,6 +146,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
|
|
|
143
146
|
text: "text";
|
|
144
147
|
richtext: "richtext";
|
|
145
148
|
url: "url";
|
|
149
|
+
link: "link";
|
|
146
150
|
image: "image";
|
|
147
151
|
imageAlt: "imageAlt";
|
|
148
152
|
color: "color";
|
|
@@ -183,6 +187,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
|
|
|
183
187
|
text: "text";
|
|
184
188
|
richtext: "richtext";
|
|
185
189
|
url: "url";
|
|
190
|
+
link: "link";
|
|
186
191
|
image: "image";
|
|
187
192
|
imageAlt: "imageAlt";
|
|
188
193
|
color: "color";
|
|
@@ -222,6 +227,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
|
|
|
222
227
|
text: "text";
|
|
223
228
|
richtext: "richtext";
|
|
224
229
|
url: "url";
|
|
230
|
+
link: "link";
|
|
225
231
|
image: "image";
|
|
226
232
|
imageAlt: "imageAlt";
|
|
227
233
|
color: "color";
|
|
@@ -273,6 +279,7 @@ export declare const blockManifestSchema: z.ZodObject<{
|
|
|
273
279
|
text: "text";
|
|
274
280
|
richtext: "richtext";
|
|
275
281
|
url: "url";
|
|
282
|
+
link: "link";
|
|
276
283
|
image: "image";
|
|
277
284
|
imageAlt: "imageAlt";
|
|
278
285
|
color: "color";
|
|
@@ -313,6 +320,7 @@ export declare const blockManifestSchema: z.ZodObject<{
|
|
|
313
320
|
text: "text";
|
|
314
321
|
richtext: "richtext";
|
|
315
322
|
url: "url";
|
|
323
|
+
link: "link";
|
|
316
324
|
image: "image";
|
|
317
325
|
imageAlt: "imageAlt";
|
|
318
326
|
color: "color";
|
|
@@ -352,6 +360,7 @@ export declare const blockManifestSchema: z.ZodObject<{
|
|
|
352
360
|
text: "text";
|
|
353
361
|
richtext: "richtext";
|
|
354
362
|
url: "url";
|
|
363
|
+
link: "link";
|
|
355
364
|
image: "image";
|
|
356
365
|
imageAlt: "imageAlt";
|
|
357
366
|
color: "color";
|
package/dist/block-manifest.js
CHANGED
|
@@ -24,7 +24,7 @@ export const jsonSchemaLikeSchema = z.lazy(() => z.object({
|
|
|
24
24
|
* JSON Schema spelling, so they have to travel beside it.
|
|
25
25
|
*/
|
|
26
26
|
export const fieldMetaSchema = z.object({
|
|
27
|
-
kind: z.enum(["text", "richtext", "url", "image", "imageAlt", "enum", "color", "number", "boolean", "headingLevel"]),
|
|
27
|
+
kind: z.enum(["text", "richtext", "url", "link", "image", "imageAlt", "enum", "color", "number", "boolean", "headingLevel"]),
|
|
28
28
|
label: z.string().optional(),
|
|
29
29
|
inlineEditable: z.boolean().optional(),
|
|
30
30
|
options: z.array(z.string()).optional(),
|
|
@@ -184,6 +184,7 @@ export function validateByJsonSchemaLike(schema, value) {
|
|
|
184
184
|
// ---------------------------------------------------------------------------
|
|
185
185
|
const IMAGE_KEY_RE = /(?:image|img)(?:Url|Src)?$|^(?:src|imageUrl|logoUrl|heroImage)$/i;
|
|
186
186
|
const IMAGE_ALT_KEY_RE = /(?:Alt)$/;
|
|
187
|
+
const LINK_KEY_RE = /(?:href|link)$/i;
|
|
187
188
|
function labelFromKey(key) {
|
|
188
189
|
// ctaText → "CTA text", imageUrl → "Image", logoAlt → "Logo alt",
|
|
189
190
|
// background_image → "Background image", video_url → "Video", src → "Src"
|
|
@@ -224,6 +225,14 @@ function inferFieldKind(key, schema) {
|
|
|
224
225
|
return "imageAlt";
|
|
225
226
|
if (IMAGE_KEY_RE.test(key))
|
|
226
227
|
return "image";
|
|
228
|
+
/*
|
|
229
|
+
* A custom block's `ctaHref` is a link, and inferring it as plain text gave
|
|
230
|
+
* an integrator's editors a bare box where the built-in blocks get a page
|
|
231
|
+
* picker. Checked after the image patterns so `imageUrl` and `logoUrl` still
|
|
232
|
+
* resolve to images — those end in "Url" too.
|
|
233
|
+
*/
|
|
234
|
+
if (LINK_KEY_RE.test(key))
|
|
235
|
+
return "link";
|
|
227
236
|
return "text";
|
|
228
237
|
}
|
|
229
238
|
function deriveItemFields(itemSchema) {
|
|
@@ -3,7 +3,16 @@ export declare const f: {
|
|
|
3
3
|
readonly text: (label?: string) => FieldMeta;
|
|
4
4
|
readonly longtext: (label?: string) => FieldMeta;
|
|
5
5
|
readonly richtext: (label?: string) => FieldMeta;
|
|
6
|
+
/** A resource address — an iframe src, a video file. Never an internal page. */
|
|
6
7
|
readonly url: (label?: string) => FieldMeta;
|
|
8
|
+
/** A navigation target: an internal page, or an external URL / mailto / tel / anchor. */
|
|
9
|
+
readonly link: (label?: string) => FieldMeta;
|
|
10
|
+
/**
|
|
11
|
+
* The "open in new tab" companion for a link prop. Named by convention
|
|
12
|
+
* (`newTabKeyFor`) so the property panel renders it inside the link control
|
|
13
|
+
* rather than as a row of its own — the same pairing as `image`/`imageAlt`.
|
|
14
|
+
*/
|
|
15
|
+
readonly newTab: (label?: string) => FieldMeta;
|
|
7
16
|
readonly image: (label?: string, imageSpec?: ImageSpec) => FieldMeta;
|
|
8
17
|
readonly imageAlt: (label?: string) => FieldMeta;
|
|
9
18
|
readonly headingLevel: () => FieldMeta;
|
package/dist/blocks/_helpers.js
CHANGED
|
@@ -2,7 +2,16 @@ export const f = {
|
|
|
2
2
|
text: (label) => ({ kind: "text", label }),
|
|
3
3
|
longtext: (label) => ({ kind: "text", label, multiline: true }),
|
|
4
4
|
richtext: (label) => ({ kind: "richtext", label }),
|
|
5
|
+
/** A resource address — an iframe src, a video file. Never an internal page. */
|
|
5
6
|
url: (label) => ({ kind: "url", label, inlineEditable: false }),
|
|
7
|
+
/** A navigation target: an internal page, or an external URL / mailto / tel / anchor. */
|
|
8
|
+
link: (label) => ({ kind: "link", label, inlineEditable: false }),
|
|
9
|
+
/**
|
|
10
|
+
* The "open in new tab" companion for a link prop. Named by convention
|
|
11
|
+
* (`newTabKeyFor`) so the property panel renders it inside the link control
|
|
12
|
+
* rather than as a row of its own — the same pairing as `image`/`imageAlt`.
|
|
13
|
+
*/
|
|
14
|
+
newTab: (label = "Open in new tab") => ({ kind: "boolean", label, inlineEditable: false }),
|
|
6
15
|
image: (label, imageSpec) => ({ kind: "image", label, inlineEditable: false, ...(imageSpec ? { imageSpec } : {}) }),
|
|
7
16
|
imageAlt: (label) => ({ kind: "imageAlt", label }),
|
|
8
17
|
headingLevel: () => ({ kind: "headingLevel", label: "Heading type", inlineEditable: false }),
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const IMAGE_PLACEHOLDER = "/hero-generated.svg";
|
|
3
3
|
export declare function isImagePlaceholder(url: string | undefined | null): boolean;
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Semantic kind for a block prop field.
|
|
6
|
+
*
|
|
7
|
+
* `link` and `url` are both href-valued strings and deliberately distinct.
|
|
8
|
+
* `link` is a *navigation target* — it may address a page on this site, so it
|
|
9
|
+
* gets the page picker, the dead-route warning, and the rename rewriter.
|
|
10
|
+
* `url` is a *resource address* — an embed's iframe src, a video file, an OG
|
|
11
|
+
* image — which is never an internal page, and where offering a list of pages
|
|
12
|
+
* would be noise.
|
|
13
|
+
*/
|
|
14
|
+
export type FieldKind = "text" | "richtext" | "url" | "link" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel";
|
|
6
15
|
/** Recommended image dimensions for an image field. */
|
|
7
16
|
export type ImageSpec = {
|
|
8
17
|
aspectRatio: "landscape" | "square" | "portrait";
|
|
@@ -108,6 +117,22 @@ export declare function getAllBlockMeta(): Readonly<Record<string, BlockMeta>>;
|
|
|
108
117
|
export declare function getImageFields(blockType: string): Set<string>;
|
|
109
118
|
/** Get list props that contain image fields: Map<listKey, Set<imageFieldKey>>. */
|
|
110
119
|
export declare function getListImageFields(blockType: string): Map<string, Set<string>>;
|
|
120
|
+
/**
|
|
121
|
+
* Every prop key on a block type that addresses a *resource* rather than a page
|
|
122
|
+
* — images, iframe sources, video files — at any depth, scalar and list-item
|
|
123
|
+
* props flattened into one set.
|
|
124
|
+
*
|
|
125
|
+
* Renaming a page rewrites the links that point at it, and the rewriter cannot
|
|
126
|
+
* tell a link from any other string: it walks the props and remaps every value
|
|
127
|
+
* that starts with the old slug. That silently rewrote `/pricing/demo.mp4` into
|
|
128
|
+
* `/plans/demo.mp4` on a rename — an asset path is not a route, and renaming a
|
|
129
|
+
* page does not move the file. This set is what the walker skips.
|
|
130
|
+
*
|
|
131
|
+
* It is an exclusion rather than a `kind: "link"` allow-list on purpose: a
|
|
132
|
+
* custom block that ships no field metadata still has its links rewritten, the
|
|
133
|
+
* way it always did.
|
|
134
|
+
*/
|
|
135
|
+
export declare function getMediaFields(blockType: string): Set<string>;
|
|
111
136
|
/** Check if a block type is a chrome block (structurally pinned). */
|
|
112
137
|
export declare function isChrome(type: string): boolean;
|
|
113
138
|
/** Get all registered chrome block type names. */
|
package/dist/blocks/_registry.js
CHANGED
|
@@ -182,6 +182,46 @@ export function getListImageFields(blockType) {
|
|
|
182
182
|
_listImageFieldsCache.set(blockType, result);
|
|
183
183
|
return result;
|
|
184
184
|
}
|
|
185
|
+
/** Cache for getMediaFields results. */
|
|
186
|
+
const _mediaFieldsCache = new Map();
|
|
187
|
+
/**
|
|
188
|
+
* Every prop key on a block type that addresses a *resource* rather than a page
|
|
189
|
+
* — images, iframe sources, video files — at any depth, scalar and list-item
|
|
190
|
+
* props flattened into one set.
|
|
191
|
+
*
|
|
192
|
+
* Renaming a page rewrites the links that point at it, and the rewriter cannot
|
|
193
|
+
* tell a link from any other string: it walks the props and remaps every value
|
|
194
|
+
* that starts with the old slug. That silently rewrote `/pricing/demo.mp4` into
|
|
195
|
+
* `/plans/demo.mp4` on a rename — an asset path is not a route, and renaming a
|
|
196
|
+
* page does not move the file. This set is what the walker skips.
|
|
197
|
+
*
|
|
198
|
+
* It is an exclusion rather than a `kind: "link"` allow-list on purpose: a
|
|
199
|
+
* custom block that ships no field metadata still has its links rewritten, the
|
|
200
|
+
* way it always did.
|
|
201
|
+
*/
|
|
202
|
+
export function getMediaFields(blockType) {
|
|
203
|
+
const cached = _mediaFieldsCache.get(blockType);
|
|
204
|
+
if (cached)
|
|
205
|
+
return cached;
|
|
206
|
+
const meta = _blockMeta[blockType];
|
|
207
|
+
const result = new Set();
|
|
208
|
+
const collect = (fields) => {
|
|
209
|
+
for (const [key, fm] of Object.entries(fields)) {
|
|
210
|
+
if (fm.kind === "image" || fm.kind === "url")
|
|
211
|
+
result.add(key);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
if (meta) {
|
|
215
|
+
collect(meta.fields);
|
|
216
|
+
for (const listMeta of Object.values(meta.listFields ?? {})) {
|
|
217
|
+
collect(listMeta.itemFields);
|
|
218
|
+
for (const branch of Object.values(listMeta.itemFieldsByType ?? {}))
|
|
219
|
+
collect(branch);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
_mediaFieldsCache.set(blockType, result);
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
185
225
|
/** Check if a block type is a chrome block (structurally pinned). */
|
|
186
226
|
export function isChrome(type) {
|
|
187
227
|
return _blockMeta[type]?.chrome === true;
|
|
@@ -332,7 +372,7 @@ function defaultScalarForField(field, fieldKey) {
|
|
|
332
372
|
const label = field.label?.trim() || fieldKey;
|
|
333
373
|
if (field.kind === "text" || field.kind === "richtext" || field.kind === "imageAlt")
|
|
334
374
|
return `New ${label}`;
|
|
335
|
-
if (field.kind === "url")
|
|
375
|
+
if (field.kind === "url" || field.kind === "link")
|
|
336
376
|
return "/";
|
|
337
377
|
if (field.kind === "image")
|
|
338
378
|
return IMAGE_PLACEHOLDER;
|
|
@@ -340,6 +380,14 @@ function defaultScalarForField(field, fieldKey) {
|
|
|
340
380
|
return "#0f766e";
|
|
341
381
|
if (field.kind === "number")
|
|
342
382
|
return 0;
|
|
383
|
+
/*
|
|
384
|
+
* Without this a boolean item field seeded a *string* ("New Open in new tab")
|
|
385
|
+
* into a new list item, which the block's own zod schema then rejected — the
|
|
386
|
+
* add-item op failed on a value this function produced. Latent until link
|
|
387
|
+
* fields introduced the first boolean inside a list.
|
|
388
|
+
*/
|
|
389
|
+
if (field.kind === "boolean")
|
|
390
|
+
return false;
|
|
343
391
|
if (field.kind === "enum")
|
|
344
392
|
return Array.isArray(field.options) && field.options.length > 0 ? field.options[0] : "";
|
|
345
393
|
return `New ${label}`;
|
package/dist/blocks/banner.js
CHANGED
|
@@ -7,6 +7,7 @@ registerBlock("Banner", {
|
|
|
7
7
|
variant: z.enum(["info", "success", "warning"]).default("info").catch("info"),
|
|
8
8
|
ctaText: z.string().optional(),
|
|
9
9
|
ctaHref: z.string().optional(),
|
|
10
|
+
ctaNewTab: z.boolean().optional(),
|
|
10
11
|
backgroundColor: z.string().optional(),
|
|
11
12
|
textColor: z.string().optional(),
|
|
12
13
|
}),
|
|
@@ -18,7 +19,8 @@ registerBlock("Banner", {
|
|
|
18
19
|
text: f.text("Banner text"),
|
|
19
20
|
variant: { kind: "enum", label: "Variant", options: ["info", "success", "warning"], inlineEditable: false },
|
|
20
21
|
ctaText: f.text("Button label"),
|
|
21
|
-
ctaHref: f.
|
|
22
|
+
ctaHref: f.link("Button link"),
|
|
23
|
+
ctaNewTab: f.newTab(),
|
|
22
24
|
backgroundColor: { kind: "color", label: "Background color", inlineEditable: false },
|
|
23
25
|
textColor: { kind: "color", label: "Text color", inlineEditable: false },
|
|
24
26
|
},
|
package/dist/blocks/card-grid.js
CHANGED
|
@@ -14,6 +14,7 @@ registerBlock("CardGrid", {
|
|
|
14
14
|
description: z.string().min(1),
|
|
15
15
|
ctaText: z.string().min(1),
|
|
16
16
|
ctaHref: z.string().min(1),
|
|
17
|
+
ctaNewTab: z.boolean().optional(),
|
|
17
18
|
imageUrl: z.string().min(1).optional(),
|
|
18
19
|
imageAlt: z.string().min(1).optional()
|
|
19
20
|
}))
|
|
@@ -37,7 +38,8 @@ registerBlock("CardGrid", {
|
|
|
37
38
|
title: f.text("Card title"),
|
|
38
39
|
description: f.longtext("Card description"),
|
|
39
40
|
ctaText: f.text("Button text"),
|
|
40
|
-
ctaHref: f.
|
|
41
|
+
ctaHref: f.link("Button link"),
|
|
42
|
+
ctaNewTab: f.newTab(),
|
|
41
43
|
imageUrl: f.image("Card image", { aspectRatio: "landscape", width: 768, height: 512 }),
|
|
42
44
|
imageAlt: f.imageAlt("Card image alt text"),
|
|
43
45
|
}
|
package/dist/blocks/card.js
CHANGED
|
@@ -7,6 +7,7 @@ registerBlock("Card", {
|
|
|
7
7
|
description: z.string().min(1),
|
|
8
8
|
ctaText: z.string().min(1),
|
|
9
9
|
ctaHref: z.string().min(1),
|
|
10
|
+
ctaNewTab: z.boolean().optional(),
|
|
10
11
|
imageUrl: z.string().min(1).optional(),
|
|
11
12
|
imageAlt: z.string().min(1).optional(),
|
|
12
13
|
variant: z.enum(["default", "full-bleed"]).default("default").catch("default"),
|
|
@@ -19,7 +20,8 @@ registerBlock("Card", {
|
|
|
19
20
|
title: f.text("Card title"),
|
|
20
21
|
description: f.longtext("Card description"),
|
|
21
22
|
ctaText: f.text("Button text"),
|
|
22
|
-
ctaHref: f.
|
|
23
|
+
ctaHref: f.link("Button link"),
|
|
24
|
+
ctaNewTab: f.newTab(),
|
|
23
25
|
imageUrl: f.image("Card image", { aspectRatio: "landscape", width: 768, height: 512 }),
|
|
24
26
|
imageAlt: f.imageAlt("Card image alt text"),
|
|
25
27
|
variant: { kind: "enum", label: "Variant", options: ["default", "full-bleed"], inlineEditable: false },
|
package/dist/blocks/carousel.js
CHANGED
|
@@ -11,6 +11,7 @@ registerBlock("Carousel", {
|
|
|
11
11
|
description: z.string().optional(),
|
|
12
12
|
ctaText: z.string().optional(),
|
|
13
13
|
ctaHref: z.string().optional(),
|
|
14
|
+
ctaNewTab: z.boolean().optional(),
|
|
14
15
|
})).min(1),
|
|
15
16
|
autoplay: z.enum(["true", "false"]).default("false").catch("false"),
|
|
16
17
|
interval: z.number().optional(),
|
|
@@ -32,7 +33,8 @@ registerBlock("Carousel", {
|
|
|
32
33
|
heading: f.text("Heading"),
|
|
33
34
|
description: f.longtext("Description"),
|
|
34
35
|
ctaText: f.text("Button label"),
|
|
35
|
-
ctaHref: f.
|
|
36
|
+
ctaHref: f.link("Button link"),
|
|
37
|
+
ctaNewTab: f.newTab(),
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
}
|
package/dist/blocks/cta.js
CHANGED
|
@@ -7,8 +7,10 @@ registerBlock("CTA", {
|
|
|
7
7
|
description: z.string().min(1),
|
|
8
8
|
ctaText: z.string().min(1),
|
|
9
9
|
ctaHref: z.string().min(1),
|
|
10
|
+
ctaNewTab: z.boolean().optional(),
|
|
10
11
|
secondaryCtaText: z.string().optional(),
|
|
11
|
-
secondaryCtaHref: z.string().optional()
|
|
12
|
+
secondaryCtaHref: z.string().optional(),
|
|
13
|
+
secondaryCtaNewTab: z.boolean().optional()
|
|
12
14
|
}),
|
|
13
15
|
meta: {
|
|
14
16
|
displayName: "Call to Action",
|
|
@@ -18,9 +20,11 @@ registerBlock("CTA", {
|
|
|
18
20
|
title: f.text("Headline"),
|
|
19
21
|
description: f.longtext("Description"),
|
|
20
22
|
ctaText: f.text("Button text"),
|
|
21
|
-
ctaHref: f.
|
|
23
|
+
ctaHref: f.link("Button link"),
|
|
24
|
+
ctaNewTab: f.newTab(),
|
|
22
25
|
secondaryCtaText: f.text("Secondary button text"),
|
|
23
|
-
secondaryCtaHref: f.
|
|
26
|
+
secondaryCtaHref: f.link("Secondary button link"),
|
|
27
|
+
secondaryCtaNewTab: f.newTab(),
|
|
24
28
|
headingLevel: f.headingLevel(),
|
|
25
29
|
}
|
|
26
30
|
}
|
package/dist/blocks/hero.js
CHANGED
|
@@ -7,13 +7,15 @@ registerBlock("Hero", {
|
|
|
7
7
|
subheading: z.string().min(1),
|
|
8
8
|
ctaText: z.string().min(1),
|
|
9
9
|
ctaHref: z.string().min(1),
|
|
10
|
+
ctaNewTab: z.boolean().optional(),
|
|
10
11
|
imageUrl: z.string().min(1),
|
|
11
12
|
imageAlt: z.string().min(1),
|
|
12
13
|
imagePosition: z.enum(["left", "right", "full"]).default("right").catch("right"),
|
|
13
14
|
textAlign: z.enum(["left", "center"]).default("left").catch("left"),
|
|
14
15
|
eyebrow: z.string().optional(),
|
|
15
16
|
secondaryCtaText: z.string().optional(),
|
|
16
|
-
secondaryCtaHref: z.string().optional()
|
|
17
|
+
secondaryCtaHref: z.string().optional(),
|
|
18
|
+
secondaryCtaNewTab: z.boolean().optional()
|
|
17
19
|
}),
|
|
18
20
|
meta: {
|
|
19
21
|
displayName: "Hero",
|
|
@@ -23,14 +25,16 @@ registerBlock("Hero", {
|
|
|
23
25
|
heading: f.text("Heading"),
|
|
24
26
|
subheading: f.text("Subheading"),
|
|
25
27
|
ctaText: f.text("CTA button text"),
|
|
26
|
-
ctaHref: f.
|
|
28
|
+
ctaHref: f.link("CTA link"),
|
|
29
|
+
ctaNewTab: f.newTab(),
|
|
27
30
|
imageUrl: f.image("Hero image", { aspectRatio: "landscape", width: 1536, height: 1024 }),
|
|
28
31
|
imageAlt: f.imageAlt("Hero image alt text"),
|
|
29
32
|
imagePosition: { kind: "enum", label: "Image position", options: ["left", "right", "full"], inlineEditable: false },
|
|
30
33
|
textAlign: { kind: "enum", label: "Text alignment", options: ["left", "center"], inlineEditable: false },
|
|
31
34
|
eyebrow: f.text("Eyebrow text"),
|
|
32
35
|
secondaryCtaText: f.text("Secondary CTA text"),
|
|
33
|
-
secondaryCtaHref: f.
|
|
36
|
+
secondaryCtaHref: f.link("Secondary CTA link"),
|
|
37
|
+
secondaryCtaNewTab: f.newTab(),
|
|
34
38
|
headingLevel: f.headingLevel(),
|
|
35
39
|
}
|
|
36
40
|
}
|
|
@@ -5,6 +5,7 @@ const navLinkLeaf = z.object({
|
|
|
5
5
|
id: z.string().optional(),
|
|
6
6
|
label: z.string().min(1),
|
|
7
7
|
href: z.string().min(1),
|
|
8
|
+
newTab: z.boolean().optional(),
|
|
8
9
|
});
|
|
9
10
|
registerBlock("SiteHeader", {
|
|
10
11
|
schema: z.object({
|
|
@@ -29,7 +30,8 @@ registerBlock("SiteHeader", {
|
|
|
29
30
|
label: "Nav links",
|
|
30
31
|
itemFields: {
|
|
31
32
|
label: f.text("Link label"),
|
|
32
|
-
href: f.
|
|
33
|
+
href: f.link("Link URL"),
|
|
34
|
+
newTab: f.newTab(),
|
|
33
35
|
},
|
|
34
36
|
},
|
|
35
37
|
},
|
package/dist/blocks/table.js
CHANGED
|
@@ -16,8 +16,11 @@ registerBlock("Table", {
|
|
|
16
16
|
fields: {
|
|
17
17
|
title: f.text("Section title"),
|
|
18
18
|
headingLevel: f.headingLevel(),
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Both are lists of scalars, which `FieldKind` cannot name — the panel
|
|
20
|
+
// recognises the value's shape instead: `headers` edits one per line,
|
|
21
|
+
// `rows` (string[][]) is shown there and edited cell-by-cell in the page.
|
|
22
|
+
headers: f.text("Column headers (one per line)"),
|
|
23
|
+
rows: f.text("Table rows (edit cells in the page)"),
|
|
21
24
|
striped: { kind: "enum", label: "Striped rows", options: ["true", "false"], inlineEditable: false },
|
|
22
25
|
},
|
|
23
26
|
}
|
|
@@ -10,6 +10,7 @@ const twoColumnChild = z.object({
|
|
|
10
10
|
text: z.string().optional(),
|
|
11
11
|
label: z.string().optional(),
|
|
12
12
|
href: z.string().optional(),
|
|
13
|
+
newTab: z.boolean().optional(),
|
|
13
14
|
src: z.string().optional(),
|
|
14
15
|
alt: z.string().optional(),
|
|
15
16
|
poster: z.string().optional(),
|
|
@@ -21,7 +22,8 @@ const twoColumnItemFields = {
|
|
|
21
22
|
type: { kind: "enum", label: "Component type", options: ["heading", "subheading", "paragraph", "list", "cta", "ctas", "image", "video"] },
|
|
22
23
|
text: f.longtext("Text content"),
|
|
23
24
|
label: f.text("Button label"),
|
|
24
|
-
href: f.
|
|
25
|
+
href: f.link("Link URL"),
|
|
26
|
+
newTab: f.newTab(),
|
|
25
27
|
src: f.image("Media source", { aspectRatio: "portrait", width: 768, height: 1024 }),
|
|
26
28
|
alt: f.imageAlt("Alt text"),
|
|
27
29
|
poster: f.image("Video poster image", { aspectRatio: "landscape", width: 768, height: 512 }),
|
package/dist/editable-path.d.ts
CHANGED
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
export declare function isImagePath(editablePath: string): boolean;
|
|
8
8
|
/** Derives the companion alt-text path from an image path. Returns the path unchanged if it is not an image path. */
|
|
9
9
|
export declare function toAltPath(editablePath: string): string;
|
|
10
|
+
/** Returns true if the editable path points to an image's alt text. */
|
|
11
|
+
export declare function isAltPath(editablePath: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Derives the image path an alt-text path belongs to — the inverse of
|
|
14
|
+
* `toAltPath`. Returns the path unchanged if it is not an alt path.
|
|
15
|
+
*
|
|
16
|
+
* Alt text has no node of its own in a rendered page: it is an attribute on the
|
|
17
|
+
* image. Anything that wants to *show* somebody where an alt-text problem is
|
|
18
|
+
* has to point at the image instead, and this is the only step between the two.
|
|
19
|
+
*/
|
|
20
|
+
export declare function toImagePath(editablePath: string): string;
|
|
10
21
|
/**
|
|
11
22
|
* Return a copy of `props` with `value` written at `editablePath`, using
|
|
12
23
|
* copy-on-write so the input (and every untouched subtree) is shared by reference.
|
package/dist/editable-path.js
CHANGED
|
@@ -13,6 +13,23 @@ export function isImagePath(editablePath) {
|
|
|
13
13
|
export function toAltPath(editablePath) {
|
|
14
14
|
return editablePath.replace(/imageUrl$/i, "imageAlt").replace(/\.src$/i, ".alt");
|
|
15
15
|
}
|
|
16
|
+
/** Matches editable paths that point to an image's alt text. */
|
|
17
|
+
const ALT_PATH_RE = /(imageAlt|\.alt)$/i;
|
|
18
|
+
/** Returns true if the editable path points to an image's alt text. */
|
|
19
|
+
export function isAltPath(editablePath) {
|
|
20
|
+
return ALT_PATH_RE.test(editablePath);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Derives the image path an alt-text path belongs to — the inverse of
|
|
24
|
+
* `toAltPath`. Returns the path unchanged if it is not an alt path.
|
|
25
|
+
*
|
|
26
|
+
* Alt text has no node of its own in a rendered page: it is an attribute on the
|
|
27
|
+
* image. Anything that wants to *show* somebody where an alt-text problem is
|
|
28
|
+
* has to point at the image instead, and this is the only step between the two.
|
|
29
|
+
*/
|
|
30
|
+
export function toImagePath(editablePath) {
|
|
31
|
+
return editablePath.replace(/imageAlt$/i, "imageUrl").replace(/\.alt$/i, ".src");
|
|
32
|
+
}
|
|
16
33
|
/**
|
|
17
34
|
* Parse an editable-target path into a flat list of access steps.
|
|
18
35
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
export { EDITOR_PROTOCOL_VERSION } from "./protocol.ts";
|
|
2
2
|
export { getConfiguredDraftSecret, getSafeInternalRedirectPath, validateDraftSecret, type DraftSecretValidationResult } from "./draft-mode.ts";
|
|
3
3
|
export type { FieldDiffKind, FieldDiff, BlockDiffStatus, BlockDiff, PageDiffStatus, PageDiff, PublishDiff, SiteConfigFieldDiff, SiteConfigDiff, } from "./publish-diff.ts";
|
|
4
|
-
export { isImagePath, toAltPath, setPropAtPath } from "./editable-path.ts";
|
|
4
|
+
export { isImagePath, toAltPath, isAltPath, toImagePath, setPropAtPath } from "./editable-path.ts";
|
|
5
|
+
export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, type LinkKind, type ParsedLink, type ResolvedLink, type LinkPageOption, } from "./links.ts";
|
|
5
6
|
export { parseInline, parseRichText, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel, clampMarkdownHeadings, unescapeMarkdownText, isRichTextDoc, fromMarkdown, toMarkdown, mergeRichTextDoc, NODE, MARK, type InlineToken, type RichTextBlock, type RichTextList, type RichTextListItem, type RichTextDoc, type RichTextNode, type RichTextMark } from "@avocadostudio-ai/richtext";
|
|
6
7
|
export { blockDefinitionSchema, blockManifestSchema, buildBlockManifest, jsonSchemaLikeSchema, validateByJsonSchemaLike, findManifestSchemaIssue, type ManifestSchemaIssue, validateManifestDefaultProps, deriveFieldMetaFromSchema, resolveManifestFieldMeta, isProseMirrorDocSchema, type BlockDefinition, type BlockManifest } from "./block-manifest.ts";
|
|
7
8
|
export { z } from "zod";
|
|
8
|
-
export { type FieldKind, type ImageSpec, type FieldMeta, type ListFieldMeta, type BlockMeta, type BlockType, type BlockInstance, type BlockRegistration, IMAGE_PLACEHOLDER, isImagePlaceholder, registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, getImageFields, getListImageFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes, blockSchemas, allowedBlockTypes, declareBlockCatalogue, getBlockCatalogue, isInBlockCatalogue, catalogueBlockTypes, undeclaredBlockTypes, getPropDisplayName, defaultListItemForBlock, blockInstanceSchema, blockInstanceSchemaLenient, validateBlockProps, getBlockJsonSchema, } from "./blocks/_registry.ts";
|
|
9
|
+
export { type FieldKind, type ImageSpec, type FieldMeta, type ListFieldMeta, type BlockMeta, type BlockType, type BlockInstance, type BlockRegistration, IMAGE_PLACEHOLDER, isImagePlaceholder, registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes, blockSchemas, allowedBlockTypes, declareBlockCatalogue, getBlockCatalogue, isInBlockCatalogue, catalogueBlockTypes, undeclaredBlockTypes, getPropDisplayName, defaultListItemForBlock, blockInstanceSchema, blockInstanceSchemaLenient, validateBlockProps, getBlockJsonSchema, } from "./blocks/_registry.ts";
|
|
9
10
|
export { defaultPropsForType, declaredDefaultPropsForType, resolveHeadingTag, resolveItemHeadingTag, DEFAULT_HEADING_LEVELS, } from "./blocks/index.ts";
|
|
10
11
|
export { blockTypeToCamel, camelToBlockType, blockTypeToLower, lowerToBlockType, } from "./block-names.ts";
|
|
11
12
|
export { makeAddBlock, generateBlockId, makeAddItem, generateItemId, ensureItemIds, type AddBlockOp, type MakeAddBlockOptions, type AddItemOp, type MakeAddItemOptions, } from "./ops/builders.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { EDITOR_PROTOCOL_VERSION } from "./protocol.js";
|
|
2
2
|
export { getConfiguredDraftSecret, getSafeInternalRedirectPath, validateDraftSecret } from "./draft-mode.js";
|
|
3
|
-
export { isImagePath, toAltPath, setPropAtPath } from "./editable-path.js";
|
|
3
|
+
export { isImagePath, toAltPath, isAltPath, toImagePath, setPropAtPath } from "./editable-path.js";
|
|
4
|
+
export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, } from "./links.js";
|
|
4
5
|
/*
|
|
5
6
|
* The rich-text grammar lives in `@avocadostudio-ai/richtext`, which owns the
|
|
6
7
|
* parser and every CMS converter and has no dependencies of its own. It is
|
|
@@ -33,7 +34,7 @@ export {
|
|
|
33
34
|
// Constants & helpers
|
|
34
35
|
IMAGE_PLACEHOLDER, isImagePlaceholder,
|
|
35
36
|
// Registry functions
|
|
36
|
-
registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, getImageFields, getListImageFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes,
|
|
37
|
+
registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes,
|
|
37
38
|
// Backwards-compatible exports
|
|
38
39
|
blockSchemas, allowedBlockTypes,
|
|
39
40
|
// The catalogue a site actually renders — see `declareBlockCatalogue`
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a link field's string actually means.
|
|
3
|
+
*
|
|
4
|
+
* A link is stored as a plain string — `/pricing`, `https://example.com`,
|
|
5
|
+
* `mailto:hi@example.com` — and three places need to agree on how to read it:
|
|
6
|
+
* the editor's link control (which icon, which label, which warning), the
|
|
7
|
+
* `seo.internal-link-dead` check (is this route real?), and the rename rewriter
|
|
8
|
+
* (is this string a route at all?). Before this module each answered the
|
|
9
|
+
* question with its own inline `startsWith("/")`, and they did not agree: the
|
|
10
|
+
* rename rewriter matched on prop *names* containing "href", so a link stored
|
|
11
|
+
* under `url` or inside Footer's `label|url` text went stale on every rename.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here validates in the blocking sense. A path this module cannot
|
|
14
|
+
* resolve is still a legitimate link — on a CMS-backed site Avocado sees only
|
|
15
|
+
* the pages it was handed, and a route can exist in Next, in the CMS, or behind
|
|
16
|
+
* a rewrite and be invisible from here. `resolveLink` reports what it knows; it
|
|
17
|
+
* never decides what the editor may type.
|
|
18
|
+
*/
|
|
19
|
+
/** How a link string addresses its target. */
|
|
20
|
+
export type LinkKind = "empty" | "page" | "external" | "email" | "phone" | "anchor";
|
|
21
|
+
/** A link string, classified. */
|
|
22
|
+
export type ParsedLink = {
|
|
23
|
+
kind: LinkKind;
|
|
24
|
+
/** The original string, untouched. */
|
|
25
|
+
raw: string;
|
|
26
|
+
/**
|
|
27
|
+
* The route, for `kind: "page"` — path only, with `?query` and `#fragment`
|
|
28
|
+
* stripped. This is what `resolveLink` matches against the site's slugs.
|
|
29
|
+
*/
|
|
30
|
+
path?: string;
|
|
31
|
+
/** The `?a=b#c` tail of a page link, preserved so a rewrite can restore it. */
|
|
32
|
+
suffix?: string;
|
|
33
|
+
/** Host for `external`, address for `email`, number for `phone`, id for `anchor`. */
|
|
34
|
+
target?: string;
|
|
35
|
+
};
|
|
36
|
+
/** A page the editor can offer as a link target. */
|
|
37
|
+
export type LinkPageOption = {
|
|
38
|
+
slug: string;
|
|
39
|
+
/** The URL, when the site says it differs from the slug (locale prefixes). */
|
|
40
|
+
path?: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
};
|
|
43
|
+
/** Classify a link string. Never throws; anything unrecognised parses as `page`-ish text. */
|
|
44
|
+
export declare function parseLink(value: unknown): ParsedLink;
|
|
45
|
+
/**
|
|
46
|
+
* Give a route its leading slash and drop a trailing one.
|
|
47
|
+
*
|
|
48
|
+
* `pricing`, `/pricing`, `/pricing/` are the same page. The editor accepts all
|
|
49
|
+
* three and stores the middle one. The homepage keeps its lone slash.
|
|
50
|
+
*/
|
|
51
|
+
export declare function normalizeLinkPath(path: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Does this route exist among the site's known pages?
|
|
54
|
+
*
|
|
55
|
+
* Compares on the path alone — a link may legitimately carry a query or a
|
|
56
|
+
* fragment, and neither changes which page it points at — and accepts a
|
|
57
|
+
* trailing slash on either side. This is the matcher `seo.internal-link-dead`
|
|
58
|
+
* uses, extracted so the inline field warning and the checks panel cannot
|
|
59
|
+
* disagree about what "dead" means.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isKnownRoute(path: string, knownSlugs: Iterable<string>): boolean;
|
|
62
|
+
/** A parsed link plus what the site knows about its target. */
|
|
63
|
+
export type ResolvedLink = ParsedLink & {
|
|
64
|
+
/** The matching page, when `kind` is `page` and the route is known. */
|
|
65
|
+
page?: LinkPageOption;
|
|
66
|
+
/** True for a `page` link whose route matches nothing the site knows about. */
|
|
67
|
+
missing?: boolean;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Classify a link *and* look up its page. Pages may be keyed by `slug` or by
|
|
71
|
+
* `path` (they differ on locale-prefixed sites — see
|
|
72
|
+
* `docs/ideas/page-identity-punch-list.md`), so both are matched.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveLink(value: unknown, pages?: readonly LinkPageOption[]): ResolvedLink;
|
|
75
|
+
/**
|
|
76
|
+
* How well a page answers what was typed, from 0 (nothing in common) to 1.
|
|
77
|
+
*
|
|
78
|
+
* The balanced mean of precision and recall over shared words. Balanced because
|
|
79
|
+
* both halves are wrong alone: precision by itself ranks a sprawling page that
|
|
80
|
+
* happens to contain the word above the page actually named for it, and recall
|
|
81
|
+
* by itself ranks a one-word slug above every longer match.
|
|
82
|
+
*
|
|
83
|
+
* Pass the site's pages as `corpus` to weight words by how distinctive they are
|
|
84
|
+
* (see `tokenWeights`); without it every word counts the same.
|
|
85
|
+
*/
|
|
86
|
+
export declare function scoreLinkCandidate(query: string, page: LinkPageOption, corpus?: readonly LinkPageOption[]): number;
|
|
87
|
+
/** Known pages ranked by how well they answer `query`, best first. */
|
|
88
|
+
export declare function rankLinkTargets(query: string, pages: readonly LinkPageOption[]): LinkPageOption[];
|
|
89
|
+
/**
|
|
90
|
+
* The page a dead internal link probably meant.
|
|
91
|
+
*
|
|
92
|
+
* `/avocado-sustainability` on a site that has `/sustainability` is a typo, a
|
|
93
|
+
* stale link, or a slug someone renamed by hand — and the site already holds
|
|
94
|
+
* everything needed to say so. Returns undefined when nothing scores well
|
|
95
|
+
* enough, or when the link already resolves; the caller offers what comes back,
|
|
96
|
+
* and never applies it on the editor's behalf.
|
|
97
|
+
*/
|
|
98
|
+
export declare function suggestLinkTarget(value: unknown, pages: readonly LinkPageOption[]): LinkPageOption | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* The absolute URL a viewer would copy out of the address bar, turned back into
|
|
101
|
+
* an internal route — when it belongs to this site.
|
|
102
|
+
*
|
|
103
|
+
* Pasting the address bar is how people actually get a URL, and an absolute one
|
|
104
|
+
* survives right up until the site moves domain. Returns undefined when the
|
|
105
|
+
* origin is someone else's, so the caller leaves the link alone.
|
|
106
|
+
*/
|
|
107
|
+
export declare function internalPathForUrl(value: string, siteOrigin: string | undefined): string | undefined;
|
|
108
|
+
/**
|
|
109
|
+
* The prop holding the "open in new tab" flag for a link prop, by convention.
|
|
110
|
+
*
|
|
111
|
+
* `ctaHref` → `ctaNewTab`, `secondaryCtaHref` → `secondaryCtaNewTab`,
|
|
112
|
+
* `href` → `newTab`. The same shape as the `image`/`imageAlt` pairing the
|
|
113
|
+
* property panel already resolves by name, so a block declares two props and
|
|
114
|
+
* the editor renders one control.
|
|
115
|
+
*/
|
|
116
|
+
export declare function newTabKeyFor(linkKey: string): string;
|
|
117
|
+
/** Anchor attributes for a link, given its stored value and new-tab flag. */
|
|
118
|
+
export declare function linkAttrs(href: unknown, newTab?: unknown): {
|
|
119
|
+
href: string;
|
|
120
|
+
target?: string;
|
|
121
|
+
rel?: string;
|
|
122
|
+
};
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a link field's string actually means.
|
|
3
|
+
*
|
|
4
|
+
* A link is stored as a plain string — `/pricing`, `https://example.com`,
|
|
5
|
+
* `mailto:hi@example.com` — and three places need to agree on how to read it:
|
|
6
|
+
* the editor's link control (which icon, which label, which warning), the
|
|
7
|
+
* `seo.internal-link-dead` check (is this route real?), and the rename rewriter
|
|
8
|
+
* (is this string a route at all?). Before this module each answered the
|
|
9
|
+
* question with its own inline `startsWith("/")`, and they did not agree: the
|
|
10
|
+
* rename rewriter matched on prop *names* containing "href", so a link stored
|
|
11
|
+
* under `url` or inside Footer's `label|url` text went stale on every rename.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here validates in the blocking sense. A path this module cannot
|
|
14
|
+
* resolve is still a legitimate link — on a CMS-backed site Avocado sees only
|
|
15
|
+
* the pages it was handed, and a route can exist in Next, in the CMS, or behind
|
|
16
|
+
* a rewrite and be invisible from here. `resolveLink` reports what it knows; it
|
|
17
|
+
* never decides what the editor may type.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* A bare "/" is the default value every link field is born with
|
|
21
|
+
* (`defaultScalarForField`), so treating it as a real link to the homepage
|
|
22
|
+
* would make every freshly added card claim to link somewhere.
|
|
23
|
+
*/
|
|
24
|
+
function isEmptyValue(value) {
|
|
25
|
+
return value === "" || value === "/";
|
|
26
|
+
}
|
|
27
|
+
/** Classify a link string. Never throws; anything unrecognised parses as `page`-ish text. */
|
|
28
|
+
export function parseLink(value) {
|
|
29
|
+
const raw = typeof value === "string" ? value : "";
|
|
30
|
+
const trimmed = raw.trim();
|
|
31
|
+
if (isEmptyValue(trimmed))
|
|
32
|
+
return { kind: "empty", raw };
|
|
33
|
+
if (/^mailto:/i.test(trimmed)) {
|
|
34
|
+
return { kind: "email", raw, target: trimmed.replace(/^mailto:/i, "") };
|
|
35
|
+
}
|
|
36
|
+
if (/^tel:/i.test(trimmed)) {
|
|
37
|
+
return { kind: "phone", raw, target: trimmed.replace(/^tel:/i, "") };
|
|
38
|
+
}
|
|
39
|
+
if (trimmed.startsWith("#")) {
|
|
40
|
+
return { kind: "anchor", raw, target: trimmed.slice(1) };
|
|
41
|
+
}
|
|
42
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
43
|
+
let target = trimmed;
|
|
44
|
+
try {
|
|
45
|
+
const u = new URL(trimmed);
|
|
46
|
+
target = u.host + (u.pathname === "/" ? "" : u.pathname);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* keep the raw string — a half-typed URL is still an external link */
|
|
50
|
+
}
|
|
51
|
+
return { kind: "external", raw, target };
|
|
52
|
+
}
|
|
53
|
+
/*
|
|
54
|
+
* A protocol-relative `//host/path` is external, not a route. Without this it
|
|
55
|
+
* would parse as a path and be reported dead against every site's slug list —
|
|
56
|
+
* and `draft-mode.ts` already rejects it for the same reason on the way in.
|
|
57
|
+
*/
|
|
58
|
+
if (trimmed.startsWith("//"))
|
|
59
|
+
return { kind: "external", raw, target: trimmed.slice(2) };
|
|
60
|
+
/*
|
|
61
|
+
* Anything else is a route. Not only strings starting with "/": an editor who
|
|
62
|
+
* types "pricing" means the pricing page, and `normalizeLinkPath` is what
|
|
63
|
+
* turns that into one. Treating it as a page is also what lets the picker
|
|
64
|
+
* offer matches while you type.
|
|
65
|
+
*/
|
|
66
|
+
const [pathPart = "", ...rest] = trimmed.split(/(?=[?#])/);
|
|
67
|
+
return { kind: "page", raw, path: pathPart, suffix: rest.join("") || undefined };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Give a route its leading slash and drop a trailing one.
|
|
71
|
+
*
|
|
72
|
+
* `pricing`, `/pricing`, `/pricing/` are the same page. The editor accepts all
|
|
73
|
+
* three and stores the middle one. The homepage keeps its lone slash.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeLinkPath(path) {
|
|
76
|
+
const trimmed = path.trim();
|
|
77
|
+
if (trimmed === "" || trimmed === "/")
|
|
78
|
+
return "/";
|
|
79
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
80
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Does this route exist among the site's known pages?
|
|
84
|
+
*
|
|
85
|
+
* Compares on the path alone — a link may legitimately carry a query or a
|
|
86
|
+
* fragment, and neither changes which page it points at — and accepts a
|
|
87
|
+
* trailing slash on either side. This is the matcher `seo.internal-link-dead`
|
|
88
|
+
* uses, extracted so the inline field warning and the checks panel cannot
|
|
89
|
+
* disagree about what "dead" means.
|
|
90
|
+
*/
|
|
91
|
+
export function isKnownRoute(path, knownSlugs) {
|
|
92
|
+
const known = knownSlugs instanceof Set ? knownSlugs : new Set(knownSlugs);
|
|
93
|
+
const target = path.split(/[?#]/)[0] ?? "";
|
|
94
|
+
const normalized = normalizeLinkPath(target);
|
|
95
|
+
return known.has(target) || known.has(normalized) || known.has(`${normalized}/`);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Classify a link *and* look up its page. Pages may be keyed by `slug` or by
|
|
99
|
+
* `path` (they differ on locale-prefixed sites — see
|
|
100
|
+
* `docs/ideas/page-identity-punch-list.md`), so both are matched.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveLink(value, pages = []) {
|
|
103
|
+
const parsed = parseLink(value);
|
|
104
|
+
if (parsed.kind !== "page")
|
|
105
|
+
return parsed;
|
|
106
|
+
const wanted = normalizeLinkPath(parsed.path ?? "");
|
|
107
|
+
const page = pages.find((p) => normalizeLinkPath(p.slug) === wanted || (p.path != null && normalizeLinkPath(p.path) === wanted));
|
|
108
|
+
if (page)
|
|
109
|
+
return { ...parsed, page };
|
|
110
|
+
return { ...parsed, missing: true };
|
|
111
|
+
}
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Suggesting a page
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
/** Words in a slug, a path or a title, lowercased and punctuation-free. */
|
|
116
|
+
function tokenize(value) {
|
|
117
|
+
return value.toLowerCase().split(/[^a-z0-9]+/i).filter((t) => t.length > 0);
|
|
118
|
+
}
|
|
119
|
+
/** Everything a page can be recognised by: its slug, its URL and its title. */
|
|
120
|
+
function pageTokens(page) {
|
|
121
|
+
return [...new Set([...tokenize(page.slug), ...tokenize(page.path ?? ""), ...tokenize(page.title ?? "")])];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* How much each word says about *which* page is meant.
|
|
125
|
+
*
|
|
126
|
+
* A word every page carries — a locale prefix, the company name — identifies
|
|
127
|
+
* nothing, and counting it equally is what makes a naive matcher propose
|
|
128
|
+
* `/de/impressum` for `/de/veranstaltungen`: the two share only the "de". This
|
|
129
|
+
* is inverse document frequency over the site's own pages, smoothed so a word
|
|
130
|
+
* on no page at all still has a finite weight.
|
|
131
|
+
*/
|
|
132
|
+
function tokenWeights(pages) {
|
|
133
|
+
const total = pages.length;
|
|
134
|
+
const seen = new Map();
|
|
135
|
+
for (const page of pages) {
|
|
136
|
+
for (const token of pageTokens(page))
|
|
137
|
+
seen.set(token, (seen.get(token) ?? 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
const weights = new Map();
|
|
140
|
+
for (const [token, count] of seen)
|
|
141
|
+
weights.set(token, Math.log(1 + total / (count + 1)));
|
|
142
|
+
return weights;
|
|
143
|
+
}
|
|
144
|
+
function weightOf(token, weights, total) {
|
|
145
|
+
if (!weights)
|
|
146
|
+
return 1;
|
|
147
|
+
return weights.get(token) ?? Math.log(1 + total);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* How well a page answers what was typed, from 0 (nothing in common) to 1.
|
|
151
|
+
*
|
|
152
|
+
* The balanced mean of precision and recall over shared words. Balanced because
|
|
153
|
+
* both halves are wrong alone: precision by itself ranks a sprawling page that
|
|
154
|
+
* happens to contain the word above the page actually named for it, and recall
|
|
155
|
+
* by itself ranks a one-word slug above every longer match.
|
|
156
|
+
*
|
|
157
|
+
* Pass the site's pages as `corpus` to weight words by how distinctive they are
|
|
158
|
+
* (see `tokenWeights`); without it every word counts the same.
|
|
159
|
+
*/
|
|
160
|
+
export function scoreLinkCandidate(query, page, corpus) {
|
|
161
|
+
const wanted = [...new Set(tokenize(query))];
|
|
162
|
+
if (wanted.length === 0)
|
|
163
|
+
return 0;
|
|
164
|
+
const have = pageTokens(page);
|
|
165
|
+
if (have.length === 0)
|
|
166
|
+
return 0;
|
|
167
|
+
const weights = corpus ? tokenWeights(corpus) : undefined;
|
|
168
|
+
const total = corpus?.length ?? 0;
|
|
169
|
+
const w = (token) => weightOf(token, weights, total);
|
|
170
|
+
const sum = (tokens) => tokens.reduce((acc, t) => acc + w(t), 0);
|
|
171
|
+
const shared = wanted.filter((t) => have.includes(t));
|
|
172
|
+
if (shared.length === 0)
|
|
173
|
+
return 0;
|
|
174
|
+
const overlap = sum(shared);
|
|
175
|
+
const precision = overlap / sum(wanted);
|
|
176
|
+
const recall = overlap / sum(have);
|
|
177
|
+
return (2 * precision * recall) / (precision + recall);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Below this a "did you mean" is noise, and a wrong suggestion is worse than
|
|
181
|
+
* none — the editor has to stop and rule it out.
|
|
182
|
+
*/
|
|
183
|
+
const SUGGESTION_MIN_SCORE = 0.45;
|
|
184
|
+
/** Known pages ranked by how well they answer `query`, best first. */
|
|
185
|
+
export function rankLinkTargets(query, pages) {
|
|
186
|
+
return pages
|
|
187
|
+
.map((page) => ({ page, score: scoreLinkCandidate(query, page, pages) }))
|
|
188
|
+
.filter((entry) => entry.score > 0)
|
|
189
|
+
.sort((a, b) => b.score - a.score)
|
|
190
|
+
.map((entry) => entry.page);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The page a dead internal link probably meant.
|
|
194
|
+
*
|
|
195
|
+
* `/avocado-sustainability` on a site that has `/sustainability` is a typo, a
|
|
196
|
+
* stale link, or a slug someone renamed by hand — and the site already holds
|
|
197
|
+
* everything needed to say so. Returns undefined when nothing scores well
|
|
198
|
+
* enough, or when the link already resolves; the caller offers what comes back,
|
|
199
|
+
* and never applies it on the editor's behalf.
|
|
200
|
+
*/
|
|
201
|
+
export function suggestLinkTarget(value, pages) {
|
|
202
|
+
const resolved = resolveLink(value, pages);
|
|
203
|
+
if (resolved.kind !== "page" || !resolved.missing)
|
|
204
|
+
return undefined;
|
|
205
|
+
const best = pages
|
|
206
|
+
.map((page) => ({ page, score: scoreLinkCandidate(resolved.path ?? "", page, pages) }))
|
|
207
|
+
.sort((a, b) => b.score - a.score)[0];
|
|
208
|
+
return best && best.score >= SUGGESTION_MIN_SCORE ? best.page : undefined;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The absolute URL a viewer would copy out of the address bar, turned back into
|
|
212
|
+
* an internal route — when it belongs to this site.
|
|
213
|
+
*
|
|
214
|
+
* Pasting the address bar is how people actually get a URL, and an absolute one
|
|
215
|
+
* survives right up until the site moves domain. Returns undefined when the
|
|
216
|
+
* origin is someone else's, so the caller leaves the link alone.
|
|
217
|
+
*/
|
|
218
|
+
export function internalPathForUrl(value, siteOrigin) {
|
|
219
|
+
if (!siteOrigin)
|
|
220
|
+
return undefined;
|
|
221
|
+
let url;
|
|
222
|
+
let origin;
|
|
223
|
+
try {
|
|
224
|
+
url = new URL(value);
|
|
225
|
+
origin = new URL(siteOrigin);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
if (url.host !== origin.host)
|
|
231
|
+
return undefined;
|
|
232
|
+
return normalizeLinkPath(url.pathname) + url.search + url.hash;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The prop holding the "open in new tab" flag for a link prop, by convention.
|
|
236
|
+
*
|
|
237
|
+
* `ctaHref` → `ctaNewTab`, `secondaryCtaHref` → `secondaryCtaNewTab`,
|
|
238
|
+
* `href` → `newTab`. The same shape as the `image`/`imageAlt` pairing the
|
|
239
|
+
* property panel already resolves by name, so a block declares two props and
|
|
240
|
+
* the editor renders one control.
|
|
241
|
+
*/
|
|
242
|
+
export function newTabKeyFor(linkKey) {
|
|
243
|
+
const base = linkKey.replace(/href$/i, "");
|
|
244
|
+
return base ? `${base}NewTab` : "newTab";
|
|
245
|
+
}
|
|
246
|
+
/** Anchor attributes for a link, given its stored value and new-tab flag. */
|
|
247
|
+
export function linkAttrs(href, newTab) {
|
|
248
|
+
const value = typeof href === "string" && href.length > 0 ? href : "#";
|
|
249
|
+
/*
|
|
250
|
+
* `noopener` is not decoration. A `_blank` link without it hands the opened
|
|
251
|
+
* page a live `window.opener` handle on this one, which is a real
|
|
252
|
+
* cross-origin hazard and the reason the pair always ships together.
|
|
253
|
+
*/
|
|
254
|
+
if (newTab === true && parseLink(value).kind === "external") {
|
|
255
|
+
return { href: value, target: "_blank", rel: "noopener noreferrer" };
|
|
256
|
+
}
|
|
257
|
+
return { href: value };
|
|
258
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/shared",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"zod": "^4.3.6",
|
|
22
|
-
"@avocadostudio-ai/richtext": "^0.3.
|
|
22
|
+
"@avocadostudio-ai/richtext": "^0.3.3"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"tsx": "^4.21.0",
|