@netlify/spark-ui 1.31.0-alpha.7 → 1.31.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.
@@ -160,6 +160,8 @@ function ne({
160
160
  return /* @__PURE__ */ e(o, { label: "Site’s live — taking you to claim it…" });
161
161
  case "dashboard":
162
162
  return /* @__PURE__ */ e(o, { label: "Site’s live — taking you to your new project…" });
163
+ default:
164
+ return null;
163
165
  }
164
166
  case "uploading": {
165
167
  const c = r.total ? Math.round(r.uploaded / r.total * 100) : 0;
@@ -1,22 +1,22 @@
1
- import { d as O, A as S, y as nt } from "../../../chunks/hooks.module.DRcxknIz.js";
2
- import { humanizeDropError as rt, isNotAuthenticatedError as R } from "../../../drop-core/errors.js";
1
+ import { d as R, A as _, y as nt } from "../../../chunks/hooks.module.DRcxknIz.js";
2
+ import { humanizeDropError as rt, isNotAuthenticatedError as v } from "../../../drop-core/errors.js";
3
3
  import { DROPZONE_EVENTS as c } from "../../../drop-core/analytics.js";
4
4
  import { renameSingleNonIndexHtmlToIndex as at, getSingleNonIndexHtmlFileName as ot, hasMultipleHtmlFilesWithoutIndex as st } from "../../../drop-core/readFiles.js";
5
5
  import { detectBuild as dt } from "../../../drop-core/detectBuild.js";
6
- import { zipFiles as ut } from "../../../drop-core/zip.js";
7
- import { AnonymousDropClient as lt } from "../../../drop-core/client.js";
6
+ import { zipFiles as lt } from "../../../drop-core/zip.js";
7
+ import { AnonymousDropClient as ut } from "../../../drop-core/client.js";
8
8
  import { AuthenticatedDropClient as ct } from "../../../drop-core/authedDropClient.js";
9
9
  import { AuthenticatedBuildClient as mt } from "../../../drop-core/buildClient.js";
10
- import { deployZippedBuild as ft, deployFiles as v } from "../../../drop-core/deploy.js";
11
- import { hasDropStashMarker as gt, loadDropStash as ht, clearDropStash as y, saveDropStash as pt } from "../../../drop-core/buildStash.js";
12
- function I(u) {
13
- switch (u.phase) {
10
+ import { deployZippedBuild as ft, deployFiles as z } from "../../../drop-core/deploy.js";
11
+ import { hasDropStashMarker as gt, loadDropStash as ht, clearDropStash as I, saveDropStash as pt } from "../../../drop-core/buildStash.js";
12
+ function N(l) {
13
+ switch (l.phase) {
14
14
  case "zipping":
15
15
  return { kind: "zipping" };
16
16
  case "creating":
17
17
  return { kind: "creating" };
18
18
  case "uploading":
19
- return { kind: "uploading", uploaded: u.uploaded ?? 0, total: u.total ?? 0 };
19
+ return { kind: "uploading", uploaded: l.uploaded ?? 0, total: l.total ?? 0 };
20
20
  case "building":
21
21
  return { kind: "building" };
22
22
  case "processing":
@@ -24,196 +24,201 @@ function I(u) {
24
24
  return { kind: "processing" };
25
25
  }
26
26
  }
27
- function wt(u) {
28
- const m = ot(u);
29
- return m ? `No index.html found — "${m}" won’t be served at the site root.` : st(u) ? "No index.html found — your site may not have a landing page." : null;
27
+ function wt(l) {
28
+ const m = ot(l);
29
+ return m ? `No index.html found — "${m}" won’t be served at the site root.` : st(l) ? "No index.html found — your site may not have a landing page." : null;
30
30
  }
31
- function St(u, m) {
31
+ function St(l, m) {
32
32
  try {
33
- localStorage.setItem("drop-token", u), localStorage.setItem("dropSiteName", m);
33
+ localStorage.setItem("drop-token", l), localStorage.setItem("dropSiteName", m);
34
34
  } catch {
35
35
  }
36
36
  }
37
37
  function Ft({
38
- apiBase: u,
38
+ apiBase: l,
39
39
  client: m,
40
- claimUrl: z,
41
- siteDashboardUrl: Y,
42
- buildSignupUrl: H,
43
- buildClient: W,
44
- authenticatedDropClient: M,
45
- getUploadToken: p,
46
- proxyBase: N,
47
- accountSlug: A,
48
- buildDeployUrl: L,
49
- waitForBuild: Z,
50
- onBuildRequired: C,
51
- onBuildSignupHandoff: F,
52
- resumeStashedBuilds: $ = !0,
53
- onDeploy: _,
40
+ claimUrl: Y,
41
+ siteDashboardUrl: H,
42
+ buildSignupUrl: W,
43
+ buildClient: M,
44
+ authenticatedDropClient: Z,
45
+ getUploadToken: w,
46
+ proxyBase: A,
47
+ accountSlug: L,
48
+ buildDeployUrl: C,
49
+ waitForBuild: $,
50
+ onBuildRequired: F,
51
+ onBuildSignupHandoff: P,
52
+ resumeStashedBuilds: q = !0,
53
+ onDeploy: k,
54
54
  onBuildDeploy: f,
55
55
  onError: g,
56
- track: q
56
+ track: U
57
57
  }) {
58
- const l = (n, i) => (
58
+ const u = (n, t) => (
59
59
  // The event interfaces carry no index signature, hence the cast to TrackFn's record.
60
- q(n, i)
61
- ), [k, a] = O({ kind: "idle" }), [U, b] = O(null), j = S(m ?? new lt(u)).current, w = S(
62
- W ?? (p ? new mt({ proxyBase: N, getUploadToken: p, accountSlug: A }) : null)
63
- ).current, D = S(
64
- M ?? (p ? new ct({ proxyBase: N, getUploadToken: p, accountSlug: A }) : null)
65
- ).current, J = k.kind !== "idle" && k.kind !== "error";
66
- async function P(n, i) {
60
+ U(n, t)
61
+ ), [b, o] = R({ kind: "idle" }), [j, D] = R(null), J = _(m ?? new ut(l)).current, S = _(
62
+ M ?? (w ? new mt({ proxyBase: A, getUploadToken: w, accountSlug: L }) : null)
63
+ ).current, E = _(
64
+ Z ?? (w ? new ct({ proxyBase: A, getUploadToken: w, accountSlug: L }) : null)
65
+ ).current, Q = b.kind !== "idle" && b.kind !== "error";
66
+ async function x(n, t) {
67
67
  try {
68
- const { site: e, build: r, buildSettings: o, url: t } = await ft(n, i, {
69
- onProgress: (s) => a(I(s)),
70
- waitForBuild: Z
68
+ const { site: i, build: r, buildSettings: s, url: a } = await ft(n, t, {
69
+ onProgress: (e) => o(N(e)),
70
+ waitForBuild: $
71
71
  });
72
72
  return {
73
- url: t,
74
- siteName: e.name,
75
- siteId: e.id,
73
+ url: a,
74
+ siteName: i.name,
75
+ siteId: i.id,
76
76
  buildId: r.id,
77
77
  deployId: r.deploy_id,
78
- buildSettings: o
78
+ buildSettings: s
79
79
  };
80
- } catch (e) {
81
- if (R(e)) return null;
82
- throw e;
80
+ } catch (i) {
81
+ if (v(i)) return null;
82
+ throw i;
83
83
  }
84
84
  }
85
- async function Q(n, i, e) {
86
- b(null), a({ kind: "reading" });
87
- const r = e.length ? { file_types: e } : {}, o = (t, s, d) => {
88
- a({ kind: "error", message: t }), l(c.DEPLOY_FAILED, { method: i, reason: s, ...r, ...d }), g == null || g({ message: t, reason: s });
85
+ async function T(n, t, i) {
86
+ D(null), o({ kind: "reading" });
87
+ const r = i.length ? { file_types: i } : {}, s = (e, d, h) => {
88
+ o({ kind: "error", message: e }), u(c.DEPLOY_FAILED, { method: t, reason: d, ...r, ...h }), g == null || g({ message: e, reason: d });
89
89
  };
90
+ let a;
90
91
  try {
91
- const t = at(await n());
92
- if (!t.length) {
93
- o("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
92
+ const e = at(await n());
93
+ if (a = e.length, !e.length) {
94
+ s("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
94
95
  return;
95
96
  }
96
- i === "drag" && l(c.FILES_DROPPED, {
97
- method: i,
98
- file_count: t.length,
97
+ t === "drag" && u(c.FILES_DROPPED, {
98
+ method: t,
99
+ file_count: e.length,
99
100
  ...r
100
101
  });
101
- const s = { source: i, fileCount: t.length, typeProps: r }, d = dt(t);
102
- d.buildRequired ? await T(t, d, s) : await G(t, s);
103
- } catch (t) {
104
- const s = t instanceof Error ? t.message : String(t);
105
- o(rt(t), s);
102
+ const d = { source: t, fileCount: e.length, typeProps: r }, h = dt(e);
103
+ h.buildRequired ? await V(e, h, d) : await K(e, d);
104
+ } catch (e) {
105
+ const d = e instanceof Error ? e.message : String(e);
106
+ s(
107
+ rt(e),
108
+ d,
109
+ a !== void 0 ? { file_count: a } : void 0
110
+ );
106
111
  }
107
112
  }
108
- async function T(n, i, { source: e, fileCount: r, typeProps: o }) {
109
- if (l(c.BUILD_REQUIRED, {
110
- method: e,
111
- reason: i.reason,
113
+ async function V(n, t, { source: i, fileCount: r, typeProps: s }) {
114
+ if (u(c.BUILD_REQUIRED, {
115
+ method: i,
116
+ reason: t.reason,
112
117
  file_count: r,
113
- ...o
114
- }), C) {
115
- a({ kind: "idle" }), C(i);
118
+ ...s
119
+ }), F) {
120
+ o({ kind: "idle" }), F(t);
116
121
  return;
117
122
  }
118
- let t = !1;
119
- if (w) {
120
- a({ kind: "zipping" });
121
- const s = await ut(n), d = await P(w, s);
123
+ let a = !1;
124
+ if (S) {
125
+ o({ kind: "zipping" });
126
+ const e = await lt(n), d = await x(S, e);
122
127
  if (d) {
123
- a({ kind: "redirecting", target: "build" }), l(c.BUILD_DEPLOY_SUCCEEDED, {
124
- method: e,
125
- reason: i.reason,
128
+ o({ kind: "redirecting", target: "build" }), u(c.BUILD_DEPLOY_SUCCEEDED, {
129
+ method: i,
130
+ reason: t.reason,
126
131
  site_name: d.siteName,
127
132
  file_count: r,
128
133
  ...d.buildSettings ? { framework: d.buildSettings.framework } : {},
129
- ...o
130
- }), f == null || f(d), window.location.assign(L(d));
134
+ ...s
135
+ }), f == null || f(d), window.location.assign(C(d));
131
136
  return;
132
137
  }
133
- t = await pt(s.file, s.buildSettings), t && l(c.PROJECT_STASHED, {
134
- method: e,
135
- reason: i.reason,
138
+ a = await pt(e.file, e.buildSettings), a && u(c.PROJECT_STASHED, {
139
+ method: i,
140
+ reason: t.reason,
136
141
  file_count: r,
137
- ...o
142
+ ...s
138
143
  });
139
144
  }
140
- if (t || l(c.DEPLOY_FAILED, {
141
- method: e,
145
+ if (a || u(c.DEPLOY_FAILED, {
146
+ method: i,
142
147
  reason: "anonymous_build_not_supported",
143
148
  file_count: r,
144
- ...o
145
- }), F) {
146
- a({ kind: "idle" }), F({ stashed: t, detection: i });
149
+ ...s
150
+ }), P) {
151
+ o({ kind: "idle" }), P({ stashed: a, detection: t });
147
152
  return;
148
153
  }
149
- a({ kind: "redirecting", target: "signup" }), window.location.assign(H);
154
+ o({ kind: "redirecting", target: "signup" }), window.location.assign(W);
150
155
  }
151
- async function V(n, i) {
156
+ async function G(n, t) {
152
157
  try {
153
- return await v(n, i, {
154
- onProgress: (e) => a(I(e))
158
+ return await z(n, t, {
159
+ onProgress: (i) => o(N(i))
155
160
  });
156
- } catch (e) {
157
- if (R(e)) return null;
158
- throw e;
161
+ } catch (i) {
162
+ if (v(i)) return null;
163
+ throw i;
159
164
  }
160
165
  }
161
- async function G(n, { source: i, fileCount: e, typeProps: r }) {
162
- b(wt(n));
163
- const o = D ? await V(D, n) : null, t = o !== null, s = D !== null && !t, d = o ?? await v(j, n, {
164
- onProgress: (et) => a(I(et))
165
- }), { url: it, token: E, deploy: h } = d;
166
- t || St(E, h.subdomain), a({ kind: "redirecting", target: t ? "dashboard" : "claim" }), l(c.DEPLOY_SUCCEEDED, {
167
- method: i,
168
- site_name: h.subdomain,
169
- file_count: e,
170
- authenticated: t,
171
- ...s ? { auth_fallback: !0 } : {},
166
+ async function K(n, { source: t, fileCount: i, typeProps: r }) {
167
+ D(wt(n));
168
+ const s = E ? await G(E, n) : null, a = s !== null, e = E !== null && !a, d = s ?? await z(J, n, {
169
+ onProgress: (et) => o(N(et))
170
+ }), { url: h, token: y, deploy: p } = d;
171
+ a || St(y, p.subdomain), o({ kind: "redirecting", target: a ? "dashboard" : "claim" }), u(c.DEPLOY_SUCCEEDED, {
172
+ method: t,
173
+ site_name: p.subdomain,
174
+ file_count: i,
175
+ authenticated: a,
176
+ ...e ? { auth_fallback: !0 } : {},
172
177
  ...r
173
- }), _ == null || _({
174
- url: it,
175
- ...t ? {} : { token: E },
176
- siteName: h.subdomain,
177
- siteId: h.id,
178
- authenticated: t
179
- }), K(t, h.subdomain, E);
178
+ }), k == null || k({
179
+ url: h,
180
+ ...a ? {} : { token: y },
181
+ siteName: p.subdomain,
182
+ siteId: p.id,
183
+ authenticated: a
184
+ }), X(a, p.subdomain, y);
180
185
  }
181
- function K(n, i, e) {
182
- n ? window.location.assign(Y(i)) : window.location.assign(`${z(i)}#drop_token=${e}`);
186
+ function X(n, t, i) {
187
+ n ? window.location.assign(H(t)) : window.location.assign(`${Y(t)}#drop_token=${i}`);
183
188
  }
184
- async function X(n) {
185
- const i = await ht();
186
- if (!i) {
187
- await y();
189
+ async function B(n) {
190
+ const t = await ht();
191
+ if (!t) {
192
+ await I();
188
193
  return;
189
194
  }
190
- l(c.RESUMED, {}), a({ kind: "creating" });
191
- const e = { file: i.zipFile, buildSettings: i.buildSettings };
195
+ o({ kind: "creating" });
196
+ const i = { file: t.zipFile, buildSettings: t.buildSettings };
192
197
  let r;
193
198
  try {
194
- r = await P(n, e);
195
- } catch (o) {
196
- await y(), B(o instanceof Error ? o.message : String(o));
199
+ r = await x(n, i);
200
+ } catch (s) {
201
+ await I(), tt(s instanceof Error ? s.message : String(s));
197
202
  return;
198
203
  }
199
204
  if (!r) {
200
- a({ kind: "idle" });
205
+ o({ kind: "idle" });
201
206
  return;
202
207
  }
203
- await y(), a({ kind: "redirecting", target: "build" }), f == null || f(r), window.location.assign(L(r));
208
+ u(c.RESUMED, {}), await I(), o({ kind: "redirecting", target: "build" }), f == null || f(r), window.location.assign(C(r));
204
209
  }
205
- function B(n) {
206
- const i = "We couldn’t automatically deploy your project. Drop it again to publish it.";
207
- a({ kind: "error", message: i }), l(c.DEPLOY_FAILED, { reason: "resume_failed" }), g == null || g({ message: i, reason: n });
210
+ function tt(n) {
211
+ const t = "We couldn’t automatically deploy your project. Drop it again to publish it.";
212
+ o({ kind: "error", message: t }), u(c.DEPLOY_FAILED, { reason: "resume_failed" }), g == null || g({ message: t, reason: n });
208
213
  }
209
- const x = S(!1);
214
+ const O = _(!1);
210
215
  nt(() => {
211
- x.current || !$ || !w || !gt() || (x.current = !0, X(w));
216
+ O.current || !q || !S || !gt() || (O.current = !0, B(S));
212
217
  }, []);
213
- function tt() {
214
- b(null), a({ kind: "idle" });
218
+ function it() {
219
+ D(null), o({ kind: "idle" });
215
220
  }
216
- return { status: k, warning: U, busy: J, deploy: Q, reset: tt };
221
+ return { status: b, warning: j, busy: Q, deploy: T, reset: it };
217
222
  }
218
223
  export {
219
224
  Ft as useDropDeploy
@@ -1,4 +1,6 @@
1
1
  import { BuildSite } from './types';
2
+ import { SettledDeploy } from './pollDeploy';
3
+ export type { SettledDeploy } from './pollDeploy';
2
4
  /**
3
5
  * Transport shared by both authenticated clients: JSON rides the consumer's
4
6
  * access-control rewrite on the session cookie. Uploads are each client's own
@@ -10,19 +12,9 @@ import { BuildSite } from './types';
10
12
  * fallback is still safe, hence NotAuthenticatedError.
11
13
  */
12
14
  export declare function createSiteInAccount(proxyBase: string, accountSlug?: string): Promise<BuildSite>;
13
- /** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
14
- export type SettledDeploy = {
15
- outcome: 'ready';
16
- } | {
17
- outcome: 'failed';
18
- errorMessage?: string;
19
- } | {
20
- outcome: 'timed-out';
21
- };
22
15
  /**
23
- * Polls the deploy on the session cookie and reports the terminal outcome —
24
- * callers map it onto their own error vocabulary. Non-ok reads are skipped,
25
- * not fatal: a proxy hiccup shouldn't kill a progressing deploy.
16
+ * Polls a deploy over the session-cookie proxy — the shared `pollUntilSettled`
17
+ * loop with the cookie transport bound in. A non-ok read is skipped, not fatal.
26
18
  */
27
19
  export declare function pollDeployUntilSettled(options: {
28
20
  proxyBase: string;
@@ -1,30 +1,29 @@
1
- import { NotAuthenticatedError as d, SiteCreateError as l } from "./errors.js";
2
- async function f(r, o) {
3
- const s = o ? `/${o}/sites` : "/sites", e = await fetch(`${r}${s}`, {
1
+ import { NotAuthenticatedError as a, SiteCreateError as l } from "./errors.js";
2
+ import { pollUntilSettled as c } from "./pollDeploy.js";
3
+ async function u(o, e) {
4
+ const n = e ? `/${e}/sites` : "/sites", t = await fetch(`${o}${n}`, {
4
5
  method: "POST",
5
6
  credentials: "include",
6
7
  headers: { "Content-Type": "application/json" },
7
8
  body: JSON.stringify({ created_via: "drop" })
8
9
  });
9
- if (e.status === 401 || e.status === 403) throw new d();
10
- if (!e.ok) throw new l(e.status);
11
- return e.json();
10
+ if (t.status === 401 || t.status === 403) throw new a();
11
+ if (!t.ok) throw new l(t.status);
12
+ return t.json();
12
13
  }
13
- const u = /* @__PURE__ */ new Set(["ready", "error", "rejected"]);
14
- async function y(r) {
15
- const { proxyBase: o, deployId: s, pollIntervalMs: e, timeoutMs: i } = r, c = i / e;
16
- for (let n = 0; n < c; n++) {
17
- const a = await fetch(`${o}/deploys/${s}`, { credentials: "include" });
18
- if (a.ok) {
19
- const t = await a.json();
20
- if (t.state && u.has(t.state))
21
- return t.state === "ready" ? { outcome: "ready" } : { outcome: "failed", errorMessage: t.error_message };
14
+ function f(o) {
15
+ const { proxyBase: e, deployId: n, pollIntervalMs: t, timeoutMs: s } = o;
16
+ return c({
17
+ deployId: n,
18
+ pollIntervalMs: t,
19
+ timeoutMs: s,
20
+ readState: async (i) => {
21
+ const r = await fetch(`${e}/deploys/${i}`, { credentials: "include" });
22
+ return r.ok ? r.json() : null;
22
23
  }
23
- await new Promise((t) => setTimeout(t, e));
24
- }
25
- return { outcome: "timed-out" };
24
+ });
26
25
  }
27
26
  export {
28
- f as createSiteInAccount,
29
- y as pollDeployUntilSettled
27
+ u as createSiteInAccount,
28
+ f as pollDeployUntilSettled
30
29
  };
@@ -1,15 +1,16 @@
1
- import { DEFAULT_API_BASE as p, DEFAULT_POLL_INTERVAL_MS as l, DEFAULT_TIMEOUT_MS as h } from "./constants.js";
2
- import { DeployTimeoutError as d } from "./errors.js";
3
- function i(r, t) {
4
- return Object.assign(new Error(`${r} failed: ${t}`), { status: t });
1
+ import { DEFAULT_API_BASE as n, DEFAULT_POLL_INTERVAL_MS as l, DEFAULT_TIMEOUT_MS as p } from "./constants.js";
2
+ import { DeployFailedError as d, DeployTimeoutError as c } from "./errors.js";
3
+ import { pollUntilSettled as h } from "./pollDeploy.js";
4
+ function a(i, t) {
5
+ return Object.assign(new Error(`${i} failed: ${t}`), { status: t });
5
6
  }
6
- class y {
7
- constructor(t = p, o = l, e = h) {
7
+ class m {
8
+ constructor(t = n, o = l, e = p) {
8
9
  this.apiBase = t, this.pollIntervalMs = o, this.timeoutMs = e;
9
10
  }
10
11
  async getToken() {
11
12
  const t = await fetch(`${this.apiBase}/drop/token`, { method: "POST" });
12
- if (!t.ok) throw i("drop/token", t.status);
13
+ if (!t.ok) throw a("drop/token", t.status);
13
14
  return (await t.json()).token;
14
15
  }
15
16
  async createDeploy(t, o) {
@@ -18,30 +19,37 @@ class y {
18
19
  headers: { "Content-Type": "application/json" },
19
20
  body: JSON.stringify({ files: t, token: o })
20
21
  });
21
- if (!e.ok) throw i("drop", e.status);
22
+ if (!e.ok) throw a("drop", e.status);
22
23
  return e.json();
23
24
  }
24
- async uploadFile(t, o, e, a) {
25
- const s = await fetch(`${this.apiBase}/deploys/${t}/files${o}`, {
25
+ async uploadFile(t, o, e, s) {
26
+ const r = await fetch(`${this.apiBase}/deploys/${t}/files${o}`, {
26
27
  method: "PUT",
27
- headers: { "Content-Type": "application/octet-stream", Authorization: `Bearer ${a}` },
28
+ headers: { "Content-Type": "application/octet-stream", Authorization: `Bearer ${s}` },
28
29
  body: e
29
30
  });
30
- if (!s.ok) throw i(`upload ${o}`, s.status);
31
+ if (!r.ok) throw a(`upload ${o}`, r.status);
31
32
  }
32
33
  async waitUntilReady(t, o) {
33
- const e = this.timeoutMs / this.pollIntervalMs;
34
- for (let a = 0; a < e; a++) {
35
- const s = await fetch(`${this.apiBase}/deploys/${t.deploy_id}`, {
36
- headers: { Authorization: `Bearer ${o}` }
37
- });
38
- if (s.ok && (await s.json()).state === "ready") return;
39
- await new Promise((n) => setTimeout(n, this.pollIntervalMs));
40
- }
41
- throw new d();
34
+ const e = await h({
35
+ deployId: t.deploy_id,
36
+ pollIntervalMs: this.pollIntervalMs,
37
+ timeoutMs: this.timeoutMs,
38
+ // `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID) that
39
+ // lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil and
40
+ // the request 401s misleadingly via require_current_actor!.
41
+ readState: async (s) => {
42
+ const r = await fetch(`${this.apiBase}/deploys/${s}`, {
43
+ headers: { Authorization: `Bearer ${o}` }
44
+ });
45
+ return r.ok ? r.json() : null;
46
+ }
47
+ });
48
+ if (e.outcome !== "ready")
49
+ throw e.outcome === "failed" ? new d(e.errorMessage) : new c();
42
50
  }
43
51
  }
44
52
  export {
45
- y as AnonymousDropClient,
46
- i as apiError
53
+ m as AnonymousDropClient,
54
+ a as apiError
47
55
  };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The single deploy poller both drop clients share. The loop and its
3
+ * terminal-state policy live here once; transport (cookie proxy vs bearer) is
4
+ * injected via `readState`, so the anonymous and authenticated paths can't
5
+ * drift on what "done" means.
6
+ */
7
+ /** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
8
+ export type SettledDeploy = {
9
+ outcome: 'ready';
10
+ } | {
11
+ outcome: 'failed';
12
+ errorMessage?: string;
13
+ } | {
14
+ outcome: 'timed-out';
15
+ };
16
+ /** The slice of a deploy record the poller reads; `null` = a read that failed and should be skipped. */
17
+ export interface DeployState {
18
+ state?: string;
19
+ error_message?: string;
20
+ }
21
+ /**
22
+ * Poll until the deploy reaches a terminal state or the timeout elapses.
23
+ * A `null` read is skipped, not fatal — a transient hiccup shouldn't kill a
24
+ * progressing deploy. Callers map the outcome onto their own error vocabulary.
25
+ */
26
+ export declare function pollUntilSettled(options: {
27
+ deployId: string;
28
+ pollIntervalMs: number;
29
+ timeoutMs: number;
30
+ readState: (deployId: string) => Promise<DeployState | null>;
31
+ }): Promise<SettledDeploy>;
@@ -0,0 +1,14 @@
1
+ const c = /* @__PURE__ */ new Set(["ready", "error", "rejected"]);
2
+ async function l(r) {
3
+ const { deployId: a, pollIntervalMs: t, timeoutMs: s, readState: n } = r, i = s / t;
4
+ for (let o = 0; o < i; o++) {
5
+ const e = await n(a);
6
+ if (e != null && e.state && c.has(e.state))
7
+ return e.state === "ready" ? { outcome: "ready" } : { outcome: "failed", errorMessage: e.error_message };
8
+ await new Promise((d) => setTimeout(d, t));
9
+ }
10
+ return { outcome: "timed-out" };
11
+ }
12
+ export {
13
+ l as pollUntilSettled
14
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/spark-ui",
3
3
  "description": "Assets, design tokens, components, and utilities",
4
- "version": "1.31.0-alpha.7",
4
+ "version": "1.31.0",
5
5
  "type": "module",
6
6
  "main": "dist/components/preact/index.js",
7
7
  "types": "dist/components/preact/index.d.ts",
@@ -65,7 +65,6 @@
65
65
  "fake-indexeddb": "^6.2.5",
66
66
  "glob": "^11.0.1",
67
67
  "happy-dom": "^20.11.6",
68
- "jsdom": "^30.0.0",
69
68
  "preact": "^10.26.4",
70
69
  "prettier": "^3.5.3",
71
70
  "prettier-plugin-astro": "^0.14.1",
@@ -309,6 +309,8 @@ function StatusView({
309
309
  return <Spinner label="Site’s live — taking you to claim it…" />;
310
310
  case 'dashboard':
311
311
  return <Spinner label="Site’s live — taking you to your new project…" />;
312
+ default:
313
+ return null;
312
314
  }
313
315
 
314
316
  case 'uploading': {
@@ -346,6 +346,9 @@ describe('stash resume on mount', () => {
346
346
  expect(clearDropStash).not.toHaveBeenCalled(); // preserved for the next authenticated visit
347
347
  expect(harness.api.status.kind).toBe('idle');
348
348
  expect(harness.errors).toHaveLength(0);
349
+ // Nothing deployed, so no resume was counted — otherwise every logged-out
350
+ // reload with a stash would inflate the metric.
351
+ expect(harness.events.map(e => e.event)).not.toContain(DROPZONE_EVENTS.RESUMED);
349
352
  });
350
353
 
351
354
  it('surfaces a real resume failure with re-drop copy, stash consumed', async () => {
@@ -268,11 +268,15 @@ export function useDropDeploy({
268
268
  onError?.({ message, reason });
269
269
  };
270
270
 
271
+ // Set once the file set is read, so a later throw still reports file_count.
272
+ let knownFileCount: number | undefined;
273
+
271
274
  try {
272
275
  // Rename a lone non-index HTML file so it is served at the site root.
273
276
  // Warnings are computed on the renamed set, so the fixable single-file
274
277
  // case no longer warns and only the multi-HTML case does.
275
278
  const files = renameSingleNonIndexHtmlToIndex(await read());
279
+ knownFileCount = files.length;
276
280
  if (!files.length) {
277
281
  fail('No files found to deploy.', 'No files found to deploy.', { file_count: 0 });
278
282
  return;
@@ -297,7 +301,11 @@ export function useDropDeploy({
297
301
  }
298
302
  } catch (err) {
299
303
  const reason = err instanceof Error ? err.message : String(err);
300
- fail(humanizeDropError(err), reason);
304
+ fail(
305
+ humanizeDropError(err),
306
+ reason,
307
+ knownFileCount !== undefined ? { file_count: knownFileCount } : undefined
308
+ );
301
309
  }
302
310
  }
303
311
 
@@ -481,7 +489,6 @@ export function useDropDeploy({
481
489
  return;
482
490
  }
483
491
 
484
- emit(DROPZONE_EVENTS.RESUMED, {});
485
492
  setStatus({ kind: 'creating' });
486
493
 
487
494
  const archive: ZipResult = { file: stash.zipFile, buildSettings: stash.buildSettings };
@@ -496,11 +503,13 @@ export function useDropDeploy({
496
503
 
497
504
  if (!built) {
498
505
  // Still no session — the visitor came back logged out. Keep the stash for
499
- // their next authenticated visit.
506
+ // their next authenticated visit. No RESUMED event: nothing deployed.
500
507
  setStatus({ kind: 'idle' });
501
508
  return;
502
509
  }
503
510
 
511
+ // Only now is a resume real — a logged-out attempt above never counts.
512
+ emit(DROPZONE_EVENTS.RESUMED, {});
504
513
  await clearDropStash();
505
514
  setStatus({ kind: 'redirecting', target: 'build' });
506
515
  onBuildDeploy?.(built);
@@ -1,5 +1,8 @@
1
1
  import type { BuildSite } from './types';
2
2
  import { NotAuthenticatedError, SiteCreateError } from './errors';
3
+ import { pollUntilSettled, type SettledDeploy } from './pollDeploy';
4
+
5
+ export type { SettledDeploy } from './pollDeploy';
3
6
 
4
7
  /**
5
8
  * Transport shared by both authenticated clients: JSON rides the consumer's
@@ -28,41 +31,24 @@ export async function createSiteInAccount(
28
31
  return res.json();
29
32
  }
30
33
 
31
- /** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
32
- export type SettledDeploy =
33
- { outcome: 'ready' } | { outcome: 'failed'; errorMessage?: string } | { outcome: 'timed-out' };
34
-
35
- /** Deploy states that end the wait — `ready` succeeds, the rest are failures. */
36
- const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
37
-
38
- interface DeployState {
39
- state?: string;
40
- error_message?: string;
41
- }
42
-
43
34
  /**
44
- * Polls the deploy on the session cookie and reports the terminal outcome —
45
- * callers map it onto their own error vocabulary. Non-ok reads are skipped,
46
- * not fatal: a proxy hiccup shouldn't kill a progressing deploy.
35
+ * Polls a deploy over the session-cookie proxy — the shared `pollUntilSettled`
36
+ * loop with the cookie transport bound in. A non-ok read is skipped, not fatal.
47
37
  */
48
- export async function pollDeployUntilSettled(options: {
38
+ export function pollDeployUntilSettled(options: {
49
39
  proxyBase: string;
50
40
  deployId: string;
51
41
  pollIntervalMs: number;
52
42
  timeoutMs: number;
53
43
  }): Promise<SettledDeploy> {
54
44
  const { proxyBase, deployId, pollIntervalMs, timeoutMs } = options;
55
- const deadline = timeoutMs / pollIntervalMs;
56
- for (let i = 0; i < deadline; i++) {
57
- const res = await fetch(`${proxyBase}/deploys/${deployId}`, { credentials: 'include' });
58
- if (res.ok) {
59
- const deploy: DeployState = await res.json();
60
- if (deploy.state && TERMINAL_STATES.has(deploy.state)) {
61
- if (deploy.state === 'ready') return { outcome: 'ready' };
62
- return { outcome: 'failed', errorMessage: deploy.error_message };
63
- }
64
- }
65
- await new Promise(r => setTimeout(r, pollIntervalMs));
66
- }
67
- return { outcome: 'timed-out' };
45
+ return pollUntilSettled({
46
+ deployId,
47
+ pollIntervalMs,
48
+ timeoutMs,
49
+ readState: async id => {
50
+ const res = await fetch(`${proxyBase}/deploys/${id}`, { credentials: 'include' });
51
+ return res.ok ? res.json() : null;
52
+ },
53
+ });
68
54
  }
@@ -0,0 +1,29 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { AnonymousDropClient } from './client';
3
+ import { DeployFailedError } from './errors';
4
+ import type { DropResponse } from './types';
5
+
6
+ const deploy: DropResponse = { id: 'site', deploy_id: 'd', subdomain: 'x', required: [] };
7
+ const client = () => new AnonymousDropClient('https://api', 1, 50);
8
+
9
+ afterEach(() => vi.unstubAllGlobals());
10
+
11
+ describe('AnonymousDropClient.waitUntilReady', () => {
12
+ it('resolves once the deploy is ready', async () => {
13
+ vi.stubGlobal('fetch', async () => ({ ok: true, json: async () => ({ state: 'ready' }) }));
14
+ await expect(client().waitUntilReady(deploy, 'tok')).resolves.toBeUndefined();
15
+ });
16
+
17
+ // Regression: a failed anonymous deploy used to poll to the full timeout.
18
+ it('fails fast on a failed deploy instead of timing out', async () => {
19
+ vi.stubGlobal('fetch', async () => ({
20
+ ok: true,
21
+ json: async () => ({ state: 'error', error_message: 'nope' }),
22
+ }));
23
+ const err = await client()
24
+ .waitUntilReady(deploy, 'tok')
25
+ .catch(e => e);
26
+ expect(err).toBeInstanceOf(DeployFailedError);
27
+ expect(err.reason).toBe('nope');
28
+ });
29
+ });
@@ -3,7 +3,8 @@
3
3
  * interface, so `deployFiles` runs the same pipeline anonymous or authenticated.
4
4
  */
5
5
  import { DEFAULT_API_BASE, DEFAULT_POLL_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from './constants';
6
- import { DeployTimeoutError } from './errors';
6
+ import { DeployFailedError, DeployTimeoutError } from './errors';
7
+ import { pollUntilSettled } from './pollDeploy';
7
8
  import type { DropResponse, Digest } from './types';
8
9
 
9
10
  /** A normal Error with the response's HTTP status attached, so callers can branch on it directly. */
@@ -61,17 +62,24 @@ export class AnonymousDropClient implements DropClient {
61
62
  }
62
63
 
63
64
  async waitUntilReady(deploy: DropResponse, token: string): Promise<void> {
64
- // `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID) that
65
- // lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil and
66
- // the request 401s misleadingly via require_current_actor!.
67
- const deadline = this.timeoutMs / this.pollIntervalMs;
68
- for (let i = 0; i < deadline; i++) {
69
- const res = await fetch(`${this.apiBase}/deploys/${deploy.deploy_id}`, {
70
- headers: { Authorization: `Bearer ${token}` },
71
- });
72
- if (res.ok && (await res.json()).state === 'ready') return;
73
- await new Promise(r => setTimeout(r, this.pollIntervalMs));
74
- }
65
+ const settled = await pollUntilSettled({
66
+ deployId: deploy.deploy_id,
67
+ pollIntervalMs: this.pollIntervalMs,
68
+ timeoutMs: this.timeoutMs,
69
+ // `/deploys/{id}` expects the deploy_id (BSON), not the site id (UUID) that
70
+ // lives at `deploy.id`. See drop_controller.rb; Deploy.find(uuid) is nil and
71
+ // the request 401s misleadingly via require_current_actor!.
72
+ readState: async id => {
73
+ const res = await fetch(`${this.apiBase}/deploys/${id}`, {
74
+ headers: { Authorization: `Bearer ${token}` },
75
+ });
76
+ return res.ok ? res.json() : null;
77
+ },
78
+ });
79
+ if (settled.outcome === 'ready') return;
80
+ // A failed deploy now surfaces immediately instead of waiting out the full
81
+ // timeout, matching the authenticated path.
82
+ if (settled.outcome === 'failed') throw new DeployFailedError(settled.errorMessage);
75
83
  throw new DeployTimeoutError();
76
84
  }
77
85
  }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pollUntilSettled } from './pollDeploy';
3
+
4
+ const base = { deployId: 'd', pollIntervalMs: 1, timeoutMs: 50 };
5
+
6
+ describe('pollUntilSettled', () => {
7
+ it('resolves ready when the deploy reaches ready', async () => {
8
+ const settled = await pollUntilSettled({ ...base, readState: async () => ({ state: 'ready' }) });
9
+ expect(settled).toEqual({ outcome: 'ready' });
10
+ });
11
+
12
+ it('reports failure with the API message on error/rejected', async () => {
13
+ for (const state of ['error', 'rejected']) {
14
+ const settled = await pollUntilSettled({
15
+ ...base,
16
+ readState: async () => ({ state, error_message: 'boom' }),
17
+ });
18
+ expect(settled).toEqual({ outcome: 'failed', errorMessage: 'boom' });
19
+ }
20
+ });
21
+
22
+ it('times out when the deploy never settles (does not hang on failure states either)', async () => {
23
+ const settled = await pollUntilSettled({
24
+ ...base,
25
+ timeoutMs: 3,
26
+ readState: async () => ({ state: 'building' }),
27
+ });
28
+ expect(settled).toEqual({ outcome: 'timed-out' });
29
+ });
30
+
31
+ it('skips a null read without settling, then resolves', async () => {
32
+ let calls = 0;
33
+ const settled = await pollUntilSettled({
34
+ ...base,
35
+ readState: async () => (++calls < 3 ? null : { state: 'ready' }),
36
+ });
37
+ expect(settled).toEqual({ outcome: 'ready' });
38
+ expect(calls).toBeGreaterThanOrEqual(3);
39
+ });
40
+
41
+ it('waits through non-terminal states then settles on the terminal one', async () => {
42
+ const states = ['uploading', 'processing', 'ready'];
43
+ let i = 0;
44
+ const settled = await pollUntilSettled({
45
+ ...base,
46
+ readState: async () => ({ state: states[Math.min(i++, states.length - 1)] }),
47
+ });
48
+ expect(settled).toEqual({ outcome: 'ready' });
49
+ });
50
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The single deploy poller both drop clients share. The loop and its
3
+ * terminal-state policy live here once; transport (cookie proxy vs bearer) is
4
+ * injected via `readState`, so the anonymous and authenticated paths can't
5
+ * drift on what "done" means.
6
+ */
7
+
8
+ /** How a watched deploy ended. `errorMessage` is the API's own explanation, when it gave one. */
9
+ export type SettledDeploy =
10
+ | { outcome: 'ready' }
11
+ | { outcome: 'failed'; errorMessage?: string }
12
+ | { outcome: 'timed-out' };
13
+
14
+ /** The slice of a deploy record the poller reads; `null` = a read that failed and should be skipped. */
15
+ export interface DeployState {
16
+ state?: string;
17
+ error_message?: string;
18
+ }
19
+
20
+ /** `ready` succeeds; `error`/`rejected` are failures; anything else keeps waiting. */
21
+ const TERMINAL_STATES = new Set(['ready', 'error', 'rejected']);
22
+
23
+ /**
24
+ * Poll until the deploy reaches a terminal state or the timeout elapses.
25
+ * A `null` read is skipped, not fatal — a transient hiccup shouldn't kill a
26
+ * progressing deploy. Callers map the outcome onto their own error vocabulary.
27
+ */
28
+ export async function pollUntilSettled(options: {
29
+ deployId: string;
30
+ pollIntervalMs: number;
31
+ timeoutMs: number;
32
+ readState: (deployId: string) => Promise<DeployState | null>;
33
+ }): Promise<SettledDeploy> {
34
+ const { deployId, pollIntervalMs, timeoutMs, readState } = options;
35
+ const deadline = timeoutMs / pollIntervalMs;
36
+ for (let i = 0; i < deadline; i++) {
37
+ const deploy = await readState(deployId);
38
+ if (deploy?.state && TERMINAL_STATES.has(deploy.state)) {
39
+ return deploy.state === 'ready'
40
+ ? { outcome: 'ready' }
41
+ : { outcome: 'failed', errorMessage: deploy.error_message };
42
+ }
43
+ await new Promise(r => setTimeout(r, pollIntervalMs));
44
+ }
45
+ return { outcome: 'timed-out' };
46
+ }
@@ -1,9 +0,0 @@
1
- /**
2
- * Wire up the "Copy URL" / "Copy page" buttons rendered by ArticleCopyActions.astro.
3
- *
4
- * This runs on every `astro:page-load` (initial load *and* every client-side
5
- * navigation via `<ClientRouter />`), so the buttons keep working after routing
6
- * to another article/post. Each wrapper is guarded so re-initialising the same
7
- * DOM node never attaches duplicate listeners.
8
- */
9
- export declare function initArticleCopyActions(root?: ParentNode): void;
@@ -1,122 +0,0 @@
1
- ---
2
- interface Props {
3
- url: string;
4
- content: string;
5
- title?: string;
6
- class?: string;
7
- copyUrlLabel?: string;
8
- copyContentLabel?: string;
9
- copiedLabel?: string;
10
- trackingProps?: Record<string, string>;
11
- trackingSelectorClass?: string;
12
- }
13
-
14
- const {
15
- url,
16
- content,
17
- title,
18
- class: className,
19
- copyUrlLabel = 'Copy URL',
20
- copyContentLabel = 'Copy page',
21
- copiedLabel = 'Copied!',
22
- trackingProps = {},
23
- trackingSelectorClass,
24
- } = Astro.props;
25
-
26
- const copyContent = title ? `# ${title}\n\n${content}` : content;
27
- ---
28
-
29
- <div class:list={['copy-actions', className]} data-copied-label={copiedLabel}>
30
- <button
31
- type="button"
32
- class:list={['copy-actions-action', 'copy-actions-copy-url', trackingSelectorClass]}
33
- data-copy="url"
34
- data-value={url}
35
- data-track-action="copy_url"
36
- {...trackingProps}
37
- >
38
- <svg
39
- class="copy-actions-icon"
40
- viewBox="0 0 16 16"
41
- fill="none"
42
- stroke="currentColor"
43
- stroke-width="1.3"
44
- stroke-linecap="round"
45
- stroke-linejoin="round"
46
- aria-hidden="true"
47
- role="presentation"
48
- >
49
- <path d="M6.5 9.5a2.5 2.5 0 0 0 3.6.1l2-2a2.5 2.5 0 0 0-3.6-3.6l-1.1 1.1"></path>
50
- <path d="M9.5 6.5a2.5 2.5 0 0 0-3.6-.1l-2 2a2.5 2.5 0 0 0 3.6 3.6l1.1-1.1"></path>
51
- </svg>
52
- <span class="copy-actions-label">{copyUrlLabel}</span>
53
- </button>
54
- <button
55
- type="button"
56
- class:list={['copy-actions-action', 'copy-actions-copy-content', trackingSelectorClass]}
57
- data-copy="page"
58
- data-track-action="copy_page"
59
- {...trackingProps}
60
- >
61
- <svg
62
- class="copy-actions-icon"
63
- viewBox="0 0 16 16"
64
- fill="currentColor"
65
- aria-hidden="true"
66
- role="presentation"
67
- >
68
- <path
69
- d="M4.83333 1C3.82214 1 3 1.78477 3 2.75V13.25C3 14.2152 3.82214 15 4.83333 15H12.1667C13.1779 15 14 14.2152 14 13.25V5.375H10.3333C9.8263 5.375 9.41667 4.98398 9.41667 4.5V1H4.83333ZM10.3333 1V4.5H14L10.3333 1ZM6.20833 8H10.7917C11.0438 8 11.25 8.19687 11.25 8.4375C11.25 8.67813 11.0438 8.875 10.7917 8.875H6.20833C5.95625 8.875 5.75 8.67813 5.75 8.4375C5.75 8.19687 5.95625 8 6.20833 8ZM6.20833 9.75H10.7917C11.0438 9.75 11.25 9.94687 11.25 10.1875C11.25 10.4281 11.0438 10.625 10.7917 10.625H6.20833C5.95625 10.625 5.75 10.4281 5.75 10.1875C5.75 9.94687 5.95625 9.75 6.20833 9.75ZM6.20833 11.5H10.7917C11.0438 11.5 11.25 11.6969 11.25 11.9375C11.25 12.1781 11.0438 12.375 10.7917 12.375H6.20833C5.95625 12.375 5.75 12.1781 5.75 11.9375C5.75 11.6969 5.95625 11.5 6.20833 11.5Z"
70
- ></path>
71
- </svg>
72
- <span class="copy-actions-label">{copyContentLabel}</span>
73
- </button>
74
- </div>
75
-
76
- <script is:inline type="text/plain" data-copy-actions-source set:text={copyContent} />
77
-
78
- <style>
79
- .copy-actions {
80
- display: flex;
81
- align-items: center;
82
- gap: var(--ntl-space-2xs);
83
- }
84
-
85
- .copy-actions-action {
86
- display: flex;
87
- align-items: center;
88
- gap: var(--ntl-space-3xs);
89
- background: none;
90
- border: none;
91
- padding: 0;
92
- font: inherit;
93
- color: var(--ntl-color-text-weak);
94
- cursor: pointer;
95
- }
96
-
97
- .copy-actions-label {
98
- font-size: var(--ntl-font-size-text-sm);
99
- }
100
-
101
- .copy-actions-icon {
102
- width: var(--ntl-space-s);
103
- height: var(--ntl-space-s);
104
- }
105
-
106
- .copy-actions-copy-url::after {
107
- content: '•';
108
- margin-inline-start: var(--ntl-space-2xs);
109
- }
110
-
111
- .copy-actions-action:hover {
112
- color: var(--ntl-color-text);
113
- }
114
- </style>
115
-
116
- <script>
117
- import { initArticleCopyActions } from './articleCopyActions';
118
-
119
- // `astro:page-load` fires on the initial load *and* after every client-side
120
- // navigation (`<ClientRouter />`).
121
- document.addEventListener('astro:page-load', () => initArticleCopyActions());
122
- </script>
@@ -1,143 +0,0 @@
1
- // @vitest-environment jsdom
2
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
- import { initArticleCopyActions } from './articleCopyActions';
4
-
5
- const ARTICLE_URL = 'https://www.netlify.com/blog/some-post/';
6
- const MARKDOWN = '# Some post\n\nBody content.';
7
-
8
- function renderCopyActions({ url = ARTICLE_URL, markdown = MARKDOWN, copiedLabel = 'Copied!' } = {}) {
9
- document.body.innerHTML = `
10
- <div class="copy-actions" data-copied-label="${copiedLabel}">
11
- <button
12
- type="button"
13
- class="copy-actions-action copy-actions-copy-url"
14
- data-copy="url"
15
- data-value="${url}"
16
- >
17
- <span>Copy URL</span>
18
- </button>
19
- <button
20
- type="button"
21
- class="copy-actions-action copy-actions-copy-content"
22
- data-copy="page"
23
- >
24
- <span>Copy page</span>
25
- </button>
26
- </div>
27
- <script type="text/plain" data-copy-actions-source>${markdown}</script>
28
- `;
29
-
30
- return {
31
- wrapper: document.querySelector<HTMLElement>('.copy-actions')!,
32
- copyUrlBtn: document.querySelector<HTMLButtonElement>('.copy-actions-copy-url')!,
33
- copyPageBtn: document.querySelector<HTMLButtonElement>('.copy-actions-copy-content')!,
34
- urlLabel: document.querySelector<HTMLElement>('.copy-actions-copy-url span')!,
35
- pageLabel: document.querySelector<HTMLElement>('.copy-actions-copy-content span')!,
36
- };
37
- }
38
-
39
- describe('initArticleCopyActions', () => {
40
- let writeText: ReturnType<typeof vi.fn>;
41
-
42
- beforeEach(() => {
43
- vi.useFakeTimers();
44
- writeText = vi.fn().mockResolvedValue(undefined);
45
- Object.defineProperty(navigator, 'clipboard', {
46
- value: { writeText },
47
- configurable: true,
48
- });
49
- });
50
-
51
- afterEach(() => {
52
- vi.runOnlyPendingTimers();
53
- vi.useRealTimers();
54
- document.body.innerHTML = '';
55
- });
56
-
57
- it('copies the article URL and shows a temporary confirmation', async () => {
58
- const { copyUrlBtn, urlLabel } = renderCopyActions();
59
- initArticleCopyActions();
60
-
61
- copyUrlBtn.click();
62
- await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(ARTICLE_URL));
63
- expect(urlLabel.textContent).toBe('Copied!');
64
- });
65
-
66
- it('copies the markdown source for the "Copy page" button', async () => {
67
- const { copyPageBtn, pageLabel } = renderCopyActions();
68
- initArticleCopyActions();
69
-
70
- copyPageBtn.click();
71
- await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(MARKDOWN));
72
- expect(pageLabel.textContent).toBe('Copied!');
73
- });
74
-
75
- it('resets the label back to its original text after the delay', async () => {
76
- const { copyUrlBtn, urlLabel } = renderCopyActions();
77
- initArticleCopyActions();
78
-
79
- copyUrlBtn.click();
80
- await vi.waitFor(() => expect(urlLabel.textContent).toBe('Copied!'));
81
-
82
- vi.advanceTimersByTime(1500);
83
- expect(urlLabel.textContent).toBe('Copy URL');
84
- });
85
-
86
- it('uses a custom copied label when data-copied-label is set', async () => {
87
- const { copyUrlBtn, urlLabel } = renderCopyActions({ copiedLabel: 'Done!' });
88
- initArticleCopyActions();
89
-
90
- copyUrlBtn.click();
91
- await vi.waitFor(() => expect(urlLabel.textContent).toBe('Done!'));
92
- });
93
-
94
- it('does not attach duplicate listeners when initialised twice on the same DOM', async () => {
95
- const { copyUrlBtn } = renderCopyActions();
96
- initArticleCopyActions();
97
- initArticleCopyActions(); // e.g. a spurious re-fire
98
-
99
- copyUrlBtn.click();
100
- await vi.waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
101
- });
102
-
103
- it('re-binds after a client-side navigation swaps in fresh buttons', async () => {
104
- // Simulate the ClientRouter bug: first page loads and works...
105
- const first = renderCopyActions({ url: 'https://example.com/first/' });
106
- initArticleCopyActions();
107
- first.copyUrlBtn.click();
108
- await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('https://example.com/first/'));
109
-
110
- // ...then a navigation replaces the DOM with a new post's buttons.
111
- const second = renderCopyActions({ url: 'https://example.com/second/' });
112
- // Re-running on `astro:page-load` is what makes the new buttons work.
113
- initArticleCopyActions();
114
-
115
- second.copyUrlBtn.click();
116
- await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('https://example.com/second/'));
117
- });
118
-
119
- it('scopes the markdown source lookup to each wrapper, not a global selector', async () => {
120
- document.body.innerHTML = `
121
- <div class="copy-actions" data-copied-label="Copied!">
122
- <button type="button" class="copy-actions-action copy-actions-copy-content" data-copy="page">
123
- <span>Copy page</span>
124
- </button>
125
- </div>
126
- <script type="text/plain" data-copy-actions-source>First post markdown</script>
127
- <div class="copy-actions" data-copied-label="Copied!">
128
- <button type="button" class="copy-actions-action copy-actions-copy-content" data-copy="page">
129
- <span>Copy page</span>
130
- </button>
131
- </div>
132
- <script type="text/plain" data-copy-actions-source>Second post markdown</script>
133
- `;
134
- initArticleCopyActions();
135
-
136
- const buttons = document.querySelectorAll<HTMLButtonElement>('.copy-actions-copy-content');
137
- buttons[1].click();
138
- await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('Second post markdown'));
139
-
140
- buttons[0].click();
141
- await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith('First post markdown'));
142
- });
143
- });
@@ -1,41 +0,0 @@
1
- const COPIED_LABEL_FALLBACK = 'Copied!';
2
- const RESET_DELAY_MS = 1500;
3
-
4
- /**
5
- * Wire up the "Copy URL" / "Copy page" buttons rendered by ArticleCopyActions.astro.
6
- *
7
- * This runs on every `astro:page-load` (initial load *and* every client-side
8
- * navigation via `<ClientRouter />`), so the buttons keep working after routing
9
- * to another article/post. Each wrapper is guarded so re-initialising the same
10
- * DOM node never attaches duplicate listeners.
11
- */
12
- export function initArticleCopyActions(root: ParentNode = document): void {
13
- root.querySelectorAll<HTMLElement>('.copy-actions').forEach(wrapper => {
14
- if (wrapper.dataset.copyBound === 'true') return;
15
- wrapper.dataset.copyBound = 'true';
16
-
17
- const source = wrapper.nextElementSibling as HTMLElement | null;
18
- const copiedLabel = wrapper.dataset.copiedLabel ?? COPIED_LABEL_FALLBACK;
19
-
20
- wrapper.querySelectorAll<HTMLButtonElement>('.copy-actions-action').forEach(btn => {
21
- const label = btn.querySelector<HTMLElement>('span');
22
- const originalText = label?.textContent;
23
-
24
- btn.addEventListener('click', async () => {
25
- const value =
26
- btn.getAttribute('data-copy') === 'url'
27
- ? btn.getAttribute('data-value')
28
- : source?.textContent;
29
-
30
- if (!value || !label) return;
31
-
32
- await navigator.clipboard.writeText(value);
33
-
34
- label.textContent = copiedLabel;
35
- setTimeout(() => {
36
- label.textContent = originalText ?? '';
37
- }, RESET_DELAY_MS);
38
- });
39
- });
40
- });
41
- }