@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.
@@ -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>>;
@@ -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(),
@@ -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";
@@ -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;
@@ -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
- /** Semantic kind for a block prop field. */
5
- export type FieldKind = "text" | "richtext" | "url" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel";
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. */
@@ -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}`;
@@ -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.url("Button link"),
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
  },
@@ -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.url("Button link"),
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
  }
@@ -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.url("Button link"),
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 },
@@ -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.url("Button link"),
36
+ ctaHref: f.link("Button link"),
37
+ ctaNewTab: f.newTab(),
36
38
  }
37
39
  }
38
40
  }
@@ -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.url("Button link"),
23
+ ctaHref: f.link("Button link"),
24
+ ctaNewTab: f.newTab(),
22
25
  secondaryCtaText: f.text("Secondary button text"),
23
- secondaryCtaHref: f.url("Secondary button link"),
26
+ secondaryCtaHref: f.link("Secondary button link"),
27
+ secondaryCtaNewTab: f.newTab(),
24
28
  headingLevel: f.headingLevel(),
25
29
  }
26
30
  }
@@ -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.url("CTA link"),
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.url("Secondary CTA link"),
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.url("Link URL"),
33
+ href: f.link("Link URL"),
34
+ newTab: f.newTab(),
33
35
  },
34
36
  },
35
37
  },
@@ -16,8 +16,11 @@ registerBlock("Table", {
16
16
  fields: {
17
17
  title: f.text("Section title"),
18
18
  headingLevel: f.headingLevel(),
19
- headers: f.text("Column headers"),
20
- rows: f.text("Table rows"),
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.url("Link URL"),
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 }),
@@ -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.
@@ -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`
@@ -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.1",
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.1"
22
+ "@avocadostudio-ai/richtext": "^0.3.3"
23
23
  },
24
24
  "devDependencies": {
25
25
  "tsx": "^4.21.0",