@proveanything/smartlinks 2.0.67 → 2.0.69

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.
@@ -94,6 +94,15 @@ Env: `SMARTLINKS_APP_ID`, `SMARTLINKS_DEPLOY_KEY` (dev key — dev-only, safe to
94
94
  optional `SMARTLINKS_API` / `--dir`. It POSTs the files to `POST /apps/{appId}/publish?channel=dev`;
95
95
  the server validates and writes them to `smartlinks.app/apps/{appId}/dev/…` (`no-store`).
96
96
 
97
+ - **Big bundles** go in batches (`/publish/files`), and any file of 4 MB or more goes straight to
98
+ storage through a one-off upload URL (`/publish/uploads`), so no file is too large to publish. The
99
+ final `/publish` call registers the release only once every file has landed.
100
+ - **Source maps (`.map`) are never published.** A release is promoted to beta and stable byte for byte,
101
+ so a map would serve the app's original source to anyone; the server drops any it's sent. Debug in
102
+ Forge's preview, where the dev server has full source. In Forge, **Release to dev** builds readable
103
+ (unminified) code; **Release to beta / stable** builds minified code (the build sees
104
+ `SMARTLINKS_BUILD=debug` or `production`).
105
+
97
106
  **Which to use:** building in Lovable → **A** (ping, no key). Building anywhere you control →
98
107
  **B** (push with a local key). For beta/stable, see the register-a-URL flow below.
99
108
 
package/openapi.yaml CHANGED
@@ -2077,6 +2077,45 @@ paths:
2077
2077
  description: Unauthorized
2078
2078
  404:
2079
2079
  description: Not found
2080
+ /admin/collection/{collectionId}/authKit/{authKitId}/identity:
2081
+ post:
2082
+ tags:
2083
+ - authKit
2084
+ summary: "Give this AuthKit its **own logins** (admin): its members become accounts of this site only — the same email can be a separate account, with its own password, on another site — rather than SmartLinks accounts shared across sites."
2085
+ operationId: authKit_setIdentity
2086
+ security:
2087
+ - bearerAuth: []
2088
+ parameters:
2089
+ - name: collectionId
2090
+ in: path
2091
+ required: true
2092
+ schema:
2093
+ type: string
2094
+ - name: authKitId
2095
+ in: path
2096
+ required: true
2097
+ schema:
2098
+ type: string
2099
+ responses:
2100
+ 200:
2101
+ description: Success
2102
+ content:
2103
+ application/json:
2104
+ schema:
2105
+ $ref: "#/components/schemas/AuthKitSetIdentityResponse"
2106
+ 400:
2107
+ description: Bad request
2108
+ 401:
2109
+ description: Unauthorized
2110
+ 404:
2111
+ description: Not found
2112
+ requestBody:
2113
+ required: true
2114
+ content:
2115
+ application/json:
2116
+ schema:
2117
+ type: object
2118
+ additionalProperties: true
2080
2119
  /admin/collection/{collectionId}/batch:
2081
2120
  get:
2082
2121
  tags:
@@ -3715,7 +3754,7 @@ paths:
3715
3754
  get:
3716
3755
  tags:
3717
3756
  - collection
3718
- summary: "Get the managed-certificate status for a collection's custom domain (admin only)."
3757
+ summary: "Get the certificate status of the collection's own domain (admin only)."
3719
3758
  operationId: collection_getDomainStatus
3720
3759
  security:
3721
3760
  - bearerAuth: []
@@ -3740,7 +3779,7 @@ paths:
3740
3779
  post:
3741
3780
  tags:
3742
3781
  - collection
3743
- summary: Register a custom domain for a collection and provision its managed certificate (admin only).
3782
+ summary: "Connect one of the collection's own domains and start its certificate (admin only)."
3744
3783
  operationId: collection_registerDomain
3745
3784
  security:
3746
3785
  - bearerAuth: []
@@ -9207,7 +9246,7 @@ paths:
9207
9246
  post:
9208
9247
  tags:
9209
9248
  - authKit
9210
- summary: authKit.linkedInLogin
9249
+ summary: Sign in with LinkedIn (OpenID Connect) via a server-side authorization code (public).
9211
9250
  operationId: authKit_linkedInLogin
9212
9251
  security: []
9213
9252
  parameters:
@@ -23327,8 +23366,38 @@ components:
23327
23366
  type: string
23328
23367
  security:
23329
23368
  $ref: "#/components/schemas/AuthKitSecurityConfig"
23369
+ collectionIds:
23370
+ type: array
23371
+ items:
23372
+ type: string
23373
+ identity:
23374
+ $ref: "#/components/schemas/AuthKitIdentity"
23330
23375
  required:
23331
23376
  - id
23377
+ AuthKitIdentity:
23378
+ type: object
23379
+ properties:
23380
+ mode:
23381
+ type: string
23382
+ enum:
23383
+ - shared
23384
+ - own
23385
+ tenantId:
23386
+ type: string
23387
+ createdAt:
23388
+ type: string
23389
+ required:
23390
+ - mode
23391
+ AuthKitSetIdentityResponse:
23392
+ type: object
23393
+ properties:
23394
+ identity:
23395
+ $ref: "#/components/schemas/AuthKitIdentity"
23396
+ message:
23397
+ type: string
23398
+ required:
23399
+ - identity
23400
+ - message
23332
23401
  AuthKitSecurityConfig:
23333
23402
  type: object
23334
23403
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.0.67",
3
+ "version": "2.0.69",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,147 +1,185 @@
1
- #!/usr/bin/env node
2
- /**
3
- * publish — the FAST dev publish for a SmartLinks app. Builds nothing itself: it takes an already-
4
- * built `dist/`, uploads it to the SmartLinks publish endpoint, which writes it straight to the CDN
5
- * (dev = no-store, instantly fresh) and registers the release. One call = host + register, no Cloud
6
- * Build, no Lovable. Works from anywhere: local, Claude, CI, or a build step.
7
- *
8
- * smartlinks-publish # build dist/ yourself first, then this
9
- * smartlinks-publish --watch # re-publish on file change (save → live)
10
- *
11
- * Env:
12
- * SMARTLINKS_APP_ID the platform app id (required)
13
- * SMARTLINKS_DEPLOY_KEY channel-scoped deploy key (dev key is dev-only, safe to keep locally)
14
- * SMARTLINKS_API API host (default https://smartlinks.app)
15
- * SMARTLINKS_CHANNEL channel (default dev; only dev is supported by this fast path today)
16
- * Flags: --dir <dist> --watch --channel <dev>
17
- */
18
-
19
- import { readFileSync, readdirSync, statSync, watch } from 'node:fs'
20
- import { join, relative, sep } from 'node:path'
21
-
22
- const args = process.argv.slice(2)
23
- const flag = (name, def) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def }
24
-
25
- const API = process.env.SMARTLINKS_API || 'https://smartlinks.app'
26
- const KEY = process.env.SMARTLINKS_DEPLOY_KEY
27
- const APP_ID = process.env.SMARTLINKS_APP_ID
28
- const CHANNEL = flag('--channel', process.env.SMARTLINKS_CHANNEL || 'dev')
29
- const DIR = flag('--dir', 'dist')
30
- const WATCH = args.includes('--watch')
31
-
32
- // Mirror of the server's write allowlist — a bundle is static web assets only.
33
- const ALLOWED = new Set(['.js', '.mjs', '.css', '.json', '.map', '.wasm', '.html', '.txt',
34
- '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.woff', '.woff2', '.ttf', '.otf'])
35
- const extOf = (p) => { const i = p.lastIndexOf('.'); return i < 0 ? '' : p.slice(i).toLowerCase() }
36
-
37
- function fail(msg) { console.error(`❌ ${msg}`); process.exit(1) }
38
- if (!APP_ID) fail('SMARTLINKS_APP_ID is required')
39
- if (!KEY) fail('SMARTLINKS_DEPLOY_KEY is required')
40
-
41
- function collectFiles(dir) {
42
- const out = {}
43
- const walk = (d) => {
44
- for (const e of readdirSync(d, { withFileTypes: true })) {
45
- const full = join(d, e.name)
46
- if (e.isDirectory()) { walk(full); continue }
47
- if (!ALLOWED.has(extOf(e.name))) continue
48
- const rel = relative(dir, full).split(sep).join('/') // forward slashes for the bucket
49
- out[rel] = readFileSync(full)
50
- }
51
- }
52
- walk(dir)
53
- return out
54
- }
55
-
56
- // One request carries at most this much file content (decoded). The server caps a request at 8 MB of
57
- // content and ~10 MB of JSON; base64 adds a third, so 6 MB keeps every request comfortably under both.
58
- const BATCH_BYTES = 6 * 1024 * 1024
59
- const BATCH_FILES = 150
60
-
61
- async function post(path, body) {
62
- const res = await fetch(`${API}/api/v1/apps/${APP_ID}${path}`, {
63
- method: 'POST',
64
- headers: { 'content-type': 'application/json', 'x-smartlinks-deploy-key': KEY },
65
- body: JSON.stringify(body),
66
- }).catch((e) => ({ ok: false, status: 0, json: async () => ({ errors: [{ message: `request failed — ${e.message}` }] }) }))
67
- const b = await res.json().catch(() => ({}))
68
- return { res, b }
69
- }
70
-
71
- function reportFailure(res, b) {
72
- console.error(`❌ publish failed (${res.status}):`)
73
- for (const e of b.errors || []) console.error(` • ${e.path || ''}: ${e.message}`)
74
- if (!b.errors && (b.message || b.error)) console.error(` • ${b.message || b.error}`)
75
- }
76
-
77
- const toB64 = (entries) => Object.fromEntries(entries.map(([p, buf]) => [p, buf.toString('base64')]))
78
-
79
- async function publishOnce() {
80
- let stat
81
- try { stat = statSync(DIR) } catch { fail(`build dir not found: ${DIR} (build first)`) }
82
- if (!stat.isDirectory()) fail(`${DIR} is not a directory`)
83
-
84
- const files = collectFiles(DIR)
85
- if (!files['app.manifest.json']) fail(`${DIR}/app.manifest.json not found — is this a built app bundle?`)
86
-
87
- const t0 = Date.now()
88
- const entries = Object.entries(files)
89
- const totalBytes = entries.reduce((n, [, buf]) => n + buf.length, 0)
90
- const qs = `?channel=${encodeURIComponent(CHANNEL)}`
91
- let finalBody
92
-
93
- if (totalBytes <= BATCH_BYTES && entries.length <= BATCH_FILES) {
94
- finalBody = { files: toB64(entries) } // small bundle: one request, as before
95
- } else {
96
- // Large bundle: upload everything except the manifest in batches, then send the manifest last —
97
- // the server only registers the release once every referenced file is confirmed uploaded.
98
- const rest = entries.filter(([p]) => p !== 'app.manifest.json').sort((a, b) => b[1].length - a[1].length)
99
- const batches = []
100
- let cur = [], curBytes = 0
101
- for (const e of rest) {
102
- if (cur.length && (curBytes + e[1].length > BATCH_BYTES || cur.length >= BATCH_FILES)) { batches.push(cur); cur = []; curBytes = 0 }
103
- if (e[1].length > BATCH_BYTES) console.warn(` ⚠︎ ${e[0]} is ${(e[1].length / 1048576).toFixed(1)} MB — larger than one request allows; the server will likely reject it`)
104
- cur.push(e); curBytes += e[1].length
105
- }
106
- if (cur.length) batches.push(cur)
107
- console.log(`📦 ${APP_ID}: ${(totalBytes / 1048576).toFixed(1)} MB in ${entries.length} files — uploading in ${batches.length} batch(es)`)
108
- for (let i = 0; i < batches.length; i++) {
109
- const { res, b } = await post(`/publish/files${qs}`, { files: toB64(batches[i]) })
110
- if (!res.ok || !b.ok) {
111
- reportFailure(res, b)
112
- if (WATCH) return false
113
- process.exit(1)
114
- }
115
- console.log(` ↑ batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
116
- }
117
- finalBody = { files: toB64([['app.manifest.json', files['app.manifest.json']]]), uploaded: rest.map(([p]) => p) }
118
- }
119
-
120
- const { res, b } = await post(`/publish${qs}`, finalBody)
121
- if (!res.ok || !b.ok) {
122
- reportFailure(res, b)
123
- if (WATCH) return false // keep watching; don't exit
124
- process.exit(1)
125
- }
126
- for (const w of b.warnings || []) console.warn(` ⚠︎ ${w.path}: ${w.message}`)
127
- console.log(`✅ ${APP_ID}@${b.version} → ${CHANNEL} (${entries.length} files, ${Date.now() - t0}ms) — functions: ${(b.functions || []).join(', ') || 'none'}`)
128
- console.log(` ${b.bundleBaseUrl}`)
129
- return true
130
- }
131
-
132
- if (!WATCH) {
133
- await publishOnce()
134
- } else {
135
- console.log(`👀 watching ${DIR} — publishing ${APP_ID} to ${CHANNEL} on change (Ctrl+C to stop)`)
136
- await publishOnce()
137
- let timer = null
138
- let publishing = false
139
- watch(DIR, { recursive: true }, () => {
140
- clearTimeout(timer)
141
- timer = setTimeout(async () => {
142
- if (publishing) return
143
- publishing = true
144
- try { await publishOnce() } finally { publishing = false }
145
- }, 300) // debounce a burst of file writes from one build
146
- })
147
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * publish — the FAST dev publish for a SmartLinks app. Builds nothing itself: it takes an already-
4
+ * built `dist/`, uploads it to the SmartLinks publish endpoint, which writes it straight to the CDN
5
+ * (dev = no-store, instantly fresh) and registers the release. One call = host + register, no Cloud
6
+ * Build, no Lovable. Works from anywhere: local, Claude, CI, or a build step.
7
+ *
8
+ * smartlinks-publish # build dist/ yourself first, then this
9
+ * smartlinks-publish --watch # re-publish on file change (save → live)
10
+ *
11
+ * Env:
12
+ * SMARTLINKS_APP_ID the platform app id (required)
13
+ * SMARTLINKS_DEPLOY_KEY channel-scoped deploy key (dev key is dev-only, safe to keep locally)
14
+ * SMARTLINKS_API API host (default https://smartlinks.app)
15
+ * SMARTLINKS_CHANNEL channel (default dev; only dev is supported by this fast path today)
16
+ * Flags: --dir <dist> --watch --channel <dev>
17
+ */
18
+
19
+ import { readFileSync, readdirSync, statSync, watch } from 'node:fs'
20
+ import { join, relative, sep } from 'node:path'
21
+
22
+ const args = process.argv.slice(2)
23
+ const flag = (name, def) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def }
24
+
25
+ const API = process.env.SMARTLINKS_API || 'https://smartlinks.app'
26
+ const KEY = process.env.SMARTLINKS_DEPLOY_KEY
27
+ const APP_ID = process.env.SMARTLINKS_APP_ID
28
+ const CHANNEL = flag('--channel', process.env.SMARTLINKS_CHANNEL || 'dev')
29
+ const DIR = flag('--dir', 'dist')
30
+ const WATCH = args.includes('--watch')
31
+
32
+ // Mirror of the server's write allowlist — a bundle is static web assets only. Source maps (.map) are
33
+ // never published: a release is promoted to beta and stable as-is, so a map would serve the app's
34
+ // original source publicly (and maps are most of a bundle's size). Debug in Forge's preview; a
35
+ // Release to dev is a readable, unminified build instead.
36
+ const ALLOWED = new Set(['.js', '.mjs', '.css', '.json', '.wasm', '.html', '.txt',
37
+ '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.woff', '.woff2', '.ttf', '.otf'])
38
+ const extOf = (p) => { const i = p.lastIndexOf('.'); return i < 0 ? '' : p.slice(i).toLowerCase() }
39
+
40
+ function fail(msg) { console.error(`❌ ${msg}`); process.exit(1) }
41
+ if (!APP_ID) fail('SMARTLINKS_APP_ID is required')
42
+ if (!KEY) fail('SMARTLINKS_DEPLOY_KEY is required')
43
+
44
+ let skippedMaps = 0
45
+
46
+ function collectFiles(dir) {
47
+ const out = {}
48
+ const walk = (d) => {
49
+ for (const e of readdirSync(d, { withFileTypes: true })) {
50
+ const full = join(d, e.name)
51
+ if (e.isDirectory()) { walk(full); continue }
52
+ if (!ALLOWED.has(extOf(e.name))) { if (extOf(e.name) === '.map') skippedMaps++; continue }
53
+ const rel = relative(dir, full).split(sep).join('/') // forward slashes for the bucket
54
+ out[rel] = readFileSync(full)
55
+ }
56
+ }
57
+ walk(dir)
58
+ return out
59
+ }
60
+
61
+ // One request carries at most this much file content (decoded). The server caps a request at 8 MB of
62
+ // content and ~10 MB of JSON; base64 adds a third, so 6 MB keeps every request comfortably under both.
63
+ const BATCH_BYTES = 6 * 1024 * 1024
64
+ const BATCH_FILES = 150
65
+ // A file at least this big goes straight to storage (a session URL from /publish/uploads) instead of
66
+ // through an API request, so no single file is ever too large to publish (e.g. a readable dev build).
67
+ const DIRECT_BYTES = 4 * 1024 * 1024
68
+
69
+ // Big files → storage directly. Returns the paths uploaded, or null when the server has no direct
70
+ // uploads yet (an older API: the caller falls back to batches).
71
+ async function uploadDirect(entries, qs) {
72
+ const { res, b } = await post(`/publish/uploads${qs}`, { files: entries.map(([p, buf]) => ({ path: p, size: buf.length })) })
73
+ if (res.status === 404) return null
74
+ if (!res.ok || !b.ok) { reportFailure(res, b); return false }
75
+ const bytes = new Map(entries)
76
+ for (const u of b.uploads || []) {
77
+ const buf = bytes.get(u.path)
78
+ const put = await fetch(u.url, { method: 'PUT', body: buf }).catch((e) => ({ ok: false, status: 0, text: async () => e.message }))
79
+ if (!put.ok) {
80
+ console.error(`❌ upload of ${u.path} failed (${put.status}): ${(await put.text().catch(() => '')).slice(0, 200)}`)
81
+ return false
82
+ }
83
+ console.log(` ↑ ${u.path} (${(buf.length / 1048576).toFixed(1)} MB, direct)`)
84
+ }
85
+ return entries.map(([p]) => p)
86
+ }
87
+
88
+ async function post(path, body) {
89
+ const res = await fetch(`${API}/api/v1/apps/${APP_ID}${path}`, {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json', 'x-smartlinks-deploy-key': KEY },
92
+ body: JSON.stringify(body),
93
+ }).catch((e) => ({ ok: false, status: 0, json: async () => ({ errors: [{ message: `request failed — ${e.message}` }] }) }))
94
+ const b = await res.json().catch(() => ({}))
95
+ return { res, b }
96
+ }
97
+
98
+ function reportFailure(res, b) {
99
+ console.error(`❌ publish failed (${res.status}):`)
100
+ for (const e of b.errors || []) console.error(` • ${e.path || ''}: ${e.message}`)
101
+ if (!b.errors && (b.message || b.error)) console.error(` • ${b.message || b.error}`)
102
+ }
103
+
104
+ const toB64 = (entries) => Object.fromEntries(entries.map(([p, buf]) => [p, buf.toString('base64')]))
105
+
106
+ async function publishOnce() {
107
+ let stat
108
+ try { stat = statSync(DIR) } catch { fail(`build dir not found: ${DIR} (build first)`) }
109
+ if (!stat.isDirectory()) fail(`${DIR} is not a directory`)
110
+
111
+ skippedMaps = 0
112
+ const files = collectFiles(DIR)
113
+ if (!files['app.manifest.json']) fail(`${DIR}/app.manifest.json not found — is this a built app bundle?`)
114
+ if (skippedMaps) console.log(` (${skippedMaps} source map${skippedMaps === 1 ? '' : 's'} left out — maps are never published)`)
115
+
116
+ const t0 = Date.now()
117
+ const entries = Object.entries(files)
118
+ const totalBytes = entries.reduce((n, [, buf]) => n + buf.length, 0)
119
+ const qs = `?channel=${encodeURIComponent(CHANNEL)}`
120
+ let finalBody
121
+
122
+ if (totalBytes <= BATCH_BYTES && entries.length <= BATCH_FILES) {
123
+ finalBody = { files: toB64(entries) } // small bundle: one request, as before
124
+ } else {
125
+ // Large bundle: upload everything except the manifest in batches, then send the manifest last —
126
+ // the server only registers the release once every referenced file is confirmed uploaded.
127
+ const rest = entries.filter(([p]) => p !== 'app.manifest.json').sort((a, b) => b[1].length - a[1].length)
128
+ const big = rest.filter(([, buf]) => buf.length >= DIRECT_BYTES)
129
+ let direct = []
130
+ if (big.length) {
131
+ const done = await uploadDirect(big, qs)
132
+ if (done === false) { if (WATCH) return false; process.exit(1) }
133
+ if (done) direct = done
134
+ else console.warn(' ⚠︎ this SmartLinks API has no direct uploads yet — sending big files in batches')
135
+ }
136
+ const viaBatches = rest.filter(([p]) => !direct.includes(p))
137
+ const batches = []
138
+ let cur = [], curBytes = 0
139
+ for (const e of viaBatches) {
140
+ if (cur.length && (curBytes + e[1].length > BATCH_BYTES || cur.length >= BATCH_FILES)) { batches.push(cur); cur = []; curBytes = 0 }
141
+ if (e[1].length > BATCH_BYTES) console.warn(` ⚠︎ ${e[0]} is ${(e[1].length / 1048576).toFixed(1)} MB — larger than one request allows; the server will likely reject it`)
142
+ cur.push(e); curBytes += e[1].length
143
+ }
144
+ if (cur.length) batches.push(cur)
145
+ console.log(`📦 ${APP_ID}: ${(totalBytes / 1048576).toFixed(1)} MB in ${entries.length} files — ${direct.length ? `${direct.length} sent directly, ` : ''}${batches.length} batch(es)`)
146
+ for (let i = 0; i < batches.length; i++) {
147
+ const { res, b } = await post(`/publish/files${qs}`, { files: toB64(batches[i]) })
148
+ if (!res.ok || !b.ok) {
149
+ reportFailure(res, b)
150
+ if (WATCH) return false
151
+ process.exit(1)
152
+ }
153
+ console.log(` ↑ batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
154
+ }
155
+ finalBody = { files: toB64([['app.manifest.json', files['app.manifest.json']]]), uploaded: rest.map(([p]) => p) }
156
+ }
157
+
158
+ const { res, b } = await post(`/publish${qs}`, finalBody)
159
+ if (!res.ok || !b.ok) {
160
+ reportFailure(res, b)
161
+ if (WATCH) return false // keep watching; don't exit
162
+ process.exit(1)
163
+ }
164
+ for (const w of b.warnings || []) console.warn(` ⚠︎ ${w.path}: ${w.message}`)
165
+ console.log(`✅ ${APP_ID}@${b.version} → ${CHANNEL} (${entries.length} files, ${Date.now() - t0}ms) — functions: ${(b.functions || []).join(', ') || 'none'}`)
166
+ console.log(` ${b.bundleBaseUrl}`)
167
+ return true
168
+ }
169
+
170
+ if (!WATCH) {
171
+ await publishOnce()
172
+ } else {
173
+ console.log(`👀 watching ${DIR} — publishing ${APP_ID} to ${CHANNEL} on change (Ctrl+C to stop)`)
174
+ await publishOnce()
175
+ let timer = null
176
+ let publishing = false
177
+ watch(DIR, { recursive: true }, () => {
178
+ clearTimeout(timer)
179
+ timer = setTimeout(async () => {
180
+ if (publishing) return
181
+ publishing = true
182
+ try { await publishOnce() } finally { publishing = false }
183
+ }, 300) // debounce a burst of file writes from one build
184
+ })
185
+ }