smoodly 0.0.51 → 0.0.52

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 (285) hide show
  1. package/README.md +14 -13
  2. package/dist/admin/auth-config.d.ts +6 -5
  3. package/dist/admin/auth.d.ts +3 -11
  4. package/dist/admin/auth.js +1 -30
  5. package/dist/admin/browser/fetch-op.js +3 -2
  6. package/dist/admin/browser/main.js +6 -2
  7. package/dist/admin/client-ops.d.ts +5 -4
  8. package/dist/admin/client-ops.js +14 -11
  9. package/dist/admin/delivery-mode.d.ts +1 -1
  10. package/dist/admin/delivery-mode.js +2 -2
  11. package/dist/admin/drift.d.ts +0 -2
  12. package/dist/admin/drift.js +1 -1
  13. package/dist/admin/editor/EditorView.d.ts +0 -3
  14. package/dist/admin/editor/EditorView.js +11 -16
  15. package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
  16. package/dist/admin/editor/FloatingToolbar.js +1 -1
  17. package/dist/admin/editor/SeoSettings.d.ts +0 -2
  18. package/dist/admin/editor/SeoSettings.js +2 -2
  19. package/dist/admin/editor/canvas-address.d.ts +8 -0
  20. package/dist/admin/editor/canvas-address.js +18 -2
  21. package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
  22. package/dist/admin/editor/canvas-diagnosis.js +3 -0
  23. package/dist/admin/editor/inline-dom.d.ts +0 -4
  24. package/dist/admin/editor/inline-dom.js +1 -1
  25. package/dist/admin/editor/preview-grant.d.ts +6 -0
  26. package/dist/admin/editor/preview-grant.js +25 -0
  27. package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
  28. package/dist/admin/files.generated.js +1 -1
  29. package/dist/admin/fixed-nodes.d.ts +0 -6
  30. package/dist/admin/fixed-nodes.js +1 -81
  31. package/dist/admin/forms/CropEditor.d.ts +4 -2
  32. package/dist/admin/forms/CropEditor.js +58 -5
  33. package/dist/admin/forms/ImagePreview.d.ts +3 -1
  34. package/dist/admin/forms/ImagePreview.js +9 -3
  35. package/dist/admin/forms/MediaGrid.js +2 -1
  36. package/dist/admin/forms/MediaPicker.js +9 -6
  37. package/dist/admin/forms/MediaWidget.js +11 -9
  38. package/dist/admin/forms/assets-context.d.ts +7 -3
  39. package/dist/admin/forms/assets-context.js +5 -3
  40. package/dist/admin/forms/crop-editor.d.ts +24 -2
  41. package/dist/admin/forms/crop-editor.js +36 -2
  42. package/dist/admin/forms/date-picker.d.ts +0 -1
  43. package/dist/admin/forms/date-picker.js +1 -1
  44. package/dist/admin/forms/tabs.d.ts +0 -1
  45. package/dist/admin/forms/tabs.js +1 -1
  46. package/dist/admin/forms/upload.d.ts +18 -11
  47. package/dist/admin/forms/upload.js +62 -24
  48. package/dist/admin/global-items.d.ts +0 -3
  49. package/dist/admin/global-items.js +0 -54
  50. package/dist/admin/handler.d.ts +22 -28
  51. package/dist/admin/handler.js +146 -47
  52. package/dist/admin/health.d.ts +0 -4
  53. package/dist/admin/index.d.ts +5 -8
  54. package/dist/admin/index.js +3 -6
  55. package/dist/admin/ops.d.ts +36 -22
  56. package/dist/admin/page-data.d.ts +14 -6
  57. package/dist/admin/page-data.js +1 -1
  58. package/dist/admin/session-flow.d.ts +43 -0
  59. package/dist/admin/session-flow.js +94 -0
  60. package/dist/admin/shell/AdminApp.d.ts +8 -3
  61. package/dist/admin/shell/AdminApp.js +37 -58
  62. package/dist/admin/shell/ContentHealthPage.js +1 -3
  63. package/dist/admin/shell/GlobalFields.d.ts +0 -3
  64. package/dist/admin/shell/GlobalFields.js +1 -1
  65. package/dist/admin/shell/IconRail.js +5 -4
  66. package/dist/admin/shell/MediaScreen.js +9 -6
  67. package/dist/admin/shell/MembersPage.d.ts +1 -2
  68. package/dist/admin/shell/MembersPage.js +19 -90
  69. package/dist/admin/shell/SettingsView.d.ts +1 -2
  70. package/dist/admin/shell/SettingsView.js +2 -2
  71. package/dist/admin/shell/SignedOutView.d.ts +5 -0
  72. package/dist/admin/shell/SignedOutView.js +9 -0
  73. package/dist/admin/shell/console-links.d.ts +5 -0
  74. package/dist/admin/shell/console-links.js +11 -0
  75. package/dist/admin/shell/members-list.d.ts +1 -14
  76. package/dist/admin/shell/members-list.js +2 -23
  77. package/dist/admin/shell/pages-tree.d.ts +0 -2
  78. package/dist/admin/shell/pages-tree.js +1 -1
  79. package/dist/admin/shell/presence-socket.d.ts +10 -0
  80. package/dist/admin/shell/presence-socket.js +191 -0
  81. package/dist/admin/shell/presence.d.ts +6 -18
  82. package/dist/admin/shell/presence.js +4 -11
  83. package/dist/admin/shell/session.d.ts +20 -12
  84. package/dist/admin/shell/session.js +113 -47
  85. package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
  86. package/dist/admin/ui/LocaleSwitcher.js +2 -2
  87. package/dist/admin/ui/primitives.d.ts +4 -14
  88. package/dist/admin/ui/primitives.js +5 -8
  89. package/dist/admin/ui/theme.js +13 -2
  90. package/dist/admin/validate.js +10 -1
  91. package/dist/api-client.d.ts +26 -0
  92. package/dist/api-client.js +107 -0
  93. package/dist/assemble.d.ts +4 -41
  94. package/dist/assemble.js +4 -302
  95. package/dist/asset-store.d.ts +28 -30
  96. package/dist/asset-store.js +26 -121
  97. package/dist/astro/components/Head.astro +4 -1
  98. package/dist/astro/components/Image.astro +12 -17
  99. package/dist/astro/components/Video.astro +5 -2
  100. package/dist/astro/head.d.ts +3 -0
  101. package/dist/astro/head.js +24 -0
  102. package/dist/astro/index.js +54 -25
  103. package/dist/astro/locale-paths.d.ts +5 -2
  104. package/dist/astro/locale-paths.js +10 -2
  105. package/dist/astro/routes/admin.js +9 -7
  106. package/dist/astro/runtime.d.ts +2 -1
  107. package/dist/astro/runtime.js +4 -3
  108. package/dist/astro/server.d.ts +7 -11
  109. package/dist/astro/server.js +22 -28
  110. package/dist/astro/shell.d.ts +2 -2
  111. package/dist/astro/static-media-node.d.ts +4 -0
  112. package/dist/astro/static-media-node.js +25 -0
  113. package/dist/astro/static-media.d.ts +17 -0
  114. package/dist/astro/static-media.js +55 -0
  115. package/dist/astro/static.d.ts +5 -10
  116. package/dist/astro/static.js +13 -29
  117. package/dist/canvas-marker.d.ts +14 -0
  118. package/dist/canvas-marker.js +36 -0
  119. package/dist/cli/api.d.ts +14 -0
  120. package/dist/cli/api.js +48 -0
  121. package/dist/cli/app-env.d.ts +2 -0
  122. package/dist/cli/app-env.js +20 -0
  123. package/dist/cli/export.d.ts +16 -2
  124. package/dist/cli/export.js +52 -35
  125. package/dist/cli/import.d.ts +7 -11
  126. package/dist/cli/import.js +50 -54
  127. package/dist/cli/load-config.d.ts +0 -1
  128. package/dist/cli/load-config.js +1 -1
  129. package/dist/cli/main.d.ts +4 -12
  130. package/dist/cli/main.js +26 -72
  131. package/dist/cli/push.d.ts +6 -0
  132. package/dist/cli/push.js +31 -0
  133. package/dist/collections.d.ts +0 -1
  134. package/dist/collections.js +1 -1
  135. package/dist/config.d.ts +20 -5
  136. package/dist/config.js +18 -1
  137. package/dist/content/content-api.d.ts +22 -23
  138. package/dist/content/content-api.js +46 -56
  139. package/dist/content/mime.d.ts +1 -0
  140. package/dist/content/mime.js +6 -0
  141. package/dist/deploy-store.d.ts +19 -0
  142. package/dist/deploy-store.js +1 -0
  143. package/dist/entry-store.d.ts +3 -89
  144. package/dist/entry-store.js +2 -550
  145. package/dist/env.d.ts +27 -20
  146. package/dist/env.js +38 -50
  147. package/dist/fields.d.ts +11 -3
  148. package/dist/fields.js +33 -2
  149. package/dist/global-id.d.ts +2 -0
  150. package/dist/global-id.js +4 -2
  151. package/dist/image/SmoodlyImage.d.ts +9 -5
  152. package/dist/image/SmoodlyImage.js +29 -15
  153. package/dist/image/SmoodlyVideo.d.ts +1 -1
  154. package/dist/image/SmoodlyVideo.js +3 -2
  155. package/dist/image/index.d.ts +2 -3
  156. package/dist/image/index.js +0 -2
  157. package/dist/image/markup.d.ts +53 -0
  158. package/dist/image/markup.js +111 -0
  159. package/dist/index.d.ts +12 -22
  160. package/dist/index.js +7 -20
  161. package/dist/media-cut.d.ts +44 -0
  162. package/dist/media-cut.js +70 -0
  163. package/dist/media.d.ts +57 -3
  164. package/dist/media.js +125 -15
  165. package/dist/member-store.d.ts +3 -22
  166. package/dist/member-store.js +1 -53
  167. package/dist/next/admin-route.d.ts +1 -1
  168. package/dist/next/admin-route.js +5 -3
  169. package/dist/next/meta.js +2 -3
  170. package/dist/next/page-renderer.d.ts +2 -2
  171. package/dist/next/page-renderer.js +22 -11
  172. package/dist/next/queries.d.ts +2 -2
  173. package/dist/path-index.d.ts +0 -24
  174. package/dist/path-index.js +0 -98
  175. package/dist/push.d.ts +30 -0
  176. package/dist/push.js +52 -0
  177. package/dist/read-counter.d.ts +2 -0
  178. package/dist/read-counter.js +6 -0
  179. package/dist/reads.d.ts +7 -7
  180. package/dist/reads.js +3 -0
  181. package/dist/registry-json.d.ts +11 -0
  182. package/dist/registry-json.js +81 -0
  183. package/dist/render.d.ts +0 -1
  184. package/dist/render.js +1 -1
  185. package/dist/resolve.d.ts +12 -6
  186. package/dist/resolve.js +11 -8
  187. package/dist/revalidate.d.ts +8 -1
  188. package/dist/revalidate.js +8 -1
  189. package/dist/richtext/index.d.ts +1 -1
  190. package/dist/section-wrapper.d.ts +1 -1
  191. package/dist/section-wrapper.js +0 -9
  192. package/dist/signed-message.d.ts +26 -0
  193. package/dist/signed-message.js +103 -0
  194. package/dist/site.d.ts +59 -18
  195. package/dist/site.js +21 -252
  196. package/dist/space-transfer.d.ts +5 -2
  197. package/dist/space-transfer.js +17 -5
  198. package/dist/store.d.ts +2 -80
  199. package/dist/store.js +3 -489
  200. package/dist/toolset.d.ts +2 -1
  201. package/dist/toolset.js +1 -1
  202. package/dist/version.d.ts +1 -1
  203. package/dist/version.js +1 -1
  204. package/package.json +4 -15
  205. package/dist/admin/ops-impl.d.ts +0 -51
  206. package/dist/admin/ops-impl.js +0 -1403
  207. package/dist/admin/shell/InviteModal.d.ts +0 -6
  208. package/dist/admin/shell/InviteModal.js +0 -28
  209. package/dist/admin/shell/LoginView.d.ts +0 -5
  210. package/dist/admin/shell/LoginView.js +0 -80
  211. package/dist/admin/shell/SchemaNotice.d.ts +0 -4
  212. package/dist/admin/shell/SchemaNotice.js +0 -4
  213. package/dist/admin/shell/SetPasswordView.d.ts +0 -7
  214. package/dist/admin/shell/SetPasswordView.js +0 -41
  215. package/dist/admin/shell/presence-realtime.d.ts +0 -3
  216. package/dist/admin/shell/presence-realtime.js +0 -32
  217. package/dist/admin/supabase-auth.d.ts +0 -7
  218. package/dist/admin/supabase-auth.js +0 -30
  219. package/dist/astro/deploy-deps.d.ts +0 -10
  220. package/dist/astro/deploy-deps.js +0 -8
  221. package/dist/astro/image.d.ts +0 -27
  222. package/dist/astro/image.js +0 -29
  223. package/dist/bind.d.ts +0 -19
  224. package/dist/bind.js +0 -34
  225. package/dist/cli/add-admin.d.ts +0 -11
  226. package/dist/cli/add-admin.js +0 -25
  227. package/dist/cli/load-collections.d.ts +0 -2
  228. package/dist/cli/load-collections.js +0 -27
  229. package/dist/cli/migrate.d.ts +0 -43
  230. package/dist/cli/migrate.js +0 -138
  231. package/dist/cli/rebuild.d.ts +0 -23
  232. package/dist/cli/rebuild.js +0 -22
  233. package/dist/cli/space-io.d.ts +0 -41
  234. package/dist/cli/space-io.js +0 -84
  235. package/dist/client.d.ts +0 -11
  236. package/dist/client.js +0 -54
  237. package/dist/current-space.d.ts +0 -4
  238. package/dist/current-space.js +0 -9
  239. package/dist/document-stamp.d.ts +0 -7
  240. package/dist/document-stamp.js +0 -48
  241. package/dist/image/loaders.d.ts +0 -15
  242. package/dist/image/loaders.js +0 -5
  243. package/dist/image/next-config.d.ts +0 -18
  244. package/dist/image/next-config.js +0 -32
  245. package/dist/image/remote-pattern.d.ts +0 -1
  246. package/dist/image/remote-pattern.js +0 -5
  247. package/dist/migrations.d.ts +0 -37
  248. package/dist/migrations.js +0 -73
  249. package/dist/project.d.ts +0 -88
  250. package/dist/project.js +0 -231
  251. package/dist/published.d.ts +0 -142
  252. package/dist/published.js +0 -164
  253. package/dist/ref-index.d.ts +0 -19
  254. package/dist/ref-index.js +0 -0
  255. package/dist/schema-read.d.ts +0 -23
  256. package/dist/schema-read.js +0 -50
  257. package/dist/schema-version.d.ts +0 -19
  258. package/dist/schema-version.js +0 -34
  259. package/dist/sql-space.d.ts +0 -44
  260. package/dist/sql-space.js +0 -289
  261. package/dist/sql.d.ts +0 -15
  262. package/dist/sql.js +0 -556
  263. package/dist/supabase-asset-store.d.ts +0 -21
  264. package/dist/supabase-asset-store.js +0 -170
  265. package/dist/supabase-entry-store.d.ts +0 -92
  266. package/dist/supabase-entry-store.js +0 -824
  267. package/dist/supabase-member-store.d.ts +0 -21
  268. package/dist/supabase-member-store.js +0 -96
  269. package/dist/supabase-path-index.d.ts +0 -30
  270. package/dist/supabase-path-index.js +0 -138
  271. package/dist/supabase-published-store.d.ts +0 -56
  272. package/dist/supabase-published-store.js +0 -314
  273. package/dist/supabase-store.d.ts +0 -103
  274. package/dist/supabase-store.js +0 -678
  275. package/migrations/20260926120000_smoodly_v1.sql +0 -535
  276. package/migrations/20260926130000_smoodly_v2.sql +0 -26
  277. package/migrations/20260926140000_smoodly_v3.sql +0 -38
  278. package/migrations/20260926150000_smoodly_v4.sql +0 -41
  279. package/migrations/20260929120000_smoodly_v5.sql +0 -56
  280. package/migrations/20260929135523_smoodly_v6.sql +0 -200
  281. package/migrations/20260930040956_smoodly_v7.sql +0 -84
  282. package/migrations/20261007120000_smoodly_v8.sql +0 -154
  283. package/migrations/20261007200000_smoodly_v9.sql +0 -30
  284. package/migrations/20261008120000_smoodly_v10.sql +0 -47
  285. package/migrations/20261009120000_smoodly_v11.sql +0 -49
package/dist/index.js CHANGED
@@ -3,7 +3,6 @@ import { collection } from "./collections.js";
3
3
  import { toolset } from "./toolset.js";
4
4
  import { globalItem } from "./global.js";
5
5
  import { marks } from "./marks.js";
6
- export { createSupabaseStores, siteOptions } from "./bind.js";
7
6
  export { schemaOf } from "./registration.js";
8
7
  export { EMPTY_PAGE_CONTEXT } from "./page-context.js";
9
8
  export { fixedSlug } from "./page.js";
@@ -19,27 +18,14 @@ export { DEFAULT_ADMIN_PATH } from "./admin-path.js";
19
18
  export { ASSET_COLLECTION, DEFAULT_MEDIA, MEDIA_TYPES, FULL_CROP, CENTER_HOTSPOT, parseAspect, cropAspect, clamp01, isMediaValue, collectAssetIds, applyAssets, } from "./media.js";
20
19
  export { z } from "./zones.js";
21
20
  export { defineConfig } from "./config.js";
22
- export { collectionSQL, coreTablesSQL } from "./sql.js";
23
- export { SCHEMA_VERSION, SCHEMA_VERSION_KEY, schemaVersionSQL, coreFileVersion, schemaBehind, schemaBehindMessage } from "./schema-version.js";
24
21
  export { draftToken, sameToken, isDraftToken, assertFresh, StaleDraftError } from "./draft-token.js";
25
- export { MemoryPageStore } from "./store.js";
26
- export { MemoryAssetStore, assetErrors, kindOf, extensionFor, objectPath, withDownload, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
22
+ export { assetErrors, kindOf, extensionFor, objectPath, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
27
23
  export { imageDimensions } from "./image-dimensions.js";
28
- export { SupabasePageStore } from "./supabase-store.js";
29
- export { SupabaseAssetStore } from "./supabase-asset-store.js";
30
- export { MemoryMemberStore, sortMembers, memberErrors } from "./member-store.js";
31
- export { SupabaseMemberStore } from "./supabase-member-store.js";
24
+ export { sortMembers, memberErrors } from "./member-store.js";
32
25
  export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.js";
33
26
  export { collectEntryRefs, collectTreeRefs } from "./refs.js";
34
27
  export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
35
- export { MemoryRefIndex } from "./ref-index.js";
36
- export { MemoryPathIndex } from "./path-index.js";
37
- export { MemoryEntryStore } from "./entry-store.js";
38
- export { SupabaseEntryStore } from "./supabase-entry-store.js";
39
- export { SupabasePathIndex } from "./supabase-path-index.js";
40
28
  export { entryErrors, compareEntries, DEFAULT_ENTRY_LOCALES, localizedKeys, splitFields, entryLocaleRow, entryFieldsIn, entryRefEdges, entryTitleIn, matchesWhere, } from "./entry-store.js";
41
- export { MemoryPublishedStore } from "./published.js";
42
- export { SupabasePublishedStore } from "./supabase-published-store.js";
43
29
  export { affectedTargets, pageTag, entryTag, globalTag, pagesTreeTag, collectionTag } from "./revalidate.js";
44
30
  export { resolveTree, resolveFields, resolveMedia } from "./resolve.js";
45
31
  export { SEGMENT_RE, isSegment, assertSegment, slugify, joinPath, splitPath, segmentFor, isHomeNode, pagePathIn, chainOf, assertDepth, pagePath, pagePathRows, entryPathRows, collectionSegment, buildPageTree, perLocaleSegments, assertSupportedLocale, unsupportedLocale, pageAlternates, entryAlternates, } from "./paths.js";
@@ -54,10 +40,11 @@ export { GLOBAL_NODE_TYPE, SETTINGS_KEY, STYLES_KEY, CSS_KEY, globalLink } from
54
40
  export { SMD_KEY } from "./smd-key.js";
55
41
  export { CSS_MAX, cssProblem, safeCss, scopedCss } from "./css.js";
56
42
  export { globalEntryId } from "./global-id.js";
57
- export { DEFAULT_SPACE_ID } from "./sql-space.js";
43
+ export { DEFAULT_SPACE_ID } from "./global-id.js";
58
44
  /** The registration namespace: schemas and their parts. The React pairing (`smoodly.section`, `<smoodly.Section>`) is smoodly/react's. */
59
45
  export const smoodly = { schema, collection, toolset, global: globalItem, marks };
60
46
  export { NODE_ATTR, ZONE_ATTR, EMPTY_ATTR, BLANK_ATTR, FIELD_ATTR, ITEM_ATTR, EDITOR_ATTR, resolveMarkPath } from "./marks.js";
61
- export { resolveSmoodlyEnv, smoodlyEnv, presenceConfig, siteUrl } from "./env.js";
62
- export { clientOptions, createSmoodlyClient } from "./client.js";
63
- export { createSmoodlySite, pageContextOf } from "./site.js";
47
+ export { apiEnv, resolveSmoodlyEnv, smoodlyEnv, presenceConfig, siteUrl } from "./env.js";
48
+ export { createSmoodlyApi, SmoodlyApiError } from "./api-client.js";
49
+ export { MESSAGE_MAX_AGE_MS, signMessage, verifyMessage } from "./signed-message.js";
50
+ export { pageContextOf, siteStatics } from "./site.js";
@@ -0,0 +1,44 @@
1
+ import type { Crop, Hotspot } from "./media.ts";
2
+ export type PixelRect = {
3
+ left: number;
4
+ top: number;
5
+ width: number;
6
+ height: number;
7
+ };
8
+ /** The qualities a cut is made at: unsigned in the address like the width, so a list bounds what Images is asked to
9
+ * make (spec 2026-10-18 image quality and max width §2). 75 matched Vercel's optimizer in step 6's speed round. */
10
+ export declare const IMAGE_QUALITIES: readonly [50, 60, 75, 85, 95, 100];
11
+ export type ImageQuality = (typeof IMAGE_QUALITIES)[number];
12
+ export declare const DEFAULT_QUALITY: ImageQuality;
13
+ export declare const isImageQuality: (n: unknown) => n is ImageQuality;
14
+ /** The refusal of a quality off the list; `label` names where it was given (`media.quality`, `f.image quality`). */
15
+ export declare const qualityError: (n: unknown, label?: string) => string;
16
+ /** A cap on a srcset's widths: a positive whole number of pixels. */
17
+ export declare const isMaxWidth: (n: unknown) => n is number;
18
+ /** Signs a rectangle of the file at `path` (`<space>/<asset>.<ext>`) and answers the cut's base address; the service
19
+ * makes one from its media secret, a site never holds it. */
20
+ export type CutSigner = (path: string, rect: PixelRect) => Promise<string>;
21
+ /** A crop's fractions as whole pixels inside the image: edges rounded, never past the image, at least one pixel. */
22
+ export declare function rectOf(crop: Crop, imageWidth: number, imageHeight: number): PixelRect;
23
+ /** The largest rectangle at `aspect` (width / height) inside the image, centred on the hotspot (the centre without
24
+ * one) and shifted to stay inside: what a crop nobody framed shows (spec §6). */
25
+ export declare function hotspotRect(aspect: number, hotspot: Hotspot | undefined, imageWidth: number, imageHeight: number): PixelRect;
26
+ /** The listed widths under the cut's own, then its own: a 200-pixel logo is `[200]`. A `maxWidth` keeps those at or
27
+ * under it and adds none; under them all it keeps the smallest, so a srcset is never empty. */
28
+ export declare function cutWidths(rectWidth: number, maxWidth?: number): number[];
29
+ export declare const cutSegment: (rect: PixelRect, sig: string) => string;
30
+ /** `<left>.<top>.<width>.<height>.<sig>`, or null: whole numbers written one way only (no sign, no leading zero), a
31
+ * width and a height of at least one, and a signature's shape. */
32
+ export declare function parseCutSegment(segment: string): {
33
+ rect: PixelRect;
34
+ sig: string;
35
+ } | null;
36
+ /** What the signature covers: the file and the rectangle, never the width or the quality (the route checks both
37
+ * against their lists). */
38
+ export declare const cutPayload: (path: string, rect: PixelRect) => string;
39
+ export declare const cutUrl: (cut: string, width: number, quality: ImageQuality) => string;
40
+ /** `<width>q<quality>`, or null: whole numbers written one way only. Whether both are listed is the route's check. */
41
+ export declare function parseCutSize(segment: string): {
42
+ width: number;
43
+ quality: number;
44
+ } | null;
@@ -0,0 +1,70 @@
1
+ /** The widths a cut is served at, plus the cut's own width; a wider one is never served (upscaling sells nothing).
2
+ * From 640 to 1080 they are Next's device sizes, so a phone gets what `next/image` gave it (a 412-pixel screen at 1.75
3
+ * finds 750, not 960; step 6 plan (d)'s speed round); 1536 spares a 1366- or 1440-wide laptop the 1920 (spec 2026-10-18
4
+ * image quality and max width §5). */
5
+ const CUT_WIDTHS = [320, 640, 750, 828, 1080, 1280, 1536, 1920, 2560];
6
+ /** The qualities a cut is made at: unsigned in the address like the width, so a list bounds what Images is asked to
7
+ * make (spec 2026-10-18 image quality and max width §2). 75 matched Vercel's optimizer in step 6's speed round. */
8
+ export const IMAGE_QUALITIES = [50, 60, 75, 85, 95, 100];
9
+ export const DEFAULT_QUALITY = 75;
10
+ export const isImageQuality = (n) => IMAGE_QUALITIES.includes(n);
11
+ /** The refusal of a quality off the list; `label` names where it was given (`media.quality`, `f.image quality`). */
12
+ export const qualityError = (n, label = "quality") => `smoodly: ${label} ${String(n)} is not one of ${IMAGE_QUALITIES.join(", ")}.`;
13
+ /** A cap on a srcset's widths: a positive whole number of pixels. */
14
+ export const isMaxWidth = (n) => typeof n === "number" && Number.isInteger(n) && n > 0;
15
+ const clampInt = (n, min, max) => Math.min(max, Math.max(min, n));
16
+ /** A crop's fractions as whole pixels inside the image: edges rounded, never past the image, at least one pixel. */
17
+ export function rectOf(crop, imageWidth, imageHeight) {
18
+ const left = clampInt(Math.round(crop.left * imageWidth), 0, imageWidth - 1);
19
+ const top = clampInt(Math.round(crop.top * imageHeight), 0, imageHeight - 1);
20
+ const right = clampInt(Math.round((crop.left + crop.width) * imageWidth), left + 1, imageWidth);
21
+ const bottom = clampInt(Math.round((crop.top + crop.height) * imageHeight), top + 1, imageHeight);
22
+ return { left, top, width: right - left, height: bottom - top };
23
+ }
24
+ /** The largest rectangle at `aspect` (width / height) inside the image, centred on the hotspot (the centre without
25
+ * one) and shifted to stay inside: what a crop nobody framed shows (spec §6). */
26
+ export function hotspotRect(aspect, hotspot, imageWidth, imageHeight) {
27
+ const spot = hotspot ?? { x: 0.5, y: 0.5 };
28
+ const wide = imageWidth / imageHeight > aspect;
29
+ const width = wide ? clampInt(Math.round(imageHeight * aspect), 1, imageWidth) : imageWidth;
30
+ const height = wide ? imageHeight : clampInt(Math.round(imageWidth / aspect), 1, imageHeight);
31
+ const left = clampInt(Math.round(spot.x * imageWidth - width / 2), 0, imageWidth - width);
32
+ const top = clampInt(Math.round(spot.y * imageHeight - height / 2), 0, imageHeight - height);
33
+ return { left, top, width, height };
34
+ }
35
+ /** The listed widths under the cut's own, then its own: a 200-pixel logo is `[200]`. A `maxWidth` keeps those at or
36
+ * under it and adds none; under them all it keeps the smallest, so a srcset is never empty. */
37
+ export function cutWidths(rectWidth, maxWidth) {
38
+ const all = [...CUT_WIDTHS.filter((w) => w < rectWidth), rectWidth];
39
+ if (maxWidth === undefined)
40
+ return all;
41
+ const kept = all.filter((w) => w <= maxWidth);
42
+ return kept.length > 0 ? kept : [all[0]];
43
+ }
44
+ export const cutSegment = (rect, sig) => `${rect.left}.${rect.top}.${rect.width}.${rect.height}.${sig}`;
45
+ const INT = /^(0|[1-9]\d{0,8})$/;
46
+ /** A signature: 16 bytes, base64url without padding, written one way: the 22nd character carries two bits and four
47
+ * zero bits of padding, so it is one of A, Q, g, w (else 16 spellings of one signature would be 16 cuts to cache). */
48
+ const SIG = /^[A-Za-z0-9_-]{21}[AQgw]$/;
49
+ /** `<left>.<top>.<width>.<height>.<sig>`, or null: whole numbers written one way only (no sign, no leading zero), a
50
+ * width and a height of at least one, and a signature's shape. */
51
+ export function parseCutSegment(segment) {
52
+ const parts = segment.split(".");
53
+ if (parts.length !== 5)
54
+ return null;
55
+ const [l, t, w, h, sig] = parts;
56
+ if (![l, t, w, h].every((p) => INT.test(p)) || !SIG.test(sig))
57
+ return null;
58
+ const rect = { left: Number(l), top: Number(t), width: Number(w), height: Number(h) };
59
+ return rect.width >= 1 && rect.height >= 1 ? { rect, sig } : null;
60
+ }
61
+ /** What the signature covers: the file and the rectangle, never the width or the quality (the route checks both
62
+ * against their lists). */
63
+ export const cutPayload = (path, rect) => `${path}:${rect.left}.${rect.top}.${rect.width}.${rect.height}`;
64
+ export const cutUrl = (cut, width, quality) => `${cut}/${width}q${quality}`;
65
+ const SIZE = /^([1-9]\d{0,8})q([1-9]\d{0,2})$/;
66
+ /** `<width>q<quality>`, or null: whole numbers written one way only. Whether both are listed is the route's check. */
67
+ export function parseCutSize(segment) {
68
+ const m = SIZE.exec(segment);
69
+ return m ? { width: Number(m[1]), quality: Number(m[2]) } : null;
70
+ }
package/dist/media.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Descriptor } from "./fields.ts";
2
+ import { type CutSigner, type ImageQuality, type PixelRect } from "./media-cut.ts";
2
3
  import { type ElementSchemas } from "./richtext-walk.ts";
3
4
  export type Crop = {
4
5
  left: number;
@@ -11,10 +12,13 @@ export type Hotspot = {
11
12
  y: number;
12
13
  };
13
14
  export type AssetKind = "image" | "video" | "file";
15
+ /** `crops`: the named crops the editor framed (spec 2026-10-18 media on R2 and Images §6); a name absent follows the
16
+ * hotspot. `crop`: the one free or `aspect` crop. */
14
17
  export type ImageValue = {
15
18
  asset: string;
16
19
  alt?: string;
17
20
  crop?: Crop;
21
+ crops?: Record<string, Crop>;
18
22
  hotspot?: Hotspot;
19
23
  aspect?: string;
20
24
  };
@@ -51,10 +55,22 @@ export type AssetRecord = {
51
55
  credit: string | null;
52
56
  /** Derived by the store from the bucket's public URL, never stored. */
53
57
  url: string;
58
+ /** The whole image's cut where the service cuts (spec 2026-10-18 media on R2 and Images §9): the admin appends a
59
+ * small width. Absent locally and on anything but a raster image. */
60
+ thumb?: string;
54
61
  createdAt: number;
55
62
  updatedAt: number;
56
63
  };
57
- export type ResolvedImage = {
64
+ /** One named crop as a view receives it: the fractions (framed, or following the hotspot), the ratio, the cut's pixel
65
+ * size (the `<source>`'s width and height) and its signed cut. */
66
+ export type ResolvedCrop = {
67
+ cut?: string;
68
+ crop: Crop;
69
+ aspect: string;
70
+ width: number;
71
+ height: number;
72
+ };
73
+ export type ResolvedImage<C extends string = string> = {
58
74
  url: string;
59
75
  width: number;
60
76
  height: number;
@@ -63,6 +79,20 @@ export type ResolvedImage = {
63
79
  crop?: Crop;
64
80
  hotspot?: Hotspot;
65
81
  aspect?: string;
82
+ /** A field with named crops: every one, in the schema's order; the first is also `crop`, `aspect` and `cut`. */
83
+ crops?: Record<C, ResolvedCrop>;
84
+ /** The signed cut of what the stored value gives, widths appended by the component (spec 2026-10-18 media on R2 and
85
+ * Images §5); absent where the service cuts nothing (locally, an SVG), and the component crops the original in CSS. */
86
+ cut?: string;
87
+ /** The field's quality and srcset cap, else the project's; absent where neither is set (spec 2026-10-18 image quality
88
+ * and max width §6). The component's props win over both. */
89
+ quality?: ImageQuality;
90
+ maxWidth?: number;
91
+ };
92
+ /** The project's image quality and srcset cap (`defineConfig({ media })`), which a field's own options override. */
93
+ export type ImageDefaults = {
94
+ quality?: ImageQuality;
95
+ maxWidth?: number;
66
96
  };
67
97
  export type ResolvedVideo = {
68
98
  url: string;
@@ -87,6 +117,12 @@ export declare const MEDIA_TYPES: ReadonlySet<string>;
87
117
  * immutable (no upsert; Replace picks a new id), so the CDN, the browser
88
118
  * and next/image may keep one for as long as they like. */
89
119
  export declare const MEDIA_CACHE_SECONDS = 31536000;
120
+ /** The largest file a space may accept (spec 2026-10-18 media on R2 and Images §4): Cloudflare's request body limit on
121
+ * Free and Pro, since every upload streams through the Worker; a pushed `media.maxSize` over it is refused. */
122
+ export declare const MEDIA_SIZE_CEILING = 104857600;
123
+ /** The raster types the service cuts (spec 2026-10-18 media on R2 and Images §5): Images takes them as input. An SVG,
124
+ * a video and a file are served as their original. */
125
+ export declare const CUT_TYPES: ReadonlySet<string>;
90
126
  export declare const DEFAULT_MEDIA: {
91
127
  readonly maxSize: number;
92
128
  readonly accept: readonly ["image/jpeg", "image/png", "image/webp", "image/avif", "image/gif", "image/svg+xml", "video/mp4", "video/webm", "application/pdf"];
@@ -104,6 +140,24 @@ export declare const isMediaValue: (v: unknown) => v is {
104
140
  /** Every asset id a value set references, walking lists and objects —
105
141
  * unlike refs, media may sit inside a container (spec §8). */
106
142
  export declare function collectAssetIds(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, out: Set<string>, elements?: ElementSchemas): void;
143
+ /** One image a read wants cut: the file, the rectangle its stored value gives, and where the signed address goes. */
144
+ export type CutRequest = {
145
+ path: string;
146
+ rect: PixelRect;
147
+ set(cut: string): void;
148
+ };
149
+ /** A stored crop the resolver may use: four fractions inside the image. */
150
+ export declare function isCrop(c: unknown): c is Crop;
151
+ /** A named crop's framed rectangle, or undefined when it follows the hotspot: its stored entry, or for the first crop a
152
+ * `crop` saved under `aspect`, at the crop's ratio (spec §6; the resolver and the crop window agree). */
153
+ export declare function framedCrop(value: Pick<ImageValue, "crop" | "crops">, names: string[], name: string, ratio: string, imageAspect: number): Crop | undefined;
154
+ /** What a crop nobody framed shows: the largest rectangle at its ratio around the hotspot, as fractions. */
155
+ export declare function followingCrop(ratio: string, hotspot: Hotspot | undefined, width: number, height: number): Crop;
107
156
  /** Replace every media value in place with its resolved object — or delete
108
- * it when the asset is missing or pending, so `image?.url` guards hold. */
109
- export declare function applyAssets(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, assets: Map<string, AssetRecord>, elements?: ElementSchemas): void;
157
+ * it when the asset is missing or pending, so `image?.url` guards hold. With
158
+ * `cuts`, every raster image resolved pushes its cut request (signCuts fills them); `defaults` are the project's
159
+ * image quality and cap, under a field's own. */
160
+ export declare function applyAssets(values: Record<string, unknown> | undefined, descriptors: Record<string, Descriptor>, assets: Map<string, AssetRecord>, elements?: ElementSchemas, cuts?: CutRequest[], defaults?: ImageDefaults): void;
161
+ /** Signs each distinct rectangle once and fills every request for it: two fields framing one photo the same way share a
162
+ * signature, an address and a cache entry. */
163
+ export declare function signCuts(requests: CutRequest[], signer: CutSigner): Promise<void>;
package/dist/media.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { hotspotRect, rectOf } from "./media-cut.js";
1
2
  import { ELEMENT_NODE, IMAGE_NODE, elementOf, elementSets, richtextBlocks } from "./richtext-walk.js";
2
3
  /** The refs target collection of every asset edge — a reserved registered name. */
3
4
  export const ASSET_COLLECTION = "asset";
@@ -6,6 +7,12 @@ export const MEDIA_TYPES = new Set(["image", "video", "file"]);
6
7
  * immutable (no upsert; Replace picks a new id), so the CDN, the browser
7
8
  * and next/image may keep one for as long as they like. */
8
9
  export const MEDIA_CACHE_SECONDS = 31536000;
10
+ /** The largest file a space may accept (spec 2026-10-18 media on R2 and Images §4): Cloudflare's request body limit on
11
+ * Free and Pro, since every upload streams through the Worker; a pushed `media.maxSize` over it is refused. */
12
+ export const MEDIA_SIZE_CEILING = 104857600;
13
+ /** The raster types the service cuts (spec 2026-10-18 media on R2 and Images §5): Images takes them as input. An SVG,
14
+ * a video and a file are served as their original. */
15
+ export const CUT_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/avif", "image/gif"]);
9
16
  export const DEFAULT_MEDIA = {
10
17
  maxSize: 25 * 1024 * 1024,
11
18
  accept: [
@@ -78,31 +85,120 @@ export function collectAssetIds(values, descriptors, out, elements = {}) {
78
85
  }
79
86
  }
80
87
  }
81
- function resolvedImage(asset, value, d) {
88
+ /** The rectangle the server cuts (spec §5): the saved crop; with none on a field with a ratio, the largest rectangle at
89
+ * that ratio around the hotspot; with neither, the whole image. */
90
+ function cutRect(image) {
91
+ if (image.crop)
92
+ return rectOf(image.crop, image.width, image.height);
93
+ if (image.aspect)
94
+ return hotspotRect(parseAspect(image.aspect), image.hotspot, image.width, image.height);
95
+ return { left: 0, top: 0, width: image.width, height: image.height };
96
+ }
97
+ /** A stored crop the resolver may use: four fractions inside the image. */
98
+ export function isCrop(c) {
99
+ if (!c || typeof c !== "object")
100
+ return false;
101
+ const { left, top, width, height } = c;
102
+ const fractions = [left, top, width, height];
103
+ // A float's hair past an edge is the crop window's arithmetic, not a crop outside the image (rectOf clamps it).
104
+ const EPS = 1e-6;
105
+ if (!fractions.every((n) => typeof n === "number" && Number.isFinite(n) && n >= -EPS && n <= 1 + EPS))
106
+ return false;
107
+ const [l, t, w, h] = fractions;
108
+ return w > 0 && h > 0 && l + w <= 1 + EPS && t + h <= 1 + EPS;
109
+ }
110
+ /** A field's named crops (`f.image({ crops })`), or undefined. */
111
+ const namedCropsOf = (d) => d.crops && typeof d.crops === "object" && Object.keys(d.crops).length > 0 ? d.crops : undefined;
112
+ /** Every named crop of a use (spec §6): the framed rectangle, else the largest one at its ratio around the hotspot. A
113
+ * `crop` saved before the field declared crops (under `aspect`) is the first crop's when its ratio is the first's
114
+ * within 1%, else ignored. The first crop's cut is the image's. */
115
+ /** A stored crop counts only at the ratio its field declares now, within 1%: a ratio changed in code makes it follow
116
+ * the hotspot at the new one, so the box, the cut and the crop window agree (the final review of plan (c)). */
117
+ const atRatio = (crop, ratio, imageAspect) => Math.abs(cropAspect(crop, imageAspect) / parseAspect(ratio) - 1) <= 0.01;
118
+ /** A named crop's framed rectangle, or undefined when it follows the hotspot: its stored entry, or for the first crop a
119
+ * `crop` saved under `aspect`, at the crop's ratio (spec §6; the resolver and the crop window agree). */
120
+ export function framedCrop(value, names, name, ratio, imageAspect) {
121
+ const stored = value.crops?.[name];
122
+ if (isCrop(stored) && atRatio(stored, ratio, imageAspect))
123
+ return stored;
124
+ if (name === names[0] && isCrop(value.crop) && atRatio(value.crop, ratio, imageAspect))
125
+ return value.crop;
126
+ return undefined;
127
+ }
128
+ /** What a crop nobody framed shows: the largest rectangle at its ratio around the hotspot, as fractions. */
129
+ export function followingCrop(ratio, hotspot, width, height) {
130
+ const rect = hotspotRect(parseAspect(ratio), hotspot, width, height);
131
+ return { left: rect.left / width, top: rect.top / height, width: rect.width / width, height: rect.height / height };
132
+ }
133
+ function resolveCrops(image, asset, value, named, cuts) {
134
+ const { width: W, height: H } = image;
135
+ const out = {};
136
+ const names = Object.keys(named);
137
+ Object.entries(named).forEach(([name, ratio], i) => {
138
+ const crop = framedCrop(value, names, name, ratio, W / H);
139
+ const rect = crop ? rectOf(crop, W, H) : hotspotRect(parseAspect(ratio), value.hotspot, W, H);
140
+ const resolved = {
141
+ crop: crop ?? { left: rect.left / W, top: rect.top / H, width: rect.width / W, height: rect.height / H },
142
+ aspect: ratio, width: rect.width, height: rect.height,
143
+ };
144
+ out[name] = resolved;
145
+ if (i === 0) {
146
+ image.crop = resolved.crop;
147
+ image.aspect = ratio;
148
+ }
149
+ if (cuts && CUT_TYPES.has(asset.mime))
150
+ cuts.push({ path: asset.path, rect, set: (cut) => { resolved.cut = cut; if (i === 0)
151
+ image.cut = cut; } });
152
+ });
153
+ image.crops = out;
154
+ }
155
+ /** The field's quality and cap, else the project's: only the ones set, so an unconfigured site's output is unchanged. */
156
+ function delivery(d, defaults) {
157
+ const quality = d.quality ?? defaults?.quality;
158
+ const maxWidth = d.maxWidth ?? defaults?.maxWidth;
159
+ return { ...(quality !== undefined ? { quality } : {}), ...(maxWidth !== undefined ? { maxWidth } : {}) };
160
+ }
161
+ function resolvedImage(asset, value, d, cuts, defaults) {
82
162
  if (asset.width === null || asset.height === null)
83
163
  return undefined;
164
+ const named = namedCropsOf(d);
165
+ if (named) {
166
+ const image = {
167
+ url: asset.url, width: asset.width, height: asset.height, mime: asset.mime, alt: value.alt ?? asset.alt ?? "",
168
+ ...(value.hotspot ? { hotspot: value.hotspot } : {}),
169
+ ...delivery(d, defaults),
170
+ };
171
+ resolveCrops(image, asset, value, named, cuts);
172
+ return image;
173
+ }
84
174
  const declared = d.aspect;
85
175
  const aspect = value.aspect ?? (typeof declared === "string" ? declared : undefined);
86
- return {
176
+ // A crop saved at another ratio than the field's now is dropped: the hotspot frames the declared one.
177
+ const crop = value.crop && (!aspect || (isCrop(value.crop) && atRatio(value.crop, aspect, asset.width / asset.height))) ? value.crop : undefined;
178
+ const image = {
87
179
  url: asset.url, width: asset.width, height: asset.height, mime: asset.mime,
88
180
  alt: value.alt ?? asset.alt ?? "",
89
- ...(value.crop ? { crop: value.crop } : {}),
181
+ ...(crop ? { crop } : {}),
90
182
  ...(value.hotspot ? { hotspot: value.hotspot } : {}),
91
183
  ...(aspect ? { aspect } : {}),
184
+ ...delivery(d, defaults),
92
185
  };
186
+ if (cuts && CUT_TYPES.has(asset.mime))
187
+ cuts.push({ path: asset.path, rect: cutRect(image), set: (cut) => { image.cut = cut; } });
188
+ return image;
93
189
  }
94
- function resolveOne(value, d, assets) {
190
+ function resolveOne(value, d, assets, cuts, defaults) {
95
191
  if (!isMediaValue(value))
96
192
  return undefined;
97
193
  const asset = assets.get(value.asset);
98
194
  if (!asset || asset.status !== "ready")
99
195
  return undefined;
100
196
  if (d.type === "image")
101
- return resolvedImage(asset, value, d);
197
+ return resolvedImage(asset, value, d, cuts, defaults);
102
198
  if (d.type === "video") {
103
199
  const v = value;
104
200
  const poster = asset.posterId ? assets.get(asset.posterId) : undefined;
105
- const posterResolved = poster ? resolvedImage(poster, { asset: poster.id }, { type: "image" }) : undefined;
201
+ const posterResolved = poster ? resolvedImage(poster, { asset: poster.id }, { type: "image" }, cuts, defaults) : undefined;
106
202
  return {
107
203
  url: asset.url, mime: asset.mime,
108
204
  ...(asset.width !== null ? { width: asset.width } : {}),
@@ -116,8 +212,10 @@ function resolveOne(value, d, assets) {
116
212
  return { url: asset.url, mime: asset.mime, size: asset.size, filename: asset.filename, ...(f.title ? { title: f.title } : {}) };
117
213
  }
118
214
  /** Replace every media value in place with its resolved object — or delete
119
- * it when the asset is missing or pending, so `image?.url` guards hold. */
120
- export function applyAssets(values, descriptors, assets, elements = {}) {
215
+ * it when the asset is missing or pending, so `image?.url` guards hold. With
216
+ * `cuts`, every raster image resolved pushes its cut request (signCuts fills them); `defaults` are the project's
217
+ * image quality and cap, under a field's own. */
218
+ export function applyAssets(values, descriptors, assets, elements = {}, cuts, defaults) {
121
219
  if (!values)
122
220
  return;
123
221
  for (const [key, d] of Object.entries(descriptors)) {
@@ -125,7 +223,7 @@ export function applyAssets(values, descriptors, assets, elements = {}) {
125
223
  if (value == null)
126
224
  continue;
127
225
  if (MEDIA_TYPES.has(d.type)) {
128
- const resolved = resolveOne(value, d, assets);
226
+ const resolved = resolveOne(value, d, assets, cuts, defaults);
129
227
  if (resolved === undefined)
130
228
  delete values[key];
131
229
  else
@@ -134,7 +232,7 @@ export function applyAssets(values, descriptors, assets, elements = {}) {
134
232
  else if (d.type === "richtext") {
135
233
  for (const node of richtextBlocks(value)) {
136
234
  if (node.type === IMAGE_NODE) {
137
- const resolved = resolveOne(node.attrs.image, { type: "image" }, assets);
235
+ const resolved = resolveOne(node.attrs.image, { type: "image" }, assets, cuts, defaults);
138
236
  if (resolved === undefined)
139
237
  delete node.attrs.image;
140
238
  else
@@ -144,28 +242,40 @@ export function applyAssets(values, descriptors, assets, elements = {}) {
144
242
  const schema = elementOf(node, elements);
145
243
  if (schema)
146
244
  for (const set of elementSets(node, schema))
147
- applyAssets(set.values, set.descriptors, assets, elements);
245
+ applyAssets(set.values, set.descriptors, assets, elements, cuts, defaults);
148
246
  }
149
247
  }
150
248
  }
151
249
  else if (d.type === "object" && typeof value === "object" && !Array.isArray(value)) {
152
- applyAssets(value, d.fields, assets, elements);
250
+ applyAssets(value, d.fields, assets, elements, cuts, defaults);
153
251
  }
154
252
  else if (d.type === "list" && Array.isArray(value)) {
155
253
  const item = d.item;
156
254
  if (MEDIA_TYPES.has(item.type)) {
157
- values[key] = value.map((v) => resolveOne(v, item, assets)).filter((v) => v !== undefined);
255
+ values[key] = value.map((v) => resolveOne(v, item, assets, cuts, defaults)).filter((v) => v !== undefined);
158
256
  }
159
257
  else if (item.type === "object") {
160
258
  for (const v of value)
161
259
  if (v && typeof v === "object")
162
- applyAssets(v, item.fields, assets, elements);
260
+ applyAssets(v, item.fields, assets, elements, cuts, defaults);
163
261
  }
164
262
  else if (item.type === "richtext") {
165
263
  // Resolves in place inside the document object `v`; the wrapper is throwaway.
166
264
  for (const v of value)
167
- applyAssets({ v }, { v: item }, assets, elements);
265
+ applyAssets({ v }, { v: item }, assets, elements, cuts, defaults);
168
266
  }
169
267
  }
170
268
  }
171
269
  }
270
+ /** Signs each distinct rectangle once and fills every request for it: two fields framing one photo the same way share a
271
+ * signature, an address and a cache entry. */
272
+ export async function signCuts(requests, signer) {
273
+ const signed = new Map();
274
+ await Promise.all(requests.map(async (r) => {
275
+ const key = `${r.path}:${r.rect.left}.${r.rect.top}.${r.rect.width}.${r.rect.height}`;
276
+ let cut = signed.get(key);
277
+ if (!cut)
278
+ signed.set(key, (cut = signer(r.path, r.rect)));
279
+ r.set(await cut);
280
+ }));
281
+ }
@@ -31,31 +31,12 @@ export interface MemberStore {
31
31
  setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
32
32
  /** Deletes the member row only; the Auth account stays. */
33
33
  remove(userId: string): Promise<void>;
34
+ /** The whole list at once, as the management API sets it (spec 2026-10-16 the control plane §3). */
35
+ replaceAll(members: MemberRecord[]): Promise<void>;
34
36
  }
35
37
  export declare const memberErrors: {
36
38
  notFound: (userId: string) => Error;
37
- noAccount: (email: string) => Error;
39
+ managed: () => Error;
38
40
  };
39
41
  /** Email first (case-insensitive), rows without one last, ties by user id. */
40
42
  export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
41
- export declare class MemoryMemberStore implements MemberStore {
42
- private rows;
43
- private url;
44
- /** Auth accounts that exist without a member row: email → user id. */
45
- private accounts;
46
- /** Emails an invite was "sent" to, in order — what a test asserts. */
47
- readonly sent: string[];
48
- constructor(rows?: MemberRecord[], url?: string | null,
49
- /** Auth accounts that exist without a member row: email → user id. */
50
- accounts?: Record<string, string>);
51
- list(): Promise<MemberRecord[]>;
52
- consoleUrl(): Promise<string | null>;
53
- invite(input: InviteInput): Promise<InviteResult>;
54
- createAccount(input: {
55
- email: string;
56
- password: string;
57
- role: MemberRole;
58
- }): Promise<MemberRecord>;
59
- setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
60
- remove(userId: string): Promise<void>;
61
- }
@@ -1,6 +1,6 @@
1
1
  export const memberErrors = {
2
2
  notFound: (userId) => new Error(`smoodly: no member "${userId}".`),
3
- noAccount: (email) => new Error(`smoodly: no Auth account for "${email}" although the invite said it exists.`),
3
+ managed: () => new Error("smoodly: members are managed at smoodly.io."),
4
4
  };
5
5
  /** Email first (case-insensitive), rows without one last, ties by user id. */
6
6
  export function sortMembers(rows) {
@@ -14,55 +14,3 @@ export function sortMembers(rows) {
14
14
  return a.email.localeCompare(b.email, undefined, { sensitivity: "base" }) || a.userId.localeCompare(b.userId);
15
15
  });
16
16
  }
17
- export class MemoryMemberStore {
18
- constructor(rows = [], url = null,
19
- /** Auth accounts that exist without a member row: email → user id. */
20
- accounts = {}) {
21
- this.rows = rows;
22
- this.url = url;
23
- this.accounts = accounts;
24
- /** Emails an invite was "sent" to, in order — what a test asserts. */
25
- this.sent = [];
26
- }
27
- async list() {
28
- return sortMembers(this.rows);
29
- }
30
- async consoleUrl() {
31
- return this.url;
32
- }
33
- async invite(input) {
34
- const key = input.email.toLowerCase();
35
- let userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId;
36
- let invited = false;
37
- if (!userId) {
38
- userId = crypto.randomUUID();
39
- this.accounts[key] = userId;
40
- this.sent.push(input.email);
41
- invited = true;
42
- }
43
- const member = { userId, email: input.email, role: input.role };
44
- this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
45
- return { member, invited };
46
- }
47
- async createAccount(input) {
48
- const key = input.email.toLowerCase();
49
- const userId = this.accounts[key] ?? this.rows.find((r) => r.email?.toLowerCase() === key)?.userId ?? crypto.randomUUID();
50
- this.accounts[key] = userId;
51
- const member = { userId, email: input.email, role: input.role };
52
- this.rows = [...this.rows.filter((r) => r.userId !== userId), member];
53
- return member;
54
- }
55
- async setRole(userId, role) {
56
- const row = this.rows.find((r) => r.userId === userId);
57
- if (!row)
58
- throw memberErrors.notFound(userId);
59
- const next = { ...row, role };
60
- this.rows = this.rows.map((r) => (r.userId === userId ? next : r));
61
- return next;
62
- }
63
- async remove(userId) {
64
- if (!this.rows.some((r) => r.userId === userId))
65
- throw memberErrors.notFound(userId);
66
- this.rows = this.rows.filter((r) => r.userId !== userId);
67
- }
68
- }
@@ -1,5 +1,5 @@
1
1
  import { type AdminHandlerDeps } from "../admin/handler.ts";
2
- export type AdminRouteDeps = Omit<AdminHandlerDeps, "effects">;
2
+ export type AdminRouteDeps = Omit<AdminHandlerDeps, "effects" | "canvasMarks">;
3
3
  /** `deps` is read per request: the app's glue reads the environment there, so `next build` needs no keys. */
4
4
  export declare function createAdminRoute(deps: () => AdminRouteDeps): {
5
5
  GET: (request: Request) => Promise<Response>;
@@ -1,7 +1,8 @@
1
1
  // packages/smoodly/src/next/admin-route.ts
2
2
  // The admin in a Next app (spec 2026-10-02 astro cloud fit §4): one route handler file exports GET and
3
- // POST from here, and that is the whole mount — the page, the package's files and the operations
4
- // endpoint (admin/handler.ts). This file adds only what is Next's: the cache and draft mode.
3
+ // POST from here, and that is the whole mount — the page, the package's files, the revalidate route the
4
+ // service calls and the preview route (admin/handler.ts). This file adds only what is Next's: the cache
5
+ // and draft mode.
5
6
  import { revalidateTag } from "next/cache";
6
7
  import { draftMode } from "next/headers";
7
8
  import { connection } from "next/server";
@@ -17,7 +18,8 @@ const effects = {
17
18
  };
18
19
  /** `deps` is read per request: the app's glue reads the environment there, so `next build` needs no keys. */
19
20
  export function createAdminRoute(deps) {
20
- const handle = createAdminHandler(() => ({ ...deps(), effects }));
21
+ // The canvas marks the path: Vercel keeps only a prerendered route's own params in the query, draft mode included.
22
+ const handle = createAdminHandler(() => ({ ...deps(), effects, canvasMarks: "path" }));
21
23
  // A request first: the handler catches every error to answer with a named one, and Next's own
22
24
  // "this route is dynamic" signal must not be among them.
23
25
  const route = async (request) => {
package/dist/next/meta.js CHANGED
@@ -1,4 +1,3 @@
1
- import { imageUrl } from "../image/loaders.js";
2
1
  import { pageSeo } from "../seo.js";
3
2
  /** Maps the effective SEO values onto Next metadata (spec 2026-09-10 page
4
3
  * fields §4, 2026-09-29 content API contract §7). Absent values leave the
@@ -16,8 +15,8 @@ export function metadataFromSeo(seo) {
16
15
  if (description)
17
16
  og.description = description;
18
17
  if (image) {
19
- // The delivery seam decides the URL; the cloud's transform loader (OQ 28) can serve a real 1200-wide render.
20
- og.images = [{ url: imageUrl(image, { width: 1200 }), width: image.width, height: image.height, ...(image.alt ? { alt: image.alt } : {}) }];
18
+ // The original, not a cut: a scraper asks no AVIF and some read neither AVIF nor WebP (spec 2026-10-18 media §7).
19
+ og.images = [{ url: image.url, width: image.width, height: image.height, ...(image.alt ? { alt: image.alt } : {}) }];
21
20
  }
22
21
  if (Object.keys(og).length > 0)
23
22
  out.openGraph = og;