@cancia/astro 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ isSafeHref,
2
3
  portableTextSubsetSchema
3
4
  } from "./chunk-BOIQNZAO.js";
4
5
 
@@ -56,6 +57,30 @@ var defineField = {
56
57
  * related: f.array(f.reference({ list: "posts" }), { label: "Related" }),
57
58
  */
58
59
  reference: (o) => z.string().meta({ widget: "reference", ...o }),
60
+ /**
61
+ * A link: label + href as ONE unit.
62
+ *
63
+ * A button/CTA is two values that must stay together — saving a new label
64
+ * against a stale href produces a broken call-to-action, so they are stored
65
+ * as a single object rather than two flat keys that could drift.
66
+ *
67
+ * cta: f.link({ label: "Primary CTA" }),
68
+ *
69
+ * `href` is NOT z.url(): a relative href ("/start", "#programmes") and a
70
+ * mailto:/tel: are all legitimate and z.url() rejects the relative forms.
71
+ * It IS constrained by `isSafeHref` — the SAME guard richtext link marks
72
+ * use — so a stored value can never carry a javascript:/data: XSS payload.
73
+ * Without this, the link widget would be a hole around a protection
74
+ * richtext already enforces.
75
+ *
76
+ * Whether to open in a new tab is derived from the href at render time
77
+ * (see `isExternalHref`) instead of being stored — one less thing that can
78
+ * fall out of sync with the URL it describes.
79
+ */
80
+ link: (o) => z.object({
81
+ label: z.string(),
82
+ href: z.string().refine(isSafeHref, "unsafe or unsupported URL scheme")
83
+ }).meta({ widget: "link", ...o }),
59
84
  /**
60
85
  * A constrained rich-text body. Stored as a Portable-Text SUBSET array (D4):
61
86
  * block styles normal/h2/h3/blockquote, bullet/number lists, strong/em marks,
@@ -145,11 +170,43 @@ function describeList(name, schema) {
145
170
  fields
146
171
  };
147
172
  }
173
+ function parseLinkValue(raw) {
174
+ const safe = (href) => isSafeHref(href) ? href : "";
175
+ if (raw && typeof raw === "object") {
176
+ const o = raw;
177
+ return { label: String(o.label ?? ""), href: safe(String(o.href ?? "")) };
178
+ }
179
+ if (typeof raw !== "string" || raw === "") return { label: "", href: "" };
180
+ const trimmed = raw.trim();
181
+ if (trimmed.startsWith("{")) {
182
+ try {
183
+ const parsed = JSON.parse(trimmed);
184
+ if (parsed && typeof parsed === "object") {
185
+ return {
186
+ label: String(parsed.label ?? ""),
187
+ href: safe(String(parsed.href ?? ""))
188
+ };
189
+ }
190
+ } catch {
191
+ }
192
+ }
193
+ return { label: raw, href: "" };
194
+ }
195
+ function serializeLinkValue(value) {
196
+ return JSON.stringify({ label: value.label ?? "", href: value.href ?? "" });
197
+ }
198
+ function isExternalHref(href) {
199
+ if (!href) return false;
200
+ return /^https?:\/\//i.test(href);
201
+ }
148
202
 
149
203
  export {
150
204
  z,
151
205
  defineList,
152
206
  defineField,
153
207
  slugify,
154
- describeList
208
+ describeList,
209
+ parseLinkValue,
210
+ serializeLinkValue,
211
+ isExternalHref
155
212
  };
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-NG5GJME5.js";
4
4
  import {
5
5
  describeList
6
- } from "./chunk-MCHQV6Y7.js";
6
+ } from "./chunk-UMCBQXB6.js";
7
7
 
8
8
  // src/routes/schemas.ts
9
9
  function json(body, status = 200) {
package/dist/content.d.ts CHANGED
@@ -2,6 +2,13 @@
2
2
  type ContentMap = Record<string, string>;
3
3
  /** A translator: `(key, fallback) => cms[`${key}.${lang}`] ?? fallback`. */
4
4
  type Translate = (key: string, fallback: string) => string;
5
+ /** The value behind a `link` field: a label and where it points. */
6
+ interface LinkValue {
7
+ label: string;
8
+ href: string;
9
+ }
10
+ /** Resolve a link field, falling back to the values authored in the page. */
11
+ type TranslateLink = (key: string, fallback: LinkValue) => LinkValue;
5
12
  interface ContentReaderOptions {
6
13
  /** Site id (the `?site=` query param and the storage `site` column). */
7
14
  site: string;
@@ -44,9 +51,16 @@ interface ContentReader {
44
51
  getCMS(): Promise<ContentMap>;
45
52
  /** Build a translator over a content map for the reader's `lang`. */
46
53
  makeT(cms: ContentMap): Translate;
47
- /** Sugar: `const { t, cms } = await reader.get()` in a page's frontmatter. */
54
+ /**
55
+ * Build a link resolver over a content map. A link field stores
56
+ * `{label, href}` as JSON; this parses it and falls back to the values
57
+ * authored in the page. Never throws; drops an unsafe href.
58
+ */
59
+ makeTLink(cms: ContentMap): TranslateLink;
60
+ /** Sugar: `const { t, tLink, cms } = await reader.get()` in frontmatter. */
48
61
  get(): Promise<{
49
62
  t: Translate;
63
+ tLink: TranslateLink;
50
64
  cms: ContentMap;
51
65
  }>;
52
66
  }
@@ -57,4 +71,4 @@ interface ContentReader {
57
71
  */
58
72
  declare function createContentReader(opts: ContentReaderOptions): ContentReader;
59
73
 
60
- export { type ContentMap, type ContentReader, type ContentReaderOptions, type Translate, createContentReader };
74
+ export { type ContentMap, type ContentReader, type ContentReaderOptions, type LinkValue, type Translate, type TranslateLink, createContentReader };
package/dist/content.js CHANGED
@@ -8,6 +8,35 @@ import "./chunk-L2VKQJPY.js";
8
8
  import "./chunk-7IA5B5CF.js";
9
9
 
10
10
  // src/content.ts
11
+ function isSafeHref(href) {
12
+ const trimmed = href.trim();
13
+ if (trimmed === "") return false;
14
+ if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return true;
15
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
16
+ if (schemeMatch) {
17
+ const firstSep = trimmed.search(/[/?#]/);
18
+ if (firstSep === -1 || schemeMatch[1].length < firstSep) return false;
19
+ }
20
+ return true;
21
+ }
22
+ function parseLink(raw, fallback) {
23
+ if (!raw) return fallback;
24
+ const trimmed = raw.trim();
25
+ if (trimmed.startsWith("{")) {
26
+ try {
27
+ const p = JSON.parse(trimmed);
28
+ if (p && typeof p === "object") {
29
+ const href = String(p.href ?? "");
30
+ return {
31
+ label: String(p.label ?? "") || fallback.label,
32
+ href: href && isSafeHref(href) ? href : fallback.href
33
+ };
34
+ }
35
+ } catch {
36
+ }
37
+ }
38
+ return { label: raw, href: fallback.href };
39
+ }
11
40
  function createContentReader(opts) {
12
41
  const site = opts.site;
13
42
  const lang = opts.lang ?? "en";
@@ -41,11 +70,14 @@ function createContentReader(opts) {
41
70
  function makeT(cms) {
42
71
  return (key, fallback) => cms[`${key}.${lang}`] ?? fallback;
43
72
  }
73
+ function makeTLink(cms) {
74
+ return (key, fallback) => parseLink(cms[`${key}.${lang}`], fallback);
75
+ }
44
76
  async function get() {
45
77
  const cms = await getCMS();
46
- return { t: makeT(cms), cms };
78
+ return { t: makeT(cms), tLink: makeTLink(cms), cms };
47
79
  }
48
- return { getCMS, makeT, get };
80
+ return { getCMS, makeT, makeTLink, get };
49
81
  }
50
82
  export {
51
83
  createContentReader
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  makeSchemasRoute
3
- } from "../chunk-IIGDU5SV.js";
3
+ } from "../chunk-VFMOVGMC.js";
4
4
  import "../chunk-NG5GJME5.js";
5
- import "../chunk-MCHQV6Y7.js";
5
+ import "../chunk-UMCBQXB6.js";
6
6
  import "../chunk-BOIQNZAO.js";
7
7
 
8
8
  // src/endpoints/schemas.ts
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-22DJVJBR.js";
7
7
  import {
8
8
  makeSchemasRoute
9
- } from "./chunk-IIGDU5SV.js";
9
+ } from "./chunk-VFMOVGMC.js";
10
10
  import "./chunk-NG5GJME5.js";
11
11
  import {
12
12
  makeLocalUploadHandler,
@@ -20,7 +20,7 @@ import {
20
20
  defineList,
21
21
  describeList,
22
22
  z
23
- } from "./chunk-MCHQV6Y7.js";
23
+ } from "./chunk-UMCBQXB6.js";
24
24
  import {
25
25
  canciaLoader
26
26
  } from "./chunk-UR5WC3RA.js";
@@ -16,7 +16,7 @@ export { P as PT_DECORATORS, a as PT_LIST_ITEMS, b as PT_STYLES, c as PortableTe
16
16
  * z.boolean() → "checkbox"
17
17
  * z.enum([...]) → "select"
18
18
  */
19
- type FieldWidget = "text" | "textarea" | "url" | "email" | "datetime" | "number" | "checkbox" | "select" | "image" | "slug" | "array" | "object" | "reference" | "richtext";
19
+ type FieldWidget = "text" | "textarea" | "url" | "email" | "datetime" | "number" | "checkbox" | "select" | "image" | "slug" | "array" | "object" | "reference" | "richtext" | "link";
20
20
  /**
21
21
  * Fields common to every widget's metadata. All optional.
22
22
  */
@@ -61,6 +61,8 @@ type FieldMeta = (FieldMetaBase & {
61
61
  list: string;
62
62
  }) | (FieldMetaBase & {
63
63
  widget: "richtext";
64
+ }) | (FieldMetaBase & {
65
+ widget: "link";
64
66
  });
65
67
  interface ListSchemaOptions<TFields extends Record<string, z.ZodTypeAny>> {
66
68
  /** Display label for the list (e.g. "Blog Posts"). */
@@ -194,6 +196,30 @@ declare const defineField: {
194
196
  reference: (o: FieldMetaBase & {
195
197
  list: string;
196
198
  }) => z.ZodString;
199
+ /**
200
+ * A link: label + href as ONE unit.
201
+ *
202
+ * A button/CTA is two values that must stay together — saving a new label
203
+ * against a stale href produces a broken call-to-action, so they are stored
204
+ * as a single object rather than two flat keys that could drift.
205
+ *
206
+ * cta: f.link({ label: "Primary CTA" }),
207
+ *
208
+ * `href` is NOT z.url(): a relative href ("/start", "#programmes") and a
209
+ * mailto:/tel: are all legitimate and z.url() rejects the relative forms.
210
+ * It IS constrained by `isSafeHref` — the SAME guard richtext link marks
211
+ * use — so a stored value can never carry a javascript:/data: XSS payload.
212
+ * Without this, the link widget would be a hole around a protection
213
+ * richtext already enforces.
214
+ *
215
+ * Whether to open in a new tab is derived from the href at render time
216
+ * (see `isExternalHref`) instead of being stored — one less thing that can
217
+ * fall out of sync with the URL it describes.
218
+ */
219
+ link: (o?: FieldMetaBase) => z.ZodObject<{
220
+ label: z.ZodString;
221
+ href: z.ZodString;
222
+ }, z.core.$strip>;
197
223
  /**
198
224
  * A constrained rich-text body. Stored as a Portable-Text SUBSET array (D4):
199
225
  * block styles normal/h2/h3/blockquote, bullet/number lists, strong/em marks,
@@ -257,7 +283,38 @@ interface ListDescription {
257
283
  */
258
284
  declare function slugify(input: string): string;
259
285
  declare function describeList(name: string, schema: ListSchema): ListDescription;
286
+ /** The value behind a `link` widget: a label and where it points. */
287
+ interface LinkValue {
288
+ label: string;
289
+ href: string;
290
+ }
291
+ /**
292
+ * Parse a stored link value. NEVER throws.
293
+ *
294
+ * The KV surface stores every value as a string, so a link round-trips as
295
+ * JSON. Three cases must all degrade gracefully rather than break a render:
296
+ *
297
+ * - proper JSON object → used as-is (missing halves coerced to "")
298
+ * - a bare string → treated as the LABEL with an empty href. This is
299
+ * the migration path for a field that used to be
300
+ * `text`, and for a fallback authored in markup.
301
+ * - empty / malformed → `{ label: "", href: "" }`
302
+ *
303
+ * A content read must never throw (same rule as createContentReader).
304
+ */
305
+ declare function parseLinkValue(raw: unknown): LinkValue;
306
+ /** Serialize a link value for storage. The inverse of `parseLinkValue`. */
307
+ declare function serializeLinkValue(value: LinkValue): string;
308
+ /**
309
+ * Should this href open in a new tab? Derived, never stored.
310
+ *
311
+ * External = has a scheme+host that isn't the current origin. Relative hrefs
312
+ * ("/start", "#programmes") and mailto:/tel: are NOT external — mailto/tel
313
+ * hand off to another app, and forcing target=_blank on them leaves a blank
314
+ * tab behind.
315
+ */
316
+ declare function isExternalHref(href: string): boolean;
260
317
  /** Map of list name → schema. What users export from src/cms/schemas.ts. */
261
318
  type SchemasModule = Record<string, ListSchema>;
262
319
 
263
- export { type FieldDescription, type FieldMeta, type FieldMetaBase, type FieldWidget, type ListDescription, type ListSchema, type ListSchemaOptions, type SchemasModule, defineField, defineList, describeList, slugify };
320
+ export { type FieldDescription, type FieldMeta, type FieldMetaBase, type FieldWidget, type LinkValue, type ListDescription, type ListSchema, type ListSchemaOptions, type SchemasModule, defineField, defineList, describeList, isExternalHref, parseLinkValue, serializeLinkValue, slugify };
@@ -2,9 +2,12 @@ import {
2
2
  defineField,
3
3
  defineList,
4
4
  describeList,
5
+ isExternalHref,
6
+ parseLinkValue,
7
+ serializeLinkValue,
5
8
  slugify,
6
9
  z
7
- } from "../chunk-MCHQV6Y7.js";
10
+ } from "../chunk-UMCBQXB6.js";
8
11
  import {
9
12
  PT_DECORATORS,
10
13
  PT_LIST_ITEMS,
@@ -20,9 +23,12 @@ export {
20
23
  defineField,
21
24
  defineList,
22
25
  describeList,
26
+ isExternalHref,
23
27
  isSafeHref,
28
+ parseLinkValue,
24
29
  portableTextSubsetSchema,
25
30
  ptBlockSchema,
31
+ serializeLinkValue,
26
32
  slugify,
27
33
  z
28
34
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cancia/astro",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Astro integration for Cancia CMS — inline editing with zero separate server",
5
5
  "license": "MIT",
6
6
  "repository": {