@cancia/astro 0.5.1 → 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.
File without changes
@@ -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) {
@@ -0,0 +1,74 @@
1
+ /** Flat content map as returned by /api/cancia/content and kv.getAll: "key.lang" → value. */
2
+ type ContentMap = Record<string, string>;
3
+ /** A translator: `(key, fallback) => cms[`${key}.${lang}`] ?? fallback`. */
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;
12
+ interface ContentReaderOptions {
13
+ /** Site id (the `?site=` query param and the storage `site` column). */
14
+ site: string;
15
+ /**
16
+ * Build-time content API base. Default: `process.env.CANCIA_CONTENT_URL`.
17
+ * When set (non-empty), `getCMS()` FETCHES from
18
+ * `${url}/api/cancia/content?site=<site>` instead of reading the DB directly.
19
+ * This is how a static build on a runtime-only volume gets live content.
20
+ */
21
+ url?: string;
22
+ /**
23
+ * Bearer token for the fetch. Default: `process.env.CANCIA_TOKEN`. Required by
24
+ * a non-public site (036 fail-closed) — without it the fetch 401s and the
25
+ * reader falls back to the direct DB read.
26
+ */
27
+ token?: string;
28
+ /**
29
+ * Direct-DB fallback path. Default:
30
+ * `process.env.CANCIA_DB_PATH ?? "<cwd>/cancia.db"`.
31
+ * Ignored when a `readDb` override is supplied.
32
+ */
33
+ dbPath?: string;
34
+ /** Default language for `makeT`/`get`. Default: `"en"`. */
35
+ lang?: string;
36
+ /** Injectable fetch (tests). Default: global `fetch`. */
37
+ fetch?: typeof fetch;
38
+ /**
39
+ * Optional DB-read override. Lets a non-sqlite project (e.g. json-file
40
+ * storage) supply its own fallback WITHOUT this reader importing every
41
+ * adapter. Default: read via `createSqliteAdapterV2({ dbPath })` (the
42
+ * recommended backend).
43
+ */
44
+ readDb?: () => Promise<ContentMap>;
45
+ }
46
+ interface ContentReader {
47
+ /**
48
+ * Read all content for the site as a flat `"key.lang" → value` map.
49
+ * Chain: fetch (when `url` set) → direct DB read → `{}`. NEVER throws.
50
+ */
51
+ getCMS(): Promise<ContentMap>;
52
+ /** Build a translator over a content map for the reader's `lang`. */
53
+ makeT(cms: ContentMap): Translate;
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. */
61
+ get(): Promise<{
62
+ t: Translate;
63
+ tLink: TranslateLink;
64
+ cms: ContentMap;
65
+ }>;
66
+ }
67
+ /**
68
+ * Create a content reader. Reads are transport-agnostic (network OR local file)
69
+ * and never throw — a static build reads content the same way whether it's a
70
+ * local DB, the site's own API, or a future hosted Cancia DB.
71
+ */
72
+ declare function createContentReader(opts: ContentReaderOptions): ContentReader;
73
+
74
+ export { type ContentMap, type ContentReader, type ContentReaderOptions, type LinkValue, type Translate, type TranslateLink, createContentReader };
@@ -0,0 +1,84 @@
1
+ import "./chunk-FOSOWSXV.js";
2
+ import "./chunk-CJDIVWO3.js";
3
+ import {
4
+ createSqliteAdapterV2
5
+ } from "./chunk-GNWD7EL2.js";
6
+ import "./chunk-U7V53JX7.js";
7
+ import "./chunk-L2VKQJPY.js";
8
+ import "./chunk-7IA5B5CF.js";
9
+
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
+ }
40
+ function createContentReader(opts) {
41
+ const site = opts.site;
42
+ const lang = opts.lang ?? "en";
43
+ const url = opts.url ?? process.env.CANCIA_CONTENT_URL;
44
+ const token = opts.token ?? process.env.CANCIA_TOKEN;
45
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
46
+ function readDb() {
47
+ if (opts.readDb) return opts.readDb();
48
+ const dbPath = opts.dbPath ?? process.env.CANCIA_DB_PATH ?? `${process.cwd()}/cancia.db`;
49
+ return createSqliteAdapterV2({ dbPath }).kv.getAll(site);
50
+ }
51
+ async function fetchFromApi(base) {
52
+ const endpoint = `${base.replace(/\/+$/, "")}/api/cancia/content?site=${encodeURIComponent(site)}`;
53
+ const res = await fetchImpl(endpoint, {
54
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
55
+ });
56
+ if (!res.ok) throw new Error(`Content fetch failed: ${res.status}`);
57
+ return await res.json();
58
+ }
59
+ async function getCMS() {
60
+ try {
61
+ return url ? await fetchFromApi(url) : await readDb();
62
+ } catch {
63
+ try {
64
+ return await readDb();
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+ }
70
+ function makeT(cms) {
71
+ return (key, fallback) => cms[`${key}.${lang}`] ?? fallback;
72
+ }
73
+ function makeTLink(cms) {
74
+ return (key, fallback) => parseLink(cms[`${key}.${lang}`], fallback);
75
+ }
76
+ async function get() {
77
+ const cms = await getCMS();
78
+ return { t: makeT(cms), tLink: makeTLink(cms), cms };
79
+ }
80
+ return { getCMS, makeT, makeTLink, get };
81
+ }
82
+ export {
83
+ createContentReader
84
+ };
@@ -1,13 +1,13 @@
1
- import {
2
- makeListsRoutes
3
- } from "../chunk-22DJVJBR.js";
4
- import "../chunk-NG5GJME5.js";
5
- import "../chunk-7IA5B5CF.js";
6
1
  import {
7
2
  extractRoute,
8
3
  invalidateOnSave
9
4
  } from "../chunk-VGRG5DN7.js";
5
+ import {
6
+ makeListsRoutes
7
+ } from "../chunk-22DJVJBR.js";
8
+ import "../chunk-NG5GJME5.js";
10
9
  import "../chunk-X6ZFFGIA.js";
10
+ import "../chunk-7IA5B5CF.js";
11
11
 
12
12
  // src/endpoints/lists.ts
13
13
  import { getCanciaRuntime } from "virtual:cancia/runtime";
@@ -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
  };
@@ -1,3 +1,4 @@
1
+ import "../chunk-FOSOWSXV.js";
1
2
  import {
2
3
  createSQLiteAdapter
3
4
  } from "../chunk-CJDIVWO3.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cancia/astro",
3
- "version": "0.5.1",
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": {
@@ -32,6 +32,10 @@
32
32
  "types": "./dist/cache.d.ts",
33
33
  "import": "./dist/cache.js"
34
34
  },
35
+ "./content": {
36
+ "types": "./dist/content.d.ts",
37
+ "import": "./dist/content.js"
38
+ },
35
39
  "./richtext": {
36
40
  "types": "./dist/richtext/index.d.ts",
37
41
  "import": "./dist/richtext/index.js"