@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.
- package/dist/{chunk-MCHQV6Y7.js → chunk-UMCBQXB6.js} +58 -1
- package/dist/{chunk-IIGDU5SV.js → chunk-VFMOVGMC.js} +1 -1
- package/dist/content.d.ts +16 -2
- package/dist/content.js +34 -2
- package/dist/endpoints/schemas.js +2 -2
- package/dist/index.js +2 -2
- package/dist/schema/index.d.ts +59 -2
- package/dist/schema/index.js +7 -1
- package/package.json +1 -1
|
@@ -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
|
};
|
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
|
-
/**
|
|
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
|
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-
|
|
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-
|
|
23
|
+
} from "./chunk-UMCBQXB6.js";
|
|
24
24
|
import {
|
|
25
25
|
canciaLoader
|
|
26
26
|
} from "./chunk-UR5WC3RA.js";
|
package/dist/schema/index.d.ts
CHANGED
|
@@ -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 };
|
package/dist/schema/index.js
CHANGED
|
@@ -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-
|
|
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
|
};
|