smoodly 0.0.52 → 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/dist/admin/client-ops.js +1 -1
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/ops.d.ts +4 -10
- package/dist/admin/page-data.d.ts +1 -1
- package/dist/admin/session-flow.d.ts +24 -2
- package/dist/admin/session-flow.js +29 -2
- package/dist/admin/shell/AdminApp.d.ts +1 -1
- package/dist/admin/shell/IconRail.js +5 -4
- package/dist/admin/shell/presence.d.ts +1 -1
- package/dist/admin/shell/session.d.ts +1 -2
- package/dist/admin/shell/session.js +10 -9
- package/dist/admin/ui/primitives.js +2 -1
- package/dist/api-client.js +1 -1
- package/dist/assemble.js +1 -1
- package/dist/astro/cache.d.ts +1 -1
- package/dist/astro/index.js +11 -6
- package/dist/astro/runtime.d.ts +1 -1
- package/dist/astro/runtime.js +1 -1
- package/dist/astro/shell.d.ts +1 -1
- package/dist/astro/shell.js +1 -1
- package/dist/content/content-api.d.ts +1 -0
- package/dist/content/content-api.js +3 -1
- package/dist/css.js +1 -1
- package/dist/entry-store.d.ts +5 -5
- package/dist/entry-store.js +7 -7
- package/dist/image/markup.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/media-cut.d.ts +1 -1
- package/dist/media-cut.js +2 -2
- package/dist/media.d.ts +1 -1
- package/dist/media.js +2 -2
- package/dist/member-store.d.ts +0 -29
- package/dist/member-store.js +0 -4
- package/dist/paths.d.ts +1 -1
- package/dist/paths.js +1 -1
- package/dist/revalidate.d.ts +1 -1
- package/dist/revalidate.js +1 -1
- package/dist/space-transfer.d.ts +1 -1
- package/dist/space-transfer.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/admin/auth-config.d.ts +0 -7
- package/dist/admin/auth-config.js +0 -1
- package/dist/admin/browser/fetch-op.d.ts +0 -10
- package/dist/admin/browser/fetch-op.js +0 -45
- package/dist/admin/browser/main.d.ts +0 -1
- package/dist/admin/browser/main.js +0 -27
package/dist/admin/handler.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { PresenceConfig } from "../env.ts";
|
|
|
3
3
|
import { type KeySet } from "../signed-message.ts";
|
|
4
4
|
import { type AdminFiles } from "./files.ts";
|
|
5
5
|
import type { AdminEffects } from "./ops.ts";
|
|
6
|
-
import type { SignInConfig } from "./
|
|
6
|
+
import type { SignInConfig } from "./session-flow.ts";
|
|
7
7
|
export type AdminHandlerDeps = {
|
|
8
8
|
config: ResolvedConfig;
|
|
9
9
|
/** Where the browser sends a person to sign in, and the dashboard it links to: public values only. Absent, the page
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export type { AdminEffects, AdminOps, AdminOpCall, AdminOpAction, OpResult, OpError, PageCreateInput, PageGetResult, GlobalListItem, GlobalGetResult, AssetUsageItem, MembersListResult } from "./ops.ts";
|
|
2
|
-
export type {
|
|
2
|
+
export type { MemberRecord } from "../member-store.ts";
|
|
3
3
|
export type { MemberRole } from "../permissions.ts";
|
|
4
4
|
export { fixedNodeSpecs, fixedNodeOf, type FixedNode } from "./fixed-nodes.ts";
|
|
5
5
|
export { initialGlobalFields } from "./global-items.ts";
|
|
6
6
|
export { makeClientOps } from "./client-ops.ts";
|
|
7
|
-
export type { SignInConfig } from "./
|
|
7
|
+
export type { SignInConfig } from "./session-flow.ts";
|
|
8
8
|
export { serializeAdminConfig } from "./serialize.ts";
|
|
9
9
|
export type { AdminRegistry, AdminSection, AdminCollection, AdminPageTemplate, AdminGlobal, AdminField } from "./serialize.ts";
|
|
10
10
|
export * from "./tree-ops.ts";
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { PageTree } from "../page-context.ts";
|
|
|
3
3
|
import type { EntryRecord, EntryUsage } from "../entry-store.ts";
|
|
4
4
|
import type { AssetRecord, AssetMeta } from "../media.ts";
|
|
5
5
|
import type { AssetPage, AssetQuery } from "../asset-store.ts";
|
|
6
|
-
import type {
|
|
6
|
+
import type { MemberRecord } from "../member-store.ts";
|
|
7
7
|
import type { MemberRole } from "../permissions.ts";
|
|
8
8
|
import type { FieldError } from "./validate.ts";
|
|
9
9
|
import type { DeployState } from "../deploy-state.ts";
|
|
@@ -126,10 +126,9 @@ export type AssetUsageItem = {
|
|
|
126
126
|
title: string;
|
|
127
127
|
href: string | null;
|
|
128
128
|
};
|
|
129
|
-
/** The Members page's read (spec 2026-09-25 §4): the rows
|
|
129
|
+
/** The Members page's read (spec 2026-09-25 §4): the rows; the dashboard manages them. */
|
|
130
130
|
export type MembersListResult = {
|
|
131
131
|
members: MemberRecord[];
|
|
132
|
-
consoleUrl: string | null;
|
|
133
132
|
};
|
|
134
133
|
/** `database` null = no smoodly_meta table (older than v1). Behind blocks the shell; ahead is silent (spec 2026-09-26 §5). */
|
|
135
134
|
/** The Deploy tile's state (spec 2026-10-09 static mode §6); `hook` false = SMOODLY_DEPLOY_HOOK is unset, Deploy disabled. */
|
|
@@ -254,18 +253,13 @@ export type AdminOps = {
|
|
|
254
253
|
usage(id: string): Promise<OpResult<AssetUsageItem[]>>;
|
|
255
254
|
};
|
|
256
255
|
members: {
|
|
257
|
-
/** The space's members
|
|
256
|
+
/** The space's members, a read-only list; admins only. Invites, roles and removals are the dashboard's
|
|
257
|
+
* (spec 2026-10-16 sign-in and the dashboard §6). */
|
|
258
258
|
list(): Promise<OpResult<MembersListResult>>;
|
|
259
259
|
/** The caller's role; every member. The shell hides Settings from editors with it. */
|
|
260
260
|
me(): Promise<OpResult<{
|
|
261
261
|
role: MemberRole;
|
|
262
262
|
}>>;
|
|
263
|
-
/** Admins only, self-host only: invites (or adds an existing account), refuses a duplicate row (spec 2026-09-25 members management §3). */
|
|
264
|
-
invite(input: InviteInput): Promise<OpResult<InviteResult>>;
|
|
265
|
-
/** Admins only, self-host only; never your own row, never the last admin. */
|
|
266
|
-
setRole(userId: string, role: MemberRole): Promise<OpResult<MemberRecord>>;
|
|
267
|
-
/** Admins only, self-host only; the same two guards. The Auth account stays. */
|
|
268
|
-
remove(userId: string): Promise<OpResult<null>>;
|
|
269
263
|
};
|
|
270
264
|
/** Redirects by hand (spec 2026-09-26 redirects §5): open to every member, as renaming is; nothing here expires a cache. */
|
|
271
265
|
redirects: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PresenceConfig } from "../env.ts";
|
|
2
2
|
import type { AdminRegistry } from "./serialize.ts";
|
|
3
|
-
import type { SignInConfig } from "./
|
|
3
|
+
import type { SignInConfig } from "./session-flow.ts";
|
|
4
4
|
export type AdminPageData = {
|
|
5
5
|
registry: AdminRegistry;
|
|
6
6
|
/** Where the browser sends a person to sign in and the dashboard it links to: public values only. */
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { MemberRole } from "../permissions.ts";
|
|
2
|
-
|
|
2
|
+
/** Where the admin sends a person to sign in, and the dashboard its account menu and Members page link to (spec
|
|
3
|
+
* 2026-10-16 sign-in and the dashboard §1, §6): `GET /v1/auth` answers it from the Worker's env; the handler and the
|
|
4
|
+
* page data carry it. Public values only. */
|
|
5
|
+
export type SignInConfig = {
|
|
6
|
+
signInUrl: string;
|
|
7
|
+
consoleUrl: string;
|
|
8
|
+
};
|
|
3
9
|
export declare const STORAGE_KEY: (space: string) => string;
|
|
4
10
|
/** The PKCE verifier, in this tab's `sessionStorage` only: the code is good for the browser tab that asked for it. */
|
|
5
11
|
export declare const VERIFIER_KEY = "smoodly-verifier";
|
|
@@ -26,10 +32,26 @@ export declare function readSession(local: Storage, space: string): StoredSessio
|
|
|
26
32
|
export declare function signInAddress(config: SignInConfig, space: string, returnUrl: string, stores: Pick<SessionStores, "tab">): Promise<string>;
|
|
27
33
|
/** The one-time code the sign-in page put in the fragment, or null. */
|
|
28
34
|
export declare function codeFrom(hash: string): string | null;
|
|
29
|
-
/**
|
|
35
|
+
/** What the admin shows when the service cannot be reached at sign-in: a refusal with a way back, never a blank screen. */
|
|
36
|
+
export declare const UNREACHABLE = "smoodly: couldn't reach Smoodly to sign you in \u2014 check the connection and sign in again.";
|
|
37
|
+
/** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. A service
|
|
38
|
+
* that cannot be reached is a refusal too (UNREACHABLE), so the shell shows it rather than waiting forever. */
|
|
30
39
|
export declare function exchange(api: string, space: string, code: string, stores: SessionStores, doFetch?: typeof fetch): Promise<StoredSession | {
|
|
31
40
|
refused: string;
|
|
32
41
|
}>;
|
|
42
|
+
/** The load-time step with no code in the fragment: the stored session adopted as it is while its pass has more than
|
|
43
|
+
* RENEW_BEFORE_MS left, else renewed; a renewal the service refuses sends the person to sign in; one that cannot reach
|
|
44
|
+
* the service keeps a pass that is still valid (the next timer tries again) and refuses with UNREACHABLE only once the
|
|
45
|
+
* pass has expired. No stored session: sign in. */
|
|
46
|
+
export declare function resume(api: string, space: string, stores: Pick<SessionStores, "local">, now: number, doFetch?: typeof fetch, lock?: TabLock): Promise<{
|
|
47
|
+
kind: "adopt";
|
|
48
|
+
session: StoredSession;
|
|
49
|
+
} | {
|
|
50
|
+
kind: "sign_in";
|
|
51
|
+
} | {
|
|
52
|
+
kind: "refused";
|
|
53
|
+
message: string;
|
|
54
|
+
}>;
|
|
33
55
|
/** Runs `fn` while holding a lock no other tab of this origin holds (Web Locks); with no Web Locks (an insecure
|
|
34
56
|
* context), at once. */
|
|
35
57
|
export type TabLock = <T>(name: string, fn: () => Promise<T>) => Promise<T>;
|
|
@@ -38,18 +38,45 @@ async function post(doFetch, url, body) {
|
|
|
38
38
|
const json = (await res.json().catch(() => null));
|
|
39
39
|
return { status: res.status, data: json?.ok ? json.data : null, message: typeof json?.message === "string" ? json.message : null };
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
41
|
+
/** What the admin shows when the service cannot be reached at sign-in: a refusal with a way back, never a blank screen. */
|
|
42
|
+
export const UNREACHABLE = "smoodly: couldn't reach Smoodly to sign you in — check the connection and sign in again.";
|
|
43
|
+
/** The code and this tab's verifier for a session, stored; or why not. The verifier is spent either way. A service
|
|
44
|
+
* that cannot be reached is a refusal too (UNREACHABLE), so the shell shows it rather than waiting forever. */
|
|
42
45
|
export async function exchange(api, space, code, stores, doFetch = fetch) {
|
|
43
46
|
const verifier = stores.tab.getItem(VERIFIER_KEY);
|
|
44
47
|
stores.tab.removeItem(VERIFIER_KEY);
|
|
45
48
|
if (!verifier)
|
|
46
49
|
return { refused: "smoodly: this sign-in was started in another tab or has already been used — sign in again." };
|
|
47
|
-
|
|
50
|
+
let answer;
|
|
51
|
+
try {
|
|
52
|
+
answer = await post(doFetch, sessionUrl(api, space, "session"), { code, verifier });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { refused: UNREACHABLE };
|
|
56
|
+
}
|
|
48
57
|
if (answer.status !== 200 || !isSession(answer.data))
|
|
49
58
|
return { refused: answer.message ?? `smoodly: the sign-in was refused (${answer.status}).` };
|
|
50
59
|
writeSession(stores.local, space, answer.data);
|
|
51
60
|
return answer.data;
|
|
52
61
|
}
|
|
62
|
+
/** The load-time step with no code in the fragment: the stored session adopted as it is while its pass has more than
|
|
63
|
+
* RENEW_BEFORE_MS left, else renewed; a renewal the service refuses sends the person to sign in; one that cannot reach
|
|
64
|
+
* the service keeps a pass that is still valid (the next timer tries again) and refuses with UNREACHABLE only once the
|
|
65
|
+
* pass has expired. No stored session: sign in. */
|
|
66
|
+
export async function resume(api, space, stores, now, doFetch = fetch, lock = tabLock) {
|
|
67
|
+
const stored = readSession(stores.local, space);
|
|
68
|
+
if (!stored)
|
|
69
|
+
return { kind: "sign_in" };
|
|
70
|
+
if (stored.expiresAt - now > RENEW_BEFORE_MS)
|
|
71
|
+
return { kind: "adopt", session: stored };
|
|
72
|
+
try {
|
|
73
|
+
const renewed = await renew(api, space, stores, stored.refresh, doFetch, lock);
|
|
74
|
+
return renewed ? { kind: "adopt", session: renewed } : { kind: "sign_in" };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return stored.expiresAt > now ? { kind: "adopt", session: stored } : { kind: "refused", message: UNREACHABLE };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
53
80
|
const tabLock = (name, fn) => {
|
|
54
81
|
const locks = globalThis.navigator?.locks;
|
|
55
82
|
return locks ? locks.request(name, fn) : fn();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AdminOpAction } from "../ops.ts";
|
|
2
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
3
|
-
import type { SignInConfig } from "../
|
|
3
|
+
import type { SignInConfig } from "../session-flow.ts";
|
|
4
4
|
import type { PresenceConfig } from "../../env.ts";
|
|
5
5
|
export declare function AdminApp({ registry, op, signIn, api, presenceConfig }: {
|
|
6
6
|
registry: AdminRegistry;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// packages/smoodly/src/admin/shell/IconRail.tsx
|
|
4
|
-
// The admin's primary navigation: the design
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The admin's primary navigation: the design handoff's 72px IconRail (the
|
|
5
|
+
// cool-blue look's components/navigation/IconRail, in the tree until 2026-10-19;
|
|
6
|
+
// the shipped tokens are design_handoff_smoodly_neutral/), which replaced the
|
|
7
|
+
// 200px SideNav so the visual editor gets the width. Ported markup-for-markup;
|
|
8
|
+
// the handoff's click handlers are the shell's router.
|
|
8
9
|
//
|
|
9
10
|
// Items are registry-derived, never separately configured: Pages, then
|
|
10
11
|
// Collections — one rail item whose flyout lists every collection — then
|
|
@@ -15,7 +15,7 @@ export type PresenceState = {
|
|
|
15
15
|
status: PresenceStatus;
|
|
16
16
|
others: PresenceEntry[];
|
|
17
17
|
};
|
|
18
|
-
/** The channel presence uses:
|
|
18
|
+
/** The channel presence uses: the service's socket (presence-socket.ts). */
|
|
19
19
|
export type PresenceChannel = {
|
|
20
20
|
subscribe(onStatus: (status: string) => void): void;
|
|
21
21
|
/** The session renewed (its new pass): reconnect with a fresh ticket. The space's object admits a live session of a
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { type StoredSession } from "../session-flow.ts";
|
|
1
|
+
import { type SignInConfig, type StoredSession } from "../session-flow.ts";
|
|
3
2
|
export type SessionUser = StoredSession["user"];
|
|
4
3
|
export type SessionState = {
|
|
5
4
|
status: "loading";
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// straight back in while their smoodly.io session lives. Another tab's renewal or sign out is adopted from the
|
|
8
8
|
// `storage` event. The pure steps are admin/session-flow.ts.
|
|
9
9
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
10
|
-
import { codeFrom, endSession, exchange, readSession, renew, RENEW_BEFORE_MS, signInAddress, STORAGE_KEY } from "../session-flow.js";
|
|
10
|
+
import { codeFrom, endSession, exchange, readSession, renew, RENEW_BEFORE_MS, resume, signInAddress, STORAGE_KEY } from "../session-flow.js";
|
|
11
11
|
/** A renewal's timer fires up to this much later, so two tabs rarely post the same refresh at once. */
|
|
12
12
|
const JITTER_MS = 30000;
|
|
13
13
|
export function useSpaceSession({ apiUrl, spaceId, signIn }) {
|
|
@@ -69,16 +69,17 @@ export function useSpaceSession({ apiUrl, spaceId, signIn }) {
|
|
|
69
69
|
adopt(got);
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// The stored session, as it is or renewed; a service that cannot be reached keeps a still-valid pass and
|
|
73
|
+
// refuses, with a way back, only once it has expired (session-flow.ts resume).
|
|
74
|
+
const step = await resume(apiUrl, spaceId, stores(), Date.now());
|
|
75
|
+
if (!live)
|
|
75
76
|
return;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
77
|
+
if (step.kind === "adopt")
|
|
78
|
+
adopt(step.session);
|
|
79
|
+
else if (step.kind === "refused")
|
|
80
|
+
setState({ status: "signed_out", reason: "refused", message: step.message });
|
|
80
81
|
else
|
|
81
|
-
await
|
|
82
|
+
await signInAgain();
|
|
82
83
|
})();
|
|
83
84
|
const onStorage = (e) => {
|
|
84
85
|
if (e.key !== STORAGE_KEY(spaceId))
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// The design system's primitives, ported 1:1 from the handoff's
|
|
4
|
-
// components/ set (
|
|
4
|
+
// components/ set (the cool-blue look, in the tree until 2026-10-19; the shipped
|
|
5
|
+
// tokens are design_handoff_smoodly_neutral/). Structure and dynamic
|
|
5
6
|
// values live here; every static rule, and all hover/focus state, lives in
|
|
6
7
|
// theme.tsx's stylesheet.
|
|
7
8
|
import { Fragment, useEffect, useRef } from "react";
|
package/dist/api-client.js
CHANGED
|
@@ -65,7 +65,7 @@ export function createSmoodlyApi(options) {
|
|
|
65
65
|
return (await call("/target-paths", { kind: target.kind, id: target.id })).data;
|
|
66
66
|
},
|
|
67
67
|
async load(target, locale, path, o) {
|
|
68
|
-
const draft = !!o?.draft || o?.version
|
|
68
|
+
const draft = !!o?.draft || !!o?.version;
|
|
69
69
|
const r = await call("/load", { kind: target.kind, id: target.id, locale, path, draft: o?.draft ? "1" : undefined, version: o?.version });
|
|
70
70
|
return withTags(r.data, r.tags, draft);
|
|
71
71
|
},
|
package/dist/assemble.js
CHANGED
|
@@ -49,7 +49,7 @@ export function checkEntryQuery(schema, collection, o) {
|
|
|
49
49
|
if (OBJECT_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
|
|
50
50
|
throw new Error(`smoodly: getEntries("${collection}"): "${key}" holds an object — filter on a scalar field instead.`);
|
|
51
51
|
}
|
|
52
|
-
// Any-of reads the text form of the whole field (
|
|
52
|
+
// Any-of reads the text form of the whole field (an array as its JSON text), which for
|
|
53
53
|
// an array is its JSON — `["a"]` never matches "a" — so refuse rather than match nothing.
|
|
54
54
|
if (Array.isArray(o.where[key]) && ARRAY_FIELD_TYPES.has(schema.fields[key].type ?? "")) {
|
|
55
55
|
throw new Error(`smoodly: getEntries("${collection}"): "${key}" is array-valued — any-of is for scalar fields; pass one value to match entries containing it.`);
|
package/dist/astro/cache.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export type Settled = {
|
|
|
17
17
|
draft: boolean;
|
|
18
18
|
/** The request is under the preview address, cookie or not. */
|
|
19
19
|
preview: boolean;
|
|
20
|
-
/** The shell rendered a page read from the
|
|
20
|
+
/** The shell rendered a page read from the service and gave it a lifetime. */
|
|
21
21
|
cacheable: boolean;
|
|
22
22
|
/** When the request began, in milliseconds. */
|
|
23
23
|
started: number;
|
package/dist/astro/index.js
CHANGED
|
@@ -79,11 +79,16 @@ export default function smoodly(options = {}) {
|
|
|
79
79
|
let staticMedia = new Map();
|
|
80
80
|
/** astro.config's `i18n`, which the build holds the config's `routing` against. */
|
|
81
81
|
let i18n;
|
|
82
|
-
/** The project's
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
/** The project's config at build, from its config file (the one `options.config` names), loaded once and shared by
|
|
83
|
+
* every hook; and its API client, from the env the setup hook loaded, made once. Two steps, since the routing
|
|
84
|
+
* check wants the config before any env is needed. */
|
|
85
|
+
let builtConfig = null;
|
|
86
|
+
const buildConfig = () => (builtConfig ?? (builtConfig = options.config ? loadConfigFile(join(root, options.config), root) : loadConfig(root)));
|
|
87
|
+
let builtApi = null;
|
|
88
|
+
const buildApi = () => (builtApi ?? (builtApi = (async () => {
|
|
89
|
+
const loaded = await buildConfig();
|
|
85
90
|
return { loaded, api: createSmoodlyApi({ ...apiEnv(process.env), config: loaded }) };
|
|
86
|
-
};
|
|
91
|
+
})()));
|
|
87
92
|
/** A build read that fails names the service's address and stops the build before any page renders (a static build
|
|
88
93
|
* with the Worker down would otherwise fail page by page). */
|
|
89
94
|
const fromApi = async (read) => {
|
|
@@ -115,7 +120,7 @@ export default function smoodly(options = {}) {
|
|
|
115
120
|
injectRoute(route);
|
|
116
121
|
addMiddleware({ entrypoint: here(`./middleware${ext}`), order: "pre" });
|
|
117
122
|
if (output === "server" && !config.cache?.provider) {
|
|
118
|
-
logger.warn("smoodly: no cache provider in astro.config — every page renders from the
|
|
123
|
+
logger.warn("smoodly: no cache provider in astro.config — every page renders from the service. Add `cache: { provider: memoryCache() }` (from \"astro/config\").");
|
|
119
124
|
}
|
|
120
125
|
// astro.config is evaluated before Astro loads .env, so `site: process.env.SITE_URL` there sees only the
|
|
121
126
|
// shell's value; the env files were read above, and SITE_URL from them sets `site` here. A value
|
|
@@ -149,7 +154,7 @@ export default function smoodly(options = {}) {
|
|
|
149
154
|
// The schema push (spec 2026-10-16 the control plane §4) comes first, in either output: the space then holds the
|
|
150
155
|
// registry this build renders with. A failed push never fails the build; the site runs against the schema the space has.
|
|
151
156
|
"astro:build:start": async ({ logger }) => {
|
|
152
|
-
const loaded =
|
|
157
|
+
const loaded = await buildConfig();
|
|
153
158
|
// The runtime's startup check, at build in either output: a deploy that builds must not refuse every request.
|
|
154
159
|
astroRouting(i18n, loaded.locales, loaded.routing);
|
|
155
160
|
try {
|
package/dist/astro/runtime.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type { PageHead } from "./head.ts";
|
|
|
19
19
|
export type { ViewFiles } from "./views-check.ts";
|
|
20
20
|
import { type Shell, type SmoodlyLocals } from "./shell.ts";
|
|
21
21
|
/** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
|
|
22
|
-
* A page read from the
|
|
22
|
+
* A page read from the service gets its lifetime here, before its view renders. The first call compares
|
|
23
23
|
* the project's view files to the registry and names every disagreement once. */
|
|
24
24
|
export declare function shell(pathname: string, search: string, state: SmoodlyLocals, views: ViewFiles, cache?: unknown): Promise<Shell>;
|
|
25
25
|
/** getStaticPaths for the static page shell (spec 2026-10-09 static mode §3): every published address of the bound site. */
|
package/dist/astro/runtime.js
CHANGED
|
@@ -25,7 +25,7 @@ import { missingViews } from "./views-check.js";
|
|
|
25
25
|
import { resolveShell } from "./shell.js";
|
|
26
26
|
let viewsChecked = false;
|
|
27
27
|
/** The shell's decision with the project's bound site: what routes/page.astro calls, with `Astro.cache`.
|
|
28
|
-
* A page read from the
|
|
28
|
+
* A page read from the service gets its lifetime here, before its view renders. The first call compares
|
|
29
29
|
* the project's view files to the registry and names every disagreement once. */
|
|
30
30
|
export async function shell(pathname, search, state, views, cache) {
|
|
31
31
|
if (!viewsChecked) {
|
package/dist/astro/shell.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type SmoodlyLocals = {
|
|
|
14
14
|
editor: boolean;
|
|
15
15
|
/** History's stored version, inside a preview only. */
|
|
16
16
|
version?: string;
|
|
17
|
-
/** Set by the shell when it rendered a page read from the
|
|
17
|
+
/** Set by the shell when it rendered a page read from the service: the response may be cached under its tags. */
|
|
18
18
|
cacheable: boolean;
|
|
19
19
|
/** Set by the shell when it rendered a page at all: in a preview, only that response is a canvas. */
|
|
20
20
|
shell: boolean;
|
package/dist/astro/shell.js
CHANGED
|
@@ -52,7 +52,7 @@ export async function resolveShell(deps, pathname, search, state, views) {
|
|
|
52
52
|
console.warn(`smoodly: no src/page-types/${hit.template}.astro — the record at ${hit.path} is a 404.`);
|
|
53
53
|
return NOT_FOUND;
|
|
54
54
|
}
|
|
55
|
-
// Only a page read from the
|
|
55
|
+
// Only a page read from the service has a tag to expire it by.
|
|
56
56
|
state.cacheable = !hit.record.id.startsWith("code:") && !state.draft;
|
|
57
57
|
state.shell = true;
|
|
58
58
|
const zoneNames = Object.keys(schema.zones ?? {});
|
|
@@ -22,6 +22,7 @@ export declare class SmoodlyContentError extends Error {
|
|
|
22
22
|
/** What content() writes through: the operations, the paths index's lookup, an upload. */
|
|
23
23
|
export type ContentTransport = {
|
|
24
24
|
op(call: AdminOpCall): Promise<OpResult<unknown>>;
|
|
25
|
+
/** The target at an address, as a draft read: what the script is writing, never an edge copy. */
|
|
25
26
|
locate(locale: string, path: string): Promise<PathHit | null>;
|
|
26
27
|
upload(bytes: Uint8Array, filename: string, mime: string | undefined, meta: UploadMeta): Promise<ImageValue>;
|
|
27
28
|
};
|
|
@@ -45,7 +45,9 @@ export function apiTransport(options) {
|
|
|
45
45
|
const json = (body) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
46
46
|
return {
|
|
47
47
|
op: (call) => send("/op", json({ path: call.path, args: call.args })),
|
|
48
|
-
|
|
48
|
+
// A draft read (DESIGN §1 Content API › Writes): the caller holds the secret key and asks about what it is writing,
|
|
49
|
+
// and a draft read is never stored at the edge, so a script sees its own create at once.
|
|
50
|
+
locate: (locale, path) => must(send(`/locate?${new URLSearchParams({ locale, path, draft: "1" })}`)),
|
|
49
51
|
// The bytes as the body with their length, the rest in the query (spec 2026-10-18 media on R2 and Images §4).
|
|
50
52
|
async upload(bytes, filename, mime, meta) {
|
|
51
53
|
const type = mime ?? mimeOf(filename);
|
package/dist/css.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The custom-CSS box (spec 2026-09-14 §4): a per-section snippet the
|
|
2
2
|
// wrapper nests under the section's own [data-smd-node] selector. CSS
|
|
3
3
|
// can do two things beyond styling — pull a remote stylesheet and beacon
|
|
4
|
-
// to a host — so those, the two
|
|
4
|
+
// to a host — so those, the two old code hooks (`expression()`, `behavior:`) and the tag-closing
|
|
5
5
|
// sequence are refused at save and stripped at render, in that order.
|
|
6
6
|
// The checks run on the snippet with its CSS escapes decoded, so
|
|
7
7
|
// `\75 rl(` cannot spell `url(` past them; and the braces must balance,
|
package/dist/entry-store.d.ts
CHANGED
|
@@ -58,8 +58,8 @@ export type Where = Record<string, WhereValue | WhereValue[]>;
|
|
|
58
58
|
* adapters read the text form of the whole array, never its elements). */
|
|
59
59
|
export declare const ARRAY_FIELD_TYPES: ReadonlySet<string>;
|
|
60
60
|
/** The memory adapter's and the tests' reading of `where`: a scalar matches an
|
|
61
|
-
* equal value or an array holding it (
|
|
62
|
-
* compared as text
|
|
61
|
+
* equal value or an array holding it (containment); an array is any-of,
|
|
62
|
+
* compared as text (textForm). */
|
|
63
63
|
export declare function matchesWhere(fields: Record<string, unknown>, where: Where): boolean;
|
|
64
64
|
export type ListOptions = {
|
|
65
65
|
/** Equality / any-of on shared fields, executed by the store (spec 2026-09-11 §3). */
|
|
@@ -256,8 +256,8 @@ export declare function entryTitleIn(record: EntryRecord, locale: string, schema
|
|
|
256
256
|
* directions (the SQLite list compiler sorts nulls last to match);
|
|
257
257
|
* the creation tiebreak follows `direction` for non-manual orders.
|
|
258
258
|
* Declared fields compare numerically when both values are numbers,
|
|
259
|
-
* otherwise as strings via `localeCompare` — the
|
|
260
|
-
*
|
|
261
|
-
* follow-ups for the mixed-type
|
|
259
|
+
* otherwise as strings via `localeCompare` — the order the SQLite store's
|
|
260
|
+
* `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
|
|
261
|
+
* follow-ups for the mixed-type, JSON-null and non-ASCII edges).
|
|
262
262
|
*/
|
|
263
263
|
export declare function compareEntries(order: EntryOrder, seq: (e: EntryRecord) => number): (a: EntryRecord, b: EntryRecord) => number;
|
package/dist/entry-store.js
CHANGED
|
@@ -15,12 +15,12 @@ import { slugOf, unsupportedLocale } from "./paths.js";
|
|
|
15
15
|
* containment, and any-of on one is refused by the site layer (both
|
|
16
16
|
* adapters read the text form of the whole array, never its elements). */
|
|
17
17
|
export const ARRAY_FIELD_TYPES = new Set(["checkboxes", "refList", "list", "tags"]);
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
18
|
+
/** A field's text form: a scalar's text, an array's or object's JSON text (no scalar
|
|
19
|
+
* equals either form), the same in every store. */
|
|
20
20
|
const textForm = (value) => (value !== null && typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
21
21
|
/** The memory adapter's and the tests' reading of `where`: a scalar matches an
|
|
22
|
-
* equal value or an array holding it (
|
|
23
|
-
* compared as text
|
|
22
|
+
* equal value or an array holding it (containment); an array is any-of,
|
|
23
|
+
* compared as text (textForm). */
|
|
24
24
|
export function matchesWhere(fields, where) {
|
|
25
25
|
return Object.entries(where).every(([key, want]) => {
|
|
26
26
|
const have = fields[key];
|
|
@@ -134,9 +134,9 @@ export function entryTitleIn(record, locale, schema) {
|
|
|
134
134
|
* directions (the SQLite list compiler sorts nulls last to match);
|
|
135
135
|
* the creation tiebreak follows `direction` for non-manual orders.
|
|
136
136
|
* Declared fields compare numerically when both values are numbers,
|
|
137
|
-
* otherwise as strings via `localeCompare` — the
|
|
138
|
-
*
|
|
139
|
-
* follow-ups for the mixed-type
|
|
137
|
+
* otherwise as strings via `localeCompare` — the order the SQLite store's
|
|
138
|
+
* `COLLATE NOCASE` gives for one type (ASCII only; see MAINTAINERS.md's
|
|
139
|
+
* follow-ups for the mixed-type, JSON-null and non-ASCII edges).
|
|
140
140
|
*/
|
|
141
141
|
export function compareEntries(order, seq) {
|
|
142
142
|
return (a, b) => {
|
package/dist/image/markup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// One image markup for both frameworks (spec 2026-10-18 media on R2 and Images §7
|
|
1
|
+
// One image markup for both frameworks (spec 2026-10-18 media on R2 and Images §7): what
|
|
2
2
|
// <SmoodlyImage> (React) and <Image> (Astro) render, decided here so both are only markup and a test holds them equal.
|
|
3
3
|
// Plain HTML, no optimizer and no Smoodly JavaScript: a box, an <img> whose srcset lists the cut's widths, or a
|
|
4
4
|
// <picture> with one <source> per crop the view places. Without a cut (locally, an SVG) the original is framed in CSS
|
package/dist/index.d.ts
CHANGED
|
@@ -30,8 +30,8 @@ export { assetErrors, kindOf, extensionFor, objectPath, PAGE_SIZE as ASSET_PAGE_
|
|
|
30
30
|
export type { AssetStore, UploadTicket, FinishInput, AssetQuery, AssetPage, BeginUploadInput, CreateAssetInput } from "./asset-store.ts";
|
|
31
31
|
export { imageDimensions } from "./image-dimensions.ts";
|
|
32
32
|
export type { ImageQuality } from "./media-cut.ts";
|
|
33
|
-
export { sortMembers
|
|
34
|
-
export type { MemberStore, MemberRecord
|
|
33
|
+
export { sortMembers } from "./member-store.ts";
|
|
34
|
+
export type { MemberStore, MemberRecord } from "./member-store.ts";
|
|
35
35
|
export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.ts";
|
|
36
36
|
export type { MemberRole, Capability } from "./permissions.ts";
|
|
37
37
|
export { collectEntryRefs, collectTreeRefs } from "./refs.ts";
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ export { defineConfig } from "./config.js";
|
|
|
21
21
|
export { draftToken, sameToken, isDraftToken, assertFresh, StaleDraftError } from "./draft-token.js";
|
|
22
22
|
export { assetErrors, kindOf, extensionFor, objectPath, PAGE_SIZE as ASSET_PAGE_SIZE } from "./asset-store.js";
|
|
23
23
|
export { imageDimensions } from "./image-dimensions.js";
|
|
24
|
-
export { sortMembers
|
|
24
|
+
export { sortMembers } from "./member-store.js";
|
|
25
25
|
export { MEMBER_ROLES, can, normalizeRole, isMemberRole } from "./permissions.js";
|
|
26
26
|
export { collectEntryRefs, collectTreeRefs } from "./refs.js";
|
|
27
27
|
export { richtextBlocks, elementSchemas, IMAGE_NODE, ELEMENT_NODE } from "./richtext-walk.js";
|
package/dist/media-cut.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type PixelRect = {
|
|
|
6
6
|
height: number;
|
|
7
7
|
};
|
|
8
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
|
|
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
10
|
export declare const IMAGE_QUALITIES: readonly [50, 60, 75, 85, 95, 100];
|
|
11
11
|
export type ImageQuality = (typeof IMAGE_QUALITIES)[number];
|
|
12
12
|
export declare const DEFAULT_QUALITY: ImageQuality;
|
package/dist/media-cut.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/** The widths a cut is served at, plus the cut's own width; a wider one is never served (upscaling sells nothing).
|
|
2
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;
|
|
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
4
|
* image quality and max width §5). */
|
|
5
5
|
const CUT_WIDTHS = [320, 640, 750, 828, 1080, 1280, 1536, 1920, 2560];
|
|
6
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
|
|
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
8
|
export const IMAGE_QUALITIES = [50, 60, 75, 85, 95, 100];
|
|
9
9
|
export const DEFAULT_QUALITY = 75;
|
|
10
10
|
export const isImageQuality = (n) => IMAGE_QUALITIES.includes(n);
|
package/dist/media.d.ts
CHANGED
|
@@ -115,7 +115,7 @@ export declare const ASSET_COLLECTION = "asset";
|
|
|
115
115
|
export declare const MEDIA_TYPES: ReadonlySet<string>;
|
|
116
116
|
/** Cache lifetime of a media object, in seconds — a year. Objects are
|
|
117
117
|
* immutable (no upsert; Replace picks a new id), so the CDN, the browser
|
|
118
|
-
*
|
|
118
|
+
* may keep one for as long as they like. */
|
|
119
119
|
export declare const MEDIA_CACHE_SECONDS = 31536000;
|
|
120
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
121
|
* Free and Pro, since every upload streams through the Worker; a pushed `media.maxSize` over it is refused. */
|
package/dist/media.js
CHANGED
|
@@ -5,7 +5,7 @@ export const ASSET_COLLECTION = "asset";
|
|
|
5
5
|
export const MEDIA_TYPES = new Set(["image", "video", "file"]);
|
|
6
6
|
/** Cache lifetime of a media object, in seconds — a year. Objects are
|
|
7
7
|
* immutable (no upsert; Replace picks a new id), so the CDN, the browser
|
|
8
|
-
*
|
|
8
|
+
* may keep one for as long as they like. */
|
|
9
9
|
export const MEDIA_CACHE_SECONDS = 31536000;
|
|
10
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
11
|
* Free and Pro, since every upload streams through the Worker; a pushed `media.maxSize` over it is refused. */
|
|
@@ -113,7 +113,7 @@ const namedCropsOf = (d) => d.crops && typeof d.crops === "object" && Object.key
|
|
|
113
113
|
* `crop` saved before the field declared crops (under `aspect`) is the first crop's when its ratio is the first's
|
|
114
114
|
* within 1%, else ignored. The first crop's cut is the image's. */
|
|
115
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
|
|
116
|
+
* the hotspot at the new one, so the box, the cut and the crop window agree. */
|
|
117
117
|
const atRatio = (crop, ratio, imageAspect) => Math.abs(cropAspect(crop, imageAspect) / parseAspect(ratio) - 1) <= 0.01;
|
|
118
118
|
/** A named crop's framed rectangle, or undefined when it follows the hotspot: its stored entry, or for the first crop a
|
|
119
119
|
* `crop` saved under `aspect`, at the crop's ratio (spec §6; the resolver and the crop window agree). */
|
package/dist/member-store.d.ts
CHANGED
|
@@ -1,42 +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 space's console URL when the control plane manages its members; null in self-host. */
|
|
21
|
-
consoleUrl(): Promise<string | null>;
|
|
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>;
|
|
34
9
|
/** The whole list at once, as the management API sets it (spec 2026-10-16 the control plane §3). */
|
|
35
10
|
replaceAll(members: MemberRecord[]): Promise<void>;
|
|
36
11
|
}
|
|
37
|
-
export declare const memberErrors: {
|
|
38
|
-
notFound: (userId: string) => Error;
|
|
39
|
-
managed: () => Error;
|
|
40
|
-
};
|
|
41
12
|
/** Email first (case-insensitive), rows without one last, ties by user id. */
|
|
42
13
|
export declare function sortMembers(rows: MemberRecord[]): MemberRecord[];
|
package/dist/member-store.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
export const memberErrors = {
|
|
2
|
-
notFound: (userId) => new Error(`smoodly: no member "${userId}".`),
|
|
3
|
-
managed: () => new Error("smoodly: members are managed at smoodly.io."),
|
|
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) => {
|
package/dist/paths.d.ts
CHANGED
|
@@ -102,7 +102,7 @@ export declare function collectionSegment(path: string | Record<string, string>
|
|
|
102
102
|
* it is a non-empty string — absent, null, or `""` all mean "no URL".
|
|
103
103
|
* There is no sibling-slug index: sibling uniqueness is the primary key
|
|
104
104
|
* of `paths` plus the pre-check each store makes per locale before it
|
|
105
|
-
* writes (spec 2026-09-06 §12), so
|
|
105
|
+
* writes (spec 2026-09-06 §12), so every store agrees on which
|
|
106
106
|
* entries can collide. */
|
|
107
107
|
export declare const slugOf: (fields: Record<string, unknown>) => string | null;
|
|
108
108
|
/** The entry's segment in one locale: its row's slug. An absent locale
|