@cancia/astro 0.6.0 → 0.8.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,15 +1,21 @@
1
1
  import {
2
2
  createJsonFileAdapterV2
3
3
  } from "./chunk-L2VKQJPY.js";
4
+ import {
5
+ isDraft
6
+ } from "./chunk-WVHTCFE6.js";
4
7
 
5
8
  // src/loader/index.ts
6
9
  import { join } from "path";
7
10
  function makeId(locale, entryId) {
8
11
  return `${locale}/${entryId}`;
9
12
  }
10
- async function syncOnce(ctx, lists, list, site) {
13
+ async function syncOnce(ctx, lists, list, site, schema, includeDrafts) {
11
14
  ctx.store.clear();
12
- const entries = await lists.list(site, list);
15
+ const all = await lists.list(site, list);
16
+ const drafts = schema?.draftField && !includeDrafts ? all.filter((e) => isDraft(schema, e.data)) : [];
17
+ const draftIds = new Set(drafts.map((e) => `${e.locale}/${e.id}`));
18
+ const entries = draftIds.size ? all.filter((e) => !draftIds.has(`${e.locale}/${e.id}`)) : all;
13
19
  for (const entry of entries) {
14
20
  const id = makeId(entry.locale, entry.id);
15
21
  const data = {
@@ -24,8 +30,9 @@ async function syncOnce(ctx, lists, list, site) {
24
30
  digest: entry._rev
25
31
  });
26
32
  }
33
+ const skipped = drafts.length ? ` (${drafts.length} draft${drafts.length === 1 ? "" : "s"} skipped)` : "";
27
34
  ctx.logger.info(
28
- `cancia: loaded ${entries.length} entr${entries.length === 1 ? "y" : "ies"} from list "${list}"`
35
+ `cancia: loaded ${entries.length} entr${entries.length === 1 ? "y" : "ies"} from list "${list}"${skipped}`
29
36
  );
30
37
  }
31
38
  function canciaLoader(opts) {
@@ -41,7 +48,7 @@ function canciaLoader(opts) {
41
48
  projectRoot: opts.projectRoot ?? process.cwd()
42
49
  });
43
50
  const { lists } = storage;
44
- await syncOnce(ctx, lists, opts.list, opts.site);
51
+ await syncOnce(ctx, lists, opts.list, opts.site, opts.schema, opts.includeDrafts);
45
52
  if (ctx.watcher && !watcherAttached) {
46
53
  watcherAttached = true;
47
54
  const root = opts.projectRoot ?? process.cwd();
@@ -53,7 +60,14 @@ function canciaLoader(opts) {
53
60
  pendingTimer = null;
54
61
  inflight = inflight.catch(() => {
55
62
  }).then(
56
- () => syncOnce(latestCtx, lists, opts.list, opts.site).catch((err) => {
63
+ () => syncOnce(
64
+ latestCtx,
65
+ lists,
66
+ opts.list,
67
+ opts.site,
68
+ opts.schema,
69
+ opts.includeDrafts
70
+ ).catch((err) => {
57
71
  latestCtx.logger.error(`cancia: resync failed \u2014 ${err.message}`);
58
72
  })
59
73
  );
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  loadListSchema
3
- } from "./chunk-NG5GJME5.js";
3
+ } from "./chunk-VL6FO446.js";
4
4
  import {
5
5
  RevConflictError
6
6
  } from "./chunk-7IA5B5CF.js";
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  loadSchemas
3
- } from "./chunk-NG5GJME5.js";
3
+ } from "./chunk-VL6FO446.js";
4
4
  import {
5
5
  describeList
6
- } from "./chunk-MCHQV6Y7.js";
6
+ } from "./chunk-WVHTCFE6.js";
7
7
 
8
8
  // src/routes/schemas.ts
9
9
  function json(body, status = 200) {
@@ -53,6 +53,56 @@ var ptBlockSchema = z.object({
53
53
  }
54
54
  });
55
55
  var portableTextSubsetSchema = z.array(ptBlockSchema);
56
+ function parseRichValue(raw) {
57
+ if (raw == null) return [];
58
+ let candidate = raw;
59
+ if (typeof raw === "string") {
60
+ const trimmed = raw.trim();
61
+ if (trimmed === "") return [];
62
+ if (trimmed.startsWith("[")) {
63
+ try {
64
+ candidate = JSON.parse(trimmed);
65
+ } catch {
66
+ return [];
67
+ }
68
+ } else {
69
+ return [textBlock(raw)];
70
+ }
71
+ }
72
+ if (!Array.isArray(candidate)) return [];
73
+ const sanitised = candidate.map(stripUnsafeHrefs);
74
+ const parsed = portableTextSubsetSchema.safeParse(sanitised);
75
+ return parsed.success ? parsed.data : [];
76
+ }
77
+ function serializeRichValue(value) {
78
+ return value.length === 0 ? "" : JSON.stringify(value);
79
+ }
80
+ function textBlock(text) {
81
+ return {
82
+ _type: "block",
83
+ _key: "legacy",
84
+ style: "normal",
85
+ markDefs: [],
86
+ children: [{ _type: "span", _key: "legacy0", text, marks: [] }]
87
+ };
88
+ }
89
+ function stripUnsafeHrefs(block) {
90
+ if (!block || typeof block !== "object") return block;
91
+ const b = block;
92
+ if (!Array.isArray(b.markDefs)) return block;
93
+ const dropped = /* @__PURE__ */ new Set();
94
+ const markDefs = b.markDefs.filter((def) => {
95
+ const href = def?.href;
96
+ if (typeof href === "string" && isSafeHref(href)) return true;
97
+ if (typeof def?._key === "string") dropped.add(def._key);
98
+ return false;
99
+ });
100
+ if (dropped.size === 0) return block;
101
+ const children = Array.isArray(b.children) ? b.children.map(
102
+ (span) => Array.isArray(span?.marks) ? { ...span, marks: span.marks.filter((m) => !dropped.has(m)) } : span
103
+ ) : b.children;
104
+ return { ...b, markDefs, children };
105
+ }
56
106
 
57
107
  export {
58
108
  PT_STYLES,
@@ -60,5 +110,7 @@ export {
60
110
  PT_DECORATORS,
61
111
  isSafeHref,
62
112
  ptBlockSchema,
63
- portableTextSubsetSchema
113
+ portableTextSubsetSchema,
114
+ parseRichValue,
115
+ serializeRichValue
64
116
  };
@@ -32,8 +32,15 @@ async function loadListSchema(projectRoot, listName, overridePath) {
32
32
  const schemas = await loadSchemas(projectRoot, overridePath);
33
33
  return schemas[listName] ?? null;
34
34
  }
35
+ function clearSchemaCache() {
36
+ _cache = null;
37
+ _cachedPath = null;
38
+ _cachedMtime = 0;
39
+ }
35
40
 
36
41
  export {
42
+ resolveSchemasPath,
37
43
  loadSchemas,
38
- loadListSchema
44
+ loadListSchema,
45
+ clearSchemaCache
39
46
  };
@@ -1,9 +1,14 @@
1
1
  import {
2
+ isSafeHref,
2
3
  portableTextSubsetSchema
3
- } from "./chunk-BOIQNZAO.js";
4
+ } from "./chunk-KHCFVVYV.js";
4
5
 
5
6
  // src/schema/index.ts
6
7
  import { z } from "zod";
8
+ function isDraft(schema, data) {
9
+ if (!schema.draftField) return false;
10
+ return data[schema.draftField] === true;
11
+ }
7
12
  function defineList(opts) {
8
13
  return {
9
14
  label: opts.label,
@@ -11,6 +16,7 @@ function defineList(opts) {
11
16
  titleField: opts.titleField,
12
17
  bodyField: opts.bodyField,
13
18
  slugField: opts.slugField,
19
+ draftField: opts.draftField,
14
20
  fields: opts.fields,
15
21
  validator: z.object(opts.fields)
16
22
  };
@@ -22,6 +28,15 @@ var defineField = {
22
28
  email: (o) => z.string().email().meta({ widget: "email", ...o }),
23
29
  datetime: (o) => z.string().datetime().meta({ widget: "datetime", ...o }),
24
30
  checkbox: (o) => z.boolean().meta({ widget: "checkbox", ...o }),
31
+ /**
32
+ * The draft flag for a list's `draftField`. A checkbox that DEFAULTS to
33
+ * false, which is what makes it safe to add to an existing list: every
34
+ * stored entry that predates the field validates and reads as published
35
+ * rather than vanishing from the built site.
36
+ *
37
+ * Label defaults to "Draft" so the affordance reads the same on every list.
38
+ */
39
+ draft: (o) => z.boolean().default(false).meta({ widget: "checkbox", label: "Draft", ...o }),
25
40
  number: (o) => {
26
41
  let n = z.number();
27
42
  if (o?.min != null) n = n.min(o.min);
@@ -56,6 +71,30 @@ var defineField = {
56
71
  * related: f.array(f.reference({ list: "posts" }), { label: "Related" }),
57
72
  */
58
73
  reference: (o) => z.string().meta({ widget: "reference", ...o }),
74
+ /**
75
+ * A link: label + href as ONE unit.
76
+ *
77
+ * A button/CTA is two values that must stay together — saving a new label
78
+ * against a stale href produces a broken call-to-action, so they are stored
79
+ * as a single object rather than two flat keys that could drift.
80
+ *
81
+ * cta: f.link({ label: "Primary CTA" }),
82
+ *
83
+ * `href` is NOT z.url(): a relative href ("/start", "#programmes") and a
84
+ * mailto:/tel: are all legitimate and z.url() rejects the relative forms.
85
+ * It IS constrained by `isSafeHref` — the SAME guard richtext link marks
86
+ * use — so a stored value can never carry a javascript:/data: XSS payload.
87
+ * Without this, the link widget would be a hole around a protection
88
+ * richtext already enforces.
89
+ *
90
+ * Whether to open in a new tab is derived from the href at render time
91
+ * (see `isExternalHref`) instead of being stored — one less thing that can
92
+ * fall out of sync with the URL it describes.
93
+ */
94
+ link: (o) => z.object({
95
+ label: z.string(),
96
+ href: z.string().refine(isSafeHref, "unsafe or unsupported URL scheme")
97
+ }).meta({ widget: "link", ...o }),
59
98
  /**
60
99
  * A constrained rich-text body. Stored as a Portable-Text SUBSET array (D4):
61
100
  * block styles normal/h2/h3/blockquote, bullet/number lists, strong/em marks,
@@ -142,14 +181,48 @@ function describeList(name, schema) {
142
181
  titleField: schema.titleField,
143
182
  bodyField: schema.bodyField,
144
183
  slugField: schema.slugField,
184
+ draftField: schema.draftField,
145
185
  fields
146
186
  };
147
187
  }
188
+ function parseLinkValue(raw) {
189
+ const safe = (href) => isSafeHref(href) ? href : "";
190
+ if (raw && typeof raw === "object") {
191
+ const o = raw;
192
+ return { label: String(o.label ?? ""), href: safe(String(o.href ?? "")) };
193
+ }
194
+ if (typeof raw !== "string" || raw === "") return { label: "", href: "" };
195
+ const trimmed = raw.trim();
196
+ if (trimmed.startsWith("{")) {
197
+ try {
198
+ const parsed = JSON.parse(trimmed);
199
+ if (parsed && typeof parsed === "object") {
200
+ return {
201
+ label: String(parsed.label ?? ""),
202
+ href: safe(String(parsed.href ?? ""))
203
+ };
204
+ }
205
+ } catch {
206
+ }
207
+ }
208
+ return { label: raw, href: "" };
209
+ }
210
+ function serializeLinkValue(value) {
211
+ return JSON.stringify({ label: value.label ?? "", href: value.href ?? "" });
212
+ }
213
+ function isExternalHref(href) {
214
+ if (!href) return false;
215
+ return /^https?:\/\//i.test(href);
216
+ }
148
217
 
149
218
  export {
150
219
  z,
220
+ isDraft,
151
221
  defineList,
152
222
  defineField,
153
223
  slugify,
154
- describeList
224
+ describeList,
225
+ parseLinkValue,
226
+ serializeLinkValue,
227
+ isExternalHref
155
228
  };
package/dist/content.d.ts CHANGED
@@ -2,6 +2,27 @@
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;
12
+ /**
13
+ * Resolve a rich-text region (plan 051).
14
+ *
15
+ * Returns `null` when there is NO stored override — which the region renders as
16
+ * "show the markup authored in the page". That is deliberate and load-bearing:
17
+ * a rich fallback cannot be a JS string, so the authored markup IS the fallback,
18
+ * which is what keeps an empty DB rendering byte-identically.
19
+ *
20
+ * `[]` is a different answer from `null`: it means a stored, deliberately empty
21
+ * value — the client removed the prose on purpose — and renders nothing.
22
+ */
23
+ type TranslateRich = (key: string) => RichValue | null;
24
+ /** A Portable-Text subset document. Structurally typed to keep this subpath Vite-free. */
25
+ type RichValue = Record<string, unknown>[];
5
26
  interface ContentReaderOptions {
6
27
  /** Site id (the `?site=` query param and the storage `site` column). */
7
28
  site: string;
@@ -44,9 +65,24 @@ interface ContentReader {
44
65
  getCMS(): Promise<ContentMap>;
45
66
  /** Build a translator over a content map for the reader's `lang`. */
46
67
  makeT(cms: ContentMap): Translate;
47
- /** Sugar: `const { t, cms } = await reader.get()` in a page's frontmatter. */
68
+ /**
69
+ * Build a link resolver over a content map. A link field stores
70
+ * `{label, href}` as JSON; this parses it and falls back to the values
71
+ * authored in the page. Never throws; drops an unsafe href.
72
+ */
73
+ makeTLink(cms: ContentMap): TranslateLink;
74
+ /**
75
+ * Build a rich-text resolver over a content map. Returns `null` when no
76
+ * override is stored, which a rich region renders as "show the markup
77
+ * authored in the page" — the reason an empty DB still renders identically.
78
+ * `[]` means a stored, deliberately empty value and renders nothing.
79
+ */
80
+ makeRich(cms: ContentMap): TranslateRich;
81
+ /** Sugar: `const { t, tLink, tRich, cms } = await reader.get()` in frontmatter. */
48
82
  get(): Promise<{
49
83
  t: Translate;
84
+ tLink: TranslateLink;
85
+ tRich: TranslateRich;
50
86
  cms: ContentMap;
51
87
  }>;
52
88
  }
@@ -57,4 +93,4 @@ interface ContentReader {
57
93
  */
58
94
  declare function createContentReader(opts: ContentReaderOptions): ContentReader;
59
95
 
60
- export { type ContentMap, type ContentReader, type ContentReaderOptions, type Translate, createContentReader };
96
+ export { type ContentMap, type ContentReader, type ContentReaderOptions, type LinkValue, type RichValue, type Translate, type TranslateLink, type TranslateRich, createContentReader };
package/dist/content.js CHANGED
@@ -8,6 +8,75 @@ 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
+ }
40
+ function parseRich(raw) {
41
+ if (raw === void 0) return null;
42
+ const trimmed = raw.trim();
43
+ if (trimmed === "") return [];
44
+ if (!trimmed.startsWith("[")) {
45
+ return [
46
+ {
47
+ _type: "block",
48
+ _key: "legacy",
49
+ style: "normal",
50
+ markDefs: [],
51
+ children: [{ _type: "span", _key: "legacy0", text: raw, marks: [] }]
52
+ }
53
+ ];
54
+ }
55
+ try {
56
+ const parsed = JSON.parse(trimmed);
57
+ if (!Array.isArray(parsed)) return [];
58
+ return parsed.map(stripUnsafeMarkDefs);
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+ function stripUnsafeMarkDefs(block) {
64
+ if (!block || typeof block !== "object") return block;
65
+ const b = block;
66
+ if (!Array.isArray(b.markDefs)) return block;
67
+ const dropped = /* @__PURE__ */ new Set();
68
+ const markDefs = b.markDefs.filter((def) => {
69
+ const href = def?.href;
70
+ if (typeof href === "string" && isSafeHref(href)) return true;
71
+ if (typeof def?._key === "string") dropped.add(def._key);
72
+ return false;
73
+ });
74
+ if (dropped.size === 0) return block;
75
+ const children = Array.isArray(b.children) ? b.children.map(
76
+ (span) => Array.isArray(span?.marks) ? { ...span, marks: span.marks.filter((m) => !dropped.has(m)) } : span
77
+ ) : b.children;
78
+ return { ...b, markDefs, children };
79
+ }
11
80
  function createContentReader(opts) {
12
81
  const site = opts.site;
13
82
  const lang = opts.lang ?? "en";
@@ -41,11 +110,17 @@ function createContentReader(opts) {
41
110
  function makeT(cms) {
42
111
  return (key, fallback) => cms[`${key}.${lang}`] ?? fallback;
43
112
  }
113
+ function makeTLink(cms) {
114
+ return (key, fallback) => parseLink(cms[`${key}.${lang}`], fallback);
115
+ }
116
+ function makeRich(cms) {
117
+ return (key) => parseRich(cms[`${key}.${lang}`]);
118
+ }
44
119
  async function get() {
45
120
  const cms = await getCMS();
46
- return { t: makeT(cms), cms };
121
+ return { t: makeT(cms), tLink: makeTLink(cms), tRich: makeRich(cms), cms };
47
122
  }
48
- return { getCMS, makeT, get };
123
+ return { getCMS, makeT, makeTLink, makeRich, get };
49
124
  }
50
125
  export {
51
126
  createContentReader
@@ -4,9 +4,9 @@ import {
4
4
  } from "../chunk-VGRG5DN7.js";
5
5
  import {
6
6
  makeListsRoutes
7
- } from "../chunk-22DJVJBR.js";
8
- import "../chunk-NG5GJME5.js";
7
+ } from "../chunk-DIV2FFYX.js";
9
8
  import "../chunk-X6ZFFGIA.js";
9
+ import "../chunk-VL6FO446.js";
10
10
  import "../chunk-7IA5B5CF.js";
11
11
 
12
12
  // src/endpoints/lists.ts
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  makeSchemasRoute
3
- } from "../chunk-IIGDU5SV.js";
4
- import "../chunk-NG5GJME5.js";
5
- import "../chunk-MCHQV6Y7.js";
6
- import "../chunk-BOIQNZAO.js";
3
+ } from "../chunk-HQIGNRIU.js";
4
+ import "../chunk-VL6FO446.js";
5
+ import "../chunk-WVHTCFE6.js";
6
+ import "../chunk-KHCFVVYV.js";
7
7
 
8
8
  // src/endpoints/schemas.ts
9
9
  import { getCanciaRuntime } from "virtual:cancia/runtime";
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { FieldDescription, FieldMeta, FieldMetaBase, FieldWidget, ListDescriptio
9
9
  export { G as GitBackedContentPaths, a as GitBackedControls, b as GitBackedOptions, c as GitBackedStorage, S as SqliteV2Options, d as closeSqliteAdapterV2, e as createGitBackedAdapter, f as createJsonFileAdapter, g as createJsonFileAdapterV2, h as createSQLiteAdapter, i as createSqliteAdapterV2 } from './git-backed-DtiH52EI.js';
10
10
  export { z } from 'zod';
11
11
  import 'astro/loaders';
12
- import './portable-text-BikSqS9T.js';
12
+ import './portable-text-D74aIuHn.js';
13
13
 
14
14
  interface R2UploadHandlerOptions {
15
15
  /** Cloudflare account ID (from R2 dashboard) */