@cancia/astro 0.7.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-UMCBQXB6.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,10 +1,14 @@
1
1
  import {
2
2
  isSafeHref,
3
3
  portableTextSubsetSchema
4
- } from "./chunk-BOIQNZAO.js";
4
+ } from "./chunk-KHCFVVYV.js";
5
5
 
6
6
  // src/schema/index.ts
7
7
  import { z } from "zod";
8
+ function isDraft(schema, data) {
9
+ if (!schema.draftField) return false;
10
+ return data[schema.draftField] === true;
11
+ }
8
12
  function defineList(opts) {
9
13
  return {
10
14
  label: opts.label,
@@ -12,6 +16,7 @@ function defineList(opts) {
12
16
  titleField: opts.titleField,
13
17
  bodyField: opts.bodyField,
14
18
  slugField: opts.slugField,
19
+ draftField: opts.draftField,
15
20
  fields: opts.fields,
16
21
  validator: z.object(opts.fields)
17
22
  };
@@ -23,6 +28,15 @@ var defineField = {
23
28
  email: (o) => z.string().email().meta({ widget: "email", ...o }),
24
29
  datetime: (o) => z.string().datetime().meta({ widget: "datetime", ...o }),
25
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 }),
26
40
  number: (o) => {
27
41
  let n = z.number();
28
42
  if (o?.min != null) n = n.min(o.min);
@@ -167,6 +181,7 @@ function describeList(name, schema) {
167
181
  titleField: schema.titleField,
168
182
  bodyField: schema.bodyField,
169
183
  slugField: schema.slugField,
184
+ draftField: schema.draftField,
170
185
  fields
171
186
  };
172
187
  }
@@ -202,6 +217,7 @@ function isExternalHref(href) {
202
217
 
203
218
  export {
204
219
  z,
220
+ isDraft,
205
221
  defineList,
206
222
  defineField,
207
223
  slugify,
package/dist/content.d.ts CHANGED
@@ -9,6 +9,20 @@ interface LinkValue {
9
9
  }
10
10
  /** Resolve a link field, falling back to the values authored in the page. */
11
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>[];
12
26
  interface ContentReaderOptions {
13
27
  /** Site id (the `?site=` query param and the storage `site` column). */
14
28
  site: string;
@@ -57,10 +71,18 @@ interface ContentReader {
57
71
  * authored in the page. Never throws; drops an unsafe href.
58
72
  */
59
73
  makeTLink(cms: ContentMap): TranslateLink;
60
- /** Sugar: `const { t, tLink, cms } = await reader.get()` in frontmatter. */
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. */
61
82
  get(): Promise<{
62
83
  t: Translate;
63
84
  tLink: TranslateLink;
85
+ tRich: TranslateRich;
64
86
  cms: ContentMap;
65
87
  }>;
66
88
  }
@@ -71,4 +93,4 @@ interface ContentReader {
71
93
  */
72
94
  declare function createContentReader(opts: ContentReaderOptions): ContentReader;
73
95
 
74
- export { type ContentMap, type ContentReader, type ContentReaderOptions, type LinkValue, type Translate, type TranslateLink, 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
@@ -37,6 +37,46 @@ function parseLink(raw, fallback) {
37
37
  }
38
38
  return { label: raw, href: fallback.href };
39
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
+ }
40
80
  function createContentReader(opts) {
41
81
  const site = opts.site;
42
82
  const lang = opts.lang ?? "en";
@@ -73,11 +113,14 @@ function createContentReader(opts) {
73
113
  function makeTLink(cms) {
74
114
  return (key, fallback) => parseLink(cms[`${key}.${lang}`], fallback);
75
115
  }
116
+ function makeRich(cms) {
117
+ return (key) => parseRich(cms[`${key}.${lang}`]);
118
+ }
76
119
  async function get() {
77
120
  const cms = await getCMS();
78
- return { t: makeT(cms), tLink: makeTLink(cms), cms };
121
+ return { t: makeT(cms), tLink: makeTLink(cms), tRich: makeRich(cms), cms };
79
122
  }
80
- return { getCMS, makeT, makeTLink, get };
123
+ return { getCMS, makeT, makeTLink, makeRich, get };
81
124
  }
82
125
  export {
83
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-VFMOVGMC.js";
4
- import "../chunk-NG5GJME5.js";
5
- import "../chunk-UMCBQXB6.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) */
package/dist/index.js CHANGED
@@ -1,13 +1,12 @@
1
+ import {
2
+ makeSchemasRoute
3
+ } from "./chunk-HQIGNRIU.js";
1
4
  import {
2
5
  runPublish
3
6
  } from "./chunk-5RCLBWRH.js";
4
7
  import {
5
8
  makeListsRoutes
6
- } from "./chunk-22DJVJBR.js";
7
- import {
8
- makeSchemasRoute
9
- } from "./chunk-VFMOVGMC.js";
10
- import "./chunk-NG5GJME5.js";
9
+ } from "./chunk-DIV2FFYX.js";
11
10
  import {
12
11
  makeLocalUploadHandler,
13
12
  makeR2UploadHandler,
@@ -15,15 +14,6 @@ import {
15
14
  setCanciaRuntime
16
15
  } from "./chunk-AGNPUTKS.js";
17
16
  import "./chunk-5IPHDIC6.js";
18
- import {
19
- defineField,
20
- defineList,
21
- describeList,
22
- z
23
- } from "./chunk-UMCBQXB6.js";
24
- import {
25
- canciaLoader
26
- } from "./chunk-UR5WC3RA.js";
27
17
  import {
28
18
  createSQLiteAdapter
29
19
  } from "./chunk-CJDIVWO3.js";
@@ -33,6 +23,10 @@ import {
33
23
  createSqliteAdapterV2
34
24
  } from "./chunk-GNWD7EL2.js";
35
25
  import "./chunk-U7V53JX7.js";
26
+ import "./chunk-VL6FO446.js";
27
+ import {
28
+ canciaLoader
29
+ } from "./chunk-3B3CXOMK.js";
36
30
  import {
37
31
  createJsonFileAdapter,
38
32
  createJsonFileAdapterV2
@@ -40,11 +34,18 @@ import {
40
34
  import {
41
35
  RevConflictError
42
36
  } from "./chunk-7IA5B5CF.js";
43
- import "./chunk-BOIQNZAO.js";
37
+ import {
38
+ defineField,
39
+ defineList,
40
+ describeList,
41
+ z
42
+ } from "./chunk-WVHTCFE6.js";
43
+ import "./chunk-KHCFVVYV.js";
44
44
 
45
45
  // src/integration.ts
46
46
  import { loadEnv } from "vite";
47
47
  import { fileURLToPath, pathToFileURL } from "url";
48
+ import { readFile } from "fs/promises";
48
49
  import { isAbsolute, join as join2 } from "path";
49
50
 
50
51
  // src/routes/content.ts
@@ -198,6 +199,211 @@ CANCIA_TOKEN=${token}
198
199
  return token;
199
200
  }
200
201
 
202
+ // src/transform/attribute-text.ts
203
+ import { parse } from "@astrojs/compiler";
204
+ var HELPER_NAME = "__canciaText";
205
+ var LINK_HELPER_NAME = "__canciaLink";
206
+ var LOADER_NAME = "__canciaContent";
207
+ var CONTENT_LOCAL = "__canciaCms";
208
+ var HELPER_MODULE = "virtual:cancia/text";
209
+ var RAW_TEXT = /* @__PURE__ */ new Set([
210
+ "script",
211
+ "style",
212
+ "pre",
213
+ "textarea",
214
+ "code",
215
+ "title",
216
+ "option",
217
+ "noscript",
218
+ "template"
219
+ ]);
220
+ function byteToCharMap(source) {
221
+ let ascii = true;
222
+ for (let i = 0; i < source.length; i++) {
223
+ if (source.charCodeAt(i) > 127) {
224
+ ascii = false;
225
+ break;
226
+ }
227
+ }
228
+ if (ascii) return (b) => b;
229
+ const map = /* @__PURE__ */ new Map();
230
+ let byte = 0;
231
+ for (let char = 0; char < source.length; char++) {
232
+ map.set(byte, char);
233
+ const code = source.codePointAt(char);
234
+ byte += code < 128 ? 1 : code < 2048 ? 2 : code < 65536 ? 3 : 4;
235
+ if (code >= 65536) char++;
236
+ }
237
+ map.set(byte, source.length);
238
+ return (b) => map.get(b) ?? b;
239
+ }
240
+ function quote(text) {
241
+ return JSON.stringify(text);
242
+ }
243
+ function findOpenAngle(node, src, at) {
244
+ const name = node.name ?? "";
245
+ const floor = Math.max(0, at - 4096);
246
+ for (let i = Math.min(at, src.length - 1); i >= floor; i--) {
247
+ if (src[i] !== "<") continue;
248
+ if (src.slice(i + 1, i + 1 + name.length) !== name) continue;
249
+ const boundary = src[i + 1 + name.length];
250
+ if (boundary === void 0 || /[\s/>]/.test(boundary)) return i;
251
+ }
252
+ return null;
253
+ }
254
+ function readAttr(openTag, name) {
255
+ const re = new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`);
256
+ return re.exec(openTag)?.[1] ?? null;
257
+ }
258
+ function findOpenTagEnd(src, lt) {
259
+ let quoteChar = null;
260
+ for (let i = lt; i < src.length; i++) {
261
+ const c = src[i];
262
+ if (quoteChar) {
263
+ if (c === quoteChar) quoteChar = null;
264
+ continue;
265
+ }
266
+ if (c === '"' || c === "'") {
267
+ quoteChar = c;
268
+ continue;
269
+ }
270
+ if (c === ">") return i + 1;
271
+ }
272
+ return null;
273
+ }
274
+ async function transformAttributeText(source) {
275
+ if (!source.includes("data-cms")) {
276
+ return { code: source, changed: false, substituted: [] };
277
+ }
278
+ const toChar = byteToCharMap(source);
279
+ let ast;
280
+ try {
281
+ ast = (await parse(source, { position: true })).ast;
282
+ } catch {
283
+ return { code: source, changed: false, substituted: [] };
284
+ }
285
+ const edits = [];
286
+ const substituted = [];
287
+ function visit(node) {
288
+ if (node.type === "element" && RAW_TEXT.has((node.name ?? "").toLowerCase())) {
289
+ return;
290
+ }
291
+ if (node.type === "element") {
292
+ const reported = node.position?.start?.offset;
293
+ if (reported !== void 0) {
294
+ const lt = findOpenAngle(node, source, toChar(reported));
295
+ if (lt !== null) {
296
+ const gt = findOpenTagEnd(source, lt);
297
+ if (gt !== null) {
298
+ const openTag = source.slice(lt, gt);
299
+ const key = readAttr(openTag, "data-cms");
300
+ if (key !== null) {
301
+ const declaredType = readAttr(openTag, "data-cms-type");
302
+ const isList = /\sdata-cms-list\s*=/.test(openTag);
303
+ if (declaredType === "link" && !isList) {
304
+ const kids = (node.children ?? []).filter(
305
+ (c) => c.type !== "text" || (c.value ?? "").trim() !== ""
306
+ );
307
+ const soleText = kids.length === 1 && kids[0].type === "text" ? kids[0] : null;
308
+ const authoredHref = readAttr(openTag, "href");
309
+ if (soleText && authoredHref !== null) {
310
+ const raw = soleText.value ?? "";
311
+ const literal = raw.replace(/\s+/g, " ").trim();
312
+ const s = soleText.position?.start?.offset;
313
+ const e = soleText.position?.end?.offset;
314
+ const hrefRe = /\shref\s*=\s*(["'])([^"']*)\1/.exec(openTag);
315
+ if (literal !== "" && s !== void 0 && e !== void 0 && hrefRe) {
316
+ const call = `${LINK_HELPER_NAME}(${CONTENT_LOCAL}, ${quote(key)}, ${quote(literal)}, ${quote(hrefRe[2])})`;
317
+ edits.push({
318
+ start: lt + hrefRe.index,
319
+ end: lt + hrefRe.index + hrefRe[0].length,
320
+ text: ` href={${call}.href}`
321
+ });
322
+ edits.push({
323
+ start: toChar(s),
324
+ end: toChar(e),
325
+ text: `{${call}.label}`
326
+ });
327
+ substituted.push({ key, line: node.position?.start?.line ?? 0 });
328
+ }
329
+ }
330
+ }
331
+ const typed = declaredType !== null;
332
+ if (!typed && !isList) {
333
+ const kids = (node.children ?? []).filter(
334
+ (c) => c.type !== "text" || (c.value ?? "").trim() !== ""
335
+ );
336
+ const soleText = kids.length === 1 && kids[0].type === "text" ? kids[0] : null;
337
+ if (soleText) {
338
+ const raw = soleText.value ?? "";
339
+ const literal = raw.replace(/\s+/g, " ").trim();
340
+ const s = soleText.position?.start?.offset;
341
+ const e = soleText.position?.end?.offset;
342
+ if (literal !== "" && s !== void 0 && e !== void 0) {
343
+ edits.push({
344
+ start: toChar(s),
345
+ end: toChar(e),
346
+ // Emitted as an EXPRESSION, not a spliced string: Astro
347
+ // escapes {expression} output, which is the XSS gate. A
348
+ // probe proved naive splicing executes a stored
349
+ // <script> tag.
350
+ text: `{${HELPER_NAME}(${CONTENT_LOCAL}, ${quote(key)}, ${quote(literal)})}`
351
+ });
352
+ substituted.push({
353
+ key,
354
+ line: node.position?.start?.line ?? 0
355
+ });
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+ }
362
+ }
363
+ }
364
+ for (const child of node.children ?? []) visit(child);
365
+ }
366
+ visit(ast);
367
+ if (edits.length === 0) {
368
+ return { code: source, changed: false, substituted: [] };
369
+ }
370
+ edits.sort((a, b) => b.start - a.start);
371
+ let code = source;
372
+ for (const edit of edits) {
373
+ code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
374
+ }
375
+ return {
376
+ code: injectImports(code, {
377
+ text: code.includes(`${HELPER_NAME}(`),
378
+ link: code.includes(`${LINK_HELPER_NAME}(`)
379
+ }),
380
+ changed: true,
381
+ substituted
382
+ };
383
+ }
384
+ function injectImports(code, used) {
385
+ const names = [
386
+ LOADER_NAME,
387
+ used.text ? HELPER_NAME : null,
388
+ used.link ? LINK_HELPER_NAME : null
389
+ ].filter(Boolean).join(", ");
390
+ const preamble = [
391
+ `import { ${names} } from ${quote(HELPER_MODULE)};`,
392
+ `const ${CONTENT_LOCAL} = await ${LOADER_NAME}();`
393
+ ].join("\n");
394
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(code);
395
+ if (!fm) {
396
+ return `---
397
+ ${preamble}
398
+ ---
399
+ ${code}`;
400
+ }
401
+ if (fm[1].includes(HELPER_MODULE)) return code;
402
+ const at = fm.index + fm[0].length - 3;
403
+ return `${code.slice(0, at)}${preamble}
404
+ ${code.slice(at)}`;
405
+ }
406
+
201
407
  // src/integration.ts
202
408
  function resolvePublish(opts, hasDeployHookEnv) {
203
409
  const publish = opts.publish;
@@ -343,6 +549,11 @@ function canciaIntegration(opts = {}) {
343
549
  injectRoute({ pattern: "/api/cancia/lists/[listName]/[id]", entrypoint: endpointPath("lists"), prerender: false });
344
550
  const runtimeModulePath = fileURLToPath(new URL("./runtime.js", import.meta.url));
345
551
  const RESOLVED_VIRTUAL_ID = "\0virtual:cancia/runtime";
552
+ const textHelperModulePath = fileURLToPath(
553
+ new URL("./transform/text-helper.js", import.meta.url)
554
+ );
555
+ const TEXT_VIRTUAL_ID = "virtual:cancia/text";
556
+ const RESOLVED_TEXT_VIRTUAL_ID = "\0virtual:cancia/text";
346
557
  updateConfig({
347
558
  vite: {
348
559
  plugins: [
@@ -363,6 +574,56 @@ function canciaIntegration(opts = {}) {
363
574
  `export { getCanciaRuntime };`
364
575
  ].join("\n");
365
576
  }
577
+ },
578
+ // ---- plan 054: attribute-only authoring ---------------------
579
+ // Rewrites `<h1 data-cms="k">text</h1>` into a helper call before
580
+ // Astro's compiler reads the file, so a developer never writes a
581
+ // t() call, an import, or a repeated key.
582
+ //
583
+ // This works because Astro compiles the `source` ARGUMENT rather
584
+ // than reading from disk (deliberate since astro#3889), so an
585
+ // upstream transform is honoured.
586
+ //
587
+ // NOTE `enforce: "pre"` does NOT place this before Astro's own
588
+ // plugin — Astro prepends its own and mergeConfig appends ours
589
+ // (measured: ours at 20, astro:build at 9). It works because Vite
590
+ // CHAINS transform hooks and Astro's has an id filter. Do not
591
+ // write anything here that depends on plugin array position.
592
+ {
593
+ name: "vite-plugin-cancia-attribute-text",
594
+ enforce: "pre",
595
+ resolveId(id) {
596
+ if (id === TEXT_VIRTUAL_ID) return RESOLVED_TEXT_VIRTUAL_ID;
597
+ },
598
+ async load(id) {
599
+ if (id.endsWith(".astro") && !id.includes("?")) {
600
+ let source;
601
+ try {
602
+ source = await readFile(id, "utf8");
603
+ } catch {
604
+ return null;
605
+ }
606
+ const result = await transformAttributeText(source);
607
+ return result.changed ? result.code : null;
608
+ }
609
+ if (id !== RESOLVED_TEXT_VIRTUAL_ID) return;
610
+ const importUrl = pathToFileURL(textHelperModulePath).href;
611
+ return [
612
+ `import { __canciaText, __canciaLink, __canciaContent, configureTextHelper } from ${JSON.stringify(importUrl)};`,
613
+ `import { getCanciaRuntime } from "virtual:cancia/runtime";`,
614
+ `configureTextHelper({`,
615
+ ` site: ${JSON.stringify(site)},`,
616
+ ` lang: ${JSON.stringify(languages[0] ?? "en")},`,
617
+ // The read goes through the runtime so it uses whatever
618
+ // storage this project configured — no second adapter path.
619
+ ` read: async () => {`,
620
+ ` const rt = getCanciaRuntime();`,
621
+ ` return rt.storageV2 ? rt.storageV2.kv.getAll(${JSON.stringify(site)}) : {};`,
622
+ ` },`,
623
+ `});`,
624
+ `export { __canciaText, __canciaLink, __canciaContent };`
625
+ ].join("\n");
626
+ }
366
627
  }
367
628
  ]
368
629
  }
@@ -1,5 +1,8 @@
1
1
  import { Loader } from 'astro/loaders';
2
2
  import { a as CanciaStorageV2 } from '../types-BMlLS-OS.js';
3
+ import { ListSchema } from '../schema/index.js';
4
+ import 'zod';
5
+ import '../portable-text-D74aIuHn.js';
3
6
 
4
7
  interface CanciaLoaderOptions {
5
8
  /** The list name as declared in src/cms/schemas.ts. */
@@ -20,6 +23,27 @@ interface CanciaLoaderOptions {
20
23
  * adapter. Ignored when `storage` is supplied.
21
24
  */
22
25
  projectRoot?: string;
26
+ /**
27
+ * The list's schema. Pass it to enable draft filtering: when the schema
28
+ * declares a `draftField`, entries flagged as drafts are omitted from the
29
+ * collection, so an unpublished entry never reaches the built site.
30
+ *
31
+ * Optional — omitting it keeps the pre-056 behaviour of emitting every
32
+ * stored entry. You are almost certainly already importing `schemas` in
33
+ * `src/content.config.ts` for the collection's `schema:`, so this is one
34
+ * more reference to the same object:
35
+ *
36
+ * loader: canciaLoader({ list: "blog", site: "x", schema: schemas.blog }),
37
+ * schema: schemas.blog.validator,
38
+ */
39
+ schema?: Pick<ListSchema, "draftField">;
40
+ /**
41
+ * Emit drafts anyway, even when `schema.draftField` is set. The escape hatch
42
+ * for a preview deployment: build the same site with drafts visible by
43
+ * flipping one flag (e.g. `includeDrafts: import.meta.env.PREVIEW === "1"`),
44
+ * rather than maintaining a second content config.
45
+ */
46
+ includeDrafts?: boolean;
23
47
  }
24
48
  declare function canciaLoader(opts: CanciaLoaderOptions): Loader;
25
49
 
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  canciaLoader
3
- } from "../chunk-UR5WC3RA.js";
3
+ } from "../chunk-3B3CXOMK.js";
4
4
  import "../chunk-L2VKQJPY.js";
5
5
  import "../chunk-7IA5B5CF.js";
6
+ import "../chunk-WVHTCFE6.js";
7
+ import "../chunk-KHCFVVYV.js";
6
8
  export {
7
9
  canciaLoader
8
10
  };
@@ -113,5 +113,29 @@ declare const portableTextSubsetSchema: z.ZodArray<z.ZodObject<{
113
113
  }, z.core.$strip>>;
114
114
  }, z.core.$strip>>;
115
115
  type PortableTextValue = z.infer<typeof portableTextSubsetSchema>;
116
+ /**
117
+ * Parse a stored rich-text value. NEVER throws.
118
+ *
119
+ * The KV surface stores every value as a string, so a rich document round-trips
120
+ * as JSON — exactly the precedent `link` set (see parseLinkValue). Four cases
121
+ * must all degrade gracefully rather than break a render:
122
+ *
123
+ * - valid PT-subset JSON → used as-is, with unsafe hrefs stripped
124
+ * - a bare string → ONE normal block containing that text. This is
125
+ * the migration path for a field promoted from
126
+ * `text` to `richtext`: it keeps rendering.
127
+ * - empty string → [] (deliberately empty — renders nothing, on
128
+ * purpose; NOT the same as "no override")
129
+ * - malformed / off-subset → [] rather than propagating bad data to the
130
+ * renderer
131
+ *
132
+ * Unsafe hrefs are dropped ON READ, not only rejected on write. Validation
133
+ * guards the save path, but a value can reach a renderer another way — an older
134
+ * row, a hand-edited DB, a different adapter — so the read is where the
135
+ * guarantee actually holds. Same rule, same reason, as parseLinkValue.
136
+ */
137
+ declare function parseRichValue(raw: unknown): PortableTextValue;
138
+ /** Serialise a rich value for the KV store. Empty array -> "" (hidden). */
139
+ declare function serializeRichValue(value: PortableTextValue): string;
116
140
 
117
- export { PT_DECORATORS as P, PT_LIST_ITEMS as a, PT_STYLES as b, type PortableTextValue as c, type PtBlock as d, type PtDecorator as e, type PtLinkMarkDef as f, type PtListItem as g, type PtSpan as h, type PtStyle as i, isSafeHref as j, ptBlockSchema as k, portableTextSubsetSchema as p };
141
+ export { PT_DECORATORS as P, PT_LIST_ITEMS as a, PT_STYLES as b, type PortableTextValue as c, type PtBlock as d, type PtDecorator as e, type PtLinkMarkDef as f, type PtListItem as g, type PtSpan as h, type PtStyle as i, isSafeHref as j, portableTextSubsetSchema as k, ptBlockSchema as l, parseRichValue as p, serializeRichValue as s };
@@ -0,0 +1,57 @@
1
+ ---
2
+ // =============================================================================
3
+ // <CanciaRichRegion> — an editable rich-text region on the INLINE surface
4
+ // =============================================================================
5
+ // A slot the client can fill with real prose: paragraphs, h2/h3, blockquotes,
6
+ // bullet/number lists, strong/em, and links. They may ADD and DELETE paragraphs
7
+ // inside it. What they cannot do is invent a new section — the structure of the
8
+ // PAGE stays the developer's; the structure INSIDE a region is the client's.
9
+ //
10
+ // Usage:
11
+ // ---
12
+ // import CanciaRichRegion from "@cancia/astro/richtext/CanciaRichRegion.astro";
13
+ // const { tRich } = await reader.get();
14
+ // ---
15
+ // <CanciaRichRegion key="about.story" value={tRich("about.story")}>
16
+ // <p>The prose authored in the page. This is the fallback.</p>
17
+ // </CanciaRichRegion>
18
+ //
19
+ // THE FALLBACK IS THE SLOT. A rich fallback cannot be a JS string, so rather
20
+ // than serialise the markup into a Portable-Text literal at annotate time
21
+ // (lossy, unreadable, and it stops the source being the source of truth), the
22
+ // authored children ARE the fallback. An empty DB therefore renders
23
+ // byte-identically — the invariant that makes annotating a live client site
24
+ // safe.
25
+ //
26
+ // Three states, and the difference between the last two matters:
27
+ // value === null → no override. Render the authored children.
28
+ // value === [] → a stored, DELIBERATELY EMPTY value. Render nothing; the
29
+ // client removed this prose on purpose. Falling back to the
30
+ // authored children here would resurrect deleted content.
31
+ // value = blocks → render the stored document.
32
+ //
33
+ // Ships as a raw .astro under dist/richtext/ (an .astro file can't be a tsup
34
+ // entry) and is Vite-free at the package boundary.
35
+ // =============================================================================
36
+ import CanciaRichText from "./CanciaRichText.astro";
37
+
38
+ export interface Props {
39
+ /** Content key — what the toolbar edits and what the value is stored under. */
40
+ key: string;
41
+ /** The resolved value from `tRich(key)`. `null` means "no override". */
42
+ value?: Record<string, unknown>[] | null;
43
+ /** Element to render as the region wrapper. Default: `div`. */
44
+ as?: string;
45
+ class?: string;
46
+ }
47
+
48
+ const { key, value = null, as: Tag = "div", class: className } = Astro.props;
49
+
50
+ // `null` (no row) is the only state that shows the authored markup. An empty
51
+ // array is a real stored value and must render empty — see the header.
52
+ const hasOverride = Array.isArray(value);
53
+ ---
54
+
55
+ <Tag data-cms={key} data-cms-type="richtext" class={className}>
56
+ {hasOverride ? <CanciaRichText value={value} /> : <slot />}
57
+ </Tag>
@@ -1,5 +1,5 @@
1
- import { i as PtStyle, g as PtListItem, d as PtBlock } from '../portable-text-BikSqS9T.js';
2
- export { P as PT_DECORATORS, a as PT_LIST_ITEMS, b as PT_STYLES, c as PortableTextValue, e as PtDecorator, f as PtLinkMarkDef, h as PtSpan, j as isSafeHref, p as portableTextSubsetSchema, k as ptBlockSchema } from '../portable-text-BikSqS9T.js';
1
+ import { i as PtStyle, g as PtListItem, d as PtBlock } from '../portable-text-D74aIuHn.js';
2
+ export { P as PT_DECORATORS, a as PT_LIST_ITEMS, b as PT_STYLES, c as PortableTextValue, e as PtDecorator, f as PtLinkMarkDef, h as PtSpan, j as isSafeHref, p as parseRichValue, k as portableTextSubsetSchema, l as ptBlockSchema, s as serializeRichValue } from '../portable-text-D74aIuHn.js';
3
3
  import 'zod';
4
4
 
5
5
  /** A row as the editor holds it: raw shorthand text + style + optional list kind. */
@@ -3,9 +3,11 @@ import {
3
3
  PT_LIST_ITEMS,
4
4
  PT_STYLES,
5
5
  isSafeHref,
6
+ parseRichValue,
6
7
  portableTextSubsetSchema,
7
- ptBlockSchema
8
- } from "../chunk-BOIQNZAO.js";
8
+ ptBlockSchema,
9
+ serializeRichValue
10
+ } from "../chunk-KHCFVVYV.js";
9
11
 
10
12
  // src/richtext/markdown.ts
11
13
  function keyGen(prefix) {
@@ -134,9 +136,11 @@ export {
134
136
  PT_STYLES,
135
137
  blockToRow,
136
138
  isSafeHref,
139
+ parseRichValue,
137
140
  portableTextSubsetSchema,
138
141
  portableTextToRows,
139
142
  ptBlockSchema,
140
143
  rowToBlock,
141
- rowsToPortableText
144
+ rowsToPortableText,
145
+ serializeRichValue
142
146
  };
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  export { z } from 'zod';
3
- export { P as PT_DECORATORS, a as PT_LIST_ITEMS, b as PT_STYLES, c as PortableTextValue, d as PtBlock, e as PtDecorator, f as PtLinkMarkDef, g as PtListItem, h as PtSpan, i as PtStyle, j as isSafeHref, p as portableTextSubsetSchema, k as ptBlockSchema } from '../portable-text-BikSqS9T.js';
3
+ export { P as PT_DECORATORS, a as PT_LIST_ITEMS, b as PT_STYLES, c as PortableTextValue, d as PtBlock, e as PtDecorator, f as PtLinkMarkDef, g as PtListItem, h as PtSpan, i as PtStyle, j as isSafeHref, p as parseRichValue, k as portableTextSubsetSchema, l as ptBlockSchema, s as serializeRichValue } from '../portable-text-D74aIuHn.js';
4
4
 
5
5
  /**
6
6
  * Widget hint the modal form uses to pick an input element. The Zod type
@@ -82,6 +82,21 @@ interface ListSchemaOptions<TFields extends Record<string, z.ZodTypeAny>> {
82
82
  * Defaults to the entry ID. The server validates slug uniqueness on save.
83
83
  */
84
84
  slugField?: keyof TFields & string;
85
+ /**
86
+ * Field name holding the entry's draft flag. When set, `canciaLoader`
87
+ * omits entries whose value is truthy from the content collection, so a
88
+ * draft never reaches the built site.
89
+ *
90
+ * The field MUST be a boolean with a default (`defineField.draft()` gives
91
+ * you exactly that) — an `undefined` draft flag reads as published, which
92
+ * is the safe direction for the pre-existing entries of a list that adds
93
+ * `draftField` later.
94
+ *
95
+ * Draft is deliberately per-list opt-in rather than an implicit field on
96
+ * every list: a list of office locations or nav links has no meaningful
97
+ * draft state, and a phantom "Draft" checkbox on every form is noise.
98
+ */
99
+ draftField?: keyof TFields & string;
85
100
  /** Field definitions. */
86
101
  fields: TFields;
87
102
  }
@@ -91,10 +106,24 @@ interface ListSchema<TFields extends Record<string, z.ZodTypeAny> = Record<strin
91
106
  titleField: string;
92
107
  bodyField: string | undefined;
93
108
  slugField: string | undefined;
109
+ draftField: string | undefined;
94
110
  fields: TFields;
95
111
  /** Zod object schema combining all fields, for one-shot validation. */
96
112
  validator: z.ZodObject<TFields>;
97
113
  }
114
+ /**
115
+ * True when `entry.data` is a draft under `schema.draftField`.
116
+ *
117
+ * The single source of truth for "is this entry a draft" — the loader, the
118
+ * toolbar preview rows and any user-side filter all call THIS, so a draft is
119
+ * decided identically everywhere. Vite-free and dependency-free by design.
120
+ *
121
+ * Reads as published (`false`) when the list declares no draftField, when the
122
+ * field is absent, or when it is `undefined` — every ambiguous case resolves
123
+ * to "published", matching the field's own default and keeping entries that
124
+ * predate the draftField visible.
125
+ */
126
+ declare function isDraft(schema: Pick<ListSchema, "draftField">, data: Record<string, unknown>): boolean;
98
127
  declare function defineList<TFields extends Record<string, z.ZodTypeAny>>(opts: ListSchemaOptions<TFields>): ListSchema<TFields>;
99
128
  /**
100
129
  * One field as seen by the modal form. Plain JSON, no Zod refs.
@@ -154,6 +183,15 @@ declare const defineField: {
154
183
  email: (o?: PlainFieldOptions) => z.ZodString;
155
184
  datetime: (o?: PlainFieldOptions) => z.ZodString;
156
185
  checkbox: (o?: PlainFieldOptions) => z.ZodBoolean;
186
+ /**
187
+ * The draft flag for a list's `draftField`. A checkbox that DEFAULTS to
188
+ * false, which is what makes it safe to add to an existing list: every
189
+ * stored entry that predates the field validates and reads as published
190
+ * rather than vanishing from the built site.
191
+ *
192
+ * Label defaults to "Draft" so the affordance reads the same on every list.
193
+ */
194
+ draft: (o?: PlainFieldOptions) => z.ZodDefault<z.ZodBoolean>;
157
195
  number: (o?: PlainFieldOptions & {
158
196
  min?: number;
159
197
  max?: number;
@@ -266,6 +304,11 @@ interface ListDescription {
266
304
  titleField: string;
267
305
  bodyField?: string;
268
306
  slugField?: string;
307
+ /**
308
+ * Field holding the draft flag, when the list opts in. The toolbar uses it
309
+ * to badge unpublished rows; the loader uses it to omit them from the build.
310
+ */
311
+ draftField?: string;
269
312
  fields: FieldDescription[];
270
313
  }
271
314
  /**
@@ -317,4 +360,4 @@ declare function isExternalHref(href: string): boolean;
317
360
  /** Map of list name → schema. What users export from src/cms/schemas.ts. */
318
361
  type SchemasModule = Record<string, ListSchema>;
319
362
 
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 };
363
+ export { type FieldDescription, type FieldMeta, type FieldMetaBase, type FieldWidget, type LinkValue, type ListDescription, type ListSchema, type ListSchemaOptions, type SchemasModule, defineField, defineList, describeList, isDraft, isExternalHref, parseLinkValue, serializeLinkValue, slugify };
@@ -2,20 +2,23 @@ import {
2
2
  defineField,
3
3
  defineList,
4
4
  describeList,
5
+ isDraft,
5
6
  isExternalHref,
6
7
  parseLinkValue,
7
8
  serializeLinkValue,
8
9
  slugify,
9
10
  z
10
- } from "../chunk-UMCBQXB6.js";
11
+ } from "../chunk-WVHTCFE6.js";
11
12
  import {
12
13
  PT_DECORATORS,
13
14
  PT_LIST_ITEMS,
14
15
  PT_STYLES,
15
16
  isSafeHref,
17
+ parseRichValue,
16
18
  portableTextSubsetSchema,
17
- ptBlockSchema
18
- } from "../chunk-BOIQNZAO.js";
19
+ ptBlockSchema,
20
+ serializeRichValue
21
+ } from "../chunk-KHCFVVYV.js";
19
22
  export {
20
23
  PT_DECORATORS,
21
24
  PT_LIST_ITEMS,
@@ -23,12 +26,15 @@ export {
23
26
  defineField,
24
27
  defineList,
25
28
  describeList,
29
+ isDraft,
26
30
  isExternalHref,
27
31
  isSafeHref,
28
32
  parseLinkValue,
33
+ parseRichValue,
29
34
  portableTextSubsetSchema,
30
35
  ptBlockSchema,
31
36
  serializeLinkValue,
37
+ serializeRichValue,
32
38
  slugify,
33
39
  z
34
40
  };
@@ -0,0 +1,23 @@
1
+ import { ListSchema, SchemasModule } from './index.js';
2
+ import 'zod';
3
+ import '../portable-text-D74aIuHn.js';
4
+
5
+ /**
6
+ * Resolve the absolute path to the user's schemas.ts. Returns null if no
7
+ * file exists at the expected location.
8
+ */
9
+ declare function resolveSchemasPath(projectRoot: string, overridePath?: string): string | null;
10
+ /**
11
+ * Load (and cache) the user's schemas module. Returns an empty map if no
12
+ * schemas.ts exists — the inline editor still works for KV edits without
13
+ * any lists defined.
14
+ */
15
+ declare function loadSchemas(projectRoot: string, overridePath?: string): Promise<SchemasModule>;
16
+ /**
17
+ * Get a single list schema, or null if it doesn't exist.
18
+ */
19
+ declare function loadListSchema(projectRoot: string, listName: string, overridePath?: string): Promise<ListSchema | null>;
20
+ /** For tests — drop the in-memory cache. */
21
+ declare function clearSchemaCache(): void;
22
+
23
+ export { clearSchemaCache, loadListSchema, loadSchemas, resolveSchemasPath };
@@ -0,0 +1,12 @@
1
+ import {
2
+ clearSchemaCache,
3
+ loadListSchema,
4
+ loadSchemas,
5
+ resolveSchemasPath
6
+ } from "../chunk-VL6FO446.js";
7
+ export {
8
+ clearSchemaCache,
9
+ loadListSchema,
10
+ loadSchemas,
11
+ resolveSchemasPath
12
+ };
@@ -0,0 +1,47 @@
1
+ import { ContentMap } from '../content.js';
2
+
3
+ /** Test/dev hook — a content change must not be served from a stale cache. */
4
+ declare function clearTextCache(): void;
5
+ interface TextHelperConfig {
6
+ site: string;
7
+ lang: string;
8
+ /** Reads the whole content map. Injected so this module imports no adapter. */
9
+ read: () => Promise<ContentMap>;
10
+ }
11
+ declare function configureTextHelper(next: TextHelperConfig): void;
12
+ /**
13
+ * Load the content map. Awaited once per page, in frontmatter.
14
+ *
15
+ * NEVER throws or rejects. A content read must not break a build — the same rule
16
+ * createContentReader holds — so every failure resolves to an empty map, and
17
+ * every call site then falls back to the text authored in the page. A site whose
18
+ * database is unreachable renders exactly as its source says.
19
+ */
20
+ declare function __canciaContent(): Promise<ContentMap>;
21
+ /**
22
+ * Resolve one annotated element's text.
23
+ *
24
+ * `??` is load-bearing, not style: a stored "" means DELIBERATELY EMPTY (the
25
+ * client removed this text on purpose) and must NOT fall through to the authored
26
+ * fallback, or deleted content reappears on every build.
27
+ */
28
+ declare function __canciaText(cms: ContentMap, key: string, fallback: string): string;
29
+ interface LinkValue {
30
+ label: string;
31
+ href: string;
32
+ }
33
+ /**
34
+ * Resolve a link field — label AND href from one stored value.
35
+ *
36
+ * A link stores `{label, href}` as JSON so a new label can never be saved
37
+ * against a stale href. Both halves have an authored fallback (the text, and the
38
+ * `href=` already in the markup), which is why a link needs no t() call either.
39
+ *
40
+ * An unsafe href is DROPPED on read, falling back to the authored one — the same
41
+ * rule parseLinkValue holds, and for the same reason: validation guards the
42
+ * write path, but a value can reach a renderer from an older row or a
43
+ * hand-edited DB, so the read is where the guarantee has to hold.
44
+ */
45
+ declare function __canciaLink(cms: ContentMap, key: string, labelFallback: string, hrefFallback: string): LinkValue;
46
+
47
+ export { type LinkValue, type TextHelperConfig, __canciaContent, __canciaLink, __canciaText, clearTextCache, configureTextHelper };
@@ -0,0 +1,64 @@
1
+ // src/transform/text-helper.ts
2
+ var contentPromise = null;
3
+ function clearTextCache() {
4
+ contentPromise = null;
5
+ }
6
+ var config = null;
7
+ function configureTextHelper(next) {
8
+ config = next;
9
+ contentPromise = null;
10
+ }
11
+ async function __canciaContent() {
12
+ if (!config) return {};
13
+ try {
14
+ if (!contentPromise) contentPromise = config.read();
15
+ return await contentPromise;
16
+ } catch {
17
+ contentPromise = null;
18
+ return {};
19
+ }
20
+ }
21
+ function lang() {
22
+ return config?.lang ?? "en";
23
+ }
24
+ function __canciaText(cms, key, fallback) {
25
+ return cms[`${key}.${lang()}`] ?? fallback;
26
+ }
27
+ function isSafeHref(href) {
28
+ const trimmed = href.trim();
29
+ if (trimmed === "") return true;
30
+ if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return true;
31
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
32
+ if (scheme) {
33
+ const firstSep = trimmed.search(/[/?#]/);
34
+ if (firstSep === -1 || scheme[1].length < firstSep) return false;
35
+ }
36
+ return true;
37
+ }
38
+ function __canciaLink(cms, key, labelFallback, hrefFallback) {
39
+ const fallback = { label: labelFallback, href: hrefFallback };
40
+ const raw = cms[`${key}.${lang()}`];
41
+ if (raw === void 0) return fallback;
42
+ if (raw === "") return { label: "", href: "" };
43
+ const trimmed = raw.trim();
44
+ if (trimmed.startsWith("{")) {
45
+ try {
46
+ const parsed = JSON.parse(trimmed);
47
+ const href = String(parsed.href ?? "");
48
+ return {
49
+ label: String(parsed.label ?? "") || labelFallback,
50
+ href: href && isSafeHref(href) ? href : hrefFallback
51
+ };
52
+ } catch {
53
+ return fallback;
54
+ }
55
+ }
56
+ return { label: raw, href: hrefFallback };
57
+ }
58
+ export {
59
+ __canciaContent,
60
+ __canciaLink,
61
+ __canciaText,
62
+ clearTextCache,
63
+ configureTextHelper
64
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cancia/astro",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Astro integration for Cancia CMS — inline editing with zero separate server",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,10 @@
20
20
  "types": "./dist/schema/index.d.ts",
21
21
  "import": "./dist/schema/index.js"
22
22
  },
23
+ "./schema-loader": {
24
+ "types": "./dist/schema/loader.d.ts",
25
+ "import": "./dist/schema/loader.js"
26
+ },
23
27
  "./loader": {
24
28
  "types": "./dist/loader/index.d.ts",
25
29
  "import": "./dist/loader/index.js"
@@ -40,7 +44,8 @@
40
44
  "types": "./dist/richtext/index.d.ts",
41
45
  "import": "./dist/richtext/index.js"
42
46
  },
43
- "./richtext/CanciaRichText.astro": "./dist/richtext/CanciaRichText.astro"
47
+ "./richtext/CanciaRichText.astro": "./dist/richtext/CanciaRichText.astro",
48
+ "./richtext/CanciaRichRegion.astro": "./dist/richtext/CanciaRichRegion.astro"
44
49
  },
45
50
  "files": [
46
51
  "dist"
@@ -68,7 +73,8 @@
68
73
  },
69
74
  "dependencies": {
70
75
  "astro-portabletext": "^0.13.0",
71
- "zod": "^4.4.3"
76
+ "zod": "^4.4.3",
77
+ "@astrojs/compiler": "^2.10.0"
72
78
  },
73
79
  "scripts": {
74
80
  "build": "tsup",