@netlify/spark-ui 1.27.6 → 1.28.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.
@@ -1,6 +1,6 @@
1
1
  import { ComponentChildren } from 'preact';
2
- import { DropClient } from './drop-core';
3
- import { DeployedSite, TrackFn } from './useDropDeploy';
2
+ import { BuildDetection, DropClient } from './drop-core';
3
+ import { DeployedSite, DeployError, TrackFn } from './useDropDeploy';
4
4
  interface Props {
5
5
  /** Content the drop layer sits on top of. The zone is invisible until you drag over it. */
6
6
  children?: ComponentChildren;
@@ -10,13 +10,35 @@ interface Props {
10
10
  client?: DropClient;
11
11
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
12
12
  claimUrl?: (siteName: string) => string;
13
+ /**
14
+ * Where to send a build-required drop (framework source, build command,
15
+ * functions) when `onBuildRequired` isn't set. Such projects can't deploy
16
+ * through the anonymous static drop API, so by default the user is redirected
17
+ * here — the app signup page with `next=/drop` — to build on the
18
+ * authenticated Drop page. Override to point at your own destination.
19
+ */
20
+ buildSignupUrl?: string;
21
+ /**
22
+ * Called when a build-required drop is detected, instead of the default
23
+ * `buildSignupUrl` redirect. Use it to own the hand-off yourself (custom
24
+ * routing, persisting the drop, gating on auth). When set, the component does
25
+ * not redirect. The static drop path is unaffected either way.
26
+ */
27
+ onBuildRequired?: (info: BuildDetection) => void;
13
28
  /** Called once the deploy is live. */
14
29
  onDeploy?: (result: DeployedSite) => void;
30
+ /**
31
+ * Called when a deploy fails, with a humanized `message` (ready to show a
32
+ * user — the same copy the built-in overlay uses) and the raw `reason`. Pair
33
+ * this with `showStatus={false}` to render your own error UI.
34
+ */
35
+ onError?: (error: DeployError) => void;
15
36
  /**
16
37
  * Render the built-in status overlay (drag tint, spinner, progress bar, error).
17
38
  * Set to `false` to suppress it entirely and keep your children visible, then
18
39
  * drive your own feedback from the `data-state` attribute on the root element.
19
- * Note: in this mode you're responsible for surfacing errors yourself.
40
+ * Note: in this mode you're responsible for surfacing errors yourself — use
41
+ * `onError` to get the message.
20
42
  */
21
43
  showStatus?: boolean;
22
44
  /**
@@ -26,6 +48,7 @@ interface Props {
26
48
  * Emitted events (all include `dropzone_id` when `analyticsId` is set):
27
49
  * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
28
50
  * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
51
+ * - `dropzone_build_required` — drop needs a build; handed off to signup ({ method, reason, file_count })
29
52
  * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
30
53
  * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count? })
31
54
  */
@@ -38,5 +61,5 @@ interface Props {
38
61
  analyticsId?: string;
39
62
  className?: string;
40
63
  }
41
- export default function DropZone({ children, apiBase, client, claimUrl, onDeploy, showStatus, onTrack, analyticsId, className, }: Props): import("preact").JSX.Element;
64
+ export default function DropZone({ children, apiBase, client, claimUrl, buildSignupUrl, onBuildRequired, onDeploy, onError, showStatus, onTrack, analyticsId, className, }: Props): import("preact").JSX.Element;
42
65
  export {};
@@ -1,52 +1,59 @@
1
1
  import { u as e } from "../../../chunks/jsxRuntime.module.Bzuv3cXw.js";
2
- import { A as k, d as S, y as $ } from "../../../chunks/hooks.module.DRcxknIz.js";
3
- import { readFileList as E, readDataTransfer as I } from "./drop-core/readFiles.js";
4
- import { useDropDeploy as P } from "./useDropDeploy.js";
5
- import { Fragment as D } from "preact";
6
- import '../../../assets/DropZone.css';const x = 120, F = (n) => `https://app.netlify.com/drop/${n}`;
7
- function K({
2
+ import { A as D, d as E, y as I } from "../../../chunks/hooks.module.DRcxknIz.js";
3
+ import { readFileList as P, readDataTransfer as U } from "./drop-core/readFiles.js";
4
+ import "./drop-core/detectBuild.js";
5
+ import { useDropDeploy as j } from "./useDropDeploy.js";
6
+ import { Fragment as k } from "preact";
7
+ import '../../../assets/DropZone.css';const B = 120, F = (n) => `https://app.netlify.com/drop/${n}`, M = "https://app.netlify.com/signup?next=/drop&utm_campaign=drop";
8
+ function H({
8
9
  children: n,
9
10
  apiBase: l,
10
11
  client: i,
11
12
  claimUrl: s = F,
12
- onDeploy: u,
13
+ buildSignupUrl: u = M,
14
+ onBuildRequired: w,
15
+ onDeploy: A,
16
+ onError: C,
13
17
  showStatus: f = !0,
14
- onTrack: d,
18
+ onTrack: c,
15
19
  analyticsId: g,
16
- className: w
20
+ className: T
17
21
  }) {
18
- const v = (r, t = {}) => d == null ? void 0 : d(r, g ? { dropzone_id: g, ...t } : t), { status: a, warning: h, busy: o, deploy: b, reset: A } = P({
22
+ const h = (r, t = {}) => c == null ? void 0 : c(r, g ? { dropzone_id: g, ...t } : t), { status: a, warning: m, busy: o, deploy: v, reset: _ } = j({
19
23
  apiBase: l,
20
24
  client: i,
21
25
  claimUrl: s,
22
- onDeploy: u,
23
- track: v
24
- }), { active: p, keepActive: C, endDrag: T } = M(), m = k(null), y = (r) => {
26
+ buildSignupUrl: u,
27
+ onBuildRequired: w,
28
+ onDeploy: A,
29
+ onError: C,
30
+ track: h
31
+ }), { active: p, keepActive: S, endDrag: R } = N(), b = D(null), y = (r) => {
25
32
  var t;
26
- v("dropzone_browse_opened", { method: r }), (t = m.current) == null || t.click();
27
- }, _ = f && (p || o || a.kind === "error"), R = [
33
+ h("dropzone_browse_opened", { method: r }), (t = b.current) == null || t.click();
34
+ }, $ = f && (p || o || a.kind === "error"), x = [
28
35
  "n-dropzone",
29
36
  p && "is-active",
30
37
  o && "is-busy",
31
38
  !f && "is-bare",
32
- w
39
+ T
33
40
  ].filter(Boolean).join(" ");
34
41
  return /* @__PURE__ */ e(
35
42
  "div",
36
43
  {
37
- class: R,
44
+ class: x,
38
45
  "data-state": a.kind,
39
46
  role: "button",
40
47
  tabIndex: o ? -1 : 0,
41
48
  "aria-label": "Drop a site folder or zip here to deploy it, or press Enter to browse for files",
42
49
  "aria-busy": o,
43
50
  onDragOver: (r) => {
44
- o || (r.preventDefault(), C());
51
+ o || (r.preventDefault(), S());
45
52
  },
46
53
  onDrop: (r) => {
47
- if (r.preventDefault(), T(), o) return;
54
+ if (r.preventDefault(), R(), o) return;
48
55
  const t = r.dataTransfer;
49
- t && b(() => I(t), "drag");
56
+ t && v(() => U(t), "drag");
50
57
  },
51
58
  onClick: (r) => {
52
59
  !o && r.target === r.currentTarget && y("click");
@@ -59,7 +66,7 @@ function K({
59
66
  /* @__PURE__ */ e(
60
67
  "input",
61
68
  {
62
- ref: m,
69
+ ref: b,
63
70
  type: "file",
64
71
  hidden: !0,
65
72
  multiple: !0,
@@ -68,17 +75,17 @@ function K({
68
75
  onChange: (r) => {
69
76
  var z;
70
77
  const t = r.currentTarget;
71
- (z = t.files) != null && z.length && b(() => E(t.files), "browse"), t.value = "";
78
+ (z = t.files) != null && z.length && v(() => P(t.files), "browse"), t.value = "";
72
79
  }
73
80
  }
74
81
  ),
75
- _ && /* @__PURE__ */ e(
82
+ $ && /* @__PURE__ */ e(
76
83
  "div",
77
84
  {
78
85
  class: "n-dropzone-overlay n-fadeIn",
79
86
  children: [
80
- /* @__PURE__ */ e(O, { status: a, active: p, onReset: A }),
81
- h && a.kind !== "error" && o && /* @__PURE__ */ e("p", { class: "n-dropzone-warning", role: "status", children: h })
87
+ /* @__PURE__ */ e(O, { status: a, active: p, onReset: _ }),
88
+ m && a.kind !== "error" && o && /* @__PURE__ */ e("p", { class: "n-dropzone-warning", role: "status", children: m })
82
89
  ]
83
90
  },
84
91
  a.kind === "idle" ? "active" : a.kind
@@ -87,10 +94,10 @@ function K({
87
94
  }
88
95
  );
89
96
  }
90
- function M() {
91
- const [n, l] = S(!1), i = k();
92
- return $(() => () => clearTimeout(i.current), []), { active: n, keepActive: () => {
93
- l(!0), clearTimeout(i.current), i.current = setTimeout(() => l(!1), x);
97
+ function N() {
98
+ const [n, l] = E(!1), i = D();
99
+ return I(() => () => clearTimeout(i.current), []), { active: n, keepActive: () => {
100
+ l(!0), clearTimeout(i.current), i.current = setTimeout(() => l(!1), B);
94
101
  }, endDrag: () => {
95
102
  clearTimeout(i.current), l(!1);
96
103
  } };
@@ -105,29 +112,34 @@ function O({
105
112
  case "idle":
106
113
  return l ? /* @__PURE__ */ e("p", { class: "n-dropzone-hint", children: "Drop to deploy" }) : null;
107
114
  case "reading":
108
- return /* @__PURE__ */ e(c, { label: "Reading files…" });
115
+ return /* @__PURE__ */ e(d, { label: "Reading files…" });
109
116
  case "creating":
110
- return /* @__PURE__ */ e(c, { label: "Creating deploy…" });
117
+ return /* @__PURE__ */ e(d, { label: "Creating deploy…" });
111
118
  case "processing":
112
- return /* @__PURE__ */ e(c, { label: "Processing — almost live…" });
119
+ return /* @__PURE__ */ e(d, { label: "Processing — almost live…" });
113
120
  case "redirecting":
114
- return /* @__PURE__ */ e(c, { label: "Site’s live — taking you to claim it…" });
121
+ return /* @__PURE__ */ e(
122
+ d,
123
+ {
124
+ label: n.target === "signup" ? "This project needs a build — taking you to Netlify…" : "Site’s live — taking you to claim it…"
125
+ }
126
+ );
115
127
  case "uploading": {
116
128
  const s = n.total ? Math.round(n.uploaded / n.total * 100) : 0;
117
- return /* @__PURE__ */ e(D, { children: [
118
- /* @__PURE__ */ e(c, { label: `Uploading ${n.uploaded}/${n.total} files…` }),
129
+ return /* @__PURE__ */ e(k, { children: [
130
+ /* @__PURE__ */ e(d, { label: `Uploading ${n.uploaded}/${n.total} files…` }),
119
131
  /* @__PURE__ */ e("div", { class: "n-dropzone-progress", role: "progressbar", "aria-valuenow": s, children: /* @__PURE__ */ e("div", { class: "n-dropzone-progress-bar", style: { width: `${s}%` } }) })
120
132
  ] });
121
133
  }
122
134
  case "error":
123
- return /* @__PURE__ */ e(D, { children: [
135
+ return /* @__PURE__ */ e(k, { children: [
124
136
  /* @__PURE__ */ e("p", { class: "n-dropzone-title n-dropzone-title--error", children: "Something went wrong" }),
125
137
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", role: "alert", children: n.message }),
126
138
  /* @__PURE__ */ e("button", { type: "button", class: "n-dropzone-browse", onClick: i, children: "Try again" })
127
139
  ] });
128
140
  }
129
141
  }
130
- function c({ label: n }) {
142
+ function d({ label: n }) {
131
143
  return /* @__PURE__ */ e("div", { class: "n-dropzone-loading", children: [
132
144
  /* @__PURE__ */ e("span", { class: "n-dropzone-spinner", "aria-hidden": "true" }),
133
145
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", "aria-live": "polite", children: [
@@ -138,5 +150,5 @@ function c({ label: n }) {
138
150
  ] });
139
151
  }
140
152
  export {
141
- K as default
153
+ H as default
142
154
  };
@@ -0,0 +1,23 @@
1
+ import { ProcessedFile } from './types';
2
+ /** Why a drop was judged to need a build — surfaced to analytics and `onBuildRequired`. */
3
+ export type BuildReason = 'netlify-toml-build' | 'package-json-build-script' | 'framework-dependency' | 'functions-directory';
4
+ export interface BuildDetection {
5
+ /** True when the dropped project needs a build step and can't be served as static files. */
6
+ buildRequired: boolean;
7
+ /** The signal that decided it (first match wins), or null for a plain static drop. */
8
+ reason: BuildReason | null;
9
+ }
10
+ /**
11
+ * Decide whether a dropped project needs a build. Runs on the already-read,
12
+ * root-normalized `ProcessedFile[]` (a dropped `.zip` is expanded and the common
13
+ * root folder stripped upstream in `readFiles`), so build config sits at the
14
+ * deploy root, e.g. `/netlify.toml`, `/package.json`.
15
+ *
16
+ * The anonymous drop API only performs static deploys, so a build-required
17
+ * project can't go through it — the consumer redirects such drops to the
18
+ * authenticated Drop page instead. This heuristic is deliberately lightweight
19
+ * (no framework-detection dependency): it reads the two config files and looks
20
+ * for the zero-config functions directories. A plain static site (HTML/CSS/JS,
21
+ * no build config) returns `{ buildRequired: false }`.
22
+ */
23
+ export declare function detectBuild(files: ProcessedFile[]): BuildDetection;
@@ -0,0 +1,68 @@
1
+ const u = /* @__PURE__ */ new Set([
2
+ "astro",
3
+ "next",
4
+ "nuxt",
5
+ "gatsby",
6
+ "vite",
7
+ "react-scripts",
8
+ "parcel",
9
+ "expo",
10
+ "gridsome",
11
+ "vuepress",
12
+ "hexo",
13
+ "@remix-run/dev",
14
+ "@sveltejs/kit",
15
+ "@angular/cli",
16
+ "@vue/cli-service",
17
+ "@11ty/eleventy",
18
+ "@docusaurus/core",
19
+ "@builder.io/qwik",
20
+ "@analogjs/platform",
21
+ "@solidjs/start"
22
+ ]), c = /^\s*\[build(\.[\w-]+)?\]/m, d = ["/netlify/functions/", "/netlify/edge-functions/"], l = new TextDecoder();
23
+ function i(e, n) {
24
+ return e.find((t) => t.path === n);
25
+ }
26
+ function o(e) {
27
+ return l.decode(e.content);
28
+ }
29
+ function a(e) {
30
+ const n = e.scripts;
31
+ if (!n || typeof n != "object") return !1;
32
+ const t = n.build;
33
+ return typeof t == "string" && t.trim().length > 0;
34
+ }
35
+ function f(e) {
36
+ for (const n of ["dependencies", "devDependencies"]) {
37
+ const t = e[n];
38
+ if (!(!t || typeof t != "object")) {
39
+ for (const r of Object.keys(t))
40
+ if (u.has(r)) return !0;
41
+ }
42
+ }
43
+ return !1;
44
+ }
45
+ function p(e) {
46
+ const n = i(e, "/netlify.toml");
47
+ if (n && c.test(o(n)))
48
+ return { buildRequired: !0, reason: "netlify-toml-build" };
49
+ const t = i(e, "/package.json");
50
+ if (t) {
51
+ let r = null;
52
+ try {
53
+ r = JSON.parse(o(t));
54
+ } catch {
55
+ r = null;
56
+ }
57
+ if (r) {
58
+ if (a(r))
59
+ return { buildRequired: !0, reason: "package-json-build-script" };
60
+ if (f(r))
61
+ return { buildRequired: !0, reason: "framework-dependency" };
62
+ }
63
+ }
64
+ return e.some((r) => d.some((s) => r.path.startsWith(s))) ? { buildRequired: !0, reason: "functions-directory" } : { buildRequired: !1, reason: null };
65
+ }
66
+ export {
67
+ p as detectBuild
68
+ };
@@ -1,6 +1,7 @@
1
1
  export * from './types';
2
2
  export * from './digest';
3
3
  export * from './readFiles';
4
+ export * from './detectBuild';
4
5
  export * from './client';
5
6
  export * from './deploy';
6
7
  export * from './errors';
@@ -1,16 +1,18 @@
1
1
  import { getDigest as o } from "./digest.js";
2
2
  import { getSingleNonIndexHtmlFileName as n, hasMultipleHtmlFilesWithoutIndex as l, readDataTransfer as i, readFileList as m, renameSingleNonIndexHtmlToIndex as p } from "./readFiles.js";
3
- import { AnonymousDropClient as x } from "./client.js";
4
- import { deployFiles as s } from "./deploy.js";
5
- import { UnsupportedDropError as g, humanizeDropError as u } from "./errors.js";
3
+ import { detectBuild as x } from "./detectBuild.js";
4
+ import { AnonymousDropClient as s } from "./client.js";
5
+ import { deployFiles as u } from "./deploy.js";
6
+ import { UnsupportedDropError as D, humanizeDropError as F } from "./errors.js";
6
7
  export {
7
- x as AnonymousDropClient,
8
- g as UnsupportedDropError,
9
- s as deployFiles,
8
+ s as AnonymousDropClient,
9
+ D as UnsupportedDropError,
10
+ u as deployFiles,
11
+ x as detectBuild,
10
12
  o as getDigest,
11
13
  n as getSingleNonIndexHtmlFileName,
12
14
  l as hasMultipleHtmlFilesWithoutIndex,
13
- u as humanizeDropError,
15
+ F as humanizeDropError,
14
16
  i as readDataTransfer,
15
17
  m as readFileList,
16
18
  p as renameSingleNonIndexHtmlToIndex
@@ -1,3 +1,3 @@
1
1
  export { default as DropZone } from './DropZone';
2
- export { AnonymousDropClient, deployFiles, readDataTransfer, readFileList, } from './drop-core';
3
- export type { DropClient, DropResponse, Digest, DeployResult, DeployProgress, DeployPhase, ProcessedFile, } from './drop-core';
2
+ export { AnonymousDropClient, deployFiles, detectBuild, readDataTransfer, readFileList, } from './drop-core';
3
+ export type { BuildDetection, BuildReason, DropClient, DropResponse, Digest, DeployResult, DeployProgress, DeployPhase, ProcessedFile, } from './drop-core';
@@ -1,11 +1,13 @@
1
- import { default as e } from "./DropZone.js";
2
- import { readDataTransfer as a, readFileList as p } from "./drop-core/readFiles.js";
3
- import { AnonymousDropClient as l } from "./drop-core/client.js";
1
+ import { default as r } from "./DropZone.js";
2
+ import { readDataTransfer as p, readFileList as a } from "./drop-core/readFiles.js";
3
+ import { detectBuild as d } from "./drop-core/detectBuild.js";
4
+ import { AnonymousDropClient as m } from "./drop-core/client.js";
4
5
  import { deployFiles as n } from "./drop-core/deploy.js";
5
6
  export {
6
- l as AnonymousDropClient,
7
- e as DropZone,
7
+ m as AnonymousDropClient,
8
+ r as DropZone,
8
9
  n as deployFiles,
9
- a as readDataTransfer,
10
- p as readFileList
10
+ d as detectBuild,
11
+ p as readDataTransfer,
12
+ a as readFileList
11
13
  };
@@ -1,4 +1,4 @@
1
- import { DropClient, ProcessedFile } from './drop-core';
1
+ import { BuildDetection, DropClient, ProcessedFile } from './drop-core';
2
2
  /** How the files reached the zone — carried through to analytics. */
3
3
  export type DeploySource = 'drag' | 'browse';
4
4
  /** The live site handed back once a deploy succeeds. */
@@ -8,6 +8,13 @@ export interface DeployedSite {
8
8
  siteName: string;
9
9
  siteId: string;
10
10
  }
11
+ /** A deploy failure, handed to `onError` so consumers can render their own error UI. */
12
+ export interface DeployError {
13
+ /** Human-readable text ready to show a user — the same copy the built-in overlay displays. */
14
+ message: string;
15
+ /** The raw underlying reason (e.g. "drop failed: 429"), useful for logging. */
16
+ reason: string;
17
+ }
11
18
  /** Finite states of a single deploy attempt. */
12
19
  export type DeployStatus = {
13
20
  kind: 'idle';
@@ -23,6 +30,7 @@ export type DeployStatus = {
23
30
  kind: 'processing';
24
31
  } | {
25
32
  kind: 'redirecting';
33
+ target: 'claim' | 'signup';
26
34
  } | {
27
35
  kind: 'error';
28
36
  message: string;
@@ -33,14 +41,17 @@ interface UseDropDeployOptions {
33
41
  apiBase?: string;
34
42
  client?: DropClient;
35
43
  claimUrl: (siteName: string) => string;
44
+ buildSignupUrl: string;
45
+ onBuildRequired?: (info: BuildDetection) => void;
36
46
  onDeploy?: (site: DeployedSite) => void;
47
+ onError?: (error: DeployError) => void;
37
48
  track: TrackFn;
38
49
  }
39
50
  /**
40
51
  * Owns the deploy state machine and its side effects (upload, local persistence,
41
52
  * analytics, redirect), keeping DropZone a thin view over the returned `status`.
42
53
  */
43
- export declare function useDropDeploy({ apiBase, client, claimUrl, onDeploy, track, }: UseDropDeployOptions): {
54
+ export declare function useDropDeploy({ apiBase, client, claimUrl, buildSignupUrl, onBuildRequired, onDeploy, onError, track, }: UseDropDeployOptions): {
44
55
  status: DeployStatus;
45
56
  warning: string | null;
46
57
  busy: boolean;
@@ -1,71 +1,89 @@
1
- import { d as p, A as I } from "../../../chunks/hooks.module.DRcxknIz.js";
2
- import { renameSingleNonIndexHtmlToIndex as S, getSingleNonIndexHtmlFileName as b, hasMultipleHtmlFilesWithoutIndex as z } from "./drop-core/readFiles.js";
3
- import { AnonymousDropClient as F } from "./drop-core/client.js";
4
- import { deployFiles as H } from "./drop-core/deploy.js";
5
- import { humanizeDropError as E } from "./drop-core/errors.js";
6
- function W(e) {
7
- switch (e.phase) {
1
+ import { d as h, A as F } from "../../../chunks/hooks.module.DRcxknIz.js";
2
+ import { renameSingleNonIndexHtmlToIndex as H, getSingleNonIndexHtmlFileName as W, hasMultipleHtmlFilesWithoutIndex as $ } from "./drop-core/readFiles.js";
3
+ import { detectBuild as v } from "./drop-core/detectBuild.js";
4
+ import { AnonymousDropClient as A } from "./drop-core/client.js";
5
+ import { deployFiles as C } from "./drop-core/deploy.js";
6
+ import { humanizeDropError as T } from "./drop-core/errors.js";
7
+ function q(n) {
8
+ switch (n.phase) {
8
9
  case "creating":
9
10
  return { kind: "creating" };
10
11
  case "uploading":
11
- return { kind: "uploading", uploaded: e.uploaded ?? 0, total: e.total ?? 0 };
12
+ return { kind: "uploading", uploaded: n.uploaded ?? 0, total: n.total ?? 0 };
12
13
  case "processing":
13
14
  case "ready":
14
15
  return { kind: "processing" };
15
16
  }
16
17
  }
17
- function $(e) {
18
- const d = b(e);
19
- return d ? `No index.html found — "${d}" won’t be served at the site root.` : z(e) ? "No index.html found — your site may not have a landing page." : null;
18
+ function M(n) {
19
+ const l = W(n);
20
+ return l ? `No index.html found — "${l}" won’t be served at the site root.` : $(n) ? "No index.html found — your site may not have a landing page." : null;
20
21
  }
21
- function P({
22
- apiBase: e,
23
- client: d,
24
- claimUrl: f,
25
- onDeploy: l,
26
- track: a
22
+ function K({
23
+ apiBase: n,
24
+ client: l,
25
+ claimUrl: _,
26
+ buildSignupUrl: k,
27
+ onBuildRequired: g,
28
+ onDeploy: a,
29
+ onError: u,
30
+ track: s
27
31
  }) {
28
- const [u, t] = p({ kind: "idle" }), [g, c] = p(null), h = I(d ?? new F(e)).current, y = u.kind !== "idle" && u.kind !== "error";
29
- async function k(N, s) {
30
- c(null), t({ kind: "reading" });
31
- const m = (n, i, o) => {
32
- t({ kind: "error", message: n }), a("dropzone_deploy_failed", { method: s, reason: i, ...o });
33
- }, x = (n, i) => {
34
- const o = n instanceof Error ? n.message : String(n);
35
- m(E(n), o, i);
32
+ const [c, i] = h({ kind: "idle" }), [y, m] = h(null), w = F(l ?? new A(n)).current, N = c.kind !== "idle" && c.kind !== "error";
33
+ async function b(I, o) {
34
+ m(null), i({ kind: "reading" });
35
+ const f = (e, t, d) => {
36
+ i({ kind: "error", message: e }), s("dropzone_deploy_failed", { method: o, reason: t, ...d }), u == null || u({ message: e, reason: t });
37
+ }, S = (e, t) => {
38
+ const d = e instanceof Error ? e.message : String(e);
39
+ f(T(e), d, t);
36
40
  };
37
41
  try {
38
- const n = S(await N());
39
- if (!n.length) {
40
- m("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
42
+ const e = H(await I());
43
+ if (!e.length) {
44
+ f("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
41
45
  return;
42
46
  }
43
- s === "drag" && a("dropzone_files_dropped", { method: s, file_count: n.length }), c($(n));
47
+ o === "drag" && s("dropzone_files_dropped", { method: o, file_count: e.length });
48
+ const t = v(e);
49
+ if (t.buildRequired) {
50
+ if (s("dropzone_build_required", {
51
+ method: o,
52
+ reason: t.reason,
53
+ file_count: e.length
54
+ }), g) {
55
+ i({ kind: "idle" }), g(t);
56
+ return;
57
+ }
58
+ i({ kind: "redirecting", target: "signup" }), window.location.assign(k);
59
+ return;
60
+ }
61
+ m(M(e));
44
62
  const {
45
- url: i,
46
- token: o,
63
+ url: d,
64
+ token: p,
47
65
  deploy: r
48
- } = await H(h, n, {
49
- onProgress: (w) => t(W(w))
66
+ } = await C(w, e, {
67
+ onProgress: (z) => i(q(z))
50
68
  });
51
69
  try {
52
- localStorage.setItem("drop-token", o), localStorage.setItem("dropSiteName", r.subdomain);
70
+ localStorage.setItem("drop-token", p), localStorage.setItem("dropSiteName", r.subdomain);
53
71
  } catch {
54
72
  }
55
- t({ kind: "redirecting" }), a("dropzone_deploy_succeeded", {
56
- method: s,
73
+ i({ kind: "redirecting", target: "claim" }), s("dropzone_deploy_succeeded", {
74
+ method: o,
57
75
  site_name: r.subdomain,
58
- file_count: n.length
59
- }), l == null || l({ url: i, token: o, siteName: r.subdomain, siteId: r.id }), window.location.assign(`${f(r.subdomain)}#drop_token=${o}`);
60
- } catch (n) {
61
- x(n);
76
+ file_count: e.length
77
+ }), a == null || a({ url: d, token: p, siteName: r.subdomain, siteId: r.id }), window.location.assign(`${_(r.subdomain)}#drop_token=${p}`);
78
+ } catch (e) {
79
+ S(e);
62
80
  }
63
81
  }
64
- function _() {
65
- c(null), t({ kind: "idle" });
82
+ function x() {
83
+ m(null), i({ kind: "idle" });
66
84
  }
67
- return { status: u, warning: g, busy: y, deploy: k, reset: _ };
85
+ return { status: c, warning: y, busy: N, deploy: b, reset: x };
68
86
  }
69
87
  export {
70
- P as useDropDeploy
88
+ K as useDropDeploy
71
89
  };
@@ -1,23 +1,24 @@
1
- import { default as f } from "./KeyboardKey/KeyboardKey.js";
1
+ import { default as p } from "./KeyboardKey/KeyboardKey.js";
2
2
  import { default as d } from "./Modal/Modal.js";
3
- import { default as m } from "./Icon/index.js";
4
- import { default as x } from "./SearchModal/components/SearchLink/SearchLink.js";
5
- import { default as i } from "./SearchModal/components/SearchHeading/SearchHeading.js";
6
- import { default as h } from "./SearchModal/components/SearchToggle/SearchToggle.js";
7
- import { A as S } from "../../chunks/AskNetlify.bx0blyGV.js";
3
+ import { default as s } from "./Icon/index.js";
4
+ import { default as u } from "./SearchModal/components/SearchLink/SearchLink.js";
5
+ import { default as c } from "./SearchModal/components/SearchHeading/SearchHeading.js";
6
+ import { default as n } from "./SearchModal/components/SearchToggle/SearchToggle.js";
7
+ import { A as g } from "../../chunks/AskNetlify.bx0blyGV.js";
8
8
  import "preact";
9
9
  import "../../chunks/hooks.module.DRcxknIz.js";
10
- import { default as y } from "./SearchModal/components/SearchModal/SearchModal.js";
11
- import { default as A } from "./DropZone/DropZone.js";
10
+ import { default as k } from "./SearchModal/components/SearchModal/SearchModal.js";
11
+ import { default as K } from "./DropZone/DropZone.js";
12
12
  import "./DropZone/drop-core/readFiles.js";
13
+ import "./DropZone/drop-core/detectBuild.js";
13
14
  export {
14
- S as AskNetlify,
15
- A as DropZone,
16
- m as Icon,
17
- f as KeyboardKey,
15
+ g as AskNetlify,
16
+ K as DropZone,
17
+ s as Icon,
18
+ p as KeyboardKey,
18
19
  d as Modal,
19
- i as SearchHeading,
20
- x as SearchLink,
21
- y as SearchModal,
22
- h as SearchToggle
20
+ c as SearchHeading,
21
+ u as SearchLink,
22
+ k as SearchModal,
23
+ n as SearchToggle
23
24
  };
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.27.6",
4
+ "version": "1.28.0",
5
5
  "type": "module",
6
6
  "main": "dist/components/preact/index.js",
7
7
  "types": "dist/components/preact/index.d.ts",
@@ -1,7 +1,13 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import type { ComponentChildren } from 'preact';
3
- import { readDataTransfer, readFileList, type DropClient } from './drop-core';
4
- import { useDropDeploy, type DeployedSite, type DeployStatus, type TrackFn } from './useDropDeploy';
3
+ import { readDataTransfer, readFileList, type BuildDetection, type DropClient } from './drop-core';
4
+ import {
5
+ useDropDeploy,
6
+ type DeployedSite,
7
+ type DeployError,
8
+ type DeployStatus,
9
+ type TrackFn,
10
+ } from './useDropDeploy';
5
11
 
6
12
  import './DropZone.css';
7
13
 
@@ -10,6 +16,13 @@ const DRAG_INACTIVE_MS = 120;
10
16
 
11
17
  const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
12
18
 
19
+ /**
20
+ * Where a build-required drop is sent by default. Mirrors the app's own Drop
21
+ * page: signup with `next=/drop`, so after signup + email verification the user
22
+ * lands back on the authenticated Drop page and can build there.
23
+ */
24
+ const defaultBuildSignupUrl = 'https://app.netlify.com/signup?next=/drop&utm_campaign=drop';
25
+
13
26
  interface Props {
14
27
  /** Content the drop layer sits on top of. The zone is invisible until you drag over it. */
15
28
  children?: ComponentChildren;
@@ -19,13 +32,35 @@ interface Props {
19
32
  client?: DropClient;
20
33
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
21
34
  claimUrl?: (siteName: string) => string;
35
+ /**
36
+ * Where to send a build-required drop (framework source, build command,
37
+ * functions) when `onBuildRequired` isn't set. Such projects can't deploy
38
+ * through the anonymous static drop API, so by default the user is redirected
39
+ * here — the app signup page with `next=/drop` — to build on the
40
+ * authenticated Drop page. Override to point at your own destination.
41
+ */
42
+ buildSignupUrl?: string;
43
+ /**
44
+ * Called when a build-required drop is detected, instead of the default
45
+ * `buildSignupUrl` redirect. Use it to own the hand-off yourself (custom
46
+ * routing, persisting the drop, gating on auth). When set, the component does
47
+ * not redirect. The static drop path is unaffected either way.
48
+ */
49
+ onBuildRequired?: (info: BuildDetection) => void;
22
50
  /** Called once the deploy is live. */
23
51
  onDeploy?: (result: DeployedSite) => void;
52
+ /**
53
+ * Called when a deploy fails, with a humanized `message` (ready to show a
54
+ * user — the same copy the built-in overlay uses) and the raw `reason`. Pair
55
+ * this with `showStatus={false}` to render your own error UI.
56
+ */
57
+ onError?: (error: DeployError) => void;
24
58
  /**
25
59
  * Render the built-in status overlay (drag tint, spinner, progress bar, error).
26
60
  * Set to `false` to suppress it entirely and keep your children visible, then
27
61
  * drive your own feedback from the `data-state` attribute on the root element.
28
- * Note: in this mode you're responsible for surfacing errors yourself.
62
+ * Note: in this mode you're responsible for surfacing errors yourself — use
63
+ * `onError` to get the message.
29
64
  */
30
65
  showStatus?: boolean;
31
66
  /**
@@ -35,6 +70,7 @@ interface Props {
35
70
  * Emitted events (all include `dropzone_id` when `analyticsId` is set):
36
71
  * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
37
72
  * - `dropzone_browse_opened` — file picker opened ({ method: 'keyboard' | 'click' })
73
+ * - `dropzone_build_required` — drop needs a build; handed off to signup ({ method, reason, file_count })
38
74
  * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
39
75
  * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count? })
40
76
  */
@@ -53,7 +89,10 @@ export default function DropZone({
53
89
  apiBase,
54
90
  client,
55
91
  claimUrl = defaultClaimUrl,
92
+ buildSignupUrl = defaultBuildSignupUrl,
93
+ onBuildRequired,
56
94
  onDeploy,
95
+ onError,
57
96
  showStatus = true,
58
97
  onTrack,
59
98
  analyticsId,
@@ -68,7 +107,10 @@ export default function DropZone({
68
107
  apiBase,
69
108
  client,
70
109
  claimUrl,
110
+ buildSignupUrl,
111
+ onBuildRequired,
71
112
  onDeploy,
113
+ onError,
72
114
  track,
73
115
  });
74
116
 
@@ -212,7 +254,15 @@ function StatusView({
212
254
  case 'processing':
213
255
  return <Spinner label="Processing — almost live…" />;
214
256
  case 'redirecting':
215
- return <Spinner label="Site’s live — taking you to claim it…" />;
257
+ return (
258
+ <Spinner
259
+ label={
260
+ status.target === 'signup'
261
+ ? 'This project needs a build — taking you to Netlify…'
262
+ : 'Site’s live — taking you to claim it…'
263
+ }
264
+ />
265
+ );
216
266
 
217
267
  case 'uploading': {
218
268
  const pct = status.total ? Math.round((status.uploaded / status.total) * 100) : 0;
@@ -0,0 +1,130 @@
1
+ import type { ProcessedFile } from './types';
2
+
3
+ /** Why a drop was judged to need a build — surfaced to analytics and `onBuildRequired`. */
4
+ export type BuildReason =
5
+ | 'netlify-toml-build'
6
+ | 'package-json-build-script'
7
+ | 'framework-dependency'
8
+ | 'functions-directory';
9
+
10
+ export interface BuildDetection {
11
+ /** True when the dropped project needs a build step and can't be served as static files. */
12
+ buildRequired: boolean;
13
+ /** The signal that decided it (first match wins), or null for a plain static drop. */
14
+ reason: BuildReason | null;
15
+ }
16
+
17
+ /**
18
+ * Dependencies whose presence implies a build even without an explicit `build`
19
+ * script (e.g. a framework wired through its own CLI). Not exhaustive — the
20
+ * `build` script check below catches the common case; this is the backstop.
21
+ */
22
+ const FRAMEWORK_DEPENDENCIES = new Set([
23
+ 'astro',
24
+ 'next',
25
+ 'nuxt',
26
+ 'gatsby',
27
+ 'vite',
28
+ 'react-scripts',
29
+ 'parcel',
30
+ 'expo',
31
+ 'gridsome',
32
+ 'vuepress',
33
+ 'hexo',
34
+ '@remix-run/dev',
35
+ '@sveltejs/kit',
36
+ '@angular/cli',
37
+ '@vue/cli-service',
38
+ '@11ty/eleventy',
39
+ '@docusaurus/core',
40
+ '@builder.io/qwik',
41
+ '@analogjs/platform',
42
+ '@solidjs/start',
43
+ ]);
44
+
45
+ /**
46
+ * A `netlify.toml` build config: a bare `[build]` table or any of its
47
+ * sub-tables (`[build.environment]`, `[build.processing]`, …). Anchored to the
48
+ * line start so a `[build]` mention inside a string/comment doesn't count.
49
+ */
50
+ const NETLIFY_TOML_BUILD_SECTION = /^\s*\[build(\.[\w-]+)?\]/m;
51
+
52
+ /** Zero-config functions directories — their presence alone requires bundling. */
53
+ const FUNCTIONS_DIR_PREFIXES = ['/netlify/functions/', '/netlify/edge-functions/'];
54
+
55
+ const decoder = new TextDecoder();
56
+
57
+ /** Find a file at the deploy root by its normalized path (leading slash). */
58
+ function rootFile(files: ProcessedFile[], path: string): ProcessedFile | undefined {
59
+ return files.find(f => f.path === path);
60
+ }
61
+
62
+ function text(file: ProcessedFile): string {
63
+ return decoder.decode(file.content);
64
+ }
65
+
66
+ /** True if `package.json` declares a non-empty `build` script. */
67
+ function hasBuildScript(pkg: Record<string, unknown>): boolean {
68
+ const scripts = pkg.scripts;
69
+ if (!scripts || typeof scripts !== 'object') return false;
70
+ const build = (scripts as Record<string, unknown>).build;
71
+ return typeof build === 'string' && build.trim().length > 0;
72
+ }
73
+
74
+ /** True if any dependency (prod or dev) is a known build-requiring framework. */
75
+ function hasFrameworkDependency(pkg: Record<string, unknown>): boolean {
76
+ for (const key of ['dependencies', 'devDependencies'] as const) {
77
+ const deps = pkg[key];
78
+ if (!deps || typeof deps !== 'object') continue;
79
+ for (const name of Object.keys(deps)) {
80
+ if (FRAMEWORK_DEPENDENCIES.has(name)) return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Decide whether a dropped project needs a build. Runs on the already-read,
88
+ * root-normalized `ProcessedFile[]` (a dropped `.zip` is expanded and the common
89
+ * root folder stripped upstream in `readFiles`), so build config sits at the
90
+ * deploy root, e.g. `/netlify.toml`, `/package.json`.
91
+ *
92
+ * The anonymous drop API only performs static deploys, so a build-required
93
+ * project can't go through it — the consumer redirects such drops to the
94
+ * authenticated Drop page instead. This heuristic is deliberately lightweight
95
+ * (no framework-detection dependency): it reads the two config files and looks
96
+ * for the zero-config functions directories. A plain static site (HTML/CSS/JS,
97
+ * no build config) returns `{ buildRequired: false }`.
98
+ */
99
+ export function detectBuild(files: ProcessedFile[]): BuildDetection {
100
+ const toml = rootFile(files, '/netlify.toml');
101
+ if (toml && NETLIFY_TOML_BUILD_SECTION.test(text(toml))) {
102
+ return { buildRequired: true, reason: 'netlify-toml-build' };
103
+ }
104
+
105
+ const pkg = rootFile(files, '/package.json');
106
+ if (pkg) {
107
+ let parsed: Record<string, unknown> | null = null;
108
+ try {
109
+ parsed = JSON.parse(text(pkg)) as Record<string, unknown>;
110
+ } catch {
111
+ // A malformed package.json isn't a reliable build signal on its own —
112
+ // fall through and let the other heuristics decide.
113
+ parsed = null;
114
+ }
115
+ if (parsed) {
116
+ if (hasBuildScript(parsed)) {
117
+ return { buildRequired: true, reason: 'package-json-build-script' };
118
+ }
119
+ if (hasFrameworkDependency(parsed)) {
120
+ return { buildRequired: true, reason: 'framework-dependency' };
121
+ }
122
+ }
123
+ }
124
+
125
+ if (files.some(f => FUNCTIONS_DIR_PREFIXES.some(prefix => f.path.startsWith(prefix)))) {
126
+ return { buildRequired: true, reason: 'functions-directory' };
127
+ }
128
+
129
+ return { buildRequired: false, reason: null };
130
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './types';
2
2
  export * from './digest';
3
3
  export * from './readFiles';
4
+ export * from './detectBuild';
4
5
  export * from './client';
5
6
  export * from './deploy';
6
7
  export * from './errors';
@@ -2,10 +2,13 @@ export { default as DropZone } from './DropZone';
2
2
  export {
3
3
  AnonymousDropClient,
4
4
  deployFiles,
5
+ detectBuild,
5
6
  readDataTransfer,
6
7
  readFileList,
7
8
  } from './drop-core';
8
9
  export type {
10
+ BuildDetection,
11
+ BuildReason,
9
12
  DropClient,
10
13
  DropResponse,
11
14
  Digest,
@@ -2,10 +2,12 @@ import { useRef, useState } from 'preact/hooks';
2
2
  import {
3
3
  AnonymousDropClient,
4
4
  deployFiles,
5
+ detectBuild,
5
6
  getSingleNonIndexHtmlFileName,
6
7
  hasMultipleHtmlFilesWithoutIndex,
7
8
  humanizeDropError,
8
9
  renameSingleNonIndexHtmlToIndex,
10
+ type BuildDetection,
9
11
  type DeployProgress,
10
12
  type DropClient,
11
13
  type ProcessedFile,
@@ -22,6 +24,14 @@ export interface DeployedSite {
22
24
  siteId: string;
23
25
  }
24
26
 
27
+ /** A deploy failure, handed to `onError` so consumers can render their own error UI. */
28
+ export interface DeployError {
29
+ /** Human-readable text ready to show a user — the same copy the built-in overlay displays. */
30
+ message: string;
31
+ /** The raw underlying reason (e.g. "drop failed: 429"), useful for logging. */
32
+ reason: string;
33
+ }
34
+
25
35
  /** Finite states of a single deploy attempt. */
26
36
  export type DeployStatus =
27
37
  | { kind: 'idle' }
@@ -29,7 +39,9 @@ export type DeployStatus =
29
39
  | { kind: 'creating' }
30
40
  | { kind: 'uploading'; uploaded: number; total: number }
31
41
  | { kind: 'processing' }
32
- | { kind: 'redirecting' }
42
+ // `claim` follows a static deploy (hand off to the claim page); `signup`
43
+ // follows a build-required drop (hand off to the authenticated Drop page).
44
+ | { kind: 'redirecting'; target: 'claim' | 'signup' }
33
45
  | { kind: 'error'; message: string };
34
46
 
35
47
  /** Provider-agnostic analytics sink. */
@@ -39,7 +51,10 @@ interface UseDropDeployOptions {
39
51
  apiBase?: string;
40
52
  client?: DropClient;
41
53
  claimUrl: (siteName: string) => string;
54
+ buildSignupUrl: string;
55
+ onBuildRequired?: (info: BuildDetection) => void;
42
56
  onDeploy?: (site: DeployedSite) => void;
57
+ onError?: (error: DeployError) => void;
43
58
  track: TrackFn;
44
59
  }
45
60
 
@@ -76,7 +91,10 @@ export function useDropDeploy({
76
91
  apiBase,
77
92
  client,
78
93
  claimUrl,
94
+ buildSignupUrl,
95
+ onBuildRequired,
79
96
  onDeploy,
97
+ onError,
80
98
  track,
81
99
  }: UseDropDeployOptions) {
82
100
  const [status, setStatus] = useState<DeployStatus>({ kind: 'idle' });
@@ -96,9 +114,12 @@ export function useDropDeploy({
96
114
  // included where the file set is known (it isn't if `read()` itself threw).
97
115
  // `reason` goes to analytics as-is (raw, e.g. "drop failed: 429"); `message`
98
116
  // is what the user sees, which for a caught error is humanized separately.
117
+ // `onError` gets both so a consumer (e.g. with `showStatus={false}`) can show
118
+ // the humanized message while keeping the raw reason for their own logging.
99
119
  const fail = (message: string, reason: string, extra?: Record<string, unknown>) => {
100
120
  setStatus({ kind: 'error', message });
101
121
  track('dropzone_deploy_failed', { method: source, reason, ...extra });
122
+ onError?.({ message, reason });
102
123
  };
103
124
 
104
125
  const failFromError = (err: unknown, extra?: Record<string, unknown>) => {
@@ -123,6 +144,30 @@ export function useDropDeploy({
123
144
  track('dropzone_files_dropped', { method: source, file_count: files.length });
124
145
  }
125
146
 
147
+ // Build-required projects can't deploy through the anonymous drop API —
148
+ // it only performs static deploys, so building one this way yields a
149
+ // broken site. Detect them up front and hand off to the authenticated
150
+ // Drop page (via signup) instead. Consumers can override the hand-off
151
+ // with `onBuildRequired`.
152
+ const build = detectBuild(files);
153
+ if (build.buildRequired) {
154
+ track('dropzone_build_required', {
155
+ method: source,
156
+ reason: build.reason,
157
+ file_count: files.length,
158
+ });
159
+ if (onBuildRequired) {
160
+ // The consumer owns the hand-off now; drop back to idle so the zone
161
+ // stays usable rather than sitting on a spinner.
162
+ setStatus({ kind: 'idle' });
163
+ onBuildRequired(build);
164
+ return;
165
+ }
166
+ setStatus({ kind: 'redirecting', target: 'signup' });
167
+ window.location.assign(buildSignupUrl);
168
+ return;
169
+ }
170
+
126
171
  setWarning(indexHtmlWarning(files));
127
172
 
128
173
  const {
@@ -144,7 +189,7 @@ export function useDropDeploy({
144
189
  /* private mode / storage disabled — non-fatal */
145
190
  }
146
191
 
147
- setStatus({ kind: 'redirecting' });
192
+ setStatus({ kind: 'redirecting', target: 'claim' });
148
193
  // Fired before the redirect below. Note: the navigation can cut a
149
194
  // fire-and-forget analytics request short — see the docs for delivery
150
195
  // hardening (sendBeacon / deferring the redirect).