@velastack/cms 0.2.6 → 0.3.0

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.
@@ -37,6 +37,7 @@ export const createCmsBackend = (options = {}) => {
37
37
  const storage = createStorage(uploadDir);
38
38
  const editors = createEditorStore(db);
39
39
  const auth = options.auth ?? localEditors();
40
+ const deploy = options.deploy;
40
41
  const restParam = options.restParam ?? 'path';
41
42
  const cookieName = options.cookie?.name ?? 'cms_session';
42
43
  const resolveProject = options.resolveProject ?? (() => 'default');
@@ -167,6 +168,7 @@ export const createCmsBackend = (options = {}) => {
167
168
  respond,
168
169
  storage,
169
170
  auth,
171
+ deploy,
170
172
  authCtx,
171
173
  mediaUrl: mediaUrlFor(event),
172
174
  mountPath,
@@ -31,12 +31,14 @@
31
31
  * show up in `vela dev`.
32
32
  */
33
33
  export { createCmsBackend, type CmsBackend } from './factory.js';
34
+ export { CmsDeployError } from './routes/deploy.js';
34
35
  export { localEditors, type LocalEditorsOptions } from './auth/local-editors.js';
35
36
  export { createEditorStore, type CmsEditorStore, type CreateEditorInput, type CmsSessionToken } from './auth/editors.js';
36
37
  export { hashPassword, verifyPassword, type ScryptParams } from './auth/scrypt.js';
37
38
  export { createCmsTestClient, type CmsTestClient, type TestResponse } from './testing/harness.js';
38
39
  export { createTestFixture, TEST_SCRYPT, type TestFixture, type TestFixtureOptions } from './testing/fixtures.js';
39
- export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsEditor, CmsSessionGrant } from './types.js';
40
+ export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsDeployAdapter, CmsDeployContext, CmsDeployRun, CmsDeployState, CmsEditor, CmsSessionGrant } from './types.js';
41
+ export type { CmsStore } from './store/queries.js';
40
42
  export type { SqliteDb, SqliteStatement } from './store/sqlite.js';
41
43
  export type { Migration } from './store/migrations/index.js';
42
44
  export { MIGRATIONS } from './store/migrations/index.js';
@@ -31,6 +31,7 @@
31
31
  * show up in `vela dev`.
32
32
  */
33
33
  export { createCmsBackend } from './factory.js';
34
+ export { CmsDeployError } from './routes/deploy.js';
34
35
  export { localEditors } from './auth/local-editors.js';
35
36
  export { createEditorStore } from './auth/editors.js';
36
37
  export { hashPassword, verifyPassword } from './auth/scrypt.js';
@@ -11,7 +11,7 @@ import type { CmsStore } from '../store/queries.js';
11
11
  import type { ReadCache } from '../store/cache.js';
12
12
  import type { CmsResponders } from '../http.js';
13
13
  import type { CmsStorage } from '../storage.js';
14
- import type { CmsAuthAdapter, CmsAuthContext, CmsEditor } from '../types.js';
14
+ import type { CmsAuthAdapter, CmsAuthContext, CmsDeployAdapter, CmsEditor } from '../types.js';
15
15
  export type RouteCtx = {
16
16
  event: RequestEvent;
17
17
  /** The tenant this request is for. `'default'` in a single-tenant mount. */
@@ -25,6 +25,8 @@ export type RouteCtx = {
25
25
  respond: CmsResponders;
26
26
  storage: CmsStorage;
27
27
  auth: CmsAuthAdapter;
28
+ /** The host's deploy adapter, when configured. */
29
+ deploy?: CmsDeployAdapter;
28
30
  authCtx: CmsAuthContext;
29
31
  /** Absolute-or-root-relative URL for an uploaded file, per the host's
30
32
  * configured media base. */
@@ -0,0 +1,9 @@
1
+ import { type RouteCtx } from './context.js';
2
+ /** Thrown by an adapter to map a failure to a status: 409 while a run is in
3
+ * flight, 503 when the project has no pipeline to deploy through. */
4
+ export declare class CmsDeployError extends Error {
5
+ readonly status: number;
6
+ constructor(status: number, message: string);
7
+ }
8
+ export declare const getDeploy: (ctx: RouteCtx) => Promise<Response>;
9
+ export declare const postDeploy: (ctx: RouteCtx) => Promise<Response>;
@@ -0,0 +1,46 @@
1
+ import { requireUser } from './context.js';
2
+ /** Thrown by an adapter to map a failure to a status: 409 while a run is in
3
+ * flight, 503 when the project has no pipeline to deploy through. */
4
+ export class CmsDeployError extends Error {
5
+ status;
6
+ constructor(status, message) {
7
+ super(message);
8
+ this.status = status;
9
+ this.name = 'CmsDeployError';
10
+ }
11
+ }
12
+ const deployContext = (ctx) => ({
13
+ event: ctx.event,
14
+ projectId: ctx.projectId,
15
+ user: requireUser(ctx),
16
+ store: ctx.store
17
+ });
18
+ /** Adapter errors carry their own status; anything else is a bare 500 so a
19
+ * host bug never reaches the bar as an HTML error page it would try to parse. */
20
+ const respond = async (run, status) => {
21
+ try {
22
+ return Response.json(await run(), { status });
23
+ }
24
+ catch (err) {
25
+ if (err instanceof CmsDeployError) {
26
+ return new Response(err.message, {
27
+ status: err.status,
28
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
29
+ });
30
+ }
31
+ return new Response(null, { status: 500 });
32
+ }
33
+ };
34
+ export const getDeploy = (ctx) => {
35
+ const { deploy } = ctx;
36
+ if (!deploy) {
37
+ return Promise.resolve(Response.json({ available: false }));
38
+ }
39
+ return respond(() => deploy.status(deployContext(ctx)), 200);
40
+ };
41
+ export const postDeploy = (ctx) => {
42
+ const { deploy } = ctx;
43
+ if (!deploy)
44
+ return Promise.resolve(new Response(null, { status: 404 }));
45
+ return respond(() => deploy.trigger(deployContext(ctx)), 202);
46
+ };
@@ -5,6 +5,7 @@ import { getUser, postLogout } from './user.js';
5
5
  import { getLogin, getLoginSuccess, postLogin } from './login.js';
6
6
  import { deleteReleaseItems, getRelease, getReleaseHistory, postReleaseDiscard, postReleaseHistoryPreviewKey, postReleaseItems, postReleasePreviewKey, postReleasePublish, postReleaseRevert } from './release.js';
7
7
  import { deleteMediaItem, getMedia, getUpload, postMedia } from './media.js';
8
+ import { getDeploy, postDeploy } from './deploy.js';
8
9
  import { postTestReset } from './test-reset.js';
9
10
  export const ROUTES = [
10
11
  { method: 'GET', segments: ['docs'], auth: 'public', handler: getDocs },
@@ -62,6 +63,8 @@ export const ROUTES = [
62
63
  auth: 'required',
63
64
  handler: postReleaseHistoryPreviewKey
64
65
  },
66
+ { method: 'GET', segments: ['deploy'], auth: 'required', handler: getDeploy },
67
+ { method: 'POST', segments: ['deploy'], auth: 'required', handler: postDeploy },
65
68
  { method: 'GET', segments: ['media'], auth: 'required', handler: getMedia },
66
69
  { method: 'POST', segments: ['media'], auth: 'required', handler: postMedia },
67
70
  { method: 'DELETE', segments: ['media', ':id'], auth: 'required', handler: deleteMediaItem },
@@ -1,5 +1,8 @@
1
1
  import type { RequestEvent } from '@sveltejs/kit';
2
+ import type { CmsDeployState } from '../core/wire.js';
3
+ import type { CmsStore } from './store/queries.js';
2
4
  import type { SqliteDb } from './store/sqlite.js';
5
+ export type { CmsDeployRun, CmsDeployState } from '../core/wire.js';
3
6
  /** An authenticated content editor. */
4
7
  export type CmsEditor = {
5
8
  id: string;
@@ -41,6 +44,29 @@ export type CmsAuthAdapter = {
41
44
  * regardless. */
42
45
  logout?(event: RequestEvent, ctx: CmsAuthContext): Promise<void>;
43
46
  };
47
+ /** Handed to every deploy adapter method. `store` is the CMS store, so the
48
+ * host can read which release is current (`store.getReleaseHistory`). */
49
+ export type CmsDeployContext = {
50
+ event: RequestEvent;
51
+ projectId: string;
52
+ user: CmsEditor;
53
+ store: CmsStore;
54
+ };
55
+ /**
56
+ * The seam between the CMS and whatever rebuilds the site.
57
+ *
58
+ * A static site bakes published content in at build time, so a publish only
59
+ * reaches visitors once the site is built again. The host owns that pipeline;
60
+ * the CMS only exposes it: `status()` backs the admin bar's menu and dialog,
61
+ * `trigger()` starts a run. Throw `CmsDeployError(409, …)` while a run is in
62
+ * flight and `CmsDeployError(503, …)` when the project has nothing to deploy to.
63
+ */
64
+ export type CmsDeployAdapter = {
65
+ /** Can this project be deployed from the bar, and what happened last. */
66
+ status(ctx: CmsDeployContext): Promise<CmsDeployState>;
67
+ /** Start a deploy; resolves with the new state. */
68
+ trigger(ctx: CmsDeployContext): Promise<CmsDeployState>;
69
+ };
44
70
  export type CmsCookieOptions = {
45
71
  name?: string;
46
72
  path?: string;
@@ -85,6 +111,10 @@ export type CmsBackendOptions = {
85
111
  restParam?: string;
86
112
  /** Identity and authorization. Defaults to `localEditors()`. */
87
113
  auth?: CmsAuthAdapter;
114
+ /** Rebuilds and deploys the site from the admin bar. Omit it — the default —
115
+ * and the bar never shows the action, which is right for a same-origin
116
+ * mount where the site reads the CMS live. */
117
+ deploy?: CmsDeployAdapter;
88
118
  cookie?: CmsCookieOptions;
89
119
  /**
90
120
  * Cross-origin access. `false` (the default) emits no CORS headers, which is
@@ -13,10 +13,13 @@
13
13
  import type { CmsPayload, CmsScopeEntry } from '../cms/scope.js';
14
14
  import CssRoot from './css-root.svelte';
15
15
  import { adminBarTheme, type AdminBarTheme } from './theme.svelte.js';
16
+ import type { CmsDeployState } from '../../core/wire.js';
16
17
  import DeletePageDialog, {
17
18
  type DeletePageMode,
18
19
  type RedirectTarget
19
20
  } from './delete-page-dialog.svelte';
21
+ import DeployDialog from './deploy-dialog.svelte';
22
+ import { isDeploying } from './deploy-state.js';
20
23
  import HistoryPanel from './history-panel.svelte';
21
24
  import KeyboardShortcutsDialog from './keyboard-shortcuts-dialog.svelte';
22
25
  import MediaPanel from './media-panel.svelte';
@@ -83,6 +86,7 @@
83
86
  let shareLinkOpen = $state(false);
84
87
  let shortcutsOpen = $state(false);
85
88
  let siteSettingsOpen = $state(false);
89
+ let deployOpen = $state(false);
86
90
  const anyPanelOpen = $derived(
87
91
  seoOpen ||
88
92
  historyOpen ||
@@ -93,7 +97,8 @@
93
97
  chooserOpen ||
94
98
  shareLinkOpen ||
95
99
  shortcutsOpen ||
96
- siteSettingsOpen
100
+ siteSettingsOpen ||
101
+ deployOpen
97
102
  );
98
103
  let publishing = $state(false);
99
104
  let publishError = $state<string | null>(null);
@@ -145,6 +150,7 @@
145
150
  shareLinkOpen = false;
146
151
  shortcutsOpen = false;
147
152
  siteSettingsOpen = false;
153
+ deployOpen = false;
148
154
  newDialog = null;
149
155
  newPageError = null;
150
156
  duplicateSource = null;
@@ -625,6 +631,71 @@
625
631
  }
626
632
  };
627
633
 
634
+ // Live-site deploy. A static site only shows visitors what was published
635
+ // when it was last built; the host's deploy adapter rebuilds it. The
636
+ // backend answers `available: false` unless the host configured one, so a
637
+ // same-origin mount never shows the item; an older backend 404s and the
638
+ // state simply stays null.
639
+ const DEPLOY_POLL_MS = 5000;
640
+ const DEPLOY_POLL_LIMIT = 120; // ~10 minutes, then leave it to the next mount
641
+ let deployState = $state<CmsDeployState | null>(null);
642
+ let deploying = $state(false);
643
+ let deployError = $state<string | null>(null);
644
+ const deployAvailable = $derived(deployState?.available === true);
645
+ const deployInFlight = $derived(isDeploying(deployState));
646
+
647
+ const fetchDeployState = async () => {
648
+ try {
649
+ const res = await fetch(`${endpoint}/deploy`, { credentials: 'include' });
650
+ if (!res.ok) return;
651
+ deployState = (await res.json()) as CmsDeployState;
652
+ } catch {
653
+ // Network failure: keep whatever we last knew.
654
+ }
655
+ };
656
+
657
+ $effect(() => {
658
+ void fetchDeployState();
659
+ });
660
+
661
+ // Poll while a run is in flight, dialog open or not; the interval is
662
+ // cleared when the run finishes or the bar unmounts.
663
+ $effect(() => {
664
+ if (!deployInFlight) return;
665
+ let ticks = 0;
666
+ const id = setInterval(() => {
667
+ if (++ticks > DEPLOY_POLL_LIMIT) {
668
+ clearInterval(id);
669
+ return;
670
+ }
671
+ void fetchDeployState();
672
+ }, DEPLOY_POLL_MS);
673
+ return () => clearInterval(id);
674
+ });
675
+
676
+ const onOpenDeploy = () => {
677
+ if (!deployAvailable || deployInFlight) return;
678
+ closeAllPanels();
679
+ deployError = null;
680
+ deployOpen = true;
681
+ };
682
+
683
+ const onConfirmDeploy = async () => {
684
+ deploying = true;
685
+ deployError = null;
686
+ try {
687
+ const res = await fetch(`${endpoint}/deploy`, { method: 'POST', credentials: 'include' });
688
+ if (!res.ok) {
689
+ deployError = (await res.text()) || 'Could not start the deploy.';
690
+ return;
691
+ }
692
+ deployState = (await res.json()) as CmsDeployState;
693
+ deployOpen = false;
694
+ } finally {
695
+ deploying = false;
696
+ }
697
+ };
698
+
628
699
  const refreshAfterReleaseChange = async () => {
629
700
  const key = cmsStore.openRelease?.preview_key ?? null;
630
701
  await setPreviewParam(key, { replace: true });
@@ -1350,6 +1421,17 @@
1350
1421
  Discard All Changes…
1351
1422
  </Menubar.Item>
1352
1423
 
1424
+ {#if deployAvailable}
1425
+ <Menubar.Label class={menuLabelClass}>Live site</Menubar.Label>
1426
+ <Menubar.Item
1427
+ class={menuItemClass}
1428
+ onSelect={onOpenDeploy}
1429
+ disabled={deployInFlight}
1430
+ >
1431
+ {deployInFlight ? 'Deploying…' : 'Deploy Site…'}
1432
+ </Menubar.Item>
1433
+ {/if}
1434
+
1353
1435
  <Menubar.Label class={menuLabelClass}>History</Menubar.Label>
1354
1436
  <Menubar.Item class={menuItemClass} onSelect={onOpenHistory}>
1355
1437
  Recent Releases
@@ -1688,6 +1770,18 @@
1688
1770
  onRegenerate={onRegeneratePreviewKey}
1689
1771
  />
1690
1772
 
1773
+ {#if deployState?.available}
1774
+ <DeployDialog
1775
+ open={deployOpen}
1776
+ onOpenChange={(next) => (deployOpen = next)}
1777
+ deploy={deployState}
1778
+ unpublishedCount={counts.total}
1779
+ {deploying}
1780
+ error={deployError}
1781
+ onConfirm={onConfirmDeploy}
1782
+ />
1783
+ {/if}
1784
+
1691
1785
  <KeyboardShortcutsDialog open={shortcutsOpen} onOpenChange={(next) => (shortcutsOpen = next)} />
1692
1786
  </div>
1693
1787
  </CssRoot>
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ import type { CmsDeployState } from '../../core/wire.js';
3
+ import { describeLatestRun, isDeploying } from './deploy-state.js';
4
+ import { Button } from './ui/button/index.js';
5
+ import * as Dialog from './ui/dialog/index.js';
6
+
7
+ type Props = {
8
+ open: boolean;
9
+ onOpenChange: (open: boolean) => void;
10
+ deploy: CmsDeployState;
11
+ /** Items in the working copy; they are not part of a deploy. */
12
+ unpublishedCount: number;
13
+ /** The POST is in flight. */
14
+ deploying: boolean;
15
+ error: string | null;
16
+ onConfirm: () => void;
17
+ };
18
+ let { open, onOpenChange, deploy, unpublishedCount, deploying, error, onConfirm }: Props =
19
+ $props();
20
+
21
+ const inFlight = $derived(isDeploying(deploy));
22
+ const status = $derived(describeLatestRun(deploy.latest));
23
+ </script>
24
+
25
+ <Dialog.Root
26
+ {open}
27
+ onOpenChange={(next) => {
28
+ if (!next && deploying) return;
29
+ onOpenChange(next);
30
+ }}
31
+ >
32
+ <Dialog.Content class="vela:max-w-[28rem]">
33
+ <Dialog.Header>
34
+ <Dialog.Title>Deploy site</Dialog.Title>
35
+ <Dialog.Description>
36
+ Rebuilds the site with the latest published content and puts it live at
37
+ <span class="vela:font-mono vela:text-bar-text">{deploy.site?.url ?? 'the live site'}</span
38
+ >. Visitors only see published changes after a deploy.
39
+ </Dialog.Description>
40
+ </Dialog.Header>
41
+
42
+ <div class="vela:px-6 vela:pb-4 vela:flex vela:flex-col vela:gap-3">
43
+ <p class="vela:m-0 vela:text-[12px] vela:text-bar-text-secondary">{status}</p>
44
+
45
+ {#if unpublishedCount > 0}
46
+ <p
47
+ class="vela:m-0 vela:px-3 vela:py-2 vela:rounded-md vela:text-[12px]
48
+ vela:bg-[var(--cms-status-warn-bg)] vela:text-[var(--cms-status-warn-text)]"
49
+ >
50
+ {unpublishedCount}
51
+ {unpublishedCount === 1 ? 'unpublished change' : 'unpublished changes'} in your working copy
52
+ will not be included. Publish first to put them live.
53
+ </p>
54
+ {/if}
55
+
56
+ {#if error}
57
+ <p
58
+ role="alert"
59
+ class="vela:m-0 vela:px-3 vela:py-2 vela:rounded-md vela:text-[12px]
60
+ vela:bg-[var(--cms-status-error-bg)] vela:text-[var(--cms-status-error-text)]"
61
+ >
62
+ {error}
63
+ </p>
64
+ {/if}
65
+ </div>
66
+
67
+ <Dialog.Footer class="vela:justify-end vela:pt-2">
68
+ <Button variant="ghost" onclick={() => onOpenChange(false)} disabled={deploying}
69
+ >Cancel</Button
70
+ >
71
+ <Button variant="default" onclick={onConfirm} disabled={deploying || inFlight}>
72
+ {deploying ? 'Deploying…' : 'Deploy'}
73
+ </Button>
74
+ </Dialog.Footer>
75
+ </Dialog.Content>
76
+ </Dialog.Root>
@@ -0,0 +1,15 @@
1
+ import type { CmsDeployState } from '../../core/wire.js';
2
+ type Props = {
3
+ open: boolean;
4
+ onOpenChange: (open: boolean) => void;
5
+ deploy: CmsDeployState;
6
+ /** Items in the working copy; they are not part of a deploy. */
7
+ unpublishedCount: number;
8
+ /** The POST is in flight. */
9
+ deploying: boolean;
10
+ error: string | null;
11
+ onConfirm: () => void;
12
+ };
13
+ declare const DeployDialog: import("svelte").Component<Props, {}, "">;
14
+ type DeployDialog = ReturnType<typeof DeployDialog>;
15
+ export default DeployDialog;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * What the bar shows for a site deploy, kept out of the component so it can be
3
+ * unit tested: whether a run is in flight, and the one-line status of the last.
4
+ */
5
+ import type { CmsDeployRun, CmsDeployState } from '../../core/wire.js';
6
+ export declare const isDeploying: (state: CmsDeployState | null) => boolean;
7
+ export declare const describeLatestRun: (latest: CmsDeployRun | null | undefined, now?: number) => string;
@@ -0,0 +1,13 @@
1
+ import { formatRelative } from './format-relative.js';
2
+ export const isDeploying = (state) => state?.latest?.status === 'pending' || state?.latest?.status === 'building';
3
+ export const describeLatestRun = (latest, now = Date.now()) => {
4
+ if (!latest)
5
+ return 'Never deployed';
6
+ if (latest.status === 'pending' || latest.status === 'building')
7
+ return 'Deploying…';
8
+ const when = formatRelative(latest.finishedAt ?? latest.createdAt, now);
9
+ if (latest.status === 'failed') {
10
+ return latest.error ? `Deploy failed ${when}: ${latest.error}` : `Deploy failed ${when}`;
11
+ }
12
+ return `Deployed ${when}`;
13
+ };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A timestamp as the bar speaks about it: "just now", "5 minutes ago", "this
3
+ * afternoon", "yesterday", then a short date. Shared by the publish and
4
+ * deploy dialogs; `now` is a parameter so tests can pin it.
5
+ */
6
+ export declare const formatRelative: (iso: string, now?: number) => string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A timestamp as the bar speaks about it: "just now", "5 minutes ago", "this
3
+ * afternoon", "yesterday", then a short date. Shared by the publish and
4
+ * deploy dialogs; `now` is a parameter so tests can pin it.
5
+ */
6
+ export const formatRelative = (iso, now = Date.now()) => {
7
+ const t = new Date(iso).getTime();
8
+ if (Number.isNaN(t))
9
+ return '';
10
+ const seconds = Math.max(0, Math.floor((now - t) / 1000));
11
+ if (seconds < 45)
12
+ return 'just now';
13
+ const minutes = Math.floor(seconds / 60);
14
+ if (minutes < 60)
15
+ return minutes <= 1 ? '1 minute ago' : `${minutes} minutes ago`;
16
+ const d = new Date(t);
17
+ const today = new Date(now);
18
+ const sameDay = d.getFullYear() === today.getFullYear() &&
19
+ d.getMonth() === today.getMonth() &&
20
+ d.getDate() === today.getDate();
21
+ if (sameDay) {
22
+ const h = d.getHours();
23
+ if (h < 12)
24
+ return 'this morning';
25
+ if (h < 17)
26
+ return 'this afternoon';
27
+ return 'this evening';
28
+ }
29
+ const yesterday = new Date(today);
30
+ yesterday.setDate(yesterday.getDate() - 1);
31
+ const isYesterday = d.getFullYear() === yesterday.getFullYear() &&
32
+ d.getMonth() === yesterday.getMonth() &&
33
+ d.getDate() === yesterday.getDate();
34
+ if (isYesterday)
35
+ return 'yesterday';
36
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
37
+ };
@@ -3,6 +3,7 @@
3
3
  import type { ReleaseItem } from '../cms/cms-store.svelte.js';
4
4
  import type { CmsPayload } from '../cms/scope.js';
5
5
  import { diffPaths, type Tree } from '../../core/path.js';
6
+ import { formatRelative } from './format-relative.js';
6
7
  import { resolveRouteOnlyParams, resolveRouteUrl } from './resolve-route.js';
7
8
  import { Badge, type BadgeVariant } from './ui/badge/index.js';
8
9
  import { Button } from './ui/button/index.js';
@@ -229,36 +230,6 @@
229
230
  return summarizeNames(meta, 'Updated');
230
231
  };
231
232
 
232
- const formatRelative = (iso: string): string => {
233
- const t = new Date(iso).getTime();
234
- if (Number.isNaN(t)) return '';
235
- const now = Date.now();
236
- const seconds = Math.max(0, Math.floor((now - t) / 1000));
237
- if (seconds < 45) return 'just now';
238
- const minutes = Math.floor(seconds / 60);
239
- if (minutes < 60) return minutes <= 1 ? '1 minute ago' : `${minutes} minutes ago`;
240
- const d = new Date(t);
241
- const today = new Date(now);
242
- const sameDay =
243
- d.getFullYear() === today.getFullYear() &&
244
- d.getMonth() === today.getMonth() &&
245
- d.getDate() === today.getDate();
246
- if (sameDay) {
247
- const h = d.getHours();
248
- if (h < 12) return 'this morning';
249
- if (h < 17) return 'this afternoon';
250
- return 'this evening';
251
- }
252
- const yesterday = new Date(today);
253
- yesterday.setDate(yesterday.getDate() - 1);
254
- const isYesterday =
255
- d.getFullYear() === yesterday.getFullYear() &&
256
- d.getMonth() === yesterday.getMonth() &&
257
- d.getDate() === yesterday.getDate();
258
- if (isYesterday) return 'yesterday';
259
- return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
260
- };
261
-
262
233
  /** Submit-disabled gate: nothing to publish when there are no items at all.
263
234
  * (Group count is fine here too — no items → no groups.) */
264
235
  const total = $derived(items.length);
@@ -155,6 +155,23 @@ export type ListMediaResult = {
155
155
  items: MediaItem[];
156
156
  total: number;
157
157
  };
158
+ /** One build-and-deploy of the site, as the host's deploy adapter reports it. */
159
+ export type CmsDeployRun = {
160
+ id: string;
161
+ status: 'pending' | 'building' | 'deployed' | 'failed';
162
+ createdAt: string;
163
+ finishedAt?: string;
164
+ error?: string;
165
+ };
166
+ /** `GET /deploy` and `POST /deploy`. `available` is `false` when the backend
167
+ * has no deploy adapter, which is how a same-origin mount hides the action. */
168
+ export type CmsDeployState = {
169
+ available: boolean;
170
+ site?: {
171
+ url: string;
172
+ };
173
+ latest?: CmsDeployRun | null;
174
+ };
158
175
  export type DocsResponse = {
159
176
  contents: Tree;
160
177
  } | PageDeleteOutcome;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velastack/cms",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "A scope-aware CMS for SvelteKit, for static or dynamic sites.",
5
5
  "keywords": [
6
6
  "svelte",