@caelo-cms/shared 0.10.21 → 0.10.23

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.
Files changed (241) hide show
  1. package/dist/ai-tools.d.ts +291 -231
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +349 -281
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. package/src/version.ts +66 -0
package/src/media.ts ADDED
@@ -0,0 +1,355 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Media library — shared primitives.
5
+ *
6
+ * Browser-safe: Zod schemas, MIME allowlist, size caps, the variant
7
+ * convention. Sharp + filesystem adapters live in `@caelo-cms/admin-core`
8
+ * (server-only). The storage-key shape is stable here so the static
9
+ * generator's URL rewriter and the admin's iframe resolver agree on
10
+ * the canonical form `<sha>/<variant>.<ext>`.
11
+ */
12
+
13
+ import { z } from "zod";
14
+
15
+ /**
16
+ * Allowlisted MIME types. Anything outside this set is rejected at the
17
+ * upload endpoint with `415 Unsupported Media Type`. SVG is allowed
18
+ * but capped tight to discourage XSS via embedded scripts; the upload
19
+ * pipeline strips `<script>` and event-handler attributes before
20
+ * persisting (see {@link sanitizeSvg} in admin-core).
21
+ */
22
+ export const MEDIA_ALLOWED_MIMES = [
23
+ "image/jpeg",
24
+ "image/png",
25
+ "image/webp",
26
+ "image/avif",
27
+ "image/gif",
28
+ "image/svg+xml",
29
+ "application/pdf",
30
+ "video/mp4",
31
+ // issue #249 — webfonts. Migrated sites reference their own font
32
+ // files from replayed CSS; the media-migration pass downloads them
33
+ // into the library so the rebuilt site survives the source host
34
+ // going away. Stored as-is (no derived variants).
35
+ "font/woff2",
36
+ "font/woff",
37
+ "font/ttf",
38
+ "font/otf",
39
+ ] as const;
40
+ export type MediaMime = (typeof MEDIA_ALLOWED_MIMES)[number];
41
+
42
+ /** Per-MIME size caps (bytes). Server enforces; client display only. */
43
+ export const MEDIA_SIZE_CAPS: Record<MediaMime, number> = {
44
+ "image/jpeg": 10 * 1024 * 1024,
45
+ "image/png": 10 * 1024 * 1024,
46
+ "image/webp": 10 * 1024 * 1024,
47
+ "image/avif": 10 * 1024 * 1024,
48
+ "image/gif": 8 * 1024 * 1024,
49
+ "image/svg+xml": 1 * 1024 * 1024,
50
+ "application/pdf": 20 * 1024 * 1024,
51
+ "video/mp4": 50 * 1024 * 1024,
52
+ "font/woff2": 5 * 1024 * 1024,
53
+ "font/woff": 5 * 1024 * 1024,
54
+ "font/ttf": 5 * 1024 * 1024,
55
+ "font/otf": 5 * 1024 * 1024,
56
+ };
57
+
58
+ /** Hard ceiling on the multipart body. Per-MIME caps narrow further. */
59
+ export const MEDIA_HARD_LIMIT_BYTES = 50 * 1024 * 1024;
60
+
61
+ /**
62
+ * Variant tags. `orig` is always present (re-encoded only for SVG
63
+ * sanitisation). Image-only WebP variants are emitted at breakpoints
64
+ * the source can satisfy — a 600px-wide source skips webp-1200 +
65
+ * webp-1600 entirely.
66
+ */
67
+ export const MEDIA_VARIANT_TAGS = [
68
+ "orig",
69
+ "webp-1600",
70
+ "webp-1200",
71
+ "webp-800",
72
+ "webp-400",
73
+ ] as const;
74
+ export type MediaVariantTag = (typeof MEDIA_VARIANT_TAGS)[number];
75
+
76
+ /** Width-in-pixels target for each WebP variant. */
77
+ export const MEDIA_VARIANT_WIDTHS: Record<Exclude<MediaVariantTag, "orig">, number> = {
78
+ "webp-1600": 1600,
79
+ "webp-1200": 1200,
80
+ "webp-800": 800,
81
+ "webp-400": 400,
82
+ };
83
+
84
+ /**
85
+ * Renderer-agnostic asset URL used in module HTML. Both the SvelteKit
86
+ * admin endpoint and the static generator's media-pass parse this
87
+ * shape; the static generator rewrites to `/_assets/...` (or a CDN
88
+ * URL) at deploy time.
89
+ *
90
+ * Format (current): `/_caelo/media/<slug>` for the orig variant,
91
+ * `/_caelo/media/<slug>/<variant>` for a named variant (webp/crops). The
92
+ * `<slug>` is `media_assets.slug` — a human-meaningful name (e.g.
93
+ * `searchviu-logo`); the UUID id stays internal. The static generator
94
+ * rewrites these to `/_assets/<slug>.<ext>` (orig) / `/_assets/<slug>/<variant>.<ext>`.
95
+ *
96
+ * Legacy form `/_caelo/media/<uuid>/<variant>` is still PARSED (existing
97
+ * persisted embeds keep resolving), but never newly EMITTED.
98
+ */
99
+ export const MEDIA_URL_PREFIX = "/_caelo/media";
100
+
101
+ /** Full-uuid shape — used to tell a legacy id ref from a slug ref. */
102
+ const MEDIA_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
103
+
104
+ /**
105
+ * Build the media URL for an asset SLUG + variant. The orig variant is
106
+ * flat (`/_caelo/media/<slug>`) so a plain image reads as a name, not a
107
+ * path; named variants (srcset webp / focal crops) nest under the slug.
108
+ * Widened to `| string` (run #10 D4): pickAiImageVariant returns whichever
109
+ * variant tag actually exists.
110
+ */
111
+ export function buildMediaUrl(slug: string, variant: MediaVariantTag | string): string {
112
+ return variant === "orig"
113
+ ? `${MEDIA_URL_PREFIX}/${slug}`
114
+ : `${MEDIA_URL_PREFIX}/${slug}/${variant}`;
115
+ }
116
+
117
+ /**
118
+ * Turn a human/asset label into a URL-safe media slug: lowercase ascii,
119
+ * kebab-case, extension + junk stripped, capped at 60 chars. NOT
120
+ * uniquified — the caller resolves collisions against the live library
121
+ * (append `-2`/`-3`…); see `resolveUniqueMediaSlug` in admin-core.
122
+ */
123
+ export function slugifyMediaName(name: string): string {
124
+ const noExt = name.replace(/\.[a-z0-9]{1,8}$/i, "");
125
+ const slug = noExt
126
+ .normalize("NFKD")
127
+ .replace(/[̀-ͯ]/g, "") // strip combining accents
128
+ .toLowerCase()
129
+ .replace(/[^a-z0-9]+/g, "-")
130
+ .replace(/^-+|-+$/g, "")
131
+ .slice(0, 60)
132
+ .replace(/-+$/g, "");
133
+ return slug.length > 0 ? slug : "image";
134
+ }
135
+
136
+ // Segment token: a slug or uuid (`[a-z0-9-]`) optionally followed by a
137
+ // variant. `orig`, `webp-<width>`, `<crop-name>-<width>` all round-trip
138
+ // without a per-crop regex update.
139
+ const mediaUrlPattern = new RegExp(
140
+ `${MEDIA_URL_PREFIX}/([a-z0-9][a-z0-9-]{0,63})(?:/([a-z][a-z0-9-]{0,63}))?`,
141
+ "g",
142
+ );
143
+
144
+ /**
145
+ * One media reference parsed from HTML. `ref` is either an asset SLUG
146
+ * (`isSlug: true`) or a legacy UUID id (`isSlug: false`); callers resolve
147
+ * a slug ref to an asset id before touching the DB. `variant` defaults to
148
+ * `orig` for the flat slug form.
149
+ */
150
+ export interface MediaRef {
151
+ readonly ref: string;
152
+ readonly isSlug: boolean;
153
+ readonly variant: string;
154
+ }
155
+
156
+ /**
157
+ * Extract every media reference in an HTML string (deduped). Used by the
158
+ * post-write usage-tracker and the static-generator media-pass. Handles
159
+ * both the current slug form and the legacy `<uuid>/<variant>` form.
160
+ */
161
+ export function extractMediaRefs(html: string): MediaRef[] {
162
+ const seen = new Set<string>();
163
+ const out: MediaRef[] = [];
164
+ for (const m of html.matchAll(mediaUrlPattern)) {
165
+ const seg1 = m[1] as string;
166
+ const seg2 = m[2];
167
+ // A full-uuid first segment with a trailing variant is the legacy id
168
+ // form; everything else is a slug (orig when no explicit variant).
169
+ const isLegacyId = MEDIA_UUID_RE.test(seg1) && seg2 !== undefined;
170
+ const ref = seg1;
171
+ const isSlug = !isLegacyId;
172
+ const variant = seg2 ?? "orig";
173
+ const key = `${isSlug ? "s" : "i"}:${ref}/${variant}`;
174
+ if (seen.has(key)) continue;
175
+ seen.add(key);
176
+ out.push({ ref, isSlug, variant });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------
182
+ // Zod schemas — exposed at the Query-API boundary.
183
+ // ---------------------------------------------------------------------
184
+
185
+ const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/, "must be hex sha256");
186
+
187
+ export const mediaUploadInputSchema = z
188
+ .object({
189
+ sha256: sha256Schema,
190
+ originalName: z.string().min(1).max(512),
191
+ /**
192
+ * Meaningful, human-facing label for the asset (e.g. "SearchVIU logo").
193
+ * The handler slugifies + uniquifies it into `media_assets.slug`, which
194
+ * becomes the public URL (`/_assets/<slug>.<ext>`); the id stays internal.
195
+ * Optional: falls back to `alt` → `originalName` → "image".
196
+ */
197
+ name: z.string().max(200).optional(),
198
+ mime: z.enum(MEDIA_ALLOWED_MIMES),
199
+ sizeBytes: z.number().int().positive(),
200
+ width: z.number().int().positive().nullable(),
201
+ height: z.number().int().positive().nullable(),
202
+ alt: z.string().max(2048).default(""),
203
+ storageKey: z.string().min(1),
204
+ /** P7 optimization #3 — stamped by the upload endpoint via getMediaStorageProvider(). */
205
+ storageProvider: z.string().min(1).max(64).default("local"),
206
+ /**
207
+ * Media provenance (0181). Where the asset came from + its licence
208
+ * when known. All optional — a plain human upload may leave them
209
+ * unset. `sourceDetail` is the origin: source URL for imported /
210
+ * external, "<provider>/<model>" for ai_generated, filename/NULL for
211
+ * a plain upload.
212
+ */
213
+ sourceKind: z.enum(["upload", "ai_generated", "imported", "external"]).optional(),
214
+ sourceDetail: z.string().max(2048).optional(),
215
+ license: z.string().max(200).optional(),
216
+ variants: z
217
+ .array(
218
+ z.object({
219
+ variant: z.string().min(1).max(64),
220
+ format: z.string().min(1).max(32),
221
+ width: z.number().int().positive().nullable(),
222
+ height: z.number().int().positive().nullable(),
223
+ sizeBytes: z.number().int().positive(),
224
+ storageKey: z.string().min(1),
225
+ }),
226
+ )
227
+ .min(1),
228
+ })
229
+ .strict();
230
+ export type MediaUploadInput = z.infer<typeof mediaUploadInputSchema>;
231
+
232
+ export const mediaListInputSchema = z
233
+ .object({
234
+ query: z.string().max(256).optional(),
235
+ mime: z.enum(MEDIA_ALLOWED_MIMES).optional(),
236
+ sort: z.enum(["recent", "most_used"]).default("recent"),
237
+ limit: z.number().int().positive().max(200).default(60),
238
+ offset: z.number().int().nonnegative().default(0),
239
+ })
240
+ .strict();
241
+ export type MediaListInput = z.infer<typeof mediaListInputSchema>;
242
+
243
+ export const mediaUpdateAltInputSchema = z
244
+ .object({
245
+ assetId: z.string().uuid(),
246
+ alt: z.string().max(2048),
247
+ })
248
+ .strict();
249
+ export type MediaUpdateAltInput = z.infer<typeof mediaUpdateAltInputSchema>;
250
+
251
+ /**
252
+ * media.set_source (0181) — record/patch an asset's provenance after it
253
+ * exists. COALESCE semantics at the handler: omitted fields stay
254
+ * unchanged, so this both sets provenance the first time and patches a
255
+ * single field (e.g. a licence the operator states later).
256
+ */
257
+ export const mediaSetSourceInputSchema = z
258
+ .object({
259
+ assetId: z.string().uuid(),
260
+ sourceKind: z.enum(["upload", "ai_generated", "imported", "external"]).optional(),
261
+ sourceDetail: z.string().max(2048).optional(),
262
+ license: z.string().max(200).optional(),
263
+ })
264
+ .strict();
265
+ export type MediaSetSourceInput = z.infer<typeof mediaSetSourceInputSchema>;
266
+
267
+ export const mediaDeleteInputSchema = z
268
+ .object({
269
+ assetId: z.string().uuid(),
270
+ force: z.boolean().default(false),
271
+ })
272
+ .strict();
273
+
274
+ export const mediaRecordUsageInputSchema = z
275
+ .object({
276
+ /** Map of assetId → net delta (positive when added, negative when removed). */
277
+ deltas: z.record(z.string().uuid(), z.number().int()),
278
+ })
279
+ .strict();
280
+ export type MediaRecordUsageInput = z.infer<typeof mediaRecordUsageInputSchema>;
281
+
282
+ export const mediaRecentForAiInputSchema = z
283
+ .object({
284
+ limit: z.number().int().positive().max(60).default(30),
285
+ })
286
+ .strict();
287
+
288
+ export const mediaSetCdnInputSchema = z
289
+ .object({
290
+ enabled: z.boolean(),
291
+ threshold: z.number().int().min(1).max(10000),
292
+ })
293
+ .strict();
294
+ export type MediaSetCdnInput = z.infer<typeof mediaSetCdnInputSchema>;
295
+
296
+ // ---------------------------------------------------------------------
297
+ // Storage adapter interface — implemented by LocalVolumeAdapter in
298
+ // admin-core, by per-cloud adapters in P15.
299
+ // ---------------------------------------------------------------------
300
+
301
+ /**
302
+ * Object-storage abstraction. The DB never holds blob bytes — only
303
+ * metadata + the storage key. Adapters are responsible for the full
304
+ * key→bytes round-trip; the URL form they expose is renderer-agnostic
305
+ * (LocalVolumeAdapter returns `/_caelo/media/<assetId>/<variant>` so
306
+ * the SvelteKit endpoint can resolve; cloud adapters can return CDN
307
+ * URLs directly).
308
+ */
309
+ export interface MediaStorageAdapter {
310
+ put(key: string, body: Uint8Array, contentType: string): Promise<void>;
311
+ get(key: string): Promise<Uint8Array>;
312
+ delete(key: string): Promise<void>;
313
+ exists(key: string): Promise<boolean>;
314
+ /** Bytes-on-disk for capacity reporting. */
315
+ totalSizeBytes(): Promise<number>;
316
+ }
317
+
318
+ /**
319
+ * Object-store prefix for images the AI produced during a chat (screenshots
320
+ * of a page, of an external site, of a crawled source page).
321
+ *
322
+ * They are NOT media assets. The operator's library is their own curated
323
+ * space; filling it with machine screenshots would make it useless, and these
324
+ * images have no life outside the conversation that produced them. They live
325
+ * under their own prefix instead, and a scheduled sweep removes the ones whose
326
+ * conversations have aged out (see `chat_images.gc`).
327
+ *
328
+ * The key is `chat-images/<UTC day>/<sha256>.<ext>`:
329
+ * - the day segment makes age the first thing you can see in a key, so a
330
+ * sweep never has to open a file to decide;
331
+ * - the content hash means re-shooting an unchanged page writes the SAME
332
+ * key. That is not just a storage saving — an identical key keeps the
333
+ * message history byte-identical, so the provider's prompt cache survives
334
+ * a re-screenshot of something that did not change.
335
+ */
336
+ export const CHAT_IMAGE_PREFIX = "chat-images";
337
+
338
+ /** Build the storage key for a chat image. `day` is `YYYY-MM-DD` (UTC). */
339
+ export function buildChatImageKey(day: string, sha256: string, ext: string): string {
340
+ return `${CHAT_IMAGE_PREFIX}/${day}/${sha256}.${ext}`;
341
+ }
342
+
343
+ /** True for keys under the chat-image prefix — the sweep's safety check. */
344
+ export function isChatImageKey(key: string): boolean {
345
+ return key.startsWith(`${CHAT_IMAGE_PREFIX}/`);
346
+ }
347
+
348
+ /** Build the canonical storage key for a given asset variant. */
349
+ export function buildStorageKey(
350
+ sha256: string,
351
+ variant: MediaVariantTag | string,
352
+ ext: string,
353
+ ): string {
354
+ return `${sha256}/${variant}.${ext}`;
355
+ }
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #264 — unit coverage for the per-page log's pure logic: the shared
5
+ * append-input schema (the same one the op + tool validate against) and the
6
+ * `formatPageLogBlock` context-block renderer (non-empty gate, entry cap,
7
+ * <2 KB budget).
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import {
12
+ formatPageLogBlock,
13
+ type PageLogEntry,
14
+ pageLogAppendInputSchema,
15
+ pageLogEntrySchema,
16
+ } from "./page-log.js";
17
+
18
+ const PAGE = "11111111-1111-4111-8111-aaaaaaaaaaaa";
19
+
20
+ function entry(over: Partial<PageLogEntry>): PageLogEntry {
21
+ return {
22
+ id: "22222222-2222-4222-8222-bbbbbbbbbbbb",
23
+ pageId: PAGE,
24
+ chatSessionId: null,
25
+ actorKind: "ai",
26
+ entryKind: "decision",
27
+ summary: "Chose a two-column hero to match the source layout.",
28
+ detail: null,
29
+ createdAt: "2026-07-13T10:00:00.000Z",
30
+ ...over,
31
+ };
32
+ }
33
+
34
+ describe("pageLogAppendInputSchema", () => {
35
+ it("accepts a minimal valid payload (no detail)", () => {
36
+ const r = pageLogAppendInputSchema.safeParse({
37
+ pageId: PAGE,
38
+ entryKind: "operator_answer",
39
+ summary: "Operator said keep the original blue, not the refreshed teal.",
40
+ });
41
+ expect(r.success).toBe(true);
42
+ });
43
+
44
+ it("accepts an object detail and every entry kind", () => {
45
+ for (const entryKind of [
46
+ "edited",
47
+ "decision",
48
+ "operator_answer",
49
+ "open_question",
50
+ "rebuilt",
51
+ "note",
52
+ ] as const) {
53
+ const r = pageLogAppendInputSchema.safeParse({
54
+ pageId: PAGE,
55
+ entryKind,
56
+ summary: "x",
57
+ detail: { chosen: "blue", operatorWords: "keep the blue" },
58
+ });
59
+ expect(r.success).toBe(true);
60
+ }
61
+ });
62
+
63
+ it("rejects an unknown entry kind, an empty summary, and a bad page id", () => {
64
+ expect(
65
+ pageLogAppendInputSchema.safeParse({ pageId: PAGE, entryKind: "deleted", summary: "x" })
66
+ .success,
67
+ ).toBe(false);
68
+ expect(
69
+ pageLogAppendInputSchema.safeParse({ pageId: PAGE, entryKind: "note", summary: "" }).success,
70
+ ).toBe(false);
71
+ expect(
72
+ pageLogAppendInputSchema.safeParse({ pageId: "not-a-uuid", entryKind: "note", summary: "x" })
73
+ .success,
74
+ ).toBe(false);
75
+ });
76
+
77
+ it("rejects unknown keys (strict) and a non-object detail", () => {
78
+ expect(
79
+ pageLogAppendInputSchema.safeParse({
80
+ pageId: PAGE,
81
+ entryKind: "note",
82
+ summary: "x",
83
+ extra: 1,
84
+ }).success,
85
+ ).toBe(false);
86
+ expect(
87
+ pageLogAppendInputSchema.safeParse({
88
+ pageId: PAGE,
89
+ entryKind: "note",
90
+ summary: "x",
91
+ detail: "a bare string",
92
+ }).success,
93
+ ).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("formatPageLogBlock", () => {
98
+ it("returns null for an empty log so the header is omitted", () => {
99
+ expect(formatPageLogBlock([])).toBeNull();
100
+ });
101
+
102
+ it("renders the header, primer, and one line per entry newest-first", () => {
103
+ const block = formatPageLogBlock([
104
+ entry({ entryKind: "rebuilt", summary: "Rebuilt from the imported source." }),
105
+ entry({ entryKind: "operator_answer", summary: "Keep the blue.", actorKind: "human" }),
106
+ ]);
107
+ expect(block).not.toBeNull();
108
+ const b = block as string;
109
+ expect(b).toContain("## Page log");
110
+ expect(b).toContain("log_page_edit");
111
+ expect(b).toContain("- [rebuilt] Rebuilt from the imported source. (AI, 2026-07-13)");
112
+ expect(b).toContain("- [operator_answer] Keep the blue. (operator, 2026-07-13)");
113
+ });
114
+
115
+ it("caps the entry list and stays under 2 KB even with long summaries", () => {
116
+ const many: PageLogEntry[] = Array.from({ length: 40 }, (_, i) =>
117
+ entry({ id: `id-${i}`, summary: "Q".repeat(500) }),
118
+ );
119
+ const block = formatPageLogBlock(many) as string;
120
+ // 8 shown + a truncation notice line.
121
+ expect(block).toContain("32 older entries omitted.");
122
+ expect((block.match(/^- \[/gm) ?? []).length).toBe(8);
123
+ expect(Buffer.byteLength(block, "utf8")).toBeLessThan(2048);
124
+ });
125
+
126
+ it("uses the singular form when exactly one older entry is omitted", () => {
127
+ const nine: PageLogEntry[] = Array.from({ length: 9 }, (_, i) => entry({ id: `id-${i}` }));
128
+ const block = formatPageLogBlock(nine) as string;
129
+ expect(block).toContain("1 older entry omitted.");
130
+ expect(block).not.toContain("entries omitted");
131
+ });
132
+ });
133
+
134
+ describe("pageLogEntrySchema", () => {
135
+ // This is the contract `page_log.list` re-validates every DB row against,
136
+ // so schema-drift shapes (non-ISO timestamp, unknown enum value) must fail.
137
+ const valid = entry({});
138
+
139
+ it("accepts a well-formed row", () => {
140
+ expect(pageLogEntrySchema.safeParse(valid).success).toBe(true);
141
+ });
142
+
143
+ it("rejects a createdAt that is not an ISO datetime", () => {
144
+ for (const createdAt of ["2026-07-13", "yesterday", "1720900000000"]) {
145
+ expect(pageLogEntrySchema.safeParse({ ...valid, createdAt }).success).toBe(false);
146
+ }
147
+ });
148
+
149
+ it("rejects an unknown actorKind or entryKind (enum drift)", () => {
150
+ expect(pageLogEntrySchema.safeParse({ ...valid, actorKind: "robot" }).success).toBe(false);
151
+ expect(pageLogEntrySchema.safeParse({ ...valid, entryKind: "deleted" }).success).toBe(false);
152
+ });
153
+
154
+ it("rejects a non-uuid id and unknown keys (strict)", () => {
155
+ expect(pageLogEntrySchema.safeParse({ ...valid, id: "row-1" }).success).toBe(false);
156
+ expect(pageLogEntrySchema.safeParse({ ...valid, extra: 1 }).success).toBe(false);
157
+ });
158
+
159
+ it("rejects a non-object detail but accepts null", () => {
160
+ expect(pageLogEntrySchema.safeParse({ ...valid, detail: "corrupt" }).success).toBe(false);
161
+ expect(pageLogEntrySchema.safeParse({ ...valid, detail: null }).success).toBe(true);
162
+ });
163
+ });
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #264 — the per-page edit LOG: durable, append-only work history so a
5
+ * later chat or a fresh subagent that touches a page knows WHY it was edited,
6
+ * what decisions were taken, and which operator answers shaped it — without
7
+ * dragging the originating chat's full transcript through its context.
8
+ *
9
+ * This is FACT (what happened), not learned BEHAVIOUR — so, unlike
10
+ * `site_ai_memory`, it is ungated: any actor appends directly. The shared
11
+ * Zod schemas here are the single source of truth for the `page_log.append`
12
+ * op input, the `log_page_edit` tool schema, and the `page_log.list` output.
13
+ * `formatPageLogBlock` renders the `## Page log` context block the AI reads
14
+ * before touching a page.
15
+ */
16
+
17
+ import { z } from "zod";
18
+ import type { ActorKind } from "./context.js";
19
+
20
+ /**
21
+ * The kinds of log entry, in the order the AI should reach for them:
22
+ * - `edited` — a substantive content/module change was made.
23
+ * - `decision` — a design/structure call the AI made and its rationale.
24
+ * - `operator_answer` — an answer the operator gave that shaped the page.
25
+ * - `open_question` — something still unresolved a future turn must settle.
26
+ * - `rebuilt` — the page was rebuilt from scratch (e.g. migration rebuild).
27
+ * - `note` — anything else worth preserving for a future editor.
28
+ */
29
+ export const pageLogEntryKinds = [
30
+ "edited",
31
+ "decision",
32
+ "operator_answer",
33
+ "open_question",
34
+ "rebuilt",
35
+ "note",
36
+ ] as const;
37
+
38
+ export const pageLogEntryKindSchema = z.enum(pageLogEntryKinds);
39
+ export type PageLogEntryKind = (typeof pageLogEntryKinds)[number];
40
+
41
+ /**
42
+ * Optional structured context beyond the one-line summary — an OBJECT, never
43
+ * a bare scalar or array, so the jsonb column stores queryable keys (chosen
44
+ * option, operator's exact words, affected module ids). Writes go through
45
+ * `jsonbParam()` in the op handler to avoid the double-encode trap (issue
46
+ * #68).
47
+ */
48
+ export const pageLogDetailSchema = z.record(z.string(), z.unknown());
49
+ export type PageLogDetail = z.infer<typeof pageLogDetailSchema>;
50
+
51
+ /** Input shape shared by `page_log.append` and the `log_page_edit` tool. */
52
+ export const pageLogAppendInputSchema = z
53
+ .object({
54
+ pageId: z.string().uuid(),
55
+ entryKind: pageLogEntryKindSchema,
56
+ summary: z.string().min(1).max(2000),
57
+ detail: pageLogDetailSchema.optional(),
58
+ })
59
+ .strict();
60
+ export type PageLogAppendInput = z.infer<typeof pageLogAppendInputSchema>;
61
+
62
+ /** One row as returned by `page_log.list`. */
63
+ export const pageLogEntrySchema = z
64
+ .object({
65
+ id: z.string().uuid(),
66
+ pageId: z.string().uuid(),
67
+ chatSessionId: z.string().uuid().nullable(),
68
+ actorKind: z.enum(["human", "ai", "plugin", "system"]),
69
+ entryKind: pageLogEntryKindSchema,
70
+ summary: z.string(),
71
+ detail: pageLogDetailSchema.nullable(),
72
+ // ISO datetime, not just any string: the value is `created_at` sliced to a
73
+ // date and rendered into the AI context block — a real contract catches
74
+ // drift (a raw pg timestamp, a number) at the op boundary instead of
75
+ // producing a garbled `## Page log` line.
76
+ createdAt: z.string().datetime(),
77
+ })
78
+ .strict();
79
+ export type PageLogEntry = z.infer<typeof pageLogEntrySchema>;
80
+
81
+ const ACTOR_LABEL: Record<ActorKind, string> = {
82
+ human: "operator",
83
+ ai: "AI",
84
+ plugin: "plugin",
85
+ system: "system",
86
+ };
87
+
88
+ /**
89
+ * Render the `## Page log` context block for a single page's recent entries
90
+ * (newest first). Returns `null` when there is nothing to show, so the
91
+ * caller omits the header entirely (CLAUDE.md §11 — render only when
92
+ * non-empty). Kept well under 2 KB: entries are capped and each summary is
93
+ * clamped, because the block rides in the system prompt on every turn that
94
+ * touches the page.
95
+ *
96
+ * @param entries page log rows, expected newest-first (as `page_log.list`
97
+ * returns them). The renderer does not re-sort.
98
+ */
99
+ export function formatPageLogBlock(entries: readonly PageLogEntry[]): string | null {
100
+ if (entries.length === 0) return null;
101
+ // Capped + clamped to stay comfortably under 2 KB: the block rides in the
102
+ // system prompt on every turn that touches the page.
103
+ const MAX_ENTRIES = 8;
104
+ const MAX_SUMMARY = 160;
105
+ const shown = entries.slice(0, MAX_ENTRIES);
106
+ const lines: string[] = [
107
+ "## Page log",
108
+ "",
109
+ "Prior work on THIS page — read it before you change the page so you build on settled decisions instead of re-litigating them. After a meaningful change, append your own entry with `log_page_edit`.",
110
+ "",
111
+ ];
112
+ for (const e of shown) {
113
+ const who = ACTOR_LABEL[e.actorKind] ?? e.actorKind;
114
+ const when = e.createdAt.slice(0, 10);
115
+ const summary =
116
+ e.summary.length > MAX_SUMMARY ? `${e.summary.slice(0, MAX_SUMMARY - 3)}...` : e.summary;
117
+ lines.push(`- [${e.entryKind}] ${summary} (${who}, ${when})`);
118
+ }
119
+ if (entries.length > MAX_ENTRIES) {
120
+ const omitted = entries.length - MAX_ENTRIES;
121
+ lines.push(`- ${omitted} older ${omitted === 1 ? "entry" : "entries"} omitted.`);
122
+ }
123
+ return lines.join("\n");
124
+ }