@jxsuite/studio 2.4.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/{cf-settings-g5fbf2zp.js → cf-settings-zj2cj1ar.js} +6 -3
- package/dist/chunks/{content-source-k9724vcv.js → content-source-7fy9qrm0.js} +10 -9
- package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-6vvx96n2.js} +9 -8
- package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-kwzvpemr.js} +13 -12
- package/dist/chunks/{files-yew27sn9.js → files-xcybanxe.js} +14 -13
- package/dist/chunks/{format-host-wewba4nq.js → format-host-8fr3545e.js} +2 -2
- package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-g01d93vc.js} +9 -8
- package/dist/chunks/{layers-panel-qnsghwwd.js → layers-panel-27essgkc.js} +12 -11
- package/dist/chunks/{publish-panel-a0pdft5w.js → publish-panel-09sy2bab.js} +8 -7
- package/dist/chunks/{quick-search-9avyc4pc.js → quick-search-sefb61v8.js} +15 -14
- package/dist/chunks/{right-panel-kcnhtc5j.js → right-panel-9r32jd5j.js} +11 -10
- package/dist/chunks/{studio-aweyr4v1.js → studio-2x9zbxmx.js} +2 -2
- package/dist/chunks/{studio-ef8t53vc.js → studio-3dkcd4c5.js} +3 -3
- package/dist/chunks/{studio-hb7c46jy.js → studio-5efrtn1m.js} +9 -7
- package/dist/chunks/{studio-v1va0vhd.js → studio-6z5eyjwn.js} +24 -25
- package/dist/chunks/{studio-ke244pam.js → studio-7p35254k.js} +33 -11
- package/dist/chunks/{studio-f5yyvqxv.js → studio-8ezdegcx.js} +13 -11
- package/dist/chunks/{studio-cz2j1c0g.js → studio-a0yp7syn.js} +55 -18
- package/dist/chunks/{studio-26sv44br.js → studio-dc8s8heb.js} +34 -23
- package/dist/chunks/{studio-jnakhg87.js → studio-g5gdqnyd.js} +4 -4
- package/dist/chunks/{studio-rqemfhz6.js → studio-hcxzyk3x.js} +2 -2
- package/dist/chunks/{studio-ws6ef6js.js → studio-hj2se583.js} +12 -10
- package/dist/chunks/{studio-pyrw8h0w.js → studio-j595e748.js} +407 -167
- package/dist/chunks/{studio-dgj7gq44.js → studio-k4jvjcfx.js} +4 -4
- package/dist/chunks/studio-kfpek536.js +28 -0
- package/dist/chunks/{studio-sccge2wf.js → studio-p5dja96s.js} +2 -2
- package/dist/chunks/{studio-0b6fs05q.js → studio-pkp61mh8.js} +5 -5
- package/dist/chunks/studio-qpfq7xhm.js +1802 -0
- package/dist/chunks/{studio-ra7ar50f.js → studio-r5xbtctf.js} +51 -1565
- package/dist/chunks/{studio-v2c5pmjj.js → studio-rh1cnamv.js} +17 -15
- package/dist/chunks/{studio-28xc8ws4.js → studio-ry1vz5sv.js} +10 -8
- package/dist/chunks/{studio-hyn3vhwr.js → studio-xntfdy1x.js} +5 -5
- package/dist/iframe-entry.js +344 -26
- package/dist/studio.js +374 -248
- package/package.json +9 -9
- package/src/browse/library-layouts.ts +3 -3
- package/src/browse/library-pane.ts +2 -2
- package/src/browse/library-preview.ts +19 -3
- package/src/canvas/asset-refs.ts +119 -0
- package/src/canvas/asset-resolve.ts +256 -0
- package/src/canvas/canvas-live-render.ts +23 -15
- package/src/canvas/iframe-entry.ts +1 -0
- package/src/canvas/iframe-host.ts +4 -0
- package/src/canvas/iframe-protocol.ts +9 -0
- package/src/canvas/iframe-render.ts +28 -10
- package/src/canvas/site-style-css.ts +2 -6
- package/src/editor/file-drop-action.ts +2 -1
- package/src/files/files.ts +2 -2
- package/src/files/media-paths.ts +37 -14
- package/src/files/media-upload.ts +47 -6
- package/src/grid/grid-idle.ts +45 -0
- package/src/grid/grid-view.ts +21 -0
- package/src/grid/schema-columns.ts +2 -1
- package/src/grid/sources/content-source.ts +24 -3
- package/src/new-project/import-tab.ts +3 -2
- package/src/panels/ai-chat/composer.ts +30 -13
- package/src/panels/frontmatter-fields.ts +6 -2
- package/src/panels/properties-panel.ts +8 -2
- package/src/panels/seo-modal.ts +5 -6
- package/src/panels/signals-panel.ts +2 -1
- package/src/platforms/cloud.ts +75 -5
- package/src/platforms/devserver.ts +5 -1
- package/src/services/ai-models.ts +101 -11
- package/src/services/ai-settings.ts +69 -71
- package/src/services/cf-settings.ts +19 -37
- package/src/services/document-assistant.ts +4 -3
- package/src/services/idle.ts +6 -1
- package/src/services/settings/definitions.ts +164 -0
- package/src/services/settings/kernel.ts +335 -0
- package/src/services/settings/write-queue.ts +116 -0
- package/src/settings/preferences-accounts.ts +6 -19
- package/src/settings/preferences-keymap.ts +8 -18
- package/src/settings/schema-field-ui.ts +2 -2
- package/src/shell.ts +6 -14
- package/src/studio.ts +32 -1
- package/src/types.ts +61 -3
- package/src/ui/ai-credentials-form.ts +52 -27
- package/src/ui/media-picker.ts +6 -11
- package/src/utils/studio-utils.ts +45 -3
- package/dist/chunks/studio-a7pzveq1.js +0 -110
- package/src/canvas/content-assets.ts +0 -263
- package/src/services/settings-store.ts +0 -99
|
@@ -13,18 +13,21 @@
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
buildScope,
|
|
16
|
+
canvasStyleValue,
|
|
16
17
|
defineElement,
|
|
17
18
|
renderNode,
|
|
18
19
|
runScoped,
|
|
20
|
+
setCanvasAssetResolver,
|
|
19
21
|
setCanvasDelinkAnchors,
|
|
20
22
|
setCanvasViewportTranspose,
|
|
21
23
|
setRootMedia,
|
|
22
24
|
setSkipAutoRequests,
|
|
23
25
|
setSkipServerFunctions,
|
|
24
26
|
setStampPropBindings,
|
|
25
|
-
transposeCanvasUnits,
|
|
26
27
|
} from "@jxsuite/runtime";
|
|
28
|
+
import { resolveAssetRef } from "./asset-resolve";
|
|
27
29
|
import { classifyRenderNode, serializeJxPath } from "./path-mapping";
|
|
30
|
+
import type { AssetContext } from "./asset-resolve";
|
|
28
31
|
import { SITE_STYLE_ID, buildSiteStyleCSS } from "./site-style-css";
|
|
29
32
|
import type { CanvasMode } from "./iframe-protocol";
|
|
30
33
|
import type { JxDocument } from "@jxsuite/schema/types";
|
|
@@ -409,7 +412,9 @@ export function applySiteStyle(
|
|
|
409
412
|
existing?.remove();
|
|
410
413
|
return;
|
|
411
414
|
}
|
|
412
|
-
|
|
415
|
+
/* `canvasStyleValue`, not `transposeCanvasUnits`: the site style block is CSS the canvas emits
|
|
416
|
+
itself, so a `url()` in it needs the same asset resolution every other declaration gets. */
|
|
417
|
+
const css = buildSiteStyleCSS(siteStyle, mediaQueries, canvasStyleValue);
|
|
413
418
|
if (existing) {
|
|
414
419
|
existing.textContent = css;
|
|
415
420
|
return;
|
|
@@ -434,7 +439,7 @@ export function applyPreviewColorScheme(doc: Document, scheme: "light" | "dark"
|
|
|
434
439
|
}
|
|
435
440
|
|
|
436
441
|
/** Inject the document's `$head` (link/meta/script) into the iframe's <head>, de-duped by href/src. */
|
|
437
|
-
export function injectHead(doc: JxDocument): void {
|
|
442
|
+
export function injectHead(doc: JxDocument, assets: AssetContext | null = null): void {
|
|
438
443
|
const head = (doc as { $head?: HeadEntry[] }).$head;
|
|
439
444
|
if (!Array.isArray(head)) {
|
|
440
445
|
return;
|
|
@@ -451,14 +456,16 @@ export function injectHead(doc: JxDocument): void {
|
|
|
451
456
|
const attrs = { ...entry.attributes } as Record<string, unknown>;
|
|
452
457
|
for (const key of ["href", "src"]) {
|
|
453
458
|
const val = attrs[key];
|
|
454
|
-
if (
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
459
|
+
if (typeof val !== "string" || val === "") {
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (!val.startsWith("/") && !val.startsWith(".") && !val.startsWith("http")) {
|
|
463
|
+
/* The bare-specifier lane owns `/node_modules/<pkg>` — the HOST's URL space, not the
|
|
464
|
+
project's. A project resolver has nothing to resolve it to, so it never sees it. */
|
|
460
465
|
attrs[key] = `/node_modules/${val}`;
|
|
466
|
+
continue;
|
|
461
467
|
}
|
|
468
|
+
attrs[key] = resolveAssetRef(val, assets) ?? val;
|
|
462
469
|
}
|
|
463
470
|
const sel = `${tag}${attrs.href ? `[href="${String(attrs.href)}"]` : ""}${attrs.src ? `[src="${String(attrs.src)}"]` : ""}`;
|
|
464
471
|
if (sel !== tag && document.head.querySelector(sel)) {
|
|
@@ -620,9 +627,20 @@ export async function renderResolvedDocument(opts: {
|
|
|
620
627
|
mode: CanvasMode;
|
|
621
628
|
mapperCtx: PathMapCtx;
|
|
622
629
|
siteStyle?: Record<string, unknown> | null;
|
|
630
|
+
/**
|
|
631
|
+
* How this host addresses project media, or null when the canvas origin serves the site's own URL
|
|
632
|
+
* space (desktop, `jx dev`) and references need no resolution at all.
|
|
633
|
+
*/
|
|
634
|
+
assets?: AssetContext | null;
|
|
623
635
|
/** This render may fetch automatic `Request` entries even outside preview (Data-panel Refresh). */
|
|
624
636
|
allowAutoRequests?: boolean;
|
|
625
637
|
}): Promise<RenderHandle> {
|
|
638
|
+
/* FIRST, before anything emits CSS or an attribute. The resolver is module-global in the runtime,
|
|
639
|
+
so it is set on every render — including to null — or a previous document's context would
|
|
640
|
+
resolve this one's references. It cannot cross the realm as a function, which is why the parent
|
|
641
|
+
posts the plain-data context and the resolver is closed over it here. */
|
|
642
|
+
const assets = opts.assets ?? null;
|
|
643
|
+
setCanvasAssetResolver(assets ? (value) => resolveAssetRef(value, assets) : null);
|
|
626
644
|
setSkipServerFunctions(opts.mode !== "preview");
|
|
627
645
|
// Same gate for automatic `$prototype: "Request"` state entries. `buildScope` re-resolves every
|
|
628
646
|
// State entry on each full render, so without this an escalating authoring action (a signals-panel
|
|
@@ -644,7 +662,7 @@ export async function renderResolvedDocument(opts: {
|
|
|
644
662
|
// Component internals get stamped).
|
|
645
663
|
setStampPropBindings(opts.mode === "design" || opts.mode === "edit");
|
|
646
664
|
applySiteStyle(opts.siteStyle, (opts.doc as { $media?: Record<string, string> }).$media ?? {});
|
|
647
|
-
injectHead(opts.doc);
|
|
665
|
+
injectHead(opts.doc, assets);
|
|
648
666
|
syncEditModeCss(opts.container.ownerDocument, opts.mode);
|
|
649
667
|
syncPreviewShell(opts.container.ownerDocument, opts.mode);
|
|
650
668
|
syncStylebookCss(opts.container.ownerDocument, opts.mode);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* honors both the OS preference and the preview toggle.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { pureSchemeOf, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
|
|
12
|
+
import { pureSchemeOf, resolveAtQuery, schemeSelectors, camelToKebab } from "@jxsuite/runtime";
|
|
13
13
|
|
|
14
14
|
/** Id of the injected site-style tag (replace-in-place, never accumulate). */
|
|
15
15
|
export const SITE_STYLE_ID = "jx-site-style";
|
|
@@ -70,11 +70,7 @@ export function buildSiteStyleCSS(
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
for (const [atKey, block] of condBlocks) {
|
|
73
|
-
const query = atKey
|
|
74
|
-
? (mediaQueries[atKey.slice(1)] ?? atKey.slice(1))
|
|
75
|
-
: atKey.startsWith("@(")
|
|
76
|
-
? atKey.slice(1)
|
|
77
|
-
: null;
|
|
73
|
+
const query = resolveAtQuery(atKey, mediaQueries);
|
|
78
74
|
if (query === null) {
|
|
79
75
|
continue;
|
|
80
76
|
}
|
|
@@ -27,6 +27,7 @@ import type { DropPreview, FileDropHit } from "../canvas/iframe-protocol";
|
|
|
27
27
|
import type { JxPath } from "../state";
|
|
28
28
|
import type { Tab } from "../tabs/tab";
|
|
29
29
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
30
|
+
import { isMediaFormat } from "../utils/studio-utils";
|
|
30
31
|
|
|
31
32
|
/** Where an asset's URL goes when it replaces an existing element's picture. */
|
|
32
33
|
const REPLACE_ATTRS: Record<string, "src" | "poster"> = {
|
|
@@ -71,7 +72,7 @@ export function resolveFileDropTarget(
|
|
|
71
72
|
}
|
|
72
73
|
if (hit.tagName.includes("-")) {
|
|
73
74
|
const entry = registry.find((c) => c.tagName === hit.tagName);
|
|
74
|
-
const imageProps = (entry?.props ?? []).filter((p) => p.format
|
|
75
|
+
const imageProps = (entry?.props ?? []).filter((p) => isMediaFormat(p.format));
|
|
75
76
|
if (imageProps.length === 1) {
|
|
76
77
|
return { mode: "replace-prop", path: hit.path as JxPath, prop: imageProps[0]!.name };
|
|
77
78
|
}
|
package/src/files/files.ts
CHANGED
|
@@ -36,7 +36,7 @@ import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
|
|
|
36
36
|
import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
|
|
37
37
|
import { markLocalMutation } from "./fs-events";
|
|
38
38
|
import { registerPanel } from "../panels/panel-registry";
|
|
39
|
-
import {
|
|
39
|
+
import { isImage, uploadAccept, uploadAssets } from "./media-upload";
|
|
40
40
|
import { isCollabPath } from "../collab/collab-state";
|
|
41
41
|
import {
|
|
42
42
|
draggable,
|
|
@@ -994,7 +994,7 @@ export function pickAndUploadTo(dir: string, renderLeftPanel: () => void): void
|
|
|
994
994
|
const input = document.createElement("input");
|
|
995
995
|
input.type = "file";
|
|
996
996
|
input.multiple = true;
|
|
997
|
-
input.accept =
|
|
997
|
+
input.accept = uploadAccept();
|
|
998
998
|
input.addEventListener("change", () => {
|
|
999
999
|
if (input.files?.length) {
|
|
1000
1000
|
void uploadFilesToDir(input.files, dir, renderLeftPanel);
|
package/src/files/media-paths.ts
CHANGED
|
@@ -25,27 +25,22 @@
|
|
|
25
25
|
* as written rather than on the file as stored.
|
|
26
26
|
*
|
|
27
27
|
* Everything here is pure string math over project-relative, forward-slashed paths. The only input
|
|
28
|
-
* from outside is the open project's content sections, read the same way `
|
|
29
|
-
* them.
|
|
28
|
+
* from outside is the open project's content sections, read the same way `canvas/asset-refs.ts`
|
|
29
|
+
* reads them.
|
|
30
30
|
*
|
|
31
31
|
* @docs studio/projects/media
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import {
|
|
34
|
+
import { encodeProjectPath, normalizeProjectPath, PUBLIC_DIR } from "@jxsuite/schema/asset-paths";
|
|
35
|
+
import { contentMountFor, hostAssetDeclarations, projectLocales } from "../canvas/asset-refs";
|
|
36
|
+
import { loopbackAssetSrc } from "../canvas/canvas-origin";
|
|
35
37
|
import { projectState } from "../store";
|
|
36
38
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
37
39
|
import type { ContentSectionEntry } from "../types";
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A project-relative path in the one spelling every function here expects: forward slashes, no `./`
|
|
44
|
-
* prefix, no leading `/`, no trailing `/`.
|
|
45
|
-
*/
|
|
46
|
-
export function normalizeProjectPath(path: string): string {
|
|
47
|
-
return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
48
|
-
}
|
|
41
|
+
/* Re-exported rather than redefined: the same normalization is what the compiler, the servers and
|
|
42
|
+
the canvas resolver all key on, so there is one definition of it in `@jxsuite/schema`. */
|
|
43
|
+
export { normalizeProjectPath } from "@jxsuite/schema/asset-paths";
|
|
49
44
|
|
|
50
45
|
/** The filename of a project-relative path. */
|
|
51
46
|
export function baseName(path: string): string {
|
|
@@ -75,7 +70,7 @@ function contentSections(): Record<string, ContentSectionEntry> | null {
|
|
|
75
70
|
* rather than duplicating the loop.
|
|
76
71
|
*/
|
|
77
72
|
function mountOf(path: string): AssetMount | null {
|
|
78
|
-
return contentMountFor(path, contentSections());
|
|
73
|
+
return contentMountFor(path, contentSections(), projectLocales());
|
|
79
74
|
}
|
|
80
75
|
|
|
81
76
|
/**
|
|
@@ -139,3 +134,31 @@ export function authoredRefTargets(path: string): string[] {
|
|
|
139
134
|
}
|
|
140
135
|
return targets;
|
|
141
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The src a PARENT-REALM preview should load for a PROJECT FILE PATH — a library thumbnail, a
|
|
140
|
+
* media-browser tile.
|
|
141
|
+
*
|
|
142
|
+
* The sibling of `previewAssetSrc` in `canvas/asset-refs`, and separate from it because the two
|
|
143
|
+
* take different things and confusing them is silent. A file path is NOT an authored reference:
|
|
144
|
+
* `public/hero.jpg` is written `/hero.jpg`, a string that shares not one segment with it. The
|
|
145
|
+
* library grid built its `<img src>` by prefixing the path with a slash, so every `public/` image
|
|
146
|
+
* asked for `/public/hero.jpg` — a URL the site does not publish — and every content-collection
|
|
147
|
+
* image skipped its mount entirely.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} path - A project-relative file path, as the file tree spells it
|
|
150
|
+
* @returns {string} A src the parent realm can load
|
|
151
|
+
*/
|
|
152
|
+
export function previewFileSrc(path: string): string {
|
|
153
|
+
const normalized = normalizeProjectPath(path);
|
|
154
|
+
if (normalized === "") {
|
|
155
|
+
return path;
|
|
156
|
+
}
|
|
157
|
+
/* In repo space nothing answers a site URL, so the site URL is the wrong question entirely: the
|
|
158
|
+
host serves the FILE, at its own path, under the declared base. */
|
|
159
|
+
const { space, fileBaseUrl } = hostAssetDeclarations();
|
|
160
|
+
if (space === "repo") {
|
|
161
|
+
return `${fileBaseUrl}${encodeProjectPath(normalized)}`;
|
|
162
|
+
}
|
|
163
|
+
return loopbackAssetSrc(mediaSiteUrl(normalized));
|
|
164
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
20
|
-
import { getPlatform } from "../platform";
|
|
20
|
+
import { getPlatform, hasPlatform } from "../platform";
|
|
21
21
|
import { notify } from "../services/notify";
|
|
22
22
|
import { activeTab } from "../workspace/workspace";
|
|
23
23
|
|
|
@@ -50,8 +50,20 @@ export const MEDIA_EXTENSIONS = new Set([
|
|
|
50
50
|
".otf",
|
|
51
51
|
]);
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/** A byte count as a person reads it, for a refusal that names the limit. */
|
|
54
|
+
function formatBytes(bytes: number): string {
|
|
55
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
56
|
+
let value = bytes;
|
|
57
|
+
let unit = 0;
|
|
58
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
59
|
+
value /= 1024;
|
|
60
|
+
unit += 1;
|
|
61
|
+
}
|
|
62
|
+
return `${unit === 0 ? value : Number(value.toFixed(1))} ${units[unit]}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What Studio itself offers, before any backend narrows it. */
|
|
66
|
+
const DEFAULT_ACCEPT = [
|
|
55
67
|
"image/*",
|
|
56
68
|
"video/*",
|
|
57
69
|
"audio/*",
|
|
@@ -63,6 +75,20 @@ export const UPLOAD_ACCEPT = [
|
|
|
63
75
|
".otf",
|
|
64
76
|
].join(",");
|
|
65
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The `accept` attribute for every upload file input.
|
|
80
|
+
*
|
|
81
|
+
* A backend may NARROW it by declaring `assetCapabilities.accept`; nothing widens it, because the
|
|
82
|
+
* picker offering a type Studio cannot place in a document helps no one. Read as a function, not a
|
|
83
|
+
* constant, because the platform is registered after this module is imported.
|
|
84
|
+
*
|
|
85
|
+
* @returns {string} An `<input accept>` value
|
|
86
|
+
*/
|
|
87
|
+
export function uploadAccept(): string {
|
|
88
|
+
const declared = hasPlatform() ? getPlatform().assetCapabilities?.accept : undefined;
|
|
89
|
+
return declared ?? DEFAULT_ACCEPT;
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
/** What kind of element an uploaded asset wants to become. */
|
|
67
93
|
export type AssetKind = "image" | "video" | "audio" | "file";
|
|
68
94
|
|
|
@@ -225,18 +251,33 @@ export async function uploadAssets(
|
|
|
225
251
|
taken = new Set(); // Directory doesn't exist yet — the backend mkdir -p's on write.
|
|
226
252
|
}
|
|
227
253
|
|
|
254
|
+
/* A DECLARED limit, or none. Studio must not invent one: a limit it made up is a file the user
|
|
255
|
+
cannot upload for no reason anyone can name. A declared one is different — refusing here saves
|
|
256
|
+
the round trip and lets the refusal say the number. */
|
|
257
|
+
const maxBytes = hasPlatform() ? getPlatform().assetCapabilities?.maxUploadBytes : undefined;
|
|
258
|
+
|
|
228
259
|
const uploaded: UploadedAsset[] = [];
|
|
229
260
|
for (const file of list) {
|
|
261
|
+
if (maxBytes !== undefined && file.size > maxBytes) {
|
|
262
|
+
notify.error(`${file.name} is too large.`, {
|
|
263
|
+
detail: `This backend accepts at most ${formatBytes(maxBytes)}; that file is ${formatBytes(file.size)}.`,
|
|
264
|
+
source: "Media",
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
230
268
|
const name = uniqueName(file.name, taken);
|
|
231
269
|
taken.add(name);
|
|
232
|
-
const
|
|
270
|
+
const requested = joinDir(dir, name);
|
|
233
271
|
try {
|
|
234
|
-
|
|
272
|
+
/* The path the backend REPORTS, not the one asked for. A store that de-duplicates by content
|
|
273
|
+
hash, appends a collision suffix, or normalizes a name writes somewhere else, and the
|
|
274
|
+
reference that goes into the document has to name what is really there. */
|
|
275
|
+
const { path } = await platform.uploadFile(requested, file);
|
|
235
276
|
uploaded.push({ kind: mediaKind(file), name, path, ref: assetRef(path) });
|
|
236
277
|
} catch (error) {
|
|
237
278
|
notify.error(`Could not upload ${file.name}.`, {
|
|
238
279
|
detail: errorMessage(error),
|
|
239
|
-
path,
|
|
280
|
+
path: requested,
|
|
240
281
|
source: "Media",
|
|
241
282
|
});
|
|
242
283
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid-idle.ts — the grid's quiescence registry, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* A separate module from `grid-view.ts` for one reason: `services/idle.ts` must be able to ASK
|
|
5
|
+
* whether any grid is mid-flight without pulling Tabulator into its import graph. It read the
|
|
6
|
+
* probes from `grid-view.ts` first, and `grid-view.ts` imports `tabulator-tables` at module load —
|
|
7
|
+
* so every test that transitively reached `idle.ts` (which is most of them, through the shell)
|
|
8
|
+
* tried to evaluate the Tabulator ESM bundle and died with `Export named 'ClipboardModule' not
|
|
9
|
+
* found` unless it had thought to mock a module it never uses.
|
|
10
|
+
*
|
|
11
|
+
* So the dependency points the other way: this file imports nothing, `idle.ts` reads it, and
|
|
12
|
+
* `grid-view.ts` writes to it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Answers `null` when its grid is settled, or a human-readable reason when it is not. */
|
|
16
|
+
export type GridProbe = () => string | null;
|
|
17
|
+
|
|
18
|
+
const liveGrids = new Set<GridProbe>();
|
|
19
|
+
|
|
20
|
+
/** Register a live grid. The returned function deregisters it — call it from `destroy()`. */
|
|
21
|
+
export function registerGridProbe(probe: GridProbe): () => void {
|
|
22
|
+
liveGrids.add(probe);
|
|
23
|
+
return () => liveGrids.delete(probe);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Grids still building, or still laying out their selection range.
|
|
28
|
+
*
|
|
29
|
+
* A probe must never throw: it runs inside the idle predicate, and an exception there would be
|
|
30
|
+
* indistinguishable from a subsystem that never settles.
|
|
31
|
+
*/
|
|
32
|
+
export function gridIdleBlockers(): readonly string[] {
|
|
33
|
+
const blockers: string[] = [];
|
|
34
|
+
for (const probe of liveGrids) {
|
|
35
|
+
try {
|
|
36
|
+
const reason = probe();
|
|
37
|
+
if (reason) {
|
|
38
|
+
blockers.push(reason);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// A table torn down between registration and probe is settled by definition.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return blockers;
|
|
45
|
+
}
|
package/src/grid/grid-view.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
} from "tabulator-tables";
|
|
41
41
|
import type { GridCellValue, GridColumn } from "./grid-source";
|
|
42
42
|
import type { GridController } from "./grid-controller";
|
|
43
|
+
import { registerGridProbe } from "./grid-idle";
|
|
43
44
|
|
|
44
45
|
export interface GridView {
|
|
45
46
|
/** Re-pull controller.effectiveRows() into the table (buffer → table sync). */
|
|
@@ -222,6 +223,23 @@ export function createGridView(host: HTMLElement, controller: GridController): G
|
|
|
222
223
|
}
|
|
223
224
|
});
|
|
224
225
|
|
|
226
|
+
/*
|
|
227
|
+
* Settled means BUILT AND LAID OUT, not merely constructed. `selectableRange: 1` gives a
|
|
228
|
+
* non-empty table an initial range, and that range's outline is a rendered element — so until it
|
|
229
|
+
* exists the picture is still changing. An empty table is exempt, because it never gets one and
|
|
230
|
+
* waiting for it would be waiting forever.
|
|
231
|
+
*/
|
|
232
|
+
const probe = (): string | null => {
|
|
233
|
+
if (!built) {
|
|
234
|
+
return `grid[${gridId}]: building`;
|
|
235
|
+
}
|
|
236
|
+
if (controller.effectiveRows().length > 0 && table.getRanges().length === 0) {
|
|
237
|
+
return `grid[${gridId}]: selection range not laid out`;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
};
|
|
241
|
+
const unregisterProbe = registerGridProbe(probe);
|
|
242
|
+
|
|
225
243
|
table.on("cellEdited", (cell: CellComponent) => {
|
|
226
244
|
if (suppress) {
|
|
227
245
|
return;
|
|
@@ -307,6 +325,9 @@ export function createGridView(host: HTMLElement, controller: GridController): G
|
|
|
307
325
|
|
|
308
326
|
return {
|
|
309
327
|
destroy() {
|
|
328
|
+
// Before `table.destroy()`: a probe left registered against a torn-down table would be asking
|
|
329
|
+
// Tabulator questions it can no longer answer.
|
|
330
|
+
unregisterProbe();
|
|
310
331
|
controller.bindView(null);
|
|
311
332
|
table.destroy();
|
|
312
333
|
},
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { GridCellValue, GridColumn, GridColumnKind } from "./grid-source";
|
|
12
12
|
import type { JsonSchema } from "../ui/schema-form";
|
|
13
|
+
import { isMediaFormat } from "../utils/studio-utils";
|
|
13
14
|
|
|
14
15
|
/** JSON-Schema property as it appears in content-type schemas (superset of ui JsonSchema). */
|
|
15
16
|
interface PropSchema extends JsonSchema {
|
|
@@ -44,7 +45,7 @@ export function kindForProp(prop: PropSchema): GridColumnKind {
|
|
|
44
45
|
return "enum";
|
|
45
46
|
}
|
|
46
47
|
const format = prop.type === "array" ? undefined : prop.format;
|
|
47
|
-
if (format
|
|
48
|
+
if (isMediaFormat(format)) {
|
|
48
49
|
return "image";
|
|
49
50
|
}
|
|
50
51
|
if (format === "date" || format === "date-time") {
|
|
@@ -269,20 +269,41 @@ function buildEntryFileSource(opts: EntryFileSourceOptions): GridSource {
|
|
|
269
269
|
const files = await opts.list();
|
|
270
270
|
entries.clear();
|
|
271
271
|
columnsCache = null;
|
|
272
|
-
|
|
272
|
+
/*
|
|
273
|
+
* Read concurrently, INSERT IN ORDER.
|
|
274
|
+
*
|
|
275
|
+
* `entries` is a Map and the grid renders it in insertion order, so setting it from inside
|
|
276
|
+
* the worker made row order the order the reads happened to FINISH in — which is a race
|
|
277
|
+
* between two `platform.readFile` calls, not a property of the collection. The two posts in
|
|
278
|
+
* the `first-collection` fixture swapped places between runs, and that alone rewrote
|
|
279
|
+
* `blog-grid.png` in 3 of the 21 images the screenshot lane pushed over 24 commits.
|
|
280
|
+
*
|
|
281
|
+
* `mapLimit` already documents that it preserves item order; the fix is to USE its result
|
|
282
|
+
* rather than to mutate shared state from inside it. The concurrency is worth keeping — a
|
|
283
|
+
* collection is a directory of files and reading it eight at a time is why the grid opens
|
|
284
|
+
* quickly.
|
|
285
|
+
*/
|
|
286
|
+
const loaded = await mapLimit(files, 8, async (path): Promise<EntryRecord | null> => {
|
|
273
287
|
try {
|
|
274
288
|
const text = await platform.readFile(path);
|
|
275
289
|
const parsed = await parseSourceForPath(path, text);
|
|
276
|
-
|
|
290
|
+
return {
|
|
277
291
|
document: parsed.document,
|
|
278
292
|
formatName: parsed.format.name,
|
|
279
293
|
frontmatter: parsed.frontmatter ?? {},
|
|
280
294
|
rawText: text,
|
|
281
|
-
}
|
|
295
|
+
};
|
|
282
296
|
} catch {
|
|
283
297
|
// Unparseable entry — leave it out rather than failing the whole grid.
|
|
298
|
+
return null;
|
|
284
299
|
}
|
|
285
300
|
});
|
|
301
|
+
for (const [index, path] of files.entries()) {
|
|
302
|
+
const record = loaded[index];
|
|
303
|
+
if (record) {
|
|
304
|
+
entries.set(path, record);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
286
307
|
})();
|
|
287
308
|
return loadPromise;
|
|
288
309
|
};
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { html } from "lit-html";
|
|
10
10
|
import { getPlatform } from "../platform";
|
|
11
|
-
import { getBaseUrl,
|
|
11
|
+
import { getBaseUrl, getOpenAiKey } from "../services/ai-settings";
|
|
12
|
+
import { preferredModel } from "../services/ai-models";
|
|
12
13
|
import { IMPORT_WARNING_PHASE } from "../services/import-client";
|
|
13
14
|
import { notify } from "../services/notify";
|
|
14
15
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
@@ -148,7 +149,7 @@ export async function startImport(ctx: ImportTabCtx) {
|
|
|
148
149
|
try {
|
|
149
150
|
const key = getOpenAiKey();
|
|
150
151
|
const baseUrl = getBaseUrl();
|
|
151
|
-
const model =
|
|
152
|
+
const model = preferredModel();
|
|
152
153
|
const result = await platform.importSite(
|
|
153
154
|
{
|
|
154
155
|
url: parsed.href,
|
|
@@ -20,13 +20,18 @@ import type { TemplateResult } from "lit-html";
|
|
|
20
20
|
import { live } from "lit-html/directives/live.js";
|
|
21
21
|
import { ref } from "lit-html/directives/ref.js";
|
|
22
22
|
import { getNodeAtPath } from "../../state";
|
|
23
|
-
import {
|
|
24
|
-
|
|
23
|
+
import {
|
|
24
|
+
aiConnection,
|
|
25
|
+
cachedModels,
|
|
26
|
+
fetchAvailableModels,
|
|
27
|
+
preferredModel,
|
|
28
|
+
} from "../../services/ai-models";
|
|
29
|
+
import { setModel } from "../../services/ai-settings";
|
|
25
30
|
import { activeTab } from "../../workspace/workspace";
|
|
26
31
|
import { primarySelection } from "../../tabs/selection";
|
|
27
32
|
import { buildMessageWithContext } from "./attached-context";
|
|
28
33
|
import type { ContextChip } from "./attached-context";
|
|
29
|
-
import type {
|
|
34
|
+
import type { AiCredentials } from "../../services/ai-models";
|
|
30
35
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
31
36
|
|
|
32
37
|
/** Tallest the textarea auto-grows before it scrolls internally. */
|
|
@@ -72,24 +77,36 @@ export function createComposer(opts: ComposerOptions): Composer {
|
|
|
72
77
|
let chips: ContextChip[] = [];
|
|
73
78
|
|
|
74
79
|
/** Null until the first fetch settles; kept on error so the picker can offer Retry. */
|
|
75
|
-
let models: AiModel[] | null = null;
|
|
76
80
|
let modelsLoading = false;
|
|
77
81
|
let modelsError = "";
|
|
78
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The connection the last fetch was made for, so a failure is not retried on every render.
|
|
85
|
+
*
|
|
86
|
+
* The list itself is NOT held here. A private copy is how the picker came to show one provider's
|
|
87
|
+
* catalogue while another was configured: it was filled once and never reconsidered. Reading it
|
|
88
|
+
* from `cachedModels(connection)` means a list can only ever be shown for the credentials it was
|
|
89
|
+
* listed under, and a credential change makes it unavailable rather than stale.
|
|
90
|
+
*/
|
|
91
|
+
let attempted: AiCredentials | null = null;
|
|
92
|
+
|
|
79
93
|
// ── Model picker ──────────────────────────────────────────────────────
|
|
80
94
|
|
|
95
|
+
function sameConnection(a: AiCredentials | null, b: AiCredentials): boolean {
|
|
96
|
+
return a !== null && a.apiKey === b.apiKey && a.baseUrl === b.baseUrl;
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
function ensureModels(force = false) {
|
|
82
|
-
|
|
100
|
+
const credentials = aiConnection();
|
|
101
|
+
const settled = cachedModels(credentials) !== null || sameConnection(attempted, credentials);
|
|
102
|
+
if (modelsLoading || (settled && !force)) {
|
|
83
103
|
return;
|
|
84
104
|
}
|
|
85
105
|
modelsLoading = true;
|
|
86
106
|
modelsError = "";
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
models = list;
|
|
90
|
-
})
|
|
107
|
+
attempted = credentials;
|
|
108
|
+
fetchAvailableModels({ credentials, force })
|
|
91
109
|
.catch((error: unknown) => {
|
|
92
|
-
models = [];
|
|
93
110
|
modelsError = (error as Error).message || "Failed to fetch models";
|
|
94
111
|
})
|
|
95
112
|
.finally(() => {
|
|
@@ -101,7 +118,7 @@ export function createComposer(opts: ComposerOptions): Composer {
|
|
|
101
118
|
function onModelChange(e: Event) {
|
|
102
119
|
const { value } = e.target as HTMLInputElement;
|
|
103
120
|
if (value === RETRY_MODELS) {
|
|
104
|
-
|
|
121
|
+
attempted = null;
|
|
105
122
|
ensureModels(true);
|
|
106
123
|
// Re-render so the picker snaps back to the stored model instead of "Retry".
|
|
107
124
|
opts.requestRender();
|
|
@@ -114,8 +131,8 @@ export function createComposer(opts: ComposerOptions): Composer {
|
|
|
114
131
|
|
|
115
132
|
function renderModelPicker(): TemplateResult {
|
|
116
133
|
ensureModels();
|
|
117
|
-
const current =
|
|
118
|
-
const listed =
|
|
134
|
+
const current = preferredModel();
|
|
135
|
+
const listed = cachedModels(aiConnection()) ?? [];
|
|
119
136
|
const items = listed.some((m) => m.id === current)
|
|
120
137
|
? listed
|
|
121
138
|
: [{ id: current, name: current }, ...listed];
|
|
@@ -14,7 +14,7 @@ import { renderFieldRow } from "../ui/field-row";
|
|
|
14
14
|
import { spNumberField, spTextField } from "../ui/field-input";
|
|
15
15
|
import { renderMediaPicker } from "../ui/media-picker";
|
|
16
16
|
import { mutateUpdateFrontmatter, transactDoc } from "../tabs/transact";
|
|
17
|
-
import { findContentTypeSchema } from "../utils/studio-utils";
|
|
17
|
+
import { findContentTypeSchema, isMediaFormat } from "../utils/studio-utils";
|
|
18
18
|
import { NULL_FORM_CONTEXT, getFormControl, referenceTarget } from "../ui/schema-form";
|
|
19
19
|
import type { JsonSchema } from "../ui/schema-form";
|
|
20
20
|
|
|
@@ -233,7 +233,11 @@ export function renderFmField(
|
|
|
233
233
|
});
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
/* Both spellings. `"uri-reference"` is the one the SPEC uses and the one the content loader keys
|
|
237
|
+
its asset rewrite on (`rewriteEntryAssets`), so a schema written against the spec got a plain
|
|
238
|
+
text box here while the same field got a media picker in the properties panel — which reads
|
|
239
|
+
`inferInputType`, and that has always accepted it. */
|
|
240
|
+
if (isMediaFormat(entry.format)) {
|
|
237
241
|
return renderFieldRow({
|
|
238
242
|
hasValue: hasVal,
|
|
239
243
|
label: displayLabel,
|
|
@@ -49,7 +49,13 @@ import { renderFieldRow, renderKvRow } from "../ui/field-row";
|
|
|
49
49
|
import type { FieldProvenance } from "./provenance";
|
|
50
50
|
import { renderDynamicSlot, slotMode } from "../ui/dynamic-slot";
|
|
51
51
|
import { spTextArea, spTextField } from "../ui/field-input";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
attrLabel,
|
|
54
|
+
camelToLabel,
|
|
55
|
+
inferInputType,
|
|
56
|
+
isMediaFormat,
|
|
57
|
+
parseCemType,
|
|
58
|
+
} from "../utils/studio-utils";
|
|
53
59
|
import { classifyHref, composeHref } from "../utils/link-target";
|
|
54
60
|
import type { LinkKind } from "../utils/link-target";
|
|
55
61
|
import {
|
|
@@ -274,7 +280,7 @@ function renderComponentPropsFieldsTemplate(
|
|
|
274
280
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
275
281
|
let debounce: ReturnType<typeof setTimeout> | undefined;
|
|
276
282
|
let widgetTpl;
|
|
277
|
-
if (prop.format
|
|
283
|
+
if (isMediaFormat(prop.format)) {
|
|
278
284
|
widgetTpl = renderMediaPicker(prop.name, staticVal, onChange);
|
|
279
285
|
} else if (prop.format === "color") {
|
|
280
286
|
widgetTpl = renderColorSelector(prop.name, staticVal, onChange);
|
package/src/panels/seo-modal.ts
CHANGED
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { html, nothing } from "lit-html";
|
|
26
26
|
import { activeRegistry } from "../commands/active-registry";
|
|
27
|
-
import {
|
|
28
|
-
import { previewAssetSrc } from "../canvas/content-assets";
|
|
27
|
+
import { previewAssetSrc } from "../canvas/asset-refs";
|
|
29
28
|
import { renderFieldRow } from "../ui/field-row";
|
|
30
29
|
import { renderMediaPicker } from "../ui/media-picker";
|
|
31
30
|
import { renderProvenanceChip } from "./provenance";
|
|
@@ -140,9 +139,9 @@ function serpCard(preview: SeoPreview): TemplateResult {
|
|
|
140
139
|
/**
|
|
141
140
|
* The mock social card.
|
|
142
141
|
*
|
|
143
|
-
* The image is resolved the same way every other image in the studio chrome is —
|
|
144
|
-
*
|
|
145
|
-
*
|
|
142
|
+
* The image is resolved the same way every other image in the studio chrome is — `previewAssetSrc`
|
|
143
|
+
* — so a content-relative `./images/hero.jpg` previews at its asset-mount URL while the authored
|
|
144
|
+
* ref stays exactly as written.
|
|
146
145
|
*/
|
|
147
146
|
function socialCard(preview: SeoPreview): TemplateResult {
|
|
148
147
|
const image = seoField(preview, "og:image").value.trim();
|
|
@@ -152,7 +151,7 @@ function socialCard(preview: SeoPreview): TemplateResult {
|
|
|
152
151
|
<div class="seo-social-media">
|
|
153
152
|
${
|
|
154
153
|
image
|
|
155
|
-
? html`<img src=${
|
|
154
|
+
? html`<img src=${previewAssetSrc(image)} alt="" />`
|
|
156
155
|
: html`<span class="seo-unset">No image</span>`
|
|
157
156
|
}
|
|
158
157
|
</div>
|