@netlify/spark-ui 1.27.7 → 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,5 +1,5 @@
1
1
  import { ComponentChildren } from 'preact';
2
- import { DropClient } from './drop-core';
2
+ import { BuildDetection, DropClient } from './drop-core';
3
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. */
@@ -10,6 +10,21 @@ 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;
15
30
  /**
@@ -33,6 +48,7 @@ interface Props {
33
48
  * Emitted events (all include `dropzone_id` when `analyticsId` is set):
34
49
  * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
35
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 })
36
52
  * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
37
53
  * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count? })
38
54
  */
@@ -45,5 +61,5 @@ interface Props {
45
61
  analyticsId?: string;
46
62
  className?: string;
47
63
  }
48
- export default function DropZone({ children, apiBase, client, claimUrl, onDeploy, onError, 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;
49
65
  export {};
@@ -1,54 +1,59 @@
1
1
  import { u as e } from "../../../chunks/jsxRuntime.module.Bzuv3cXw.js";
2
- import { A as k, d as $, y as E } from "../../../chunks/hooks.module.DRcxknIz.js";
3
- import { readFileList as I, readDataTransfer as P } from "./drop-core/readFiles.js";
4
- import { useDropDeploy as x } from "./useDropDeploy.js";
5
- import { Fragment as D } from "preact";
6
- import '../../../assets/DropZone.css';const F = 120, M = (n) => `https://app.netlify.com/drop/${n}`;
7
- function L({
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
- claimUrl: s = M,
12
- onDeploy: u,
13
- onError: w,
12
+ claimUrl: s = F,
13
+ buildSignupUrl: u = M,
14
+ onBuildRequired: w,
15
+ onDeploy: A,
16
+ onError: C,
14
17
  showStatus: f = !0,
15
- onTrack: d,
18
+ onTrack: c,
16
19
  analyticsId: g,
17
- className: A
20
+ className: T
18
21
  }) {
19
- 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: C } = x({
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({
20
23
  apiBase: l,
21
24
  client: i,
22
25
  claimUrl: s,
23
- onDeploy: u,
24
- onError: w,
25
- track: v
26
- }), { active: p, keepActive: T, endDrag: _ } = O(), 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) => {
27
32
  var t;
28
- v("dropzone_browse_opened", { method: r }), (t = m.current) == null || t.click();
29
- }, R = f && (p || o || a.kind === "error"), S = [
33
+ h("dropzone_browse_opened", { method: r }), (t = b.current) == null || t.click();
34
+ }, $ = f && (p || o || a.kind === "error"), x = [
30
35
  "n-dropzone",
31
36
  p && "is-active",
32
37
  o && "is-busy",
33
38
  !f && "is-bare",
34
- A
39
+ T
35
40
  ].filter(Boolean).join(" ");
36
41
  return /* @__PURE__ */ e(
37
42
  "div",
38
43
  {
39
- class: S,
44
+ class: x,
40
45
  "data-state": a.kind,
41
46
  role: "button",
42
47
  tabIndex: o ? -1 : 0,
43
48
  "aria-label": "Drop a site folder or zip here to deploy it, or press Enter to browse for files",
44
49
  "aria-busy": o,
45
50
  onDragOver: (r) => {
46
- o || (r.preventDefault(), T());
51
+ o || (r.preventDefault(), S());
47
52
  },
48
53
  onDrop: (r) => {
49
- if (r.preventDefault(), _(), o) return;
54
+ if (r.preventDefault(), R(), o) return;
50
55
  const t = r.dataTransfer;
51
- t && b(() => P(t), "drag");
56
+ t && v(() => U(t), "drag");
52
57
  },
53
58
  onClick: (r) => {
54
59
  !o && r.target === r.currentTarget && y("click");
@@ -61,7 +66,7 @@ function L({
61
66
  /* @__PURE__ */ e(
62
67
  "input",
63
68
  {
64
- ref: m,
69
+ ref: b,
65
70
  type: "file",
66
71
  hidden: !0,
67
72
  multiple: !0,
@@ -70,17 +75,17 @@ function L({
70
75
  onChange: (r) => {
71
76
  var z;
72
77
  const t = r.currentTarget;
73
- (z = t.files) != null && z.length && b(() => I(t.files), "browse"), t.value = "";
78
+ (z = t.files) != null && z.length && v(() => P(t.files), "browse"), t.value = "";
74
79
  }
75
80
  }
76
81
  ),
77
- R && /* @__PURE__ */ e(
82
+ $ && /* @__PURE__ */ e(
78
83
  "div",
79
84
  {
80
85
  class: "n-dropzone-overlay n-fadeIn",
81
86
  children: [
82
- /* @__PURE__ */ e(U, { status: a, active: p, onReset: C }),
83
- 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 })
84
89
  ]
85
90
  },
86
91
  a.kind === "idle" ? "active" : a.kind
@@ -89,15 +94,15 @@ function L({
89
94
  }
90
95
  );
91
96
  }
92
- function O() {
93
- const [n, l] = $(!1), i = k();
94
- return E(() => () => clearTimeout(i.current), []), { active: n, keepActive: () => {
95
- l(!0), clearTimeout(i.current), i.current = setTimeout(() => l(!1), F);
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);
96
101
  }, endDrag: () => {
97
102
  clearTimeout(i.current), l(!1);
98
103
  } };
99
104
  }
100
- function U({
105
+ function O({
101
106
  status: n,
102
107
  active: l,
103
108
  onReset: i
@@ -107,29 +112,34 @@ function U({
107
112
  case "idle":
108
113
  return l ? /* @__PURE__ */ e("p", { class: "n-dropzone-hint", children: "Drop to deploy" }) : null;
109
114
  case "reading":
110
- return /* @__PURE__ */ e(c, { label: "Reading files…" });
115
+ return /* @__PURE__ */ e(d, { label: "Reading files…" });
111
116
  case "creating":
112
- return /* @__PURE__ */ e(c, { label: "Creating deploy…" });
117
+ return /* @__PURE__ */ e(d, { label: "Creating deploy…" });
113
118
  case "processing":
114
- return /* @__PURE__ */ e(c, { label: "Processing — almost live…" });
119
+ return /* @__PURE__ */ e(d, { label: "Processing — almost live…" });
115
120
  case "redirecting":
116
- 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
+ );
117
127
  case "uploading": {
118
128
  const s = n.total ? Math.round(n.uploaded / n.total * 100) : 0;
119
- return /* @__PURE__ */ e(D, { children: [
120
- /* @__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…` }),
121
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}%` } }) })
122
132
  ] });
123
133
  }
124
134
  case "error":
125
- return /* @__PURE__ */ e(D, { children: [
135
+ return /* @__PURE__ */ e(k, { children: [
126
136
  /* @__PURE__ */ e("p", { class: "n-dropzone-title n-dropzone-title--error", children: "Something went wrong" }),
127
137
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", role: "alert", children: n.message }),
128
138
  /* @__PURE__ */ e("button", { type: "button", class: "n-dropzone-browse", onClick: i, children: "Try again" })
129
139
  ] });
130
140
  }
131
141
  }
132
- function c({ label: n }) {
142
+ function d({ label: n }) {
133
143
  return /* @__PURE__ */ e("div", { class: "n-dropzone-loading", children: [
134
144
  /* @__PURE__ */ e("span", { class: "n-dropzone-spinner", "aria-hidden": "true" }),
135
145
  /* @__PURE__ */ e("p", { class: "n-dropzone-subtitle", "aria-live": "polite", children: [
@@ -140,5 +150,5 @@ function c({ label: n }) {
140
150
  ] });
141
151
  }
142
152
  export {
143
- L as default
153
+ H as default
144
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. */
@@ -30,6 +30,7 @@ export type DeployStatus = {
30
30
  kind: 'processing';
31
31
  } | {
32
32
  kind: 'redirecting';
33
+ target: 'claim' | 'signup';
33
34
  } | {
34
35
  kind: 'error';
35
36
  message: string;
@@ -40,6 +41,8 @@ interface UseDropDeployOptions {
40
41
  apiBase?: string;
41
42
  client?: DropClient;
42
43
  claimUrl: (siteName: string) => string;
44
+ buildSignupUrl: string;
45
+ onBuildRequired?: (info: BuildDetection) => void;
43
46
  onDeploy?: (site: DeployedSite) => void;
44
47
  onError?: (error: DeployError) => void;
45
48
  track: TrackFn;
@@ -48,7 +51,7 @@ interface UseDropDeployOptions {
48
51
  * Owns the deploy state machine and its side effects (upload, local persistence,
49
52
  * analytics, redirect), keeping DropZone a thin view over the returned `status`.
50
53
  */
51
- export declare function useDropDeploy({ apiBase, client, claimUrl, onDeploy, onError, track, }: UseDropDeployOptions): {
54
+ export declare function useDropDeploy({ apiBase, client, claimUrl, buildSignupUrl, onBuildRequired, onDeploy, onError, track, }: UseDropDeployOptions): {
52
55
  status: DeployStatus;
53
56
  warning: string | null;
54
57
  busy: boolean;
@@ -1,9 +1,10 @@
1
- import { d as f, A as S } from "../../../chunks/hooks.module.DRcxknIz.js";
2
- import { renameSingleNonIndexHtmlToIndex as b, getSingleNonIndexHtmlFileName as z, hasMultipleHtmlFilesWithoutIndex as F } from "./drop-core/readFiles.js";
3
- import { AnonymousDropClient as H } from "./drop-core/client.js";
4
- import { deployFiles as W } from "./drop-core/deploy.js";
5
- import { humanizeDropError as $ } from "./drop-core/errors.js";
6
- function v(n) {
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) {
7
8
  switch (n.phase) {
8
9
  case "creating":
9
10
  return { kind: "creating" };
@@ -14,59 +15,75 @@ function v(n) {
14
15
  return { kind: "processing" };
15
16
  }
16
17
  }
17
- function A(n) {
18
- const r = z(n);
19
- return r ? `No index.html found — "${r}" won’t be served at the site root.` : F(n) ? "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 q({
22
+ function K({
22
23
  apiBase: n,
23
- client: r,
24
- claimUrl: g,
25
- onDeploy: s,
26
- onError: a,
27
- track: u
24
+ client: l,
25
+ claimUrl: _,
26
+ buildSignupUrl: k,
27
+ onBuildRequired: g,
28
+ onDeploy: a,
29
+ onError: u,
30
+ track: s
28
31
  }) {
29
- const [c, i] = f({ kind: "idle" }), [h, m] = f(null), y = S(r ?? new H(n)).current, k = c.kind !== "idle" && c.kind !== "error";
30
- async function _(x, l) {
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) {
31
34
  m(null), i({ kind: "reading" });
32
- const p = (e, o, t) => {
33
- i({ kind: "error", message: e }), u("dropzone_deploy_failed", { method: l, reason: o, ...t }), a == null || a({ message: e, reason: o });
34
- }, w = (e, o) => {
35
- const t = e instanceof Error ? e.message : String(e);
36
- p($(e), t, o);
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);
37
40
  };
38
41
  try {
39
- const e = b(await x());
42
+ const e = H(await I());
40
43
  if (!e.length) {
41
- p("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
44
+ f("No files found to deploy.", "No files found to deploy.", { file_count: 0 });
42
45
  return;
43
46
  }
44
- l === "drag" && u("dropzone_files_dropped", { method: l, file_count: e.length }), m(A(e));
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));
45
62
  const {
46
- url: o,
47
- token: t,
48
- deploy: d
49
- } = await W(y, e, {
50
- onProgress: (I) => i(v(I))
63
+ url: d,
64
+ token: p,
65
+ deploy: r
66
+ } = await C(w, e, {
67
+ onProgress: (z) => i(q(z))
51
68
  });
52
69
  try {
53
- localStorage.setItem("drop-token", t), localStorage.setItem("dropSiteName", d.subdomain);
70
+ localStorage.setItem("drop-token", p), localStorage.setItem("dropSiteName", r.subdomain);
54
71
  } catch {
55
72
  }
56
- i({ kind: "redirecting" }), u("dropzone_deploy_succeeded", {
57
- method: l,
58
- site_name: d.subdomain,
73
+ i({ kind: "redirecting", target: "claim" }), s("dropzone_deploy_succeeded", {
74
+ method: o,
75
+ site_name: r.subdomain,
59
76
  file_count: e.length
60
- }), s == null || s({ url: o, token: t, siteName: d.subdomain, siteId: d.id }), window.location.assign(`${g(d.subdomain)}#drop_token=${t}`);
77
+ }), a == null || a({ url: d, token: p, siteName: r.subdomain, siteId: r.id }), window.location.assign(`${_(r.subdomain)}#drop_token=${p}`);
61
78
  } catch (e) {
62
- w(e);
79
+ S(e);
63
80
  }
64
81
  }
65
- function N() {
82
+ function x() {
66
83
  m(null), i({ kind: "idle" });
67
84
  }
68
- return { status: c, warning: h, busy: k, deploy: _, reset: N };
85
+ return { status: c, warning: y, busy: N, deploy: b, reset: x };
69
86
  }
70
87
  export {
71
- q as useDropDeploy
88
+ K as useDropDeploy
72
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.7",
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,6 +1,6 @@
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';
3
+ import { readDataTransfer, readFileList, type BuildDetection, type DropClient } from './drop-core';
4
4
  import {
5
5
  useDropDeploy,
6
6
  type DeployedSite,
@@ -16,6 +16,13 @@ const DRAG_INACTIVE_MS = 120;
16
16
 
17
17
  const defaultClaimUrl = (siteName: string) => `https://app.netlify.com/drop/${siteName}`;
18
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
+
19
26
  interface Props {
20
27
  /** Content the drop layer sits on top of. The zone is invisible until you drag over it. */
21
28
  children?: ComponentChildren;
@@ -25,6 +32,21 @@ interface Props {
25
32
  client?: DropClient;
26
33
  /** Build the "claim this site" URL from the assigned site name (subdomain). */
27
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;
28
50
  /** Called once the deploy is live. */
29
51
  onDeploy?: (result: DeployedSite) => void;
30
52
  /**
@@ -48,6 +70,7 @@ interface Props {
48
70
  * Emitted events (all include `dropzone_id` when `analyticsId` is set):
49
71
  * - `dropzone_files_dropped` — files dropped via drag ({ method: 'drag', file_count })
50
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 })
51
74
  * - `dropzone_deploy_succeeded` — deploy went live ({ method, site_name, file_count })
52
75
  * - `dropzone_deploy_failed` — deploy errored out ({ method, reason, file_count? })
53
76
  */
@@ -66,6 +89,8 @@ export default function DropZone({
66
89
  apiBase,
67
90
  client,
68
91
  claimUrl = defaultClaimUrl,
92
+ buildSignupUrl = defaultBuildSignupUrl,
93
+ onBuildRequired,
69
94
  onDeploy,
70
95
  onError,
71
96
  showStatus = true,
@@ -82,6 +107,8 @@ export default function DropZone({
82
107
  apiBase,
83
108
  client,
84
109
  claimUrl,
110
+ buildSignupUrl,
111
+ onBuildRequired,
85
112
  onDeploy,
86
113
  onError,
87
114
  track,
@@ -227,7 +254,15 @@ function StatusView({
227
254
  case 'processing':
228
255
  return <Spinner label="Processing — almost live…" />;
229
256
  case 'redirecting':
230
- 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
+ );
231
266
 
232
267
  case 'uploading': {
233
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,
@@ -37,7 +39,9 @@ export type DeployStatus =
37
39
  | { kind: 'creating' }
38
40
  | { kind: 'uploading'; uploaded: number; total: number }
39
41
  | { kind: 'processing' }
40
- | { 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' }
41
45
  | { kind: 'error'; message: string };
42
46
 
43
47
  /** Provider-agnostic analytics sink. */
@@ -47,6 +51,8 @@ interface UseDropDeployOptions {
47
51
  apiBase?: string;
48
52
  client?: DropClient;
49
53
  claimUrl: (siteName: string) => string;
54
+ buildSignupUrl: string;
55
+ onBuildRequired?: (info: BuildDetection) => void;
50
56
  onDeploy?: (site: DeployedSite) => void;
51
57
  onError?: (error: DeployError) => void;
52
58
  track: TrackFn;
@@ -85,6 +91,8 @@ export function useDropDeploy({
85
91
  apiBase,
86
92
  client,
87
93
  claimUrl,
94
+ buildSignupUrl,
95
+ onBuildRequired,
88
96
  onDeploy,
89
97
  onError,
90
98
  track,
@@ -136,6 +144,30 @@ export function useDropDeploy({
136
144
  track('dropzone_files_dropped', { method: source, file_count: files.length });
137
145
  }
138
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
+
139
171
  setWarning(indexHtmlWarning(files));
140
172
 
141
173
  const {
@@ -157,7 +189,7 @@ export function useDropDeploy({
157
189
  /* private mode / storage disabled — non-fatal */
158
190
  }
159
191
 
160
- setStatus({ kind: 'redirecting' });
192
+ setStatus({ kind: 'redirecting', target: 'claim' });
161
193
  // Fired before the redirect below. Note: the navigation can cut a
162
194
  // fire-and-forget analytics request short — see the docs for delivery
163
195
  // hardening (sendBeacon / deferring the redirect).