@jxsuite/studio 3.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
- package/dist/chunks/Overlay.dev-etqjwyf9.js +23 -0
- package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
- package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
- package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
- package/dist/chunks/content-source-48xg86wj.js +49 -0
- package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-43tfn7cs.js} +33 -25
- package/dist/chunks/entry-commands-e63f1xc0.js +46 -0
- package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-txv08xxg.js} +34 -26
- package/dist/chunks/file-ops-wbh4hhsx.js +48 -0
- package/dist/chunks/files-3kfkwhk4.js +93 -0
- package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
- package/dist/chunks/{format-host-8fr3545e.js → format-host-w16q81tf.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-g01d93vc.js → git-panel-532whr1y.js} +22 -16
- package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
- package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8er6gbxy.js} +6 -4
- package/dist/chunks/layers-panel-07hm7g5j.js +67 -0
- package/dist/chunks/layers-q75tj2mf.js +45 -0
- package/dist/chunks/lit-html-j055mkh8.js +22 -0
- package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
- package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-vx9bhp1d.js} +4 -4
- package/dist/chunks/notify-yjm79n4s.js +30 -0
- package/dist/chunks/parse-24d0gay3.js +19 -0
- package/dist/chunks/platform-ba1j1gbh.js +16 -0
- package/dist/chunks/publish-panel-v9st96ja.js +29 -0
- package/dist/chunks/quick-search-zccjp6gv.js +66 -0
- package/dist/chunks/references-3qx8fkyp.js +29 -0
- package/dist/chunks/right-panel-vm3b6kwy.js +46 -0
- package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +18 -0
- package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
- package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
- package/dist/chunks/sp-tooltip.dev-4sastdnw.js +15 -0
- package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
- package/dist/chunks/{studio-wdt9y92a.js → studio-0bq6eh7v.js} +2 -2
- package/dist/chunks/studio-1ngbejft.js +181 -0
- package/dist/chunks/studio-1wagb550.js +118 -0
- package/dist/chunks/{studio-j595e748.js → studio-2b946z26.js} +172 -95
- package/dist/chunks/{studio-pkp61mh8.js → studio-3bqpr0zh.js} +25 -17
- package/dist/chunks/{studio-k4jvjcfx.js → studio-4b5z1x43.js} +46 -12
- package/dist/chunks/{studio-ry1vz5sv.js → studio-4n2b5gzb.js} +15 -13
- package/dist/chunks/{studio-p5dja96s.js → studio-4r1pv6zf.js} +2 -2
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-tzx3xmvx.js → studio-5fx3g9jg.js} +33 -52
- package/dist/chunks/{studio-rh1cnamv.js → studio-5myknm0a.js} +565 -135
- package/dist/chunks/studio-6yw4y3my.js +91 -0
- package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
- package/dist/chunks/studio-92ab2jqe.js +282 -0
- package/dist/chunks/{studio-6z5eyjwn.js → studio-950k0dx6.js} +72 -135
- package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-hcxzyk3x.js → studio-ahn3pdgx.js} +6 -6
- package/dist/chunks/studio-b55y6ewx.js +498 -0
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-dc8s8heb.js → studio-cmb03bg5.js} +6904 -8321
- package/dist/chunks/studio-dm19q43r.js +195 -0
- package/dist/chunks/{studio-2x9zbxmx.js → studio-ef49pnzx.js} +11 -9
- package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
- package/dist/chunks/{studio-5efrtn1m.js → studio-ew0djmhg.js} +23 -19
- package/dist/chunks/{studio-tr20m8g8.js → studio-f3wqjtk5.js} +39 -3
- package/dist/chunks/{studio-xntfdy1x.js → studio-fbf92ckf.js} +36 -23
- package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
- package/dist/chunks/{studio-7p35254k.js → studio-gfgb8pe6.js} +36 -193
- package/dist/chunks/studio-jc994dnr.js +1066 -0
- package/dist/chunks/{studio-hj2se583.js → studio-jkftxyvz.js} +69 -3423
- package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
- package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
- package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
- package/dist/chunks/studio-msd847zn.js +1315 -0
- package/dist/chunks/{studio-g5gdqnyd.js → studio-mtfnaqs9.js} +19 -19
- package/dist/chunks/{studio-7ejemhej.js → studio-n6j9t10q.js} +6 -6
- package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
- package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
- package/dist/chunks/studio-q43a5y9h.js +394 -0
- package/dist/chunks/studio-q7v10cg2.js +106 -0
- package/dist/chunks/{studio-6kv3apa9.js → studio-q9jd1a60.js} +27 -11
- package/dist/chunks/studio-qcy05dp8.js +1743 -0
- package/dist/chunks/{studio-a0yp7syn.js → studio-qjy2z9bv.js} +29 -132
- package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
- package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
- package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
- package/dist/chunks/studio-t7qb9db8.js +665 -0
- package/dist/chunks/studio-tmc5emrw.js +8 -0
- package/dist/chunks/{studio-r5xbtctf.js → studio-trxrabqf.js} +292 -2297
- package/dist/chunks/studio-wcy8trfx.js +263 -0
- package/dist/chunks/{studio-3dkcd4c5.js → studio-x4qkdat5.js} +62 -57
- package/dist/chunks/studio-x813zp48.js +195 -0
- package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/{studio-8ezdegcx.js → studio-zqazsct2.js} +40 -36
- package/dist/chunks/workspace-fjx52ryv.js +89 -0
- package/dist/iframe-entry.js +141 -56
- package/dist/manifest.json +7 -0
- package/dist/studio.js +2605 -1950
- package/favicon.ico +0 -0
- package/index.html +1 -0
- package/package.json +20 -18
- package/src/about/about-modal.ts +4 -3
- package/src/browse/library-pane.ts +57 -3
- package/src/canvas/canvas-render.ts +23 -1
- package/src/canvas/iframe-patch.ts +47 -21
- package/src/canvas/iframe-render.ts +22 -6
- package/src/commands/app-commands.ts +4 -0
- package/src/commands/command-args.ts +29 -0
- package/src/commands/context.ts +15 -1
- package/src/commands/defaults.ts +29 -1
- package/src/commands/levels.ts +16 -0
- package/src/commands/live-context.ts +3 -0
- package/src/content/collection-match.ts +267 -0
- package/src/content/entry-commands.ts +38 -29
- package/src/content/entry-fields.ts +6 -0
- package/src/editor/shortcuts.ts +5 -0
- package/src/files/file-ops.ts +10 -42
- package/src/files/files.ts +487 -59
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- package/src/files/media-paths.ts +13 -42
- package/src/files/media-usage.ts +41 -131
- package/src/files/serialize-document.ts +60 -0
- package/src/format/convert-file.ts +511 -0
- package/src/format/format-choices.ts +199 -0
- package/src/grid/grid-view.ts +16 -2
- package/src/hosting/document.ts +2 -0
- package/src/hosting/layout.ts +8 -0
- package/src/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +357 -163
- package/src/new-project/location-fields.ts +8 -5
- package/src/new-project/new-project-modal.ts +27 -34
- package/src/packages/ensure-deps.ts +14 -7
- package/src/packages/jxsuite-update.ts +11 -11
- package/src/page-params.ts +10 -46
- package/src/panels/activity-bar.ts +45 -16
- package/src/panels/ai-chat/attached-context.ts +6 -1
- package/src/panels/ai-chat/chat-view.ts +255 -13
- package/src/panels/ai-chat/composer.ts +31 -97
- package/src/panels/ai-panel.ts +116 -2
- package/src/panels/navigator-panels.ts +7 -6
- package/src/panels/settings-menu.ts +799 -0
- package/src/panels/toolbar.ts +135 -15
- package/src/platforms/cloud.ts +261 -70
- package/src/platforms/devserver.ts +67 -33
- package/src/preview/preview-overlay.ts +226 -0
- package/src/publish/publish-panel.ts +62 -2
- package/src/services/ai-ask.ts +247 -0
- package/src/services/ai-import-tools.ts +532 -0
- package/src/services/ai-models.ts +108 -1
- package/src/services/ai-project-tools.ts +20 -26
- package/src/services/ai-system-prompt.ts +88 -2
- package/src/services/ai-turn-signal.ts +70 -0
- package/src/services/connection.ts +74 -0
- package/src/services/context-manager.ts +101 -1
- package/src/services/document-assistant.ts +45 -4
- package/src/services/import-client.ts +33 -6
- package/src/services/import-run.ts +158 -0
- package/src/services/import-seed.ts +120 -0
- package/src/services/jx-validate.ts +58 -6
- package/src/services/project-adoption.ts +71 -0
- package/src/services/references.ts +0 -0
- package/src/services/settings/definitions.ts +17 -0
- package/src/services/tool-executor.ts +45 -2
- package/src/settings/contexts-section.ts +45 -3
- package/src/settings/dependencies-editor.ts +43 -23
- package/src/settings/general-settings.ts +30 -12
- package/src/settings/preferences-accounts.ts +193 -12
- package/src/settings/preferences-dialog.ts +93 -59
- package/src/settings/preferences-sections.ts +57 -0
- package/src/settings/settings-document.ts +65 -9
- package/src/studio.ts +33 -6
- package/src/tabs/transact.ts +42 -19
- package/src/types.ts +129 -7
- package/src/ui/ai-managed-connect.ts +62 -13
- package/src/ui/ai-model-picker.ts +189 -0
- package/src/ui/cf-account-picker.ts +200 -0
- package/src/ui/choice-field.ts +72 -0
- package/src/ui/layers.ts +83 -5
- package/src/utils/studio-utils.ts +22 -52
- package/styles/forced-colors.css +7 -0
- package/styles/overlays.css +256 -0
- package/styles/panels.css +13 -1
- package/styles/shell.css +147 -0
- package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
- package/dist/chunks/content-source-7fy9qrm0.js +0 -40
- package/dist/chunks/files-xcybanxe.js +0 -70
- package/dist/chunks/layers-panel-27essgkc.js +0 -60
- package/dist/chunks/publish-panel-09sy2bab.js +0 -22
- package/dist/chunks/quick-search-sefb61v8.js +0 -55
- package/dist/chunks/right-panel-9r32jd5j.js +0 -39
- package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
- package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
- package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
- package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
- package/dist/chunks/studio-bw5dz6km.js +0 -608
- package/dist/chunks/studio-c31yb4t6.js +0 -55
- package/src/canvas/site-style-css.ts +0 -117
package/src/panels/toolbar.ts
CHANGED
|
@@ -41,12 +41,13 @@ import { openQuickSearch } from "./quick-search";
|
|
|
41
41
|
import { showPromptDialog } from "../ui/layers";
|
|
42
42
|
import { getPlatform, hasPlatform } from "../platform";
|
|
43
43
|
import { getPreviewNavigateHandler } from "../canvas/preview-navigate";
|
|
44
|
+
import { armPreviewOverlay, flushPreviewOverlay } from "../preview/preview-overlay";
|
|
44
45
|
import { documentUrlPattern, dynamicRouteParams } from "../page-params";
|
|
45
46
|
import { getNodeAtPath, nodeLabel, projectState } from "../store";
|
|
46
47
|
import { activeRegistry } from "../commands/active-registry";
|
|
47
48
|
import { notify } from "../services/notify";
|
|
48
49
|
import type { Tab } from "../tabs/tab";
|
|
49
|
-
import type { SiteBuildResult } from "../types";
|
|
50
|
+
import type { SiteBuildResult, SitePreviewResult, StudioPlatform } from "../types";
|
|
50
51
|
import type { CommandRegistry } from "../commands/registry";
|
|
51
52
|
import type { EffectScope } from "@vue/reactivity";
|
|
52
53
|
import type { TemplateResult } from "lit-html";
|
|
@@ -253,27 +254,96 @@ export async function runOpenInBrowser() {
|
|
|
253
254
|
notify.warn(target.reason, { key: OPEN_IN_BROWSER, source: "Preview" });
|
|
254
255
|
return;
|
|
255
256
|
}
|
|
256
|
-
/* Build first, because the reader is about to see the OUTPUT and the author is looking at the
|
|
257
|
-
DOCUMENT. Without this they differ by however long it has been since anyone ran a build —
|
|
258
|
-
which for most projects is "always", since nothing in Studio had ever written the output at
|
|
259
|
-
all. A preview that quietly shows last week's page is worse than one that says it cannot open.
|
|
260
|
-
|
|
261
|
-
The build is also what NAMES the origin. The built site is served on a port of its own, because
|
|
262
|
-
the editor's own paths mean the project's sources while a built page means its output by the
|
|
263
|
-
same paths — so a backend that cannot build cannot preview either, and says so instead of
|
|
264
|
-
sending the reader to an address where half the site is the wrong file. */
|
|
265
257
|
const platform = hasPlatform() ? getPlatform() : null;
|
|
266
|
-
if (!platform?.buildSite) {
|
|
267
|
-
notify.warn("This backend cannot
|
|
258
|
+
if (!platform?.previewSite && !platform?.buildSite) {
|
|
259
|
+
notify.warn("This backend cannot preview the site.", {
|
|
260
|
+
key: OPEN_IN_BROWSER,
|
|
261
|
+
source: "Preview",
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* A LIVE preview is what this action is for, and a build is the fallback for a backend that
|
|
267
|
+
cannot do one. The two answer different questions: "what does this page look like at its real
|
|
268
|
+
URL?" wants the tree as it stands, and "does my site build?" is `Build Site`. */
|
|
269
|
+
if (platform.previewSite) {
|
|
270
|
+
await runLivePreview(platform.previewSite, target.path);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await runBuiltPreview(platform.buildSite!, target.path);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Preview the working tree, and open a tab only if one is not already showing this project.
|
|
278
|
+
*
|
|
279
|
+
* The overlay is flushed FIRST, and that ordering is the whole point: the page the author is about
|
|
280
|
+
* to look at should carry the keystroke they just typed, not the one before the debounce.
|
|
281
|
+
*/
|
|
282
|
+
async function runLivePreview(
|
|
283
|
+
previewSite: NonNullable<StudioPlatform["previewSite"]>,
|
|
284
|
+
path: string,
|
|
285
|
+
) {
|
|
286
|
+
armPreviewOverlay();
|
|
287
|
+
await flushPreviewOverlay();
|
|
288
|
+
notify.info("Opening a live preview…", { key: OPEN_IN_BROWSER, source: "Preview" });
|
|
289
|
+
let result: SitePreviewResult;
|
|
290
|
+
try {
|
|
291
|
+
result = await previewSite({ route: path });
|
|
292
|
+
} catch (error) {
|
|
293
|
+
notify.error(`The site could not be previewed: ${errorText(error)}`, {
|
|
268
294
|
key: OPEN_IN_BROWSER,
|
|
269
295
|
source: "Preview",
|
|
270
296
|
});
|
|
271
297
|
return;
|
|
272
298
|
}
|
|
299
|
+
if (!result.url) {
|
|
300
|
+
notify.warn("This backend serves no preview of the site.", {
|
|
301
|
+
key: OPEN_IN_BROWSER,
|
|
302
|
+
source: "Preview",
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* `reused` is honoured, and honouring it is the feature. A tab already holding this project's
|
|
308
|
+
preview has been pointed at the route; opening another would leave the author with two tabs on
|
|
309
|
+
one project, which is exactly what retargeting the first one exists to prevent. What it costs
|
|
310
|
+
is that the browser does not come forward — no page can raise a background tab, and handing the
|
|
311
|
+
URL to the OS again would open a duplicate rather than switch to it — so the notification says
|
|
312
|
+
where to look. A reader who CLOSED their tab is not stuck here: the stream closes with it, so
|
|
313
|
+
`reused` comes back false and a fresh tab opens without anyone having to ask. */
|
|
314
|
+
if (result.reused) {
|
|
315
|
+
notify.info(`Updated your preview tab — switch to your browser to see ${path}`, {
|
|
316
|
+
key: OPEN_IN_BROWSER,
|
|
317
|
+
source: "Preview",
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (result.errors.length > 0) {
|
|
323
|
+
notify.warn(`Previewed with ${result.errors.length} problem(s): ${result.errors[0]}`, {
|
|
324
|
+
key: OPEN_IN_BROWSER,
|
|
325
|
+
source: "Preview",
|
|
326
|
+
});
|
|
327
|
+
} else {
|
|
328
|
+
notify.success(
|
|
329
|
+
`Opened a live preview of ${result.routes} page(s) — rendered from your working tree, not a build.`,
|
|
330
|
+
{ key: OPEN_IN_BROWSER, source: "Preview" },
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
openUrlExternally(`${result.url}${path}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The compiler-backed path, for a backend that declares no live preview.
|
|
338
|
+
*
|
|
339
|
+
* Kept so nothing regresses on a backend that predates `previewSite`: the cloud adapter answers
|
|
340
|
+
* `buildSite` with `mode: "live"` of its own, and `jx dev` answers it with a real build.
|
|
341
|
+
*/
|
|
342
|
+
async function runBuiltPreview(buildSite: NonNullable<StudioPlatform["buildSite"]>, path: string) {
|
|
273
343
|
notify.info("Building the site…", { key: OPEN_IN_BROWSER, source: "Preview" });
|
|
274
344
|
let result: SiteBuildResult;
|
|
275
345
|
try {
|
|
276
|
-
result = await
|
|
346
|
+
result = await buildSite();
|
|
277
347
|
} catch (error) {
|
|
278
348
|
notify.error(`The site could not be built: ${errorText(error)}`, {
|
|
279
349
|
key: OPEN_IN_BROWSER,
|
|
@@ -288,20 +358,70 @@ export async function runOpenInBrowser() {
|
|
|
288
358
|
});
|
|
289
359
|
return;
|
|
290
360
|
}
|
|
361
|
+
/* A live preview is not a build and the report must not say it was: what it renders is the
|
|
362
|
+
working tree, unsaved edits and all, assembled in the reader's browser rather than compiled. */
|
|
363
|
+
const live = result.mode === "live";
|
|
291
364
|
if (result.errors.length > 0) {
|
|
292
365
|
/* Named, and the page still opens. A partial build produced pages, and the author can see the
|
|
293
366
|
one they asked for while reading what failed — which is the whole difference between a
|
|
294
367
|
preview and a build log. */
|
|
295
|
-
|
|
368
|
+
const verb = live ? "previewed" : "built";
|
|
369
|
+
notify.warn(`The site ${verb} with ${result.errors.length} error(s): ${result.errors[0]}`, {
|
|
296
370
|
key: OPEN_IN_BROWSER,
|
|
297
371
|
source: "Preview",
|
|
298
372
|
});
|
|
373
|
+
} else if (live) {
|
|
374
|
+
notify.success(
|
|
375
|
+
`Opened a live preview of ${result.routes} page(s) — rendered from your working tree, not a build.`,
|
|
376
|
+
{ key: OPEN_IN_BROWSER, source: "Preview" },
|
|
377
|
+
);
|
|
299
378
|
} else {
|
|
300
379
|
notify.success(`Built ${result.routes} page(s).`, { key: OPEN_IN_BROWSER, source: "Preview" });
|
|
301
380
|
}
|
|
302
|
-
openUrlExternally(`${result.url}${
|
|
381
|
+
openUrlExternally(`${result.url}${path}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Run `Build Site`, the compiler-backed answer to "does my site build?".
|
|
386
|
+
*
|
|
387
|
+
* `View: Open in Browser` stopped meaning this when it started previewing the working tree, and the
|
|
388
|
+
* question did not stop being worth asking: a build runs the bundler, the image pipeline and every
|
|
389
|
+
* emitter, and a live preview runs none of them. So it keeps its own verb, its own report, and the
|
|
390
|
+
* output origin it always opened.
|
|
391
|
+
*/
|
|
392
|
+
export async function runBuildSite() {
|
|
393
|
+
const platform = hasPlatform() ? getPlatform() : null;
|
|
394
|
+
if (!platform?.buildSite) {
|
|
395
|
+
notify.warn("This backend cannot build the site.", { key: BUILD_SITE, source: "Build" });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
notify.info("Building the site…", { key: BUILD_SITE, source: "Build" });
|
|
399
|
+
let result: SiteBuildResult;
|
|
400
|
+
try {
|
|
401
|
+
result = await platform.buildSite();
|
|
402
|
+
} catch (error) {
|
|
403
|
+
notify.error(`The site could not be built: ${errorText(error)}`, {
|
|
404
|
+
key: BUILD_SITE,
|
|
405
|
+
source: "Build",
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (result.errors.length > 0) {
|
|
410
|
+
notify.warn(`Built with ${result.errors.length} error(s): ${result.errors[0]}`, {
|
|
411
|
+
key: BUILD_SITE,
|
|
412
|
+
source: "Build",
|
|
413
|
+
});
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
notify.success(`Built ${result.routes} page(s), ${result.files} file(s).`, {
|
|
417
|
+
key: BUILD_SITE,
|
|
418
|
+
source: "Build",
|
|
419
|
+
});
|
|
303
420
|
}
|
|
304
421
|
|
|
422
|
+
/** One notification key for the build, kept apart so a build report never replaces a preview's. */
|
|
423
|
+
const BUILD_SITE = "project.buildSite";
|
|
424
|
+
|
|
305
425
|
/** One notification key for the whole flow, so building → built → failed replaces rather than piles. */
|
|
306
426
|
const OPEN_IN_BROWSER = "view.openInBrowser";
|
|
307
427
|
|
package/src/platforms/cloud.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* `/api/v1/ai/chat`. The session is pre-bound to one (repo, branch) — the shell picks the project
|
|
7
7
|
* before Studio boots — while project-less mode (null) powers the /studio welcome screen.
|
|
8
8
|
*
|
|
9
|
-
* Cloud omissions (Studio degrades per @jxsuite/protocol's route table): pickDirectory
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Cloud omissions (Studio degrades per @jxsuite/protocol's route table): pickDirectory, package
|
|
10
|
+
* install/outdated/set-versions (package ops are manifest-only edits), multi-window, gitClone,
|
|
11
|
+
* resolveClass, component discovery, code services.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { negotiateCollab } from "@jxsuite/collab/negotiate";
|
|
@@ -16,8 +16,12 @@ import type { CollabNegotiation } from "@jxsuite/collab/negotiate";
|
|
|
16
16
|
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
17
17
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
18
18
|
import { componentMetaFrom } from "@jxsuite/schema/component-meta";
|
|
19
|
+
import { streamImport } from "../services/import-client";
|
|
19
20
|
import type {
|
|
20
21
|
AccountStatus,
|
|
22
|
+
CfAccountSummary,
|
|
23
|
+
CfConnection,
|
|
24
|
+
CfConnectOutcome,
|
|
21
25
|
ComponentMeta,
|
|
22
26
|
CreateProjectDestination,
|
|
23
27
|
DirEntry,
|
|
@@ -26,8 +30,12 @@ import type {
|
|
|
26
30
|
GitBranchesResult,
|
|
27
31
|
GitLogEntry,
|
|
28
32
|
GitStatusResult,
|
|
33
|
+
ImportProgressEvent,
|
|
34
|
+
ImportReadyEvent,
|
|
35
|
+
ImportSiteOptions,
|
|
29
36
|
PackageInfo,
|
|
30
37
|
ProjectListEntry,
|
|
38
|
+
SiteBuildResult,
|
|
31
39
|
ProjectSchemasResponse,
|
|
32
40
|
ReferencesResult,
|
|
33
41
|
RenameResult,
|
|
@@ -123,12 +131,16 @@ export function projectRootKey(project: CloudProject): string {
|
|
|
123
131
|
return `${project.owner}/${project.repo}@${project.branch}`;
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Current hosted Cloudflare connection; null when none is brokered yet.
|
|
136
|
+
*
|
|
137
|
+
* A LAPSED row is not a healthy one. This reader used to answer `{connected: true}` for any body
|
|
138
|
+
* whose `connected` flag was set and drop `needsReconnect` on the floor, so a row whose grant had
|
|
139
|
+
* expired came back indistinguishable from one that works — which is how `cfConnect`'s poll came to
|
|
140
|
+
* close the popup the moment it opened, over a row from a previous session, while the user was
|
|
141
|
+
* still on Cloudflare's login page. Every diagnostic the broker sends now passes straight through.
|
|
142
|
+
*/
|
|
143
|
+
async function fetchCfConnection(): Promise<CfConnection | null> {
|
|
132
144
|
const res = await fetch("/api/v1/cf/connection", { credentials: "include" });
|
|
133
145
|
if (!res.ok) {
|
|
134
146
|
return null;
|
|
@@ -137,7 +149,15 @@ async function fetchCfConnection(): Promise<{
|
|
|
137
149
|
connected: boolean;
|
|
138
150
|
accountId?: string | null;
|
|
139
151
|
accountName?: string | null;
|
|
152
|
+
needsReconnect?: boolean;
|
|
153
|
+
needsAccount?: boolean;
|
|
154
|
+
code?: CfConnection["code"];
|
|
155
|
+
reason?: string;
|
|
156
|
+
hasRefreshToken?: boolean;
|
|
157
|
+
expiresAt?: number;
|
|
140
158
|
};
|
|
159
|
+
/* Cloud never emits {connected: false} to the PAL — no brokered row at all is `null`, and a row
|
|
160
|
+
that cannot be used arrives as connected + needsReconnect. See CfConnection. */
|
|
141
161
|
if (!body.connected) {
|
|
142
162
|
return null;
|
|
143
163
|
}
|
|
@@ -145,10 +165,131 @@ async function fetchCfConnection(): Promise<{
|
|
|
145
165
|
connected: true,
|
|
146
166
|
...(body.accountId ? { accountId: body.accountId } : {}),
|
|
147
167
|
...(body.accountName ? { accountName: body.accountName } : {}),
|
|
168
|
+
...(body.needsReconnect ? { needsReconnect: true } : {}),
|
|
169
|
+
...(body.needsAccount ? { needsAccount: true } : {}),
|
|
170
|
+
...(body.code ? { code: body.code } : {}),
|
|
171
|
+
...(body.reason ? { reason: body.reason } : {}),
|
|
172
|
+
...(body.hasRefreshToken === undefined ? {} : { hasRefreshToken: body.hasRefreshToken }),
|
|
173
|
+
...(body.expiresAt === undefined ? {} : { expiresAt: body.expiresAt }),
|
|
148
174
|
};
|
|
149
175
|
}
|
|
150
176
|
|
|
151
|
-
|
|
177
|
+
interface CfConnectFlow {
|
|
178
|
+
popup: Window | null;
|
|
179
|
+
promise: Promise<CfConnectOutcome | null>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The connect flow currently running, or null.
|
|
184
|
+
*
|
|
185
|
+
* Module-level rather than per-platform because the popup's TARGET NAME ("cf-connect") is global to
|
|
186
|
+
* the browsing context: a second `window.open` on that name re-uses the first flow's popup, and the
|
|
187
|
+
* first flow's cleanup then closes it out from under the second — the user watches their half-typed
|
|
188
|
+
* Cloudflare login vanish. Joining the in-flight promise is the only answer that leaves both
|
|
189
|
+
* callers correct.
|
|
190
|
+
*/
|
|
191
|
+
let cfConnectFlow: CfConnectFlow | null = null;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drive one hosted OAuth connect to a {@link CfConnectOutcome}. See `cfConnect` for the semantics;
|
|
195
|
+
* this lives at module scope so the single-flight handle can too.
|
|
196
|
+
*/
|
|
197
|
+
async function runCfConnect(handle: CfConnectFlow): Promise<CfConnectOutcome | null> {
|
|
198
|
+
/* The baseline is read BEFORE the popup opens, and it is what keeps the poll honest: a row that
|
|
199
|
+
was already healthy proves nothing about THIS flow, so the poll must not settle on one. Reading
|
|
200
|
+
it after the popup opened would race the callback and could capture the new row as "old". */
|
|
201
|
+
const baseline = await fetchCfConnection().catch(() => null);
|
|
202
|
+
const healthyBaseline = Boolean(baseline?.connected && !baseline.needsReconnect);
|
|
203
|
+
const popup = window.open("/api/v1/cf/connect", "cf-connect", "width=980,height=780");
|
|
204
|
+
if (!popup) {
|
|
205
|
+
/* Popup blocked: the whole page is now navigating to the broker. Not a failure — the caller
|
|
206
|
+
must render nothing at all rather than an error it will never get to show. */
|
|
207
|
+
location.assign("/api/v1/cf/connect");
|
|
208
|
+
return { status: "redirect" };
|
|
209
|
+
}
|
|
210
|
+
handle.popup = popup;
|
|
211
|
+
const deadline = Date.now() + 180_000;
|
|
212
|
+
return new Promise<CfConnectOutcome>((resolve, reject) => {
|
|
213
|
+
let timer = 0;
|
|
214
|
+
const cleanup = () => {
|
|
215
|
+
window.removeEventListener("message", onMessage);
|
|
216
|
+
window.clearTimeout(timer);
|
|
217
|
+
if (!popup.closed) {
|
|
218
|
+
popup.close();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const settle = (outcome: CfConnectOutcome) => {
|
|
222
|
+
cleanup();
|
|
223
|
+
resolve(outcome);
|
|
224
|
+
};
|
|
225
|
+
const fail = (reason: string) => {
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new Error(reason));
|
|
228
|
+
};
|
|
229
|
+
/** A usable row: brokered, and its grant has not lapsed. */
|
|
230
|
+
const usable = (connection: CfConnection | null): connection is CfConnection =>
|
|
231
|
+
Boolean(connection?.connected && !connection.needsReconnect);
|
|
232
|
+
const onMessage = (event: MessageEvent) => {
|
|
233
|
+
if (event.origin !== location.origin) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const data = event.data as { source?: string; status?: string; reason?: string | null };
|
|
237
|
+
if (!data || data.source !== "jx-cf") {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (data.status === "error") {
|
|
241
|
+
fail(data.reason ?? "Cloudflare authorization failed");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
/* Any other status — "connected", "pick-account", or one a shell of another vintage sends —
|
|
245
|
+
is a CLAIM that the callback ran, and the broker row is what adjudicates it. */
|
|
246
|
+
void fetchCfConnection().then(
|
|
247
|
+
(connection) => {
|
|
248
|
+
settle(usable(connection) ? { connection, status: "connected" } : { status: "timeout" });
|
|
249
|
+
},
|
|
250
|
+
/* This promise had no rejection handler, so one network blip left the listener installed,
|
|
251
|
+
the timer armed and the popup open forever. */
|
|
252
|
+
() => {
|
|
253
|
+
fail("Cloudflare connected, but the connection could not be confirmed");
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
};
|
|
257
|
+
window.addEventListener("message", onMessage);
|
|
258
|
+
const poll = async () => {
|
|
259
|
+
if (Date.now() > deadline) {
|
|
260
|
+
settle({ status: "timeout" });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let connection: CfConnection | null = null;
|
|
264
|
+
let answered = true;
|
|
265
|
+
try {
|
|
266
|
+
connection = await fetchCfConnection();
|
|
267
|
+
} catch {
|
|
268
|
+
answered = false; // A blip is not an answer: re-arm and ask again.
|
|
269
|
+
}
|
|
270
|
+
/* The poll is a FALLBACK for shells whose postMessage never arrives, so it may only settle on
|
|
271
|
+
proof that THIS flow stored a fresh token: a usable row that was NOT already usable at the
|
|
272
|
+
baseline. For a reconnect, `needsReconnect` flipping false is that proof; for a connection
|
|
273
|
+
that was healthy all along nothing the poll can see is, so it must never settle — the relay
|
|
274
|
+
or the popup-closed path answers that case instead. */
|
|
275
|
+
if (answered && usable(connection) && !healthyBaseline) {
|
|
276
|
+
settle({ connection, status: "connected" });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (popup.closed) {
|
|
280
|
+
const final = await fetchCfConnection().catch(() => null);
|
|
281
|
+
settle(usable(final) ? { connection: final, status: "connected" } : { status: "canceled" });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
timer = window.setTimeout(() => {
|
|
285
|
+
void poll();
|
|
286
|
+
}, 1500);
|
|
287
|
+
};
|
|
288
|
+
timer = window.setTimeout(() => {
|
|
289
|
+
void poll();
|
|
290
|
+
}, 1500);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
152
293
|
|
|
153
294
|
/** Parse an "owner/repo@branch" root key; null when malformed. */
|
|
154
295
|
export function parseRootKey(root: string): CloudProject | null {
|
|
@@ -765,6 +906,25 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
765
906
|
// The GitHub repo IS the remote; nothing to add.
|
|
766
907
|
},
|
|
767
908
|
|
|
909
|
+
/**
|
|
910
|
+
* Open in Browser, cloud edition — a LIVE preview rather than a build.
|
|
911
|
+
*
|
|
912
|
+
* The backend cannot run `jx build`: it never executes project JS, and the compiler needs
|
|
913
|
+
* `sharp`, a bundler and a filesystem, none of which exist in a Worker. What it can do is serve
|
|
914
|
+
* the working tree as a real site on an origin of its own, rendering each page with the same
|
|
915
|
+
* runtime the canvas uses — so the reply names `mode: "live"` and Studio says so, instead of
|
|
916
|
+
* letting a reader assume they are looking at build output.
|
|
917
|
+
*
|
|
918
|
+
* That it is not a build is also the best thing about it: the preview is the tree as it stands,
|
|
919
|
+
* including edits nobody has saved and edits another collaborator is making right now.
|
|
920
|
+
*/
|
|
921
|
+
async buildSite() {
|
|
922
|
+
return okJson<SiteBuildResult>(
|
|
923
|
+
await api("/build", { method: "POST" }),
|
|
924
|
+
"The site could not be previewed.",
|
|
925
|
+
);
|
|
926
|
+
},
|
|
927
|
+
|
|
768
928
|
// ─── Project catalogue & navigation (Studio welcome / New Project UI) ──
|
|
769
929
|
|
|
770
930
|
async listProjects(): Promise<ProjectListEntry[]> {
|
|
@@ -876,6 +1036,33 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
876
1036
|
};
|
|
877
1037
|
},
|
|
878
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* AI-guided site import, streamed from the PLATFORM's import route rather than a session one.
|
|
1041
|
+
*
|
|
1042
|
+
* Deliberately not routed through `api()`: importing is how a cloud project comes into
|
|
1043
|
+
* existence, so the one mode it has to work in is the project-less hub, where `api()` rejects
|
|
1044
|
+
* before a request is ever made and `base` is "".
|
|
1045
|
+
*
|
|
1046
|
+
* The path is same-origin and relative, so `streamImport`'s plain `fetch` carries the session
|
|
1047
|
+
* cookie under the default `same-origin` credentials mode — the same authority every
|
|
1048
|
+
* `/api/v1/*` call here sends `credentials: "include"` for, with no cloud-shaped option added
|
|
1049
|
+
* to the shared client. A bring-your-own-key run rides on the headers that client already sets,
|
|
1050
|
+
* so the backend sees `X-Api-Key` / `X-Api-Base-URL` exactly as the OSS server does.
|
|
1051
|
+
*
|
|
1052
|
+
* `onReady` is forwarded and this backend never calls it, which is a difference worth naming:
|
|
1053
|
+
* adopting the project mid-run would navigate this page to the editor and abort the request
|
|
1054
|
+
* still writing the import, so the stream carries no `ready` line and the caller adopts on
|
|
1055
|
+
* `done`. That is the same path a backend older than `ready` has always taken.
|
|
1056
|
+
*/
|
|
1057
|
+
async importSite(
|
|
1058
|
+
opts: ImportSiteOptions,
|
|
1059
|
+
onProgress: (evt: ImportProgressEvent) => void,
|
|
1060
|
+
signal?: AbortSignal,
|
|
1061
|
+
onReady?: (evt: ImportReadyEvent) => void,
|
|
1062
|
+
) {
|
|
1063
|
+
return await streamImport("/api/v1/import/site", opts, onProgress, signal, onReady);
|
|
1064
|
+
},
|
|
1065
|
+
|
|
879
1066
|
// ─── Identity & Cloudflare publish surface ──────────────────────────────
|
|
880
1067
|
|
|
881
1068
|
async getUser() {
|
|
@@ -981,71 +1168,75 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
981
1168
|
* OAuth errors (denial, invalid_scope misregistration) REJECT with the real reason instead of
|
|
982
1169
|
* timing out. A 1.5s poll remains as fallback for older shells / blocked message delivery, and
|
|
983
1170
|
* popup-blocked browsers fall back to a full-page redirect.
|
|
1171
|
+
*
|
|
1172
|
+
* Promise semantics, which are narrower than they look:
|
|
1173
|
+
*
|
|
1174
|
+
* - **Resolves `null`** only under the SSR guard, where there is no window to open.
|
|
1175
|
+
* - **Resolves an outcome** for all four real endings — `connected`, `redirect` (popup blocked,
|
|
1176
|
+
* page navigating away), `canceled` (popup closed with nothing stored), `timeout` (180s).
|
|
1177
|
+
* - **Rejects** only on a relayed OAuth error, or on a relayed success this platform could not
|
|
1178
|
+
* confirm against the broker.
|
|
1179
|
+
*
|
|
1180
|
+
* Concurrent calls JOIN the flow already running rather than starting a second one: both would
|
|
1181
|
+
* open the same "cf-connect" target, and the first one's cleanup would then close the popup the
|
|
1182
|
+
* second is waiting on.
|
|
984
1183
|
*/
|
|
985
|
-
|
|
1184
|
+
cfConnect() {
|
|
986
1185
|
if (typeof window === "undefined" || typeof location === "undefined") {
|
|
987
|
-
return null;
|
|
1186
|
+
return Promise.resolve(null);
|
|
988
1187
|
}
|
|
989
|
-
const
|
|
990
|
-
if (
|
|
991
|
-
|
|
992
|
-
|
|
1188
|
+
const running = cfConnectFlow;
|
|
1189
|
+
if (running) {
|
|
1190
|
+
try {
|
|
1191
|
+
running.popup?.focus();
|
|
1192
|
+
} catch {
|
|
1193
|
+
// A cross-origin popup may refuse focus; joining the flow is what mattered.
|
|
1194
|
+
}
|
|
1195
|
+
return running.promise;
|
|
993
1196
|
}
|
|
994
|
-
const
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
return;
|
|
1023
|
-
}
|
|
1024
|
-
void fetchCfConnection().then(settle);
|
|
1025
|
-
};
|
|
1026
|
-
window.addEventListener("message", onMessage);
|
|
1027
|
-
const poll = async () => {
|
|
1028
|
-
if (Date.now() > deadline) {
|
|
1029
|
-
settle(null);
|
|
1030
|
-
return;
|
|
1031
|
-
}
|
|
1032
|
-
const connection = await fetchCfConnection();
|
|
1033
|
-
if (connection) {
|
|
1034
|
-
settle(connection);
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
if (popup.closed) {
|
|
1038
|
-
settle(await fetchCfConnection());
|
|
1039
|
-
return;
|
|
1040
|
-
}
|
|
1041
|
-
timer = window.setTimeout(() => {
|
|
1042
|
-
void poll();
|
|
1043
|
-
}, 1500);
|
|
1044
|
-
};
|
|
1045
|
-
timer = window.setTimeout(() => {
|
|
1046
|
-
void poll();
|
|
1047
|
-
}, 1500);
|
|
1197
|
+
const handle: CfConnectFlow = { popup: null, promise: Promise.resolve(null) };
|
|
1198
|
+
cfConnectFlow = handle;
|
|
1199
|
+
handle.promise = runCfConnect(handle).finally(() => {
|
|
1200
|
+
if (cfConnectFlow === handle) {
|
|
1201
|
+
cfConnectFlow = null;
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
return handle.promise;
|
|
1205
|
+
},
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Every Cloudflare account this connection can reach (the account picker's rows).
|
|
1209
|
+
*
|
|
1210
|
+
* A non-OK body is the broker's own unusable-connection payload — `{error, code}` naming a
|
|
1211
|
+
* lapsed grant or a connection that never existed — so its sentence is the one worth showing.
|
|
1212
|
+
*/
|
|
1213
|
+
async cfAccounts() {
|
|
1214
|
+
const res = await fetch("/api/v1/cf/accounts", { credentials: "include" });
|
|
1215
|
+
return okJson<CfAccountSummary[]>(res, "Failed to list Cloudflare accounts");
|
|
1216
|
+
},
|
|
1217
|
+
|
|
1218
|
+
/** Store the chosen account on the brokered connection. */
|
|
1219
|
+
async cfSelectAccount(account: { id: string; name?: string }) {
|
|
1220
|
+
const res = await fetch("/api/v1/cf/select-account", {
|
|
1221
|
+
body: JSON.stringify({ accountId: account.id, accountName: account.name }),
|
|
1222
|
+
credentials: "include",
|
|
1223
|
+
headers: { "Content-Type": "application/json" },
|
|
1224
|
+
method: "POST",
|
|
1048
1225
|
});
|
|
1226
|
+
if (!res.ok) {
|
|
1227
|
+
throw new Error(await errorMessage(res, "Failed to select a Cloudflare account"));
|
|
1228
|
+
}
|
|
1229
|
+
},
|
|
1230
|
+
|
|
1231
|
+
/** Forget the brokered connection (the broker revokes its tokens upstream). */
|
|
1232
|
+
async cfDisconnect() {
|
|
1233
|
+
const res = await fetch("/api/v1/cf/connection", {
|
|
1234
|
+
credentials: "include",
|
|
1235
|
+
method: "DELETE",
|
|
1236
|
+
});
|
|
1237
|
+
if (!res.ok) {
|
|
1238
|
+
throw new Error(await errorMessage(res, "Failed to disconnect Cloudflare"));
|
|
1239
|
+
}
|
|
1049
1240
|
},
|
|
1050
1241
|
|
|
1051
1242
|
/** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */
|