@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.
- package/dist/components/preact/DropZone/DropZone.js +2 -0
- package/dist/components/preact/DropZone/useDropDeploy.js +139 -134
- package/dist/drop-core/authedApi.d.ts +4 -12
- package/dist/drop-core/authedApi.js +19 -20
- package/dist/drop-core/client.js +31 -23
- package/dist/drop-core/pollDeploy.d.ts +31 -0
- package/dist/drop-core/pollDeploy.js +14 -0
- package/dist/drop-core/pollDeploy.test.d.ts +1 -0
- package/package.json +1 -2
- package/packages/components/preact/DropZone/DropZone.tsx +2 -0
- package/packages/components/preact/DropZone/useDropDeploy.test.ts +3 -0
- package/packages/components/preact/DropZone/useDropDeploy.ts +12 -3
- package/packages/drop-core/authedApi.ts +15 -29
- package/packages/drop-core/client.test.ts +29 -0
- package/packages/drop-core/client.ts +20 -12
- package/packages/drop-core/pollDeploy.test.ts +50 -0
- package/packages/drop-core/pollDeploy.ts +46 -0
- package/dist/components/articleCopyActions.d.ts +0 -9
- package/packages/components/ArticleCopyActions.astro +0 -122
- package/packages/components/articleCopyActions.test.ts +0 -143
- package/packages/components/articleCopyActions.ts +0 -41
- /package/dist/{components/articleCopyActions.test.d.ts → drop-core/client.test.d.ts} +0 -0
|
@@ -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
|
|
2
|
-
import { humanizeDropError as rt, isNotAuthenticatedError as
|
|
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
|
|
7
|
-
import { AnonymousDropClient as
|
|
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
|
|
11
|
-
import { hasDropStashMarker as gt, loadDropStash as ht, clearDropStash as
|
|
12
|
-
function
|
|
13
|
-
switch (
|
|
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:
|
|
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(
|
|
28
|
-
const m = ot(
|
|
29
|
-
return m ? `No index.html found — "${m}" won’t be served at the site root.` : st(
|
|
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(
|
|
31
|
+
function St(l, m) {
|
|
32
32
|
try {
|
|
33
|
-
localStorage.setItem("drop-token",
|
|
33
|
+
localStorage.setItem("drop-token", l), localStorage.setItem("dropSiteName", m);
|
|
34
34
|
} catch {
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
function Ft({
|
|
38
|
-
apiBase:
|
|
38
|
+
apiBase: l,
|
|
39
39
|
client: m,
|
|
40
|
-
claimUrl:
|
|
41
|
-
siteDashboardUrl:
|
|
42
|
-
buildSignupUrl:
|
|
43
|
-
buildClient:
|
|
44
|
-
authenticatedDropClient:
|
|
45
|
-
getUploadToken:
|
|
46
|
-
proxyBase:
|
|
47
|
-
accountSlug:
|
|
48
|
-
buildDeployUrl:
|
|
49
|
-
waitForBuild:
|
|
50
|
-
onBuildRequired:
|
|
51
|
-
onBuildSignupHandoff:
|
|
52
|
-
resumeStashedBuilds:
|
|
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:
|
|
56
|
+
track: U
|
|
57
57
|
}) {
|
|
58
|
-
const
|
|
58
|
+
const u = (n, t) => (
|
|
59
59
|
// The event interfaces carry no index signature, hence the cast to TrackFn's record.
|
|
60
|
-
|
|
61
|
-
), [
|
|
62
|
-
|
|
63
|
-
).current,
|
|
64
|
-
|
|
65
|
-
).current,
|
|
66
|
-
async function
|
|
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:
|
|
69
|
-
onProgress: (
|
|
70
|
-
waitForBuild:
|
|
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:
|
|
74
|
-
siteName:
|
|
75
|
-
siteId:
|
|
73
|
+
url: a,
|
|
74
|
+
siteName: i.name,
|
|
75
|
+
siteId: i.id,
|
|
76
76
|
buildId: r.id,
|
|
77
77
|
deployId: r.deploy_id,
|
|
78
|
-
buildSettings:
|
|
78
|
+
buildSettings: s
|
|
79
79
|
};
|
|
80
|
-
} catch (
|
|
81
|
-
if (
|
|
82
|
-
throw
|
|
80
|
+
} catch (i) {
|
|
81
|
+
if (v(i)) return null;
|
|
82
|
+
throw i;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
async function
|
|
86
|
-
|
|
87
|
-
const r =
|
|
88
|
-
|
|
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
|
|
92
|
-
if (!
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
method:
|
|
98
|
-
file_count:
|
|
97
|
+
t === "drag" && u(c.FILES_DROPPED, {
|
|
98
|
+
method: t,
|
|
99
|
+
file_count: e.length,
|
|
99
100
|
...r
|
|
100
101
|
});
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
} catch (
|
|
104
|
-
const
|
|
105
|
-
|
|
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
|
|
109
|
-
if (
|
|
110
|
-
method:
|
|
111
|
-
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
|
-
...
|
|
114
|
-
}),
|
|
115
|
-
|
|
118
|
+
...s
|
|
119
|
+
}), F) {
|
|
120
|
+
o({ kind: "idle" }), F(t);
|
|
116
121
|
return;
|
|
117
122
|
}
|
|
118
|
-
let
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
const
|
|
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
|
-
|
|
124
|
-
method:
|
|
125
|
-
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
|
-
...
|
|
130
|
-
}), f == null || f(d), window.location.assign(
|
|
134
|
+
...s
|
|
135
|
+
}), f == null || f(d), window.location.assign(C(d));
|
|
131
136
|
return;
|
|
132
137
|
}
|
|
133
|
-
|
|
134
|
-
method:
|
|
135
|
-
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
|
-
...
|
|
142
|
+
...s
|
|
138
143
|
});
|
|
139
144
|
}
|
|
140
|
-
if (
|
|
141
|
-
method:
|
|
145
|
+
if (a || u(c.DEPLOY_FAILED, {
|
|
146
|
+
method: i,
|
|
142
147
|
reason: "anonymous_build_not_supported",
|
|
143
148
|
file_count: r,
|
|
144
|
-
...
|
|
145
|
-
}),
|
|
146
|
-
|
|
149
|
+
...s
|
|
150
|
+
}), P) {
|
|
151
|
+
o({ kind: "idle" }), P({ stashed: a, detection: t });
|
|
147
152
|
return;
|
|
148
153
|
}
|
|
149
|
-
|
|
154
|
+
o({ kind: "redirecting", target: "signup" }), window.location.assign(W);
|
|
150
155
|
}
|
|
151
|
-
async function
|
|
156
|
+
async function G(n, t) {
|
|
152
157
|
try {
|
|
153
|
-
return await
|
|
154
|
-
onProgress: (
|
|
158
|
+
return await z(n, t, {
|
|
159
|
+
onProgress: (i) => o(N(i))
|
|
155
160
|
});
|
|
156
|
-
} catch (
|
|
157
|
-
if (
|
|
158
|
-
throw
|
|
161
|
+
} catch (i) {
|
|
162
|
+
if (v(i)) return null;
|
|
163
|
+
throw i;
|
|
159
164
|
}
|
|
160
165
|
}
|
|
161
|
-
async function
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
onProgress: (et) =>
|
|
165
|
-
}), { url:
|
|
166
|
-
|
|
167
|
-
method:
|
|
168
|
-
site_name:
|
|
169
|
-
file_count:
|
|
170
|
-
authenticated:
|
|
171
|
-
...
|
|
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
|
-
}),
|
|
174
|
-
url:
|
|
175
|
-
...
|
|
176
|
-
siteName:
|
|
177
|
-
siteId:
|
|
178
|
-
authenticated:
|
|
179
|
-
}),
|
|
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
|
|
182
|
-
n ? window.location.assign(
|
|
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
|
|
185
|
-
const
|
|
186
|
-
if (!
|
|
187
|
-
await
|
|
189
|
+
async function B(n) {
|
|
190
|
+
const t = await ht();
|
|
191
|
+
if (!t) {
|
|
192
|
+
await I();
|
|
188
193
|
return;
|
|
189
194
|
}
|
|
190
|
-
|
|
191
|
-
const
|
|
195
|
+
o({ kind: "creating" });
|
|
196
|
+
const i = { file: t.zipFile, buildSettings: t.buildSettings };
|
|
192
197
|
let r;
|
|
193
198
|
try {
|
|
194
|
-
r = await
|
|
195
|
-
} catch (
|
|
196
|
-
await
|
|
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
|
-
|
|
205
|
+
o({ kind: "idle" });
|
|
201
206
|
return;
|
|
202
207
|
}
|
|
203
|
-
await
|
|
208
|
+
u(c.RESUMED, {}), await I(), o({ kind: "redirecting", target: "build" }), f == null || f(r), window.location.assign(C(r));
|
|
204
209
|
}
|
|
205
|
-
function
|
|
206
|
-
const
|
|
207
|
-
|
|
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
|
|
214
|
+
const O = _(!1);
|
|
210
215
|
nt(() => {
|
|
211
|
-
|
|
216
|
+
O.current || !q || !S || !gt() || (O.current = !0, B(S));
|
|
212
217
|
}, []);
|
|
213
|
-
function
|
|
214
|
-
|
|
218
|
+
function it() {
|
|
219
|
+
D(null), o({ kind: "idle" });
|
|
215
220
|
}
|
|
216
|
-
return { status:
|
|
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
|
|
24
|
-
*
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
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 (
|
|
10
|
-
if (!
|
|
11
|
-
return
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
}
|
|
25
|
-
return { outcome: "timed-out" };
|
|
24
|
+
});
|
|
26
25
|
}
|
|
27
26
|
export {
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
u as createSiteInAccount,
|
|
28
|
+
f as pollDeployUntilSettled
|
|
30
29
|
};
|
package/dist/drop-core/client.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { DEFAULT_API_BASE as
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
7
|
-
constructor(t =
|
|
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
|
|
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
|
|
22
|
+
if (!e.ok) throw a("drop", e.status);
|
|
22
23
|
return e.json();
|
|
23
24
|
}
|
|
24
|
-
async uploadFile(t, o, e,
|
|
25
|
-
const
|
|
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 ${
|
|
28
|
+
headers: { "Content-Type": "application/octet-stream", Authorization: `Bearer ${s}` },
|
|
28
29
|
body: e
|
|
29
30
|
});
|
|
30
|
-
if (!
|
|
31
|
+
if (!r.ok) throw a(`upload ${o}`, r.status);
|
|
31
32
|
}
|
|
32
33
|
async waitUntilReady(t, o) {
|
|
33
|
-
const e =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
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(
|
|
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
|
|
45
|
-
*
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
}
|
|
File without changes
|