@cancia/astro 0.7.0 → 0.9.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,55 +1,14 @@
1
+ import {
2
+ createRateLimiter,
3
+ runAuth
4
+ } from "../chunk-GJIX7MWC.js";
5
+
1
6
  // src/endpoints/auth.ts
2
7
  import { getCanciaRuntime } from "virtual:cancia/runtime";
3
- var buckets = /* @__PURE__ */ new Map();
4
- var MAX_FAILURES = 5;
5
- var WINDOW_MS = 6e4;
6
- function getIp(req) {
7
- return req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? req.headers.get("x-real-ip") ?? "unknown";
8
- }
9
- function isRateLimited(ip) {
10
- const bucket = buckets.get(ip);
11
- if (!bucket || Date.now() > bucket.resetAt) return false;
12
- return bucket.failures >= MAX_FAILURES;
13
- }
14
- function recordFailure(ip) {
15
- const now = Date.now();
16
- const bucket = buckets.get(ip);
17
- if (!bucket || now > bucket.resetAt) {
18
- buckets.set(ip, { failures: 1, resetAt: now + WINDOW_MS });
19
- } else {
20
- bucket.failures += 1;
21
- }
22
- }
8
+ var limiter = createRateLimiter();
23
9
  async function POST({ request }) {
24
10
  const { secret } = getCanciaRuntime();
25
- const ip = getIp(request);
26
- if (isRateLimited(ip))
27
- return new Response(
28
- JSON.stringify({ error: "Too many attempts. Try again in a minute." }),
29
- { status: 429, headers: { "Content-Type": "application/json" } }
30
- );
31
- let token;
32
- try {
33
- const body = await request.json();
34
- token = body?.token;
35
- } catch {
36
- return new Response(
37
- JSON.stringify({ error: "Invalid request body" }),
38
- { status: 400, headers: { "Content-Type": "application/json" } }
39
- );
40
- }
41
- if (!token || token !== secret) {
42
- recordFailure(ip);
43
- return new Response(
44
- JSON.stringify({ error: "Invalid token" }),
45
- { status: 401, headers: { "Content-Type": "application/json" } }
46
- );
47
- }
48
- buckets.delete(ip);
49
- return new Response(
50
- JSON.stringify({ ok: true }),
51
- { status: 200, headers: { "Content-Type": "application/json" } }
52
- );
11
+ return runAuth({ request, secret, limiter });
53
12
  }
54
13
  export {
55
14
  POST
@@ -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,29 +1,23 @@
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
- makeListsRoutes
6
- } from "./chunk-22DJVJBR.js";
8
+ createRateLimiter,
9
+ runAuth
10
+ } from "./chunk-GJIX7MWC.js";
7
11
  import {
8
- makeSchemasRoute
9
- } from "./chunk-VFMOVGMC.js";
10
- import "./chunk-NG5GJME5.js";
12
+ makeListsRoutes
13
+ } from "./chunk-DIV2FFYX.js";
11
14
  import {
12
15
  makeLocalUploadHandler,
13
16
  makeR2UploadHandler,
14
17
  makeUploadRoute,
15
18
  setCanciaRuntime
16
- } from "./chunk-AGNPUTKS.js";
19
+ } from "./chunk-LJSSPOSW.js";
17
20
  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
21
  import {
28
22
  createSQLiteAdapter
29
23
  } from "./chunk-CJDIVWO3.js";
@@ -31,20 +25,31 @@ import {
31
25
  closeSqliteAdapterV2,
32
26
  createGitBackedAdapter,
33
27
  createSqliteAdapterV2
34
- } from "./chunk-GNWD7EL2.js";
28
+ } from "./chunk-MXVOJRAM.js";
35
29
  import "./chunk-U7V53JX7.js";
30
+ import "./chunk-VL6FO446.js";
31
+ import {
32
+ canciaLoader
33
+ } from "./chunk-GBTFXQEA.js";
36
34
  import {
37
35
  createJsonFileAdapter,
38
36
  createJsonFileAdapterV2
39
- } from "./chunk-L2VKQJPY.js";
37
+ } from "./chunk-2NVZWZPE.js";
40
38
  import {
41
39
  RevConflictError
42
40
  } from "./chunk-7IA5B5CF.js";
43
- import "./chunk-BOIQNZAO.js";
41
+ import {
42
+ defineField,
43
+ defineList,
44
+ describeList,
45
+ z
46
+ } from "./chunk-WVHTCFE6.js";
47
+ import "./chunk-KHCFVVYV.js";
44
48
 
45
49
  // src/integration.ts
46
50
  import { loadEnv } from "vite";
47
51
  import { fileURLToPath, pathToFileURL } from "url";
52
+ import { readFile } from "fs/promises";
48
53
  import { isAbsolute, join as join2 } from "path";
49
54
 
50
55
  // src/routes/content.ts
@@ -114,61 +119,10 @@ function makePublishRoute(deployHook, secret, hookOptions, dispatch, mode) {
114
119
  }
115
120
 
116
121
  // src/routes/auth.ts
117
- var buckets = /* @__PURE__ */ new Map();
118
- var MAX_FAILURES = 5;
119
- var WINDOW_MS = 6e4;
120
- function getIp(req) {
121
- return req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? req.headers.get("x-real-ip") ?? "unknown";
122
- }
123
- function isRateLimited(ip) {
124
- const now = Date.now();
125
- const bucket = buckets.get(ip);
126
- if (!bucket || now > bucket.resetAt) return false;
127
- return bucket.failures >= MAX_FAILURES;
128
- }
129
- function recordFailure(ip) {
130
- const now = Date.now();
131
- const bucket = buckets.get(ip);
132
- if (!bucket || now > bucket.resetAt) {
133
- buckets.set(ip, { failures: 1, resetAt: now + WINDOW_MS });
134
- } else {
135
- bucket.failures += 1;
136
- }
137
- }
138
- function clearFailures(ip) {
139
- buckets.delete(ip);
140
- }
141
122
  function makeAuthRoute(secret) {
123
+ const limiter = createRateLimiter();
142
124
  return async function authRoute(req) {
143
- const ip = getIp(req);
144
- if (isRateLimited(ip)) {
145
- return new Response(
146
- JSON.stringify({ error: "Too many attempts. Try again in a minute." }),
147
- { status: 429, headers: { "Content-Type": "application/json" } }
148
- );
149
- }
150
- let token;
151
- try {
152
- const body = await req.json();
153
- token = body?.token;
154
- } catch {
155
- return new Response(
156
- JSON.stringify({ error: "Invalid request body" }),
157
- { status: 400, headers: { "Content-Type": "application/json" } }
158
- );
159
- }
160
- if (!token || token !== secret) {
161
- recordFailure(ip);
162
- return new Response(
163
- JSON.stringify({ error: "Invalid token" }),
164
- { status: 401, headers: { "Content-Type": "application/json" } }
165
- );
166
- }
167
- clearFailures(ip);
168
- return new Response(
169
- JSON.stringify({ ok: true }),
170
- { status: 200, headers: { "Content-Type": "application/json" } }
171
- );
125
+ return runAuth({ request: req, secret, limiter });
172
126
  };
173
127
  }
174
128
 
@@ -198,6 +152,211 @@ CANCIA_TOKEN=${token}
198
152
  return token;
199
153
  }
200
154
 
155
+ // src/transform/attribute-text.ts
156
+ import { parse } from "@astrojs/compiler";
157
+ var HELPER_NAME = "__canciaText";
158
+ var LINK_HELPER_NAME = "__canciaLink";
159
+ var LOADER_NAME = "__canciaContent";
160
+ var CONTENT_LOCAL = "__canciaCms";
161
+ var HELPER_MODULE = "virtual:cancia/text";
162
+ var RAW_TEXT = /* @__PURE__ */ new Set([
163
+ "script",
164
+ "style",
165
+ "pre",
166
+ "textarea",
167
+ "code",
168
+ "title",
169
+ "option",
170
+ "noscript",
171
+ "template"
172
+ ]);
173
+ function byteToCharMap(source) {
174
+ let ascii = true;
175
+ for (let i = 0; i < source.length; i++) {
176
+ if (source.charCodeAt(i) > 127) {
177
+ ascii = false;
178
+ break;
179
+ }
180
+ }
181
+ if (ascii) return (b) => b;
182
+ const map = /* @__PURE__ */ new Map();
183
+ let byte = 0;
184
+ for (let char = 0; char < source.length; char++) {
185
+ map.set(byte, char);
186
+ const code = source.codePointAt(char);
187
+ byte += code < 128 ? 1 : code < 2048 ? 2 : code < 65536 ? 3 : 4;
188
+ if (code >= 65536) char++;
189
+ }
190
+ map.set(byte, source.length);
191
+ return (b) => map.get(b) ?? b;
192
+ }
193
+ function quote(text) {
194
+ return JSON.stringify(text);
195
+ }
196
+ function findOpenAngle(node, src, at) {
197
+ const name = node.name ?? "";
198
+ const floor = Math.max(0, at - 4096);
199
+ for (let i = Math.min(at, src.length - 1); i >= floor; i--) {
200
+ if (src[i] !== "<") continue;
201
+ if (src.slice(i + 1, i + 1 + name.length) !== name) continue;
202
+ const boundary = src[i + 1 + name.length];
203
+ if (boundary === void 0 || /[\s/>]/.test(boundary)) return i;
204
+ }
205
+ return null;
206
+ }
207
+ function readAttr(openTag, name) {
208
+ const re = new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`);
209
+ return re.exec(openTag)?.[1] ?? null;
210
+ }
211
+ function findOpenTagEnd(src, lt) {
212
+ let quoteChar = null;
213
+ for (let i = lt; i < src.length; i++) {
214
+ const c = src[i];
215
+ if (quoteChar) {
216
+ if (c === quoteChar) quoteChar = null;
217
+ continue;
218
+ }
219
+ if (c === '"' || c === "'") {
220
+ quoteChar = c;
221
+ continue;
222
+ }
223
+ if (c === ">") return i + 1;
224
+ }
225
+ return null;
226
+ }
227
+ async function transformAttributeText(source) {
228
+ if (!source.includes("data-cms")) {
229
+ return { code: source, changed: false, substituted: [] };
230
+ }
231
+ const toChar = byteToCharMap(source);
232
+ let ast;
233
+ try {
234
+ ast = (await parse(source, { position: true })).ast;
235
+ } catch {
236
+ return { code: source, changed: false, substituted: [] };
237
+ }
238
+ const edits = [];
239
+ const substituted = [];
240
+ function visit(node) {
241
+ if (node.type === "element" && RAW_TEXT.has((node.name ?? "").toLowerCase())) {
242
+ return;
243
+ }
244
+ if (node.type === "element") {
245
+ const reported = node.position?.start?.offset;
246
+ if (reported !== void 0) {
247
+ const lt = findOpenAngle(node, source, toChar(reported));
248
+ if (lt !== null) {
249
+ const gt = findOpenTagEnd(source, lt);
250
+ if (gt !== null) {
251
+ const openTag = source.slice(lt, gt);
252
+ const key = readAttr(openTag, "data-cms");
253
+ if (key !== null) {
254
+ const declaredType = readAttr(openTag, "data-cms-type");
255
+ const isList = /\sdata-cms-list\s*=/.test(openTag);
256
+ if (declaredType === "link" && !isList) {
257
+ const kids = (node.children ?? []).filter(
258
+ (c) => c.type !== "text" || (c.value ?? "").trim() !== ""
259
+ );
260
+ const soleText = kids.length === 1 && kids[0].type === "text" ? kids[0] : null;
261
+ const authoredHref = readAttr(openTag, "href");
262
+ if (soleText && authoredHref !== null) {
263
+ const raw = soleText.value ?? "";
264
+ const literal = raw.replace(/\s+/g, " ").trim();
265
+ const s = soleText.position?.start?.offset;
266
+ const e = soleText.position?.end?.offset;
267
+ const hrefRe = /\shref\s*=\s*(["'])([^"']*)\1/.exec(openTag);
268
+ if (literal !== "" && s !== void 0 && e !== void 0 && hrefRe) {
269
+ const call = `${LINK_HELPER_NAME}(${CONTENT_LOCAL}, ${quote(key)}, ${quote(literal)}, ${quote(hrefRe[2])})`;
270
+ edits.push({
271
+ start: lt + hrefRe.index,
272
+ end: lt + hrefRe.index + hrefRe[0].length,
273
+ text: ` href={${call}.href}`
274
+ });
275
+ edits.push({
276
+ start: toChar(s),
277
+ end: toChar(e),
278
+ text: `{${call}.label}`
279
+ });
280
+ substituted.push({ key, line: node.position?.start?.line ?? 0 });
281
+ }
282
+ }
283
+ }
284
+ const typed = declaredType !== null;
285
+ if (!typed && !isList) {
286
+ const kids = (node.children ?? []).filter(
287
+ (c) => c.type !== "text" || (c.value ?? "").trim() !== ""
288
+ );
289
+ const soleText = kids.length === 1 && kids[0].type === "text" ? kids[0] : null;
290
+ if (soleText) {
291
+ const raw = soleText.value ?? "";
292
+ const literal = raw.replace(/\s+/g, " ").trim();
293
+ const s = soleText.position?.start?.offset;
294
+ const e = soleText.position?.end?.offset;
295
+ if (literal !== "" && s !== void 0 && e !== void 0) {
296
+ edits.push({
297
+ start: toChar(s),
298
+ end: toChar(e),
299
+ // Emitted as an EXPRESSION, not a spliced string: Astro
300
+ // escapes {expression} output, which is the XSS gate. A
301
+ // probe proved naive splicing executes a stored
302
+ // <script> tag.
303
+ text: `{${HELPER_NAME}(${CONTENT_LOCAL}, ${quote(key)}, ${quote(literal)})}`
304
+ });
305
+ substituted.push({
306
+ key,
307
+ line: node.position?.start?.line ?? 0
308
+ });
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+ }
316
+ }
317
+ for (const child of node.children ?? []) visit(child);
318
+ }
319
+ visit(ast);
320
+ if (edits.length === 0) {
321
+ return { code: source, changed: false, substituted: [] };
322
+ }
323
+ edits.sort((a, b) => b.start - a.start);
324
+ let code = source;
325
+ for (const edit of edits) {
326
+ code = code.slice(0, edit.start) + edit.text + code.slice(edit.end);
327
+ }
328
+ return {
329
+ code: injectImports(code, {
330
+ text: code.includes(`${HELPER_NAME}(`),
331
+ link: code.includes(`${LINK_HELPER_NAME}(`)
332
+ }),
333
+ changed: true,
334
+ substituted
335
+ };
336
+ }
337
+ function injectImports(code, used) {
338
+ const names = [
339
+ LOADER_NAME,
340
+ used.text ? HELPER_NAME : null,
341
+ used.link ? LINK_HELPER_NAME : null
342
+ ].filter(Boolean).join(", ");
343
+ const preamble = [
344
+ `import { ${names} } from ${quote(HELPER_MODULE)};`,
345
+ `const ${CONTENT_LOCAL} = await ${LOADER_NAME}();`
346
+ ].join("\n");
347
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(code);
348
+ if (!fm) {
349
+ return `---
350
+ ${preamble}
351
+ ---
352
+ ${code}`;
353
+ }
354
+ if (fm[1].includes(HELPER_MODULE)) return code;
355
+ const at = fm.index + fm[0].length - 3;
356
+ return `${code.slice(0, at)}${preamble}
357
+ ${code.slice(at)}`;
358
+ }
359
+
201
360
  // src/integration.ts
202
361
  function resolvePublish(opts, hasDeployHookEnv) {
203
362
  const publish = opts.publish;
@@ -343,6 +502,11 @@ function canciaIntegration(opts = {}) {
343
502
  injectRoute({ pattern: "/api/cancia/lists/[listName]/[id]", entrypoint: endpointPath("lists"), prerender: false });
344
503
  const runtimeModulePath = fileURLToPath(new URL("./runtime.js", import.meta.url));
345
504
  const RESOLVED_VIRTUAL_ID = "\0virtual:cancia/runtime";
505
+ const textHelperModulePath = fileURLToPath(
506
+ new URL("./transform/text-helper.js", import.meta.url)
507
+ );
508
+ const TEXT_VIRTUAL_ID = "virtual:cancia/text";
509
+ const RESOLVED_TEXT_VIRTUAL_ID = "\0virtual:cancia/text";
346
510
  updateConfig({
347
511
  vite: {
348
512
  plugins: [
@@ -363,6 +527,56 @@ function canciaIntegration(opts = {}) {
363
527
  `export { getCanciaRuntime };`
364
528
  ].join("\n");
365
529
  }
530
+ },
531
+ // ---- plan 054: attribute-only authoring ---------------------
532
+ // Rewrites `<h1 data-cms="k">text</h1>` into a helper call before
533
+ // Astro's compiler reads the file, so a developer never writes a
534
+ // t() call, an import, or a repeated key.
535
+ //
536
+ // This works because Astro compiles the `source` ARGUMENT rather
537
+ // than reading from disk (deliberate since astro#3889), so an
538
+ // upstream transform is honoured.
539
+ //
540
+ // NOTE `enforce: "pre"` does NOT place this before Astro's own
541
+ // plugin — Astro prepends its own and mergeConfig appends ours
542
+ // (measured: ours at 20, astro:build at 9). It works because Vite
543
+ // CHAINS transform hooks and Astro's has an id filter. Do not
544
+ // write anything here that depends on plugin array position.
545
+ {
546
+ name: "vite-plugin-cancia-attribute-text",
547
+ enforce: "pre",
548
+ resolveId(id) {
549
+ if (id === TEXT_VIRTUAL_ID) return RESOLVED_TEXT_VIRTUAL_ID;
550
+ },
551
+ async load(id) {
552
+ if (id.endsWith(".astro") && !id.includes("?")) {
553
+ let source;
554
+ try {
555
+ source = await readFile(id, "utf8");
556
+ } catch {
557
+ return null;
558
+ }
559
+ const result = await transformAttributeText(source);
560
+ return result.changed ? result.code : null;
561
+ }
562
+ if (id !== RESOLVED_TEXT_VIRTUAL_ID) return;
563
+ const importUrl = pathToFileURL(textHelperModulePath).href;
564
+ return [
565
+ `import { __canciaText, __canciaLink, __canciaContent, configureTextHelper } from ${JSON.stringify(importUrl)};`,
566
+ `import { getCanciaRuntime } from "virtual:cancia/runtime";`,
567
+ `configureTextHelper({`,
568
+ ` site: ${JSON.stringify(site)},`,
569
+ ` lang: ${JSON.stringify(languages[0] ?? "en")},`,
570
+ // The read goes through the runtime so it uses whatever
571
+ // storage this project configured — no second adapter path.
572
+ ` read: async () => {`,
573
+ ` const rt = getCanciaRuntime();`,
574
+ ` return rt.storageV2 ? rt.storageV2.kv.getAll(${JSON.stringify(site)}) : {};`,
575
+ ` },`,
576
+ `});`,
577
+ `export { __canciaText, __canciaLink, __canciaContent };`
578
+ ].join("\n");
579
+ }
366
580
  }
367
581
  ]
368
582
  }
@@ -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";
4
- import "../chunk-L2VKQJPY.js";
3
+ } from "../chunk-GBTFXQEA.js";
4
+ import "../chunk-2NVZWZPE.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. */