smoodly 0.0.47 → 0.0.48
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/browser/main.js +2 -0
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/delivery-mode.d.ts +5 -0
- package/dist/admin/delivery-mode.js +20 -0
- package/dist/admin/editor/EditorView.js +3 -0
- package/dist/admin/files.generated.js +1 -1
- package/dist/admin/handler.d.ts +5 -1
- package/dist/admin/handler.js +2 -1
- package/dist/admin/ops-impl.d.ts +13 -0
- package/dist/admin/ops-impl.js +46 -0
- package/dist/admin/ops.d.ts +11 -0
- package/dist/admin/page-data.d.ts +2 -0
- package/dist/admin/shell/AdminApp.js +47 -1
- package/dist/admin/shell/EntryForm.js +3 -0
- package/dist/admin/shell/GlobalForm.js +5 -1
- package/dist/admin/shell/IconRail.d.ts +8 -1
- package/dist/admin/shell/IconRail.js +8 -3
- package/dist/admin/shell/deploy-tile.d.ts +20 -0
- package/dist/admin/shell/deploy-tile.js +35 -0
- package/dist/admin/ui/PublishControl.js +3 -1
- package/dist/admin/ui/theme.js +12 -0
- package/dist/astro/deploy-deps.d.ts +10 -0
- package/dist/astro/deploy-deps.js +8 -0
- package/dist/astro/index.d.ts +9 -4
- package/dist/astro/index.js +75 -15
- package/dist/astro/middleware.js +4 -0
- package/dist/astro/routes/admin.js +2 -1
- package/dist/astro/routes/preview.astro +17 -5
- package/dist/astro/routes/static-page.astro +21 -0
- package/dist/astro/runtime.d.ts +6 -0
- package/dist/astro/runtime.js +4 -1
- package/dist/astro/server.d.ts +6 -0
- package/dist/astro/server.js +7 -1
- package/dist/astro/static.d.ts +36 -0
- package/dist/astro/static.js +59 -0
- package/dist/cli/load-config.d.ts +3 -0
- package/dist/cli/load-config.js +8 -1
- package/dist/deploy-state.d.ts +28 -0
- package/dist/deploy-state.js +31 -0
- package/dist/project.d.ts +3 -0
- package/dist/project.js +8 -5
- package/dist/published.d.ts +31 -4
- package/dist/published.js +35 -7
- package/dist/schema-version.d.ts +1 -1
- package/dist/schema-version.js +1 -1
- package/dist/site.js +2 -2
- package/dist/sql-space.d.ts +3 -2
- package/dist/sql-space.js +3 -2
- package/dist/sql.js +10 -0
- package/dist/supabase-published-store.d.ts +14 -3
- package/dist/supabase-published-store.js +72 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/migrations/20261009120000_smoodly_v11.sql +49 -0
- package/package.json +1 -1
package/dist/admin/handler.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { PageStore } from "../store.ts";
|
|
|
7
7
|
import type { Projector } from "../project.ts";
|
|
8
8
|
import { type AdminAuth } from "./auth.ts";
|
|
9
9
|
import { type AdminFiles } from "./files.ts";
|
|
10
|
-
import { type AdminEffects } from "./ops-impl.ts";
|
|
10
|
+
import { type AdminEffects, type DeployDeps } from "./ops-impl.ts";
|
|
11
11
|
import type { AuthConfig } from "./auth-config.ts";
|
|
12
12
|
export type AdminHandlerDeps = {
|
|
13
13
|
config: ResolvedConfig;
|
|
@@ -29,6 +29,10 @@ export type AdminHandlerDeps = {
|
|
|
29
29
|
effects?: AdminEffects;
|
|
30
30
|
/** Where drafts are served, when not at the page's own address (page-data.ts). */
|
|
31
31
|
previewBase?: string;
|
|
32
|
+
/** Static mode (spec 2026-10-09): the deploy operations' deps and the mode the page data names; absent = live. */
|
|
33
|
+
deploy?: DeployDeps & {
|
|
34
|
+
mode: "live" | "static";
|
|
35
|
+
};
|
|
32
36
|
/** The prebuilt files; default: the ones the package's build wrote. Tests pass their own. */
|
|
33
37
|
files?: AdminFiles;
|
|
34
38
|
};
|
package/dist/admin/handler.js
CHANGED
|
@@ -80,6 +80,7 @@ export function createAdminHandler(deps) {
|
|
|
80
80
|
opUrl: `${base}/${ADMIN_INTERNAL}/op`,
|
|
81
81
|
filesBase: `${base}/${ADMIN_INTERNAL}/${files.hash}`,
|
|
82
82
|
previewBase: d.previewBase ?? "",
|
|
83
|
+
delivery: d.deploy?.mode ?? "live",
|
|
83
84
|
};
|
|
84
85
|
return html(200, adminPageHtml(data));
|
|
85
86
|
}
|
|
@@ -112,7 +113,7 @@ export function createAdminHandler(deps) {
|
|
|
112
113
|
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) {
|
|
113
114
|
return json(413, refused("validation", "smoodly: the operation is larger than 4 MB."));
|
|
114
115
|
}
|
|
115
|
-
const base = { config: d.config, pages: d.pages, entries: d.entries, href: d.href, assets: d.assets, members: d.members, projector: d.projector, effects: d.effects };
|
|
116
|
+
const base = { config: d.config, pages: d.pages, entries: d.entries, href: d.href, assets: d.assets, members: d.members, projector: d.projector, effects: d.effects, deploy: d.deploy };
|
|
116
117
|
let status = 200;
|
|
117
118
|
// The gate needs the token alone; the call is read once it has admitted the session.
|
|
118
119
|
const result = await gateAdminOp(d.auth, { path: "", args: [], token }, async (_gate, session) => {
|
package/dist/admin/ops-impl.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { AssetStore } from "../asset-store.ts";
|
|
|
4
4
|
import type { MemberStore } from "../member-store.ts";
|
|
5
5
|
import type { PageStore } from "../store.ts";
|
|
6
6
|
import type { AdminOpCall, AdminOps, OpResult } from "./ops.ts";
|
|
7
|
+
import type { PublishedStore } from "../published.ts";
|
|
7
8
|
import type { AdminSession } from "./auth.ts";
|
|
8
9
|
import type { Projector } from "../project.ts";
|
|
9
10
|
export type AdminEffects = {
|
|
@@ -32,7 +33,19 @@ export declare function createAdminOps(deps: {
|
|
|
32
33
|
* the new document — a draft save excepted (R17). The truth is written before; a projection that throws is
|
|
33
34
|
* logged, its targets marked stale to heal on read, and the op succeeds. */
|
|
34
35
|
projector: Projector;
|
|
36
|
+
/** Static mode's Deploy tile (spec 2026-10-09 static mode §5–§7); absent on a live site, where the group answers not_found. */
|
|
37
|
+
deploy?: DeployDeps;
|
|
35
38
|
}): AdminOps;
|
|
39
|
+
/** What the deploy operations need (spec 2026-10-09 static mode §7): the live site's origin, where the marker is
|
|
40
|
+
* read from; the host's hook from SMOODLY_DEPLOY_HOOK (absent = Deploy disabled); the delivery store's reads. */
|
|
41
|
+
export type DeployDeps = {
|
|
42
|
+
siteUrl: string;
|
|
43
|
+
hook?: string;
|
|
44
|
+
store: Pick<PublishedStore, "newestPublishedAt" | "countPublishedAfter" | "deployRequest" | "requestDeploy" | "lastRemovalAt">;
|
|
45
|
+
/** Test seams. */
|
|
46
|
+
fetch?: typeof fetch;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
};
|
|
36
49
|
/** Path-dispatch for the one-endpoint seam. Own-property checks only —
|
|
37
50
|
* "constructor.constructor" and prototype paths must not resolve. */
|
|
38
51
|
export declare function runAdminOp(ops: AdminOps, call: AdminOpCall): Promise<OpResult<unknown>>;
|
package/dist/admin/ops-impl.js
CHANGED
|
@@ -17,6 +17,7 @@ import { CUSTOM_NODE_TYPE, customSchema } from "../custom/index.js";
|
|
|
17
17
|
import { SETTINGS_KEY, CSS_KEY } from "../global.js";
|
|
18
18
|
import { withDefaults } from "../field-defaults.js";
|
|
19
19
|
import { elementSchemas } from "../richtext-walk.js";
|
|
20
|
+
import { deployState, parseMarker, MARKER_PATH } from "../deploy-state.js";
|
|
20
21
|
import { REDIRECT_PATH_MESSAGE, normalizeRedirectPath, redirectPathProblem } from "./redirect-path.js";
|
|
21
22
|
import { SCHEMA_VERSION } from "../schema-version.js";
|
|
22
23
|
import { draftToken, isDraftToken, StaleDraftError } from "../draft-token.js";
|
|
@@ -1322,6 +1323,51 @@ export function createAdminOps(deps) {
|
|
|
1322
1323
|
enable: () => guard(async () => (await effects?.draft?.enable(), ok(null))),
|
|
1323
1324
|
disable: () => guard(async () => (await effects?.draft?.disable(), ok(null))),
|
|
1324
1325
|
},
|
|
1326
|
+
deploy: deployOps(deps.deploy, by, guard),
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
/** The marker is fetched with a throwaway query and no-store, so no cache between answers with the old
|
|
1330
|
+
* file (§5). A site that does not answer is "unreachable", never a throw; a 404 (a site built before this
|
|
1331
|
+
* version, or live mode by mistake) is reachable with no marker, so every row counts as waiting. */
|
|
1332
|
+
function deployOps(d, by, guard) {
|
|
1333
|
+
const notConfigured = () => err("not_found", "smoodly: this site has no deploy step (live mode).");
|
|
1334
|
+
if (!d)
|
|
1335
|
+
return { status: async () => notConfigured(), trigger: async () => notConfigured() };
|
|
1336
|
+
const get = d.fetch ?? fetch;
|
|
1337
|
+
const now = () => d.now?.() ?? Date.now();
|
|
1338
|
+
const read = async () => {
|
|
1339
|
+
const at = now();
|
|
1340
|
+
let marker = null;
|
|
1341
|
+
let reachable = true;
|
|
1342
|
+
try {
|
|
1343
|
+
// A hanging address is "unreachable" after five seconds, never a held operation.
|
|
1344
|
+
const res = await get(`${d.siteUrl.replace(/\/$/, "")}/${MARKER_PATH}?t=${at}`, { cache: "no-store", signal: AbortSignal.timeout(5000) });
|
|
1345
|
+
marker = res.ok ? parseMarker(await res.text()) : null;
|
|
1346
|
+
reachable = res.ok || res.status === 404;
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
reachable = false;
|
|
1350
|
+
}
|
|
1351
|
+
const through = marker?.publishedThrough ?? 0;
|
|
1352
|
+
const [press, newer, removedAt] = await Promise.all([d.store.deployRequest(), d.store.countPublishedAfter(through), d.store.lastRemovalAt()]);
|
|
1353
|
+
// An unpublish or a delete after the build is a change too: one the count of newer rows cannot see.
|
|
1354
|
+
const waiting = newer + (removedAt !== null && removedAt > through ? 1 : 0);
|
|
1355
|
+
return { ...deployState({ marker, reachable, press, waiting, now: at }), hook: Boolean(d.hook) };
|
|
1356
|
+
};
|
|
1357
|
+
return {
|
|
1358
|
+
status: () => guard(async () => ok(await read())),
|
|
1359
|
+
trigger: () => guard(async () => {
|
|
1360
|
+
if (!d.hook)
|
|
1361
|
+
return err("blocked", "smoodly: SMOODLY_DEPLOY_HOOK is not set — see the deploying guide.");
|
|
1362
|
+
const current = await read();
|
|
1363
|
+
if (current.state === "building")
|
|
1364
|
+
return err("blocked", "smoodly: a build is already in flight.");
|
|
1365
|
+
const res = await get(d.hook, { method: "POST", signal: AbortSignal.timeout(10000) });
|
|
1366
|
+
if (!res.ok)
|
|
1367
|
+
return err("blocked", `smoodly: the deploy hook answered ${res.status}.`);
|
|
1368
|
+
await d.store.requestDeploy(by ?? null, now());
|
|
1369
|
+
return ok(await read());
|
|
1370
|
+
}),
|
|
1325
1371
|
};
|
|
1326
1372
|
}
|
|
1327
1373
|
/** Path-dispatch for the one-endpoint seam. Own-property checks only —
|
package/dist/admin/ops.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { AssetPage, AssetQuery, FinishInput, UploadTicket } from "../asset-
|
|
|
6
6
|
import type { InviteInput, InviteResult, MemberRecord } from "../member-store.ts";
|
|
7
7
|
import type { MemberRole } from "../permissions.ts";
|
|
8
8
|
import type { FieldError } from "./validate.ts";
|
|
9
|
+
import type { DeployState } from "../deploy-state.ts";
|
|
9
10
|
import type { DraftToken } from "../draft-token.ts";
|
|
10
11
|
import type { PathTarget } from "../paths.ts";
|
|
11
12
|
import type { Finding } from "./drift.ts";
|
|
@@ -124,6 +125,10 @@ export type SchemaStatus = {
|
|
|
124
125
|
database: number | null;
|
|
125
126
|
required: number;
|
|
126
127
|
};
|
|
128
|
+
/** The Deploy tile's state (spec 2026-10-09 static mode §6); `hook` false = SMOODLY_DEPLOY_HOOK is unset, Deploy disabled. */
|
|
129
|
+
export type DeployStatus = DeployState & {
|
|
130
|
+
hook: boolean;
|
|
131
|
+
};
|
|
127
132
|
/** A redirect row with its target joined (spec 2026-09-26 redirects §6): `currentPath`/`url` null when the target has no live path in the locale. */
|
|
128
133
|
export type RedirectItem = {
|
|
129
134
|
locale: string;
|
|
@@ -287,6 +292,12 @@ export type AdminOps = {
|
|
|
287
292
|
enable(): Promise<OpResult<null>>;
|
|
288
293
|
disable(): Promise<OpResult<null>>;
|
|
289
294
|
};
|
|
295
|
+
/** Static mode's Deploy tile (spec 2026-10-09 static mode §5–§7); on a live site both answer not_found. */
|
|
296
|
+
deploy: {
|
|
297
|
+
status(): Promise<OpResult<DeployStatus>>;
|
|
298
|
+
/** Calls the host's hook and records the press; refused while a build is in flight or without a hook. */
|
|
299
|
+
trigger(): Promise<OpResult<DeployStatus>>;
|
|
300
|
+
};
|
|
290
301
|
};
|
|
291
302
|
/** `token`: the editor's Supabase Auth session token, attached by the
|
|
292
303
|
* client facade, sent as the Authorization header and verified by the
|
|
@@ -14,6 +14,8 @@ export type AdminPageData = {
|
|
|
14
14
|
/** What the editor puts before a page's address to load its draft: "" where the framework serves drafts
|
|
15
15
|
* at the page's own address (Next), the preview address's base where it does not (Astro). */
|
|
16
16
|
previewBase: string;
|
|
17
|
+
/** Static mode shows the Deploy tile (spec 2026-10-09 static mode §6); live by default. */
|
|
18
|
+
delivery: "live" | "static";
|
|
17
19
|
};
|
|
18
20
|
export declare const ADMIN_DATA_ID = "smoodly-admin-data";
|
|
19
21
|
export declare const ADMIN_ROOT_ID = "smoodly-admin-root";
|
|
@@ -25,6 +25,7 @@ import { SetPasswordView } from "./SetPasswordView.js";
|
|
|
25
25
|
import { useAdminSession } from "./session.js";
|
|
26
26
|
import { IconRail } from "./IconRail.js";
|
|
27
27
|
import { ConfirmHost } from "../ui/ConfirmDialog.js";
|
|
28
|
+
import { notify } from "../ui/confirm.js";
|
|
28
29
|
import { useRoute } from "./router.js";
|
|
29
30
|
import { AdminPathProvider } from "./base.js";
|
|
30
31
|
import { mountMismatch } from "../../admin-path.js";
|
|
@@ -36,6 +37,8 @@ import { ElementsProvider } from "../forms/elements-context.js";
|
|
|
36
37
|
import { TimeZoneProvider } from "../forms/time-zone-context.js";
|
|
37
38
|
import { SchemaNotice } from "./SchemaNotice.js";
|
|
38
39
|
import { schemaBehind } from "../../schema-version.js";
|
|
40
|
+
import { delivery, onPublished } from "../delivery-mode.js";
|
|
41
|
+
import { pollEvery } from "./deploy-tile.js";
|
|
39
42
|
import { createPresence, followTokenRefresh } from "./presence.js";
|
|
40
43
|
import { connectRealtime } from "./presence-realtime.js";
|
|
41
44
|
import { PresenceProvider } from "./presence-context.js";
|
|
@@ -80,6 +83,49 @@ export function AdminApp({ registry, op, auth, presenceConfig }) {
|
|
|
80
83
|
.catch(() => { });
|
|
81
84
|
return () => { live = false; };
|
|
82
85
|
}, [signedIn, ops]);
|
|
86
|
+
// The Deploy tile (spec 2026-10-09 static mode §6): on a static site, deploy.status every 30 s, every 5 s
|
|
87
|
+
// while a build is in flight, and at once after any publish; the press records and re-reads.
|
|
88
|
+
const [deploy, setDeploy] = useState(null);
|
|
89
|
+
const [deployBusy, setDeployBusy] = useState(false);
|
|
90
|
+
const [publishes, setPublishes] = useState(0);
|
|
91
|
+
useEffect(() => onPublished(() => setPublishes((n) => n + 1)), []);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!signedIn || delivery() !== "static")
|
|
94
|
+
return;
|
|
95
|
+
let live = true;
|
|
96
|
+
let timer;
|
|
97
|
+
const tick = async () => {
|
|
98
|
+
let state = "live";
|
|
99
|
+
try {
|
|
100
|
+
const r = await ops.deploy.status();
|
|
101
|
+
if (!live)
|
|
102
|
+
return;
|
|
103
|
+
if (r.ok) {
|
|
104
|
+
setDeploy(r.data);
|
|
105
|
+
state = r.data.state;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* the next tick asks again */ }
|
|
109
|
+
if (live)
|
|
110
|
+
timer = setTimeout(tick, pollEvery(state));
|
|
111
|
+
};
|
|
112
|
+
void tick();
|
|
113
|
+
return () => { live = false; if (timer)
|
|
114
|
+
clearTimeout(timer); };
|
|
115
|
+
}, [signedIn, ops, publishes]);
|
|
116
|
+
const onDeploy = async () => {
|
|
117
|
+
setDeployBusy(true);
|
|
118
|
+
try {
|
|
119
|
+
const r = await ops.deploy.trigger();
|
|
120
|
+
if (r.ok)
|
|
121
|
+
setDeploy(r.data);
|
|
122
|
+
else
|
|
123
|
+
await notify({ title: "Deploy", message: r.message });
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
setDeployBusy(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
83
129
|
const userId = session.state.status === "signed_in" ? session.state.session.user.id : "";
|
|
84
130
|
// Presence (spec 2026-09-26 concurrency §5; gated 2026-09-26 security pass §1):
|
|
85
131
|
// one channel per signed-in session, the content project's public config as the
|
|
@@ -144,5 +190,5 @@ export function AdminApp({ registry, op, auth, presenceConfig }) {
|
|
|
144
190
|
main = _jsx(MediaScreen, {});
|
|
145
191
|
else
|
|
146
192
|
main = _jsx(ErrorPane, { message: "Not found." });
|
|
147
|
-
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
|
|
193
|
+
return (_jsx(AdminPathProvider, { value: registry.adminPath, children: _jsx(PresenceProvider, { value: presence, children: _jsx(ElementsProvider, { elements: registry.elements, children: _jsx(TimeZoneProvider, { zone: registry.timeZone, children: _jsxs(AssetsProvider, { ops: ops, media: registry.media, children: [_jsxs("div", { className: "sm-shell", children: [_jsx(IconRail, { registry: registry, active: head, email: email, settings: role !== null, deploy: deploy ? { status: deploy, busy: deployBusy, onDeploy: () => { void onDeploy(); } } : undefined, onSignOut: () => session.client.auth.signOut() }), _jsx("main", { className: "sm-main", children: main }, `${head}/${tail ?? ""}`)] }), _jsx(ConfirmHost, {})] }) }) }) }) }));
|
|
148
194
|
}
|
|
@@ -17,6 +17,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
17
17
|
// renders verbatim — it is product surface.
|
|
18
18
|
import { Eye } from "lucide-react";
|
|
19
19
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
20
|
+
import { notePublished } from "../delivery-mode.js";
|
|
20
21
|
import { stillFailing, validateFields } from "../validate.js";
|
|
21
22
|
import { elementSchemas } from "../../richtext-walk.js";
|
|
22
23
|
import { FieldWidget } from "../forms/FieldWidget.js";
|
|
@@ -287,6 +288,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
287
288
|
setFieldErrors({});
|
|
288
289
|
void reloadIndexes();
|
|
289
290
|
setPublished(true);
|
|
291
|
+
notePublished();
|
|
290
292
|
setTimeout(() => setPublished(false), 1600);
|
|
291
293
|
};
|
|
292
294
|
const unpublish = async () => {
|
|
@@ -303,6 +305,7 @@ export function EntryForm({ ops, registry, collection, id }) {
|
|
|
303
305
|
if (r.ok) {
|
|
304
306
|
setRecord(r.data);
|
|
305
307
|
void reloadIndexes();
|
|
308
|
+
notePublished();
|
|
306
309
|
}
|
|
307
310
|
else
|
|
308
311
|
fail(r);
|
|
@@ -12,6 +12,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
12
12
|
// validates the draft and lists the fields in an amber banner. "Used on
|
|
13
13
|
// N pages" is the refs index's answer.
|
|
14
14
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { notePublished } from "../delivery-mode.js";
|
|
15
16
|
import { stillFailing, validateNode } from "../validate.js";
|
|
16
17
|
import { elementSchemas } from "../../richtext-walk.js";
|
|
17
18
|
import { CSS_KEY, SETTINGS_KEY, STYLES_KEY } from "../../global.js";
|
|
@@ -183,6 +184,7 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
183
184
|
presence?.announce(target, r.data.locales[locale]?.draftSavedAt ?? null);
|
|
184
185
|
setFieldErrors({});
|
|
185
186
|
setPublished(true);
|
|
187
|
+
notePublished();
|
|
186
188
|
setTimeout(() => setPublished(false), 1600);
|
|
187
189
|
};
|
|
188
190
|
const unpublish = async () => {
|
|
@@ -196,8 +198,10 @@ export function GlobalForm({ ops, registry, name }) {
|
|
|
196
198
|
r = { ok: false, code: "internal", message: "Couldn't reach the server." };
|
|
197
199
|
}
|
|
198
200
|
setBusy(null);
|
|
199
|
-
if (r.ok)
|
|
201
|
+
if (r.ok) {
|
|
200
202
|
setRecord(r.data);
|
|
203
|
+
notePublished();
|
|
204
|
+
}
|
|
201
205
|
else
|
|
202
206
|
fail(r);
|
|
203
207
|
};
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import type { DeployStatus } from "../ops.ts";
|
|
1
2
|
import type { AdminRegistry } from "../serialize.ts";
|
|
2
|
-
export declare function IconRail({ registry, active, email, settings, onSignOut }: {
|
|
3
|
+
export declare function IconRail({ registry, active, email, settings, deploy, onSignOut }: {
|
|
3
4
|
registry: AdminRegistry;
|
|
4
5
|
active: string;
|
|
5
6
|
email?: string;
|
|
6
7
|
/** Whether the Settings tile shows — once the role is known, in the shell (every member has a Settings page). */
|
|
7
8
|
settings?: boolean;
|
|
9
|
+
/** The Deploy tile (spec 2026-10-09 static mode §6), above the avatar on a static site; absent on a live one. */
|
|
10
|
+
deploy?: {
|
|
11
|
+
status: DeployStatus;
|
|
12
|
+
busy: boolean;
|
|
13
|
+
onDeploy: () => void;
|
|
14
|
+
};
|
|
8
15
|
onSignOut: () => void;
|
|
9
16
|
}): import("react").JSX.Element;
|
|
@@ -20,7 +20,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
20
20
|
// ↑/↓ move between entries, a pointer down outside closes. A child being
|
|
21
21
|
// active highlights its parent tile and the entry inside the menu.
|
|
22
22
|
import { useEffect, useId, useRef, useState } from "react";
|
|
23
|
-
import { Globe, Image, LibraryBig, PanelTop, Settings } from "lucide-react";
|
|
23
|
+
import { Globe, Image, LibraryBig, PanelTop, RadioTower, Settings } from "lucide-react";
|
|
24
|
+
import { tileCopy } from "./deploy-tile.js";
|
|
24
25
|
import { ThemeToggle } from "../ui/primitives.js";
|
|
25
26
|
import { useAdminHref } from "./base.js";
|
|
26
27
|
import { RouteLink } from "./RouteLink.js";
|
|
@@ -33,7 +34,7 @@ const ICON = { size: 18, strokeWidth: 2, "aria-hidden": true };
|
|
|
33
34
|
function AppIcon() {
|
|
34
35
|
return (_jsxs("svg", { className: "sm-rail__logo", viewBox: "0 0 28 27", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "Smoodly", children: [_jsx("path", { style: { fill: "var(--logo-outer)" }, d: "M0 16.1859C0 7.6335 7.88106 0 16.5324 0H16.6037C23.429 0 28 4.59428 28 10.8141C28 19.3666 20.2618 27 11.3279 27C4.43425 27 0 22.2645 0 16.1859Z" }), _jsx("path", { style: { fill: "var(--logo-inner)" }, d: "M7 14.7932C7 10.6754 10.6591 7 14.6757 7H14.7089C17.8777 7 20 9.21206 20 12.2068C20 16.3247 16.4072 20 12.2594 20C9.05876 20 7 17.7199 7 14.7932Z" })] }));
|
|
35
36
|
}
|
|
36
|
-
export function IconRail({ registry, active, email, settings = true, onSignOut }) {
|
|
37
|
+
export function IconRail({ registry, active, email, settings = true, deploy, onSignOut }) {
|
|
37
38
|
const adminHref = useAdminHref();
|
|
38
39
|
const root = useRef(null);
|
|
39
40
|
const uid = useId();
|
|
@@ -89,5 +90,9 @@ export function IconRail({ registry, active, email, settings = true, onSignOut }
|
|
|
89
90
|
const collectionsOpen = open === "collections" || (hovered && open === null);
|
|
90
91
|
const name = email?.split("@")[0] ?? "Editor";
|
|
91
92
|
const initials = name.slice(0, 2).toUpperCase();
|
|
92
|
-
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }),
|
|
93
|
+
return (_jsxs("nav", { className: "sm-rail", "aria-label": "Primary", ref: root, onKeyDown: onKeyDown, children: [_jsx(AppIcon, {}), _jsxs("ul", { className: "sm-rail__list", children: [link("pages", "Pages", _jsx(PanelTop, { ...ICON }), adminHref("/pages")), collections.length > 0 && (_jsxs("li", { className: "sm-rail__item", onMouseEnter: () => setHovered(true), onMouseLeave: hoverLeave, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-current": collectionActive ? "page" : undefined, "aria-haspopup": "menu", "aria-expanded": collectionsOpen, "aria-controls": menuId("collections"), onClick: () => toggle("collections"), children: [_jsx("span", { className: "sm-rail__tile", children: _jsx(LibraryBig, { ...ICON }) }), _jsx("span", { className: "sm-rail__label", children: "Collections" })] }), _jsx("ul", { className: "sm-rail__menu sm-rail__menu--collections", role: "menu", id: menuId("collections"), "data-open": collectionsOpen, children: collections.map((c) => (_jsx("li", { role: "none", children: _jsx(RouteLink, { role: "menuitem", className: "sm-rail__menu-item", href: adminHref(`/${c.name}`), "aria-current": c.name === active ? "page" : undefined, onClick: () => { close(); hoverLeave(); }, children: _jsx("span", { children: c.title }) }) }, c.name))) })] })), link("media", "Media", _jsx(Image, { ...ICON }), adminHref("/media")), registry.globals.length > 0 && link("globals", "Site", _jsx(Globe, { ...ICON }), adminHref("/globals")), settings && link("settings", "Settings", _jsx(Settings, { ...ICON }), adminHref("/settings"))] }), deploy && (() => {
|
|
94
|
+
const copy = tileCopy(deploy.status, Date.now());
|
|
95
|
+
const id = menuId("deploy");
|
|
96
|
+
return (_jsxs("div", { className: "sm-rail__item sm-rail__deploy", "data-state": deploy.status.state, children: [_jsxs("button", { type: "button", className: "sm-rail__btn", "aria-haspopup": "menu", "aria-expanded": open === "deploy", "aria-controls": id, onClick: () => toggle("deploy"), title: copy.detail, children: [_jsxs("span", { className: `sm-rail__tile${copy.lit ? " sm-rail__tile--lit" : ""}${copy.warn ? " sm-rail__tile--warn" : ""}`, children: [_jsx(RadioTower, { ...ICON }), copy.badge && _jsx("span", { className: "sm-rail__badge", "aria-label": `${copy.badge} changes waiting`, children: copy.badge })] }), _jsx("span", { className: "sm-rail__label", children: copy.label })] }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: id, "data-open": open === "deploy", children: [_jsx("li", { className: "sm-rail__menu-detail", role: "presentation", children: copy.detail }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", disabled: copy.action === null || deploy.busy, title: copy.disabledReason ?? undefined, onClick: () => { close(); deploy.onDeploy(); }, children: copy.action === "retry" ? "Deploy again" : "Deploy now" }) }), copy.disabledReason && _jsx("li", { className: "sm-rail__menu-note", role: "presentation", children: copy.disabledReason })] })] }));
|
|
97
|
+
})(), _jsxs("div", { className: "sm-rail__item sm-rail__foot", children: [_jsx("button", { type: "button", className: "sm-rail__user", title: name, "aria-haspopup": "menu", "aria-expanded": open === "user", "aria-controls": menuId("user"), onClick: () => toggle("user"), children: initials }), _jsxs("ul", { className: "sm-rail__menu sm-rail__menu--up", role: "menu", id: menuId("user"), "data-open": open === "user", children: [_jsx("li", { className: "sm-rail__menu-title", role: "presentation", children: name }), _jsx("li", { role: "none", children: _jsx(ThemeToggle, {}) }), _jsx("li", { role: "none", children: _jsx("button", { type: "button", role: "menuitem", className: "sm-rail__menu-item", onClick: () => { close(); onSignOut(); }, children: "Sign out" }) })] })] })] }));
|
|
93
98
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DeployStatus } from "../ops.ts";
|
|
2
|
+
export type TileCopy = {
|
|
3
|
+
label: string;
|
|
4
|
+
/** The count of changes waiting, on the tile; null = no badge. */
|
|
5
|
+
badge: string | null;
|
|
6
|
+
/** The icon lit: a build in flight. */
|
|
7
|
+
lit: boolean;
|
|
8
|
+
/** The icon as a warning: the build has not landed, or the site did not answer. */
|
|
9
|
+
warn: boolean;
|
|
10
|
+
/** The flyout's first line. */
|
|
11
|
+
detail: string;
|
|
12
|
+
/** What the flyout's button does; null = disabled, with the reason. */
|
|
13
|
+
action: "deploy" | "retry" | null;
|
|
14
|
+
disabledReason: string | null;
|
|
15
|
+
};
|
|
16
|
+
export declare function tileCopy(status: DeployStatus, now: number): TileCopy;
|
|
17
|
+
/** How often the shell asks deploy.status: every 5 s while a build is in flight, every 30 s otherwise. */
|
|
18
|
+
export declare const pollEvery: (state: DeployStatus["state"]) => number;
|
|
19
|
+
/** The publish button's word the moment after a publish: on a static site, that it is not live yet (§6). */
|
|
20
|
+
export declare const publishFlashWord: (delivery: "live" | "static") => string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const ago = (from, now) => {
|
|
2
|
+
const s = Math.max(0, Math.round((now - from) / 1000));
|
|
3
|
+
if (s < 60)
|
|
4
|
+
return `${s} s ago`;
|
|
5
|
+
const m = Math.round(s / 60);
|
|
6
|
+
if (m < 60)
|
|
7
|
+
return `${m} min ago`;
|
|
8
|
+
const h = Math.round(m / 60);
|
|
9
|
+
if (h < 48)
|
|
10
|
+
return `${h} hour${h === 1 ? "" : "s"} ago`;
|
|
11
|
+
return `${Math.round(h / 24)} days ago`;
|
|
12
|
+
};
|
|
13
|
+
const NO_HOOK = "SMOODLY_DEPLOY_HOOK is not set. See the deploying guide.";
|
|
14
|
+
export function tileCopy(status, now) {
|
|
15
|
+
const noHook = status.hook ? null : NO_HOOK;
|
|
16
|
+
const offer = (action) => ({ action: noHook ? null : action, disabledReason: noHook });
|
|
17
|
+
const changes = `${status.waiting} change${status.waiting === 1 ? "" : "s"}`;
|
|
18
|
+
const since = status.builtAt === null ? "never deployed" : `since the last deploy, ${ago(status.builtAt, now)}`;
|
|
19
|
+
switch (status.state) {
|
|
20
|
+
case "live":
|
|
21
|
+
return { label: "Deploy", badge: null, lit: false, warn: false, detail: `The site is live. ${status.builtAt === null ? "Never deployed" : `Last deploy ${ago(status.builtAt, now)}`}.`, ...offer("deploy") };
|
|
22
|
+
case "waiting":
|
|
23
|
+
return { label: "Deploy", badge: String(status.waiting), lit: false, warn: false, detail: `${changes}${status.builtAt === null ? ", " : " "}${since}.`, ...offer("deploy") };
|
|
24
|
+
case "building":
|
|
25
|
+
return { label: "Deploy", badge: null, lit: true, warn: false, detail: `Building… started ${ago(status.pressedAt ?? now, now)}.`, action: null, disabledReason: "A build is in flight." };
|
|
26
|
+
case "not-landed":
|
|
27
|
+
return { label: "Deploy", badge: null, lit: false, warn: true, detail: "The build has not landed. Check your host.", ...offer("retry") };
|
|
28
|
+
case "unreachable":
|
|
29
|
+
return { label: "Deploy", badge: status.waiting > 0 ? String(status.waiting) : null, lit: false, warn: true, detail: "The live site did not answer. Check SITE_URL.", ...offer("deploy") };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** How often the shell asks deploy.status: every 5 s while a build is in flight, every 30 s otherwise. */
|
|
33
|
+
export const pollEvery = (state) => (state === "building" ? 5000 : 30000);
|
|
34
|
+
/** The publish button's word the moment after a publish: on a static site, that it is not live yet (§6). */
|
|
35
|
+
export const publishFlashWord = (delivery) => (delivery === "static" ? "Published ✓ · not live until you deploy" : "Published ✓");
|
|
@@ -3,6 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { ActionMenu } from "./ActionMenu.js";
|
|
4
4
|
import { Button, StatusBadge } from "./primitives.js";
|
|
5
5
|
import { publishActions, statusLabel } from "./status-label.js";
|
|
6
|
+
import { delivery } from "../delivery-mode.js";
|
|
7
|
+
import { publishFlashWord } from "../shell/deploy-tile.js";
|
|
6
8
|
const BUSY_WORD = {
|
|
7
9
|
publish: "Publishing…", discard: "Discarding…", unpublish: "Unpublishing…",
|
|
8
10
|
};
|
|
@@ -19,6 +21,6 @@ export function publishMenu(opts) {
|
|
|
19
21
|
export function PublishControl({ status, pending = false, busy = null, flash = false, disabled = false, compact = false, onPublish, onDiscard, onUnpublish, onHistory, }) {
|
|
20
22
|
const can = publishActions(status, pending);
|
|
21
23
|
const inert = disabled || busy !== null;
|
|
22
|
-
const word = busy ? BUSY_WORD[busy] : flash ?
|
|
24
|
+
const word = busy ? BUSY_WORD[busy] : flash ? publishFlashWord(delivery()) : "Publish";
|
|
23
25
|
return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 }, children: [status && _jsx(StatusBadge, { status: statusLabel(status) }), _jsx(Button, { variant: flash && !busy ? "ok" : "primary", size: compact ? "sm" : "md", disabled: inert || !can.publish, onClick: onPublish, children: word }), _jsx(ActionMenu, { label: "More actions", disabled: inert || status === null, small: compact, items: publishMenu({ can, onDiscard, onUnpublish, onHistory }) })] }));
|
|
24
26
|
}
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -226,6 +226,18 @@ body:has(.sm-admin){margin:0}
|
|
|
226
226
|
the unavailable ones only by being inert (no pointer, no hover). */
|
|
227
227
|
.sm-rail__btn[aria-disabled="true"]{cursor:default}
|
|
228
228
|
.sm-rail__btn[aria-disabled="true"]:hover .sm-rail__tile{background:transparent}
|
|
229
|
+
/* The Deploy tile (spec 2026-10-09 static mode §6): above the avatar; the badge is the count waiting, the
|
|
230
|
+
lit tile a build in flight, the warning tile a build that has not landed or a site that did not answer. */
|
|
231
|
+
.sm-rail__deploy{margin-top:auto}
|
|
232
|
+
.sm-rail__deploy+.sm-rail__foot{margin-top:0}
|
|
233
|
+
.sm-rail__badge{position:absolute;top:-4px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:var(--radius-round);background:var(--accent);color:#fff;font-size:10px;font-weight:600;line-height:16px;text-align:center}
|
|
234
|
+
.sm-rail__tile--lit{background:var(--accent-soft);color:var(--accent);animation:sm-rail-pulse 1.6s ease-in-out infinite}
|
|
235
|
+
.sm-rail__tile--warn{background:var(--warn-soft);color:var(--warn)}
|
|
236
|
+
@keyframes sm-rail-pulse{0%,100%{opacity:1}50%{opacity:.55}}
|
|
237
|
+
.sm-rail__menu-detail{font-size:var(--text-sm);color:var(--text);padding:6px 8px 4px;white-space:normal}
|
|
238
|
+
.sm-rail__menu-note{font-size:var(--text-xs);color:var(--text-muted);padding:4px 8px 6px;white-space:normal}
|
|
239
|
+
.sm-rail__menu-item:disabled{cursor:default;color:var(--text-muted)}
|
|
240
|
+
.sm-rail__menu-item:disabled:hover{background:none}
|
|
229
241
|
.sm-rail__foot{margin-top:auto;display:flex;flex-direction:column;align-items:center}
|
|
230
242
|
.sm-rail__user{
|
|
231
243
|
width:28px;height:28px;border-radius:var(--radius-round);background:var(--accent-soft);color:var(--accent);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DeployDeps } from "../admin/ops-impl.ts";
|
|
2
|
+
export declare function deployDepsOf(input: {
|
|
3
|
+
output: "static" | "server";
|
|
4
|
+
/** astro.config's `site` (SITE_URL), the live address the marker is read from. */
|
|
5
|
+
site: string | undefined;
|
|
6
|
+
env: Record<string, string | undefined>;
|
|
7
|
+
store: DeployDeps["store"];
|
|
8
|
+
}): (DeployDeps & {
|
|
9
|
+
mode: "live" | "static";
|
|
10
|
+
}) | undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function deployDepsOf(input) {
|
|
2
|
+
if (input.output !== "static")
|
|
3
|
+
return undefined;
|
|
4
|
+
if (!input.site)
|
|
5
|
+
throw new Error("smoodly: static output needs `site` (SITE_URL) — the Deploy tile reads the build marker from the live address.");
|
|
6
|
+
const hook = input.env.SMOODLY_DEPLOY_HOOK?.trim() || undefined;
|
|
7
|
+
return { mode: "static", siteUrl: input.site.replace(/\/$/, ""), hook, store: input.store };
|
|
8
|
+
}
|
package/dist/astro/index.d.ts
CHANGED
|
@@ -2,23 +2,28 @@ import type { AstroIntegration } from "astro";
|
|
|
2
2
|
import type { ResolvedConfig } from "../config.ts";
|
|
3
3
|
import { type SmoodlyContent } from "../content/content-api.ts";
|
|
4
4
|
import { type AstroI18n } from "./locale-paths.ts";
|
|
5
|
+
export type AstroOutput = "static" | "server";
|
|
5
6
|
export type SmoodlyOptions = {
|
|
6
7
|
/** Where the admin is mounted: the same value as `admin.path` in smoodly.config.ts. Default "/admin". */
|
|
7
8
|
adminPath?: string;
|
|
8
9
|
/** The config file, from the project root. Default "smoodly.config.ts". */
|
|
9
10
|
config?: string;
|
|
10
11
|
};
|
|
11
|
-
/** The three routes, most specific first; a page file of the project's own wins over the shell for its address.
|
|
12
|
-
|
|
12
|
+
/** The three routes, most specific first; a page file of the project's own wins over the shell for its address.
|
|
13
|
+
* The admin and the preview address are always on demand; the page shell is the prerendered one in static
|
|
14
|
+
* output (spec 2026-10-09 static mode §3) and the on-demand one in server output. */
|
|
15
|
+
export declare function routes(adminPath: string, output?: AstroOutput): {
|
|
13
16
|
pattern: string;
|
|
14
17
|
entrypoint: string;
|
|
18
|
+
prerender: boolean;
|
|
15
19
|
}[];
|
|
16
20
|
/** The views by file name, under the project's source dir (root-relative, "/src" by default): a section's
|
|
17
21
|
* is sections/<section name>.astro, an element's elements/<element name>.astro, a page type's
|
|
18
22
|
* page-types/<page type name>.astro, an entry page's entry-pages/<collection name>.astro. */
|
|
19
23
|
export declare function viewsModule(srcDir: string): string;
|
|
20
|
-
/** `root` is the project's directory; `srcDir` its source dir relative to the root, with a leading slash
|
|
21
|
-
|
|
24
|
+
/** `root` is the project's directory; `srcDir` its source dir relative to the root, with a leading slash;
|
|
25
|
+
* `output` astro.config's, which `smoodly:build` tells the preview route and the server. */
|
|
26
|
+
export declare function virtualModules(root: string, configFile: string, srcDir?: string, output?: AstroOutput): {
|
|
22
27
|
name: string;
|
|
23
28
|
resolveId(id: string): string | undefined;
|
|
24
29
|
load(id: string): string | undefined;
|