smoodly 0.0.51 → 0.0.53
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.
- package/README.md +14 -13
- package/dist/admin/auth.d.ts +3 -11
- package/dist/admin/auth.js +1 -30
- package/dist/admin/client-ops.d.ts +5 -4
- package/dist/admin/client-ops.js +15 -12
- package/dist/admin/delivery-mode.d.ts +1 -1
- package/dist/admin/delivery-mode.js +2 -2
- package/dist/admin/drift.d.ts +0 -2
- package/dist/admin/drift.js +1 -1
- package/dist/admin/editor/EditorView.d.ts +0 -3
- package/dist/admin/editor/EditorView.js +11 -16
- package/dist/admin/editor/FloatingToolbar.d.ts +0 -1
- package/dist/admin/editor/FloatingToolbar.js +1 -1
- package/dist/admin/editor/SeoSettings.d.ts +0 -2
- package/dist/admin/editor/SeoSettings.js +2 -2
- package/dist/admin/editor/canvas-address.d.ts +8 -0
- package/dist/admin/editor/canvas-address.js +18 -2
- package/dist/admin/editor/canvas-diagnosis.d.ts +2 -0
- package/dist/admin/editor/canvas-diagnosis.js +3 -0
- package/dist/admin/editor/inline-dom.d.ts +0 -4
- package/dist/admin/editor/inline-dom.js +1 -1
- package/dist/admin/editor/preview-grant.d.ts +6 -0
- package/dist/admin/editor/preview-grant.js +25 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +2 -2
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/fixed-nodes.d.ts +0 -6
- package/dist/admin/fixed-nodes.js +1 -81
- package/dist/admin/forms/CropEditor.d.ts +4 -2
- package/dist/admin/forms/CropEditor.js +58 -5
- package/dist/admin/forms/ImagePreview.d.ts +3 -1
- package/dist/admin/forms/ImagePreview.js +9 -3
- package/dist/admin/forms/MediaGrid.js +2 -1
- package/dist/admin/forms/MediaPicker.js +9 -6
- package/dist/admin/forms/MediaWidget.js +11 -9
- package/dist/admin/forms/assets-context.d.ts +7 -3
- package/dist/admin/forms/assets-context.js +5 -3
- package/dist/admin/forms/crop-editor.d.ts +24 -2
- package/dist/admin/forms/crop-editor.js +36 -2
- package/dist/admin/forms/date-picker.d.ts +0 -1
- package/dist/admin/forms/date-picker.js +1 -1
- package/dist/admin/forms/tabs.d.ts +0 -1
- package/dist/admin/forms/tabs.js +1 -1
- package/dist/admin/forms/upload.d.ts +18 -11
- package/dist/admin/forms/upload.js +62 -24
- package/dist/admin/global-items.d.ts +0 -3
- package/dist/admin/global-items.js +0 -54
- package/dist/admin/handler.d.ts +22 -28
- package/dist/admin/handler.js +146 -47
- package/dist/admin/health.d.ts +0 -4
- package/dist/admin/index.d.ts +6 -9
- package/dist/admin/index.js +3 -6
- package/dist/admin/ops.d.ts +40 -32
- package/dist/admin/page-data.d.ts +14 -6
- package/dist/admin/page-data.js +1 -1
- package/dist/admin/session-flow.d.ts +65 -0
- package/dist/admin/session-flow.js +121 -0
- package/dist/admin/shell/AdminApp.d.ts +8 -3
- package/dist/admin/shell/AdminApp.js +37 -58
- package/dist/admin/shell/ContentHealthPage.js +1 -3
- package/dist/admin/shell/GlobalFields.d.ts +0 -3
- package/dist/admin/shell/GlobalFields.js +1 -1
- package/dist/admin/shell/IconRail.js +10 -8
- package/dist/admin/shell/MediaScreen.js +9 -6
- package/dist/admin/shell/MembersPage.d.ts +1 -2
- package/dist/admin/shell/MembersPage.js +19 -90
- package/dist/admin/shell/SettingsView.d.ts +1 -2
- package/dist/admin/shell/SettingsView.js +2 -2
- package/dist/admin/shell/SignedOutView.d.ts +5 -0
- package/dist/admin/shell/SignedOutView.js +9 -0
- package/dist/admin/shell/console-links.d.ts +5 -0
- package/dist/admin/shell/console-links.js +11 -0
- package/dist/admin/shell/members-list.d.ts +1 -14
- package/dist/admin/shell/members-list.js +2 -23
- package/dist/admin/shell/pages-tree.d.ts +0 -2
- package/dist/admin/shell/pages-tree.js +1 -1
- package/dist/admin/shell/presence-socket.d.ts +10 -0
- package/dist/admin/shell/presence-socket.js +191 -0
- package/dist/admin/shell/presence.d.ts +6 -18
- package/dist/admin/shell/presence.js +4 -11
- package/dist/admin/shell/session.d.ts +19 -12
- package/dist/admin/shell/session.js +114 -47
- package/dist/admin/ui/LocaleSwitcher.d.ts +0 -4
- package/dist/admin/ui/LocaleSwitcher.js +2 -2
- package/dist/admin/ui/primitives.d.ts +4 -14
- package/dist/admin/ui/primitives.js +7 -9
- package/dist/admin/ui/theme.js +13 -2
- package/dist/admin/validate.js +10 -1
- package/dist/api-client.d.ts +26 -0
- package/dist/api-client.js +107 -0
- package/dist/assemble.d.ts +4 -41
- package/dist/assemble.js +5 -303
- package/dist/asset-store.d.ts +28 -30
- package/dist/asset-store.js +26 -121
- package/dist/astro/cache.d.ts +1 -1
- package/dist/astro/components/Head.astro +4 -1
- package/dist/astro/components/Image.astro +12 -17
- package/dist/astro/components/Video.astro +5 -2
- package/dist/astro/head.d.ts +3 -0
- package/dist/astro/head.js +24 -0
- package/dist/astro/index.js +61 -27
- package/dist/astro/locale-paths.d.ts +5 -2
- package/dist/astro/locale-paths.js +10 -2
- package/dist/astro/routes/admin.js +9 -7
- package/dist/astro/runtime.d.ts +3 -2
- package/dist/astro/runtime.js +5 -4
- package/dist/astro/server.d.ts +7 -11
- package/dist/astro/server.js +22 -28
- package/dist/astro/shell.d.ts +3 -3
- package/dist/astro/shell.js +1 -1
- package/dist/astro/static-media-node.d.ts +4 -0
- package/dist/astro/static-media-node.js +25 -0
- package/dist/astro/static-media.d.ts +17 -0
- package/dist/astro/static-media.js +55 -0
- package/dist/astro/static.d.ts +5 -10
- package/dist/astro/static.js +13 -29
- package/dist/canvas-marker.d.ts +14 -0
- package/dist/canvas-marker.js +36 -0
- package/dist/cli/api.d.ts +14 -0
- package/dist/cli/api.js +48 -0
- package/dist/cli/app-env.d.ts +2 -0
- package/dist/cli/app-env.js +20 -0
- package/dist/cli/export.d.ts +16 -2
- package/dist/cli/export.js +52 -35
- package/dist/cli/import.d.ts +7 -11
- package/dist/cli/import.js +50 -54
- package/dist/cli/load-config.d.ts +0 -1
- package/dist/cli/load-config.js +1 -1
- package/dist/cli/main.d.ts +4 -12
- package/dist/cli/main.js +26 -72
- package/dist/cli/push.d.ts +6 -0
- package/dist/cli/push.js +31 -0
- package/dist/collections.d.ts +0 -1
- package/dist/collections.js +1 -1
- package/dist/config.d.ts +20 -5
- package/dist/config.js +18 -1
- package/dist/content/content-api.d.ts +23 -23
- package/dist/content/content-api.js +48 -56
- package/dist/content/mime.d.ts +1 -0
- package/dist/content/mime.js +6 -0
- package/dist/css.js +1 -1
- package/dist/deploy-store.d.ts +19 -0
- package/dist/entry-store.d.ts +8 -94
- package/dist/entry-store.js +9 -557
- package/dist/env.d.ts +27 -20
- package/dist/env.js +38 -50
- package/dist/fields.d.ts +11 -3
- package/dist/fields.js +33 -2
- package/dist/global-id.d.ts +2 -0
- package/dist/global-id.js +4 -2
- package/dist/image/SmoodlyImage.d.ts +9 -5
- package/dist/image/SmoodlyImage.js +29 -15
- package/dist/image/SmoodlyVideo.d.ts +1 -1
- package/dist/image/SmoodlyVideo.js +3 -2
- package/dist/image/index.d.ts +2 -3
- package/dist/image/index.js +0 -2
- package/dist/image/markup.d.ts +53 -0
- package/dist/image/markup.js +111 -0
- package/dist/index.d.ts +13 -23
- package/dist/index.js +7 -20
- package/dist/media-cut.d.ts +44 -0
- package/dist/media-cut.js +70 -0
- package/dist/media.d.ts +58 -4
- package/dist/media.js +126 -16
- package/dist/member-store.d.ts +2 -50
- package/dist/member-store.js +0 -56
- package/dist/next/admin-route.d.ts +1 -1
- package/dist/next/admin-route.js +5 -3
- package/dist/next/meta.js +2 -3
- package/dist/next/page-renderer.d.ts +2 -2
- package/dist/next/page-renderer.js +22 -11
- package/dist/next/queries.d.ts +2 -2
- package/dist/path-index.d.ts +0 -24
- package/dist/path-index.js +0 -98
- package/dist/paths.d.ts +1 -1
- package/dist/paths.js +1 -1
- package/dist/push.d.ts +30 -0
- package/dist/push.js +52 -0
- package/dist/read-counter.d.ts +2 -0
- package/dist/read-counter.js +6 -0
- package/dist/reads.d.ts +7 -7
- package/dist/reads.js +3 -0
- package/dist/registry-json.d.ts +11 -0
- package/dist/registry-json.js +81 -0
- package/dist/render.d.ts +0 -1
- package/dist/render.js +1 -1
- package/dist/resolve.d.ts +12 -6
- package/dist/resolve.js +11 -8
- package/dist/revalidate.d.ts +8 -1
- package/dist/revalidate.js +8 -1
- package/dist/richtext/index.d.ts +1 -1
- package/dist/section-wrapper.d.ts +1 -1
- package/dist/section-wrapper.js +0 -9
- package/dist/signed-message.d.ts +26 -0
- package/dist/signed-message.js +103 -0
- package/dist/site.d.ts +59 -18
- package/dist/site.js +21 -252
- package/dist/space-transfer.d.ts +6 -3
- package/dist/space-transfer.js +18 -6
- package/dist/store.d.ts +2 -80
- package/dist/store.js +3 -489
- package/dist/toolset.d.ts +2 -1
- package/dist/toolset.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -15
- package/dist/admin/auth-config.d.ts +0 -6
- package/dist/admin/browser/fetch-op.d.ts +0 -10
- package/dist/admin/browser/fetch-op.js +0 -44
- package/dist/admin/browser/main.d.ts +0 -1
- package/dist/admin/browser/main.js +0 -23
- package/dist/admin/ops-impl.d.ts +0 -51
- package/dist/admin/ops-impl.js +0 -1403
- package/dist/admin/shell/InviteModal.d.ts +0 -6
- package/dist/admin/shell/InviteModal.js +0 -28
- package/dist/admin/shell/LoginView.d.ts +0 -5
- package/dist/admin/shell/LoginView.js +0 -80
- package/dist/admin/shell/SchemaNotice.d.ts +0 -4
- package/dist/admin/shell/SchemaNotice.js +0 -4
- package/dist/admin/shell/SetPasswordView.d.ts +0 -7
- package/dist/admin/shell/SetPasswordView.js +0 -41
- package/dist/admin/shell/presence-realtime.d.ts +0 -3
- package/dist/admin/shell/presence-realtime.js +0 -32
- package/dist/admin/supabase-auth.d.ts +0 -7
- package/dist/admin/supabase-auth.js +0 -30
- package/dist/astro/deploy-deps.d.ts +0 -10
- package/dist/astro/deploy-deps.js +0 -8
- package/dist/astro/image.d.ts +0 -27
- package/dist/astro/image.js +0 -29
- package/dist/bind.d.ts +0 -19
- package/dist/bind.js +0 -34
- package/dist/cli/add-admin.d.ts +0 -11
- package/dist/cli/add-admin.js +0 -25
- package/dist/cli/load-collections.d.ts +0 -2
- package/dist/cli/load-collections.js +0 -27
- package/dist/cli/migrate.d.ts +0 -43
- package/dist/cli/migrate.js +0 -138
- package/dist/cli/rebuild.d.ts +0 -23
- package/dist/cli/rebuild.js +0 -22
- package/dist/cli/space-io.d.ts +0 -41
- package/dist/cli/space-io.js +0 -84
- package/dist/client.d.ts +0 -11
- package/dist/client.js +0 -54
- package/dist/current-space.d.ts +0 -4
- package/dist/current-space.js +0 -9
- package/dist/document-stamp.d.ts +0 -7
- package/dist/document-stamp.js +0 -48
- package/dist/image/loaders.d.ts +0 -15
- package/dist/image/loaders.js +0 -5
- package/dist/image/next-config.d.ts +0 -18
- package/dist/image/next-config.js +0 -32
- package/dist/image/remote-pattern.d.ts +0 -1
- package/dist/image/remote-pattern.js +0 -5
- package/dist/migrations.d.ts +0 -37
- package/dist/migrations.js +0 -73
- package/dist/project.d.ts +0 -88
- package/dist/project.js +0 -231
- package/dist/published.d.ts +0 -142
- package/dist/published.js +0 -164
- package/dist/ref-index.d.ts +0 -19
- package/dist/ref-index.js +0 -0
- package/dist/schema-read.d.ts +0 -23
- package/dist/schema-read.js +0 -50
- package/dist/schema-version.d.ts +0 -19
- package/dist/schema-version.js +0 -34
- package/dist/sql-space.d.ts +0 -44
- package/dist/sql-space.js +0 -289
- package/dist/sql.d.ts +0 -15
- package/dist/sql.js +0 -556
- package/dist/supabase-asset-store.d.ts +0 -21
- package/dist/supabase-asset-store.js +0 -170
- package/dist/supabase-entry-store.d.ts +0 -92
- package/dist/supabase-entry-store.js +0 -824
- package/dist/supabase-member-store.d.ts +0 -21
- package/dist/supabase-member-store.js +0 -96
- package/dist/supabase-path-index.d.ts +0 -30
- package/dist/supabase-path-index.js +0 -138
- package/dist/supabase-published-store.d.ts +0 -56
- package/dist/supabase-published-store.js +0 -314
- package/dist/supabase-store.d.ts +0 -103
- package/dist/supabase-store.js +0 -678
- package/migrations/20260926120000_smoodly_v1.sql +0 -535
- package/migrations/20260926130000_smoodly_v2.sql +0 -26
- package/migrations/20260926140000_smoodly_v3.sql +0 -38
- package/migrations/20260926150000_smoodly_v4.sql +0 -41
- package/migrations/20260929120000_smoodly_v5.sql +0 -56
- package/migrations/20260929135523_smoodly_v6.sql +0 -200
- package/migrations/20260930040956_smoodly_v7.sql +0 -84
- package/migrations/20261007120000_smoodly_v8.sql +0 -154
- package/migrations/20261007200000_smoodly_v9.sql +0 -30
- package/migrations/20261008120000_smoodly_v10.sql +0 -47
- package/migrations/20261009120000_smoodly_v11.sql +0 -49
- /package/dist/{admin/auth-config.js → deploy-store.js} +0 -0
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 {
|
|
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 {
|
|
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 } 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 "./
|
|
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 {
|
|
63
|
-
export {
|
|
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 the 2026-10-18 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; the 2026-10-18 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 the 2026-10-18 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
|
-
|
|
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;
|
|
@@ -85,8 +115,14 @@ export declare const ASSET_COLLECTION = "asset";
|
|
|
85
115
|
export declare const MEDIA_TYPES: ReadonlySet<string>;
|
|
86
116
|
/** Cache lifetime of a media object, in seconds — a year. Objects are
|
|
87
117
|
* immutable (no upsert; Replace picks a new id), so the CDN, the browser
|
|
88
|
-
*
|
|
118
|
+
* 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
|
-
|
|
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,11 +1,18 @@
|
|
|
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";
|
|
4
5
|
export const MEDIA_TYPES = new Set(["image", "video", "file"]);
|
|
5
6
|
/** Cache lifetime of a media object, in seconds — a year. Objects are
|
|
6
7
|
* immutable (no upsert; Replace picks a new id), so the CDN, the browser
|
|
7
|
-
*
|
|
8
|
+
* 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
|
-
|
|
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. */
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/member-store.d.ts
CHANGED
|
@@ -1,61 +1,13 @@
|
|
|
1
|
-
import type { MemberRole } from "./permissions.ts";
|
|
2
1
|
export type MemberRecord = {
|
|
3
2
|
userId: string;
|
|
4
3
|
email: string | null;
|
|
5
4
|
role: string;
|
|
6
5
|
};
|
|
7
|
-
export type InviteInput = {
|
|
8
|
-
email: string;
|
|
9
|
-
role: MemberRole;
|
|
10
|
-
redirectTo: string;
|
|
11
|
-
};
|
|
12
|
-
/** `invited` false = the email already had a confirmed account, so no mail was sent; the row was added. (A pending invitee is re-invited: same account, mail again, `invited` true.) */
|
|
13
|
-
export type InviteResult = {
|
|
14
|
-
member: MemberRecord;
|
|
15
|
-
invited: boolean;
|
|
16
|
-
};
|
|
17
6
|
export interface MemberStore {
|
|
18
7
|
/** Every member of the current space, by email then user id. */
|
|
19
8
|
list(): Promise<MemberRecord[]>;
|
|
20
|
-
/** The
|
|
21
|
-
|
|
22
|
-
/** Creates or finds the Auth user for the email, mails the invite when it created one, upserts the row (spec 2026-09-25 members management §3). */
|
|
23
|
-
invite(input: InviteInput): Promise<InviteResult>;
|
|
24
|
-
/** Creates the Auth user with a password (confirmed, no mail) and upserts the row — the installer's first editor, `add-admin`.
|
|
25
|
-
* An existing account keeps its password and only gains the membership. */
|
|
26
|
-
createAccount(input: {
|
|
27
|
-
email: string;
|
|
28
|
-
password: string;
|
|
29
|
-
role: MemberRole;
|
|
30
|
-
}): Promise<MemberRecord>;
|
|
31
|
-
setRole(userId: string, role: MemberRole): Promise<MemberRecord>;
|
|
32
|
-
/** Deletes the member row only; the Auth account stays. */
|
|
33
|
-
remove(userId: string): Promise<void>;
|
|
9
|
+
/** The whole list at once, as the management API sets it (spec 2026-10-16 the control plane §3). */
|
|
10
|
+
replaceAll(members: MemberRecord[]): Promise<void>;
|
|
34
11
|
}
|
|
35
|
-
export declare const memberErrors: {
|
|
36
|
-
notFound: (userId: string) => Error;
|
|
37
|
-
noAccount: (email: string) => Error;
|
|
38
|
-
};
|
|
39
12
|
/** Email first (case-insensitive), rows without one last, ties by user id. */
|
|
40
13
|
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
|
-
}
|
package/dist/member-store.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
export const memberErrors = {
|
|
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.`),
|
|
4
|
-
};
|
|
5
1
|
/** Email first (case-insensitive), rows without one last, ties by user id. */
|
|
6
2
|
export function sortMembers(rows) {
|
|
7
3
|
return [...rows].sort((a, b) => {
|
|
@@ -14,55 +10,3 @@ export function sortMembers(rows) {
|
|
|
14
10
|
return a.email.localeCompare(b.email, undefined, { sensitivity: "base" }) || a.userId.localeCompare(b.userId);
|
|
15
11
|
});
|
|
16
12
|
}
|
|
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>;
|