smoodly 0.0.47 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/admin/browser/main.js +2 -0
  2. package/dist/admin/client-ops.js +1 -0
  3. package/dist/admin/delivery-mode.d.ts +5 -0
  4. package/dist/admin/delivery-mode.js +20 -0
  5. package/dist/admin/editor/EditorView.js +3 -0
  6. package/dist/admin/files.generated.js +1 -1
  7. package/dist/admin/handler.d.ts +5 -1
  8. package/dist/admin/handler.js +2 -1
  9. package/dist/admin/ops-impl.d.ts +13 -0
  10. package/dist/admin/ops-impl.js +46 -0
  11. package/dist/admin/ops.d.ts +11 -0
  12. package/dist/admin/page-data.d.ts +2 -0
  13. package/dist/admin/shell/AdminApp.js +47 -1
  14. package/dist/admin/shell/EntryForm.js +3 -0
  15. package/dist/admin/shell/GlobalForm.js +5 -1
  16. package/dist/admin/shell/IconRail.d.ts +8 -1
  17. package/dist/admin/shell/IconRail.js +8 -3
  18. package/dist/admin/shell/deploy-tile.d.ts +20 -0
  19. package/dist/admin/shell/deploy-tile.js +35 -0
  20. package/dist/admin/ui/PublishControl.js +3 -1
  21. package/dist/admin/ui/theme.js +12 -0
  22. package/dist/astro/deploy-deps.d.ts +10 -0
  23. package/dist/astro/deploy-deps.js +8 -0
  24. package/dist/astro/index.d.ts +9 -4
  25. package/dist/astro/index.js +75 -15
  26. package/dist/astro/middleware.js +4 -0
  27. package/dist/astro/routes/admin.js +2 -1
  28. package/dist/astro/routes/preview.astro +17 -5
  29. package/dist/astro/routes/static-page.astro +21 -0
  30. package/dist/astro/runtime.d.ts +6 -0
  31. package/dist/astro/runtime.js +4 -1
  32. package/dist/astro/server.d.ts +8 -0
  33. package/dist/astro/server.js +9 -1
  34. package/dist/astro/static.d.ts +39 -0
  35. package/dist/astro/static.js +63 -0
  36. package/dist/cli/load-config.d.ts +3 -0
  37. package/dist/cli/load-config.js +8 -1
  38. package/dist/deploy-state.d.ts +28 -0
  39. package/dist/deploy-state.js +31 -0
  40. package/dist/project.d.ts +3 -0
  41. package/dist/project.js +8 -5
  42. package/dist/published.d.ts +31 -4
  43. package/dist/published.js +35 -7
  44. package/dist/schema-version.d.ts +1 -1
  45. package/dist/schema-version.js +1 -1
  46. package/dist/site.js +2 -2
  47. package/dist/sql-space.d.ts +3 -2
  48. package/dist/sql-space.js +3 -2
  49. package/dist/sql.js +10 -0
  50. package/dist/supabase-published-store.d.ts +14 -3
  51. package/dist/supabase-published-store.js +72 -5
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/migrations/20261009120000_smoodly_v11.sql +49 -0
  55. package/package.json +1 -1
@@ -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
  };
@@ -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) => {
@@ -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>>;
@@ -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 —
@@ -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"))] }), _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
+ 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 ? "Published ✓" : "Publish";
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
  }
@@ -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
+ }
@@ -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
- export declare function routes(adminPath: string): {
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
- export declare function virtualModules(root: string, configFile: string, srcDir?: string): {
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;