@jxsuite/studio 2.4.3 → 4.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/README.md +5 -5
- package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
- package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
- package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
- package/dist/chunks/content-source-t88vb2dr.js +49 -0
- package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
- package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
- package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
- package/dist/chunks/file-ops-rskmhxyq.js +48 -0
- package/dist/chunks/files-0cratsr0.js +92 -0
- package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
- package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
- package/dist/chunks/layers-panel-z3tqe5cd.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-rws7k4br.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-54jvhskq.js +28 -0
- package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
- package/dist/chunks/references-3qx8fkyp.js +29 -0
- package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
- package/dist/chunks/sp-overlay.dev-yztmtrs1.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-xcvpnqr0.js +15 -0
- package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
- package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
- package/dist/chunks/studio-2zkz8evn.js +3163 -0
- package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
- package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
- package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
- package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
- package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
- package/dist/chunks/studio-92ab2jqe.js +282 -0
- package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
- package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
- package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
- package/dist/chunks/studio-eesa1myv.js +498 -0
- package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
- package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
- package/dist/chunks/studio-gv8vvshc.js +118 -0
- package/dist/chunks/studio-hbtp0eh1.js +1315 -0
- package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
- package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
- package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
- package/dist/chunks/studio-kyh9avvv.js +28 -0
- package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
- package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
- package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
- 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-sccge2wf.js → studio-p8j86wag.js} +2 -2
- package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
- package/dist/chunks/studio-q43a5y9h.js +394 -0
- package/dist/chunks/studio-q7v10cg2.js +106 -0
- package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
- package/dist/chunks/studio-r1r7afxx.js +1066 -0
- 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-sf7zjgv1.js +91 -0
- package/dist/chunks/studio-t7qb9db8.js +665 -0
- package/dist/chunks/studio-tmc5emrw.js +8 -0
- package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
- package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
- package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
- package/dist/chunks/studio-wcy8trfx.js +263 -0
- package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
- package/dist/chunks/studio-xrapxwwc.js +1539 -0
- package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/workspace-xpres40v.js +89 -0
- package/dist/iframe-entry.js +450 -48
- package/dist/studio.js +2455 -1761
- package/package.json +16 -15
- package/src/browse/library-layouts.ts +3 -3
- package/src/browse/library-pane.ts +59 -5
- 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/canvas-render.ts +23 -1
- package/src/canvas/iframe-entry.ts +1 -0
- package/src/canvas/iframe-host.ts +4 -0
- package/src/canvas/iframe-patch.ts +47 -21
- package/src/canvas/iframe-protocol.ts +9 -0
- package/src/canvas/iframe-render.ts +50 -16
- 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/file-drop-action.ts +2 -1
- package/src/editor/shortcuts.ts +5 -0
- package/src/files/file-ops.ts +10 -42
- package/src/files/files.ts +487 -61
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- package/src/files/media-paths.ts +37 -14
- package/src/files/media-upload.ts +47 -6
- 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-idle.ts +45 -0
- package/src/grid/grid-view.ts +35 -0
- package/src/grid/schema-columns.ts +2 -1
- package/src/grid/sources/content-source.ts +24 -3
- package/src/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +333 -160
- 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 +21 -80
- package/src/panels/ai-panel.ts +116 -2
- package/src/panels/frontmatter-fields.ts +6 -2
- package/src/panels/navigator-panels.ts +7 -6
- package/src/panels/properties-panel.ts +8 -2
- package/src/panels/seo-modal.ts +5 -6
- package/src/panels/settings-menu.ts +799 -0
- package/src/panels/signals-panel.ts +2 -1
- package/src/panels/toolbar.ts +135 -15
- package/src/platforms/cloud.ts +34 -3
- package/src/platforms/devserver.ts +72 -34
- package/src/preview/preview-overlay.ts +226 -0
- package/src/services/ai-ask.ts +247 -0
- package/src/services/ai-import-tools.ts +494 -0
- package/src/services/ai-models.ts +101 -11
- package/src/services/ai-project-tools.ts +20 -26
- package/src/services/ai-settings.ts +69 -71
- package/src/services/ai-system-prompt.ts +88 -2
- package/src/services/ai-turn-signal.ts +70 -0
- package/src/services/cf-settings.ts +19 -37
- package/src/services/connection.ts +74 -0
- package/src/services/context-manager.ts +89 -0
- package/src/services/document-assistant.ts +49 -7
- package/src/services/idle.ts +6 -1
- 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 +181 -0
- package/src/services/settings/kernel.ts +335 -0
- package/src/services/settings/write-queue.ts +116 -0
- package/src/services/tool-executor.ts +34 -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 +6 -19
- package/src/settings/preferences-dialog.ts +18 -41
- package/src/settings/preferences-keymap.ts +8 -18
- package/src/settings/preferences-sections.ts +57 -0
- package/src/settings/schema-field-ui.ts +2 -2
- package/src/settings/settings-document.ts +65 -9
- package/src/shell.ts +6 -14
- package/src/studio.ts +65 -7
- package/src/tabs/transact.ts +42 -19
- package/src/types.ts +164 -8
- package/src/ui/ai-credentials-form.ts +52 -27
- package/src/ui/ai-model-picker.ts +162 -0
- package/src/ui/choice-field.ts +72 -0
- package/src/ui/layers.ts +83 -5
- package/src/ui/media-picker.ts +6 -11
- package/src/utils/studio-utils.ts +43 -31
- package/styles/forced-colors.css +7 -0
- package/styles/overlays.css +256 -0
- package/styles/panels.css +13 -1
- package/styles/shell.css +111 -0
- package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
- package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
- package/dist/chunks/content-source-k9724vcv.js +0 -39
- package/dist/chunks/files-yew27sn9.js +0 -69
- package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
- package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
- package/dist/chunks/quick-search-9avyc4pc.js +0 -54
- package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
- 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-a7pzveq1.js +0 -110
- package/dist/chunks/studio-c31yb4t6.js +0 -55
- package/dist/chunks/studio-ra7ar50f.js +0 -6682
- package/src/canvas/content-assets.ts +0 -263
- package/src/canvas/site-style-css.ts +0 -121
- package/src/services/settings-store.ts +0 -99
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert-file — moving one document between formats, in place.
|
|
3
|
+
*
|
|
4
|
+
* A conversion IS a rename with the bytes rewritten on the way, and that framing is what makes it
|
|
5
|
+
* safe: `platform.renameFile` is the only door to the backend's refactor pass, so going through it
|
|
6
|
+
* is what rewrites every `$ref`/`$src` that pointed at the old name. Writing a new file beside the
|
|
7
|
+
* old one and deleting it would leave every reference dangling.
|
|
8
|
+
*
|
|
9
|
+
* **The bytes are written BEFORE the rename**, which is the one ordering decision in this file.
|
|
10
|
+
* `applyRename` (`packages/server/src/refactor/apply.ts`) runs AFTER the move and `loadDoc`s every
|
|
11
|
+
* document in the project — including the one that just moved, for tag derivation and for its own
|
|
12
|
+
* references. Renaming first would hand it markdown at a `.json` path, which throws into
|
|
13
|
+
* `report.errors`, silently skips tag derivation, and leaves the moved file's own references
|
|
14
|
+
* unrewritten. Writing first means the file parses at every point the refactor looks at it, which
|
|
15
|
+
* is also why `report.errors` can be surfaced unfiltered afterwards: the only entry that can appear
|
|
16
|
+
* means a reference really was left stale.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here reaches the DOM at import time. `packages/studio/tests/app-commands.test.ts` imports
|
|
19
|
+
* the command surface in a bare Bun process and asserts empty stderr, so every module that touches
|
|
20
|
+
* `document` — the dialogs, the workspace, the file layer — is reached through `await import()`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
argsSchema,
|
|
25
|
+
derivedEnumProperty,
|
|
26
|
+
stringArg,
|
|
27
|
+
stringProperty,
|
|
28
|
+
} from "../commands/command-args";
|
|
29
|
+
import { convertTargetExtensions, convertTargets } from "./format-choices";
|
|
30
|
+
import type { AnyCommand, CommandRegistry } from "../commands/registry";
|
|
31
|
+
|
|
32
|
+
/** What a conversion will do, resolved in full before the reader is asked to confirm it. */
|
|
33
|
+
export interface ConversionPlan {
|
|
34
|
+
targetExt: string;
|
|
35
|
+
targetPath: string;
|
|
36
|
+
/** The converted source text. Computed before anything is written, so a throw costs nothing. */
|
|
37
|
+
text: string;
|
|
38
|
+
/** Consequence sentences, in the order they are shown. */
|
|
39
|
+
lines: string[];
|
|
40
|
+
/** A refusal. Non-null means the dialog states it and cannot be confirmed. */
|
|
41
|
+
blocker: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The directory holding a path. */
|
|
45
|
+
function parentDir(path: string): string {
|
|
46
|
+
const normalized = path.replaceAll("\\", "/");
|
|
47
|
+
return normalized.includes("/") ? normalized.slice(0, normalized.lastIndexOf("/")) : ".";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The extension of a path, lowercased and including the dot, or `""`. */
|
|
51
|
+
function extensionOf(path: string): string {
|
|
52
|
+
const dot = path.lastIndexOf(".");
|
|
53
|
+
return dot > 0 ? path.slice(dot).toLowerCase() : "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The same path carrying a different extension. */
|
|
57
|
+
function withExtension(path: string, ext: string): string {
|
|
58
|
+
const current = extensionOf(path);
|
|
59
|
+
return current === "" ? `${path}${ext}` : `${path.slice(0, path.length - current.length)}${ext}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The document a source file holds, as `{ document, frontmatter }`.
|
|
64
|
+
*
|
|
65
|
+
* `.json` is the native shape and is its own document with no frontmatter; anything else goes
|
|
66
|
+
* through the format's `parse` and the `$studio.documentMode` split the studio already applies when
|
|
67
|
+
* it opens one. Going through `parseSourceForPath` rather than `formatParse` directly is what makes
|
|
68
|
+
* the conversion's input identical to the editor's.
|
|
69
|
+
*/
|
|
70
|
+
async function readDocument(
|
|
71
|
+
path: string,
|
|
72
|
+
source: string,
|
|
73
|
+
): Promise<{ document: Record<string, unknown>; frontmatter: Record<string, unknown> }> {
|
|
74
|
+
const { formatForPath } = await import("./format-host");
|
|
75
|
+
if (formatForPath(path)) {
|
|
76
|
+
const { parseSourceForPath } = await import("../files/file-ops");
|
|
77
|
+
const parsed = await parseSourceForPath(path, source);
|
|
78
|
+
return { document: parsed.document, frontmatter: parsed.frontmatter };
|
|
79
|
+
}
|
|
80
|
+
return { document: JSON.parse(source) as Record<string, unknown>, frontmatter: {} };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The source text a document becomes in the target format.
|
|
85
|
+
*
|
|
86
|
+
* The frontmatter is merged back onto the document root, which is the inverse of the split above
|
|
87
|
+
* and exactly what `files/file-ops.ts`'s `serializeDocument` does on every save. It is also why a
|
|
88
|
+
* markdown → JSON conversion writes a FLAT document — frontmatter keys at the root beside
|
|
89
|
+
* `children` — rather than the split shape: `Markdown.parse` produces flat, `Markdown.serialize`
|
|
90
|
+
* re-emits every non-`children` root key as YAML, and flat is therefore the shape that round-trips.
|
|
91
|
+
* Writing the split shape would drop the frontmatter on the way out.
|
|
92
|
+
*/
|
|
93
|
+
async function writeDocument(
|
|
94
|
+
targetExt: string,
|
|
95
|
+
document: Record<string, unknown>,
|
|
96
|
+
frontmatter: Record<string, unknown>,
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
const merged = { ...frontmatter, ...document };
|
|
99
|
+
if (targetExt === ".json") {
|
|
100
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
101
|
+
}
|
|
102
|
+
const { formatByExtension, formatSerialize } = await import("./format-host");
|
|
103
|
+
const format = formatByExtension(targetExt, "serialize");
|
|
104
|
+
if (!format) {
|
|
105
|
+
throw new Error(`No installed format writes ${targetExt} files.`);
|
|
106
|
+
}
|
|
107
|
+
/*
|
|
108
|
+
* Frontmatter is asked for whenever the MERGED document carries anything but its body.
|
|
109
|
+
*
|
|
110
|
+
* Keying it off the split `frontmatter` alone is wrong in exactly the direction that matters: a
|
|
111
|
+
* `.json` source has no split — every key, `title` included, is already on the document — so the
|
|
112
|
+
* split map is empty and the serializer would be told to write no frontmatter at all. The title
|
|
113
|
+
* would vanish, and only on the JSON → markdown leg.
|
|
114
|
+
*/
|
|
115
|
+
return formatSerialize(
|
|
116
|
+
format.name,
|
|
117
|
+
{ ...merged, children: document.children ?? [] },
|
|
118
|
+
{ frontmatter: Object.keys(merged).some((key) => key !== "children"), mode: "roundtrip" },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether the converted file reads back as itself.
|
|
124
|
+
*
|
|
125
|
+
* Compared as TEXT, not as documents. A document comparison reports a difference for every
|
|
126
|
+
* normalization the format legitimately performs — Markdown alone turns a bare `textContent` into
|
|
127
|
+
* `children: [{ tagName: "p" }]` — and a warning that fires on almost every file is one nobody
|
|
128
|
+
* reads. Serializing twice asks the only question that matters to the author: will the file I open
|
|
129
|
+
* tomorrow still say this?
|
|
130
|
+
*
|
|
131
|
+
* A `.json` target is stable by construction (`JSON.parse` of `JSON.stringify` is exact), so the
|
|
132
|
+
* risk there is a schema question and is answered separately.
|
|
133
|
+
*/
|
|
134
|
+
async function readsBackIdentically(targetPath: string, text: string): Promise<boolean> {
|
|
135
|
+
if (extensionOf(targetPath) === ".json") {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const round = await readDocument(targetPath, text);
|
|
140
|
+
const again = await writeDocument(extensionOf(targetPath), round.document, round.frontmatter);
|
|
141
|
+
return again === text;
|
|
142
|
+
} catch {
|
|
143
|
+
// Unanswerable is not the same as unstable, and the schema/serialize checks already speak for
|
|
144
|
+
// The cases that can actually fail. Staying silent beats inventing a warning.
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Everything the reader must be told, resolved BEFORE the dialog opens.
|
|
151
|
+
*
|
|
152
|
+
* `specs/studio.md` §9.1.1 requires a destructive dialog to state what it breaks, and requires the
|
|
153
|
+
* count to be on screen when the button is pressed rather than two frames later. A conversion is
|
|
154
|
+
* destructive in exactly that sense: the original file is gone, and every reference to it is being
|
|
155
|
+
* rewritten on the author's behalf.
|
|
156
|
+
*
|
|
157
|
+
* @param path - The file being converted.
|
|
158
|
+
* @param source - Its current text.
|
|
159
|
+
* @param targetExt - The extension it will carry.
|
|
160
|
+
* @returns The plan, whose `blocker` is non-null when the conversion must not proceed.
|
|
161
|
+
*/
|
|
162
|
+
export async function buildPlan(
|
|
163
|
+
path: string,
|
|
164
|
+
source: string,
|
|
165
|
+
targetExt: string,
|
|
166
|
+
): Promise<ConversionPlan> {
|
|
167
|
+
const targetPath = withExtension(path, targetExt);
|
|
168
|
+
const lines: string[] = [];
|
|
169
|
+
|
|
170
|
+
let text: string;
|
|
171
|
+
try {
|
|
172
|
+
const { document, frontmatter } = await readDocument(path, source);
|
|
173
|
+
text = await writeDocument(targetExt, document, frontmatter);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const { errorMessage } = await import("@jxsuite/schema/parse");
|
|
176
|
+
return {
|
|
177
|
+
blocker: errorMessage(error),
|
|
178
|
+
lines,
|
|
179
|
+
targetExt,
|
|
180
|
+
targetPath,
|
|
181
|
+
text: "",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (targetExt === ".json") {
|
|
186
|
+
/* A three-state answer, because `validateDoc` fails OPEN: an unavailable validator returns no
|
|
187
|
+
errors, which is indistinguishable from a valid document. Claiming validity on the strength of
|
|
188
|
+
a schema that was never compiled is the "never render 0" defect one layer down. */
|
|
189
|
+
const { validateDocOrNull } = await import("../services/jx-validate");
|
|
190
|
+
const errors = await validateDocOrNull(JSON.parse(text));
|
|
191
|
+
if (errors === null) {
|
|
192
|
+
lines.push(
|
|
193
|
+
"The result could not be checked against the document schema — that is not the same as " +
|
|
194
|
+
"it being valid.",
|
|
195
|
+
);
|
|
196
|
+
} else if (errors.length > 0) {
|
|
197
|
+
const where = [...new Set(errors.map((e) => e.split(":")[0]))].join(", ");
|
|
198
|
+
return {
|
|
199
|
+
blocker: `The result would not be a valid Jx document (${where}).`,
|
|
200
|
+
lines,
|
|
201
|
+
targetExt,
|
|
202
|
+
targetPath,
|
|
203
|
+
text,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!(await readsBackIdentically(targetPath, text))) {
|
|
209
|
+
lines.push("This file will not read back identically after conversion.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* The count only. Which referrers the engine could not rewrite is reported AFTER the move, per
|
|
213
|
+
file, by the same `notifyMoveOutcome` a rename and a drag-move use — a fact beats the guess
|
|
214
|
+
this used to make from the referrer's extension, which could be wrong in both directions. */
|
|
215
|
+
const { loadUsages, usageWarning } = await import("../services/references");
|
|
216
|
+
const sentence = usageWarning(await loadUsages({ path }), "convert");
|
|
217
|
+
if (sentence !== null) {
|
|
218
|
+
lines.push(sentence);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
lines.push(`${path} becomes ${targetPath}. The original file is not kept.`);
|
|
222
|
+
return { blocker: null, lines, targetExt, targetPath, text };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Convert one file to another format, in place.
|
|
227
|
+
*
|
|
228
|
+
* Every refusal fires before anything on disk moves, and each is a Problem carrying the path,
|
|
229
|
+
* because the thing the author has to do next is about that path.
|
|
230
|
+
*
|
|
231
|
+
* @param path - Project-relative path of the file to convert.
|
|
232
|
+
* @param targetExt - The extension to convert to. Omitted, the reader is asked.
|
|
233
|
+
* @returns The new path, or null when refused or cancelled.
|
|
234
|
+
*/
|
|
235
|
+
export async function convertFile(path: string, targetExt?: string): Promise<string | null> {
|
|
236
|
+
const { notify } = await import("../services/notify");
|
|
237
|
+
const { errorMessage } = await import("@jxsuite/schema/parse");
|
|
238
|
+
const fail = (message: string, detail: string) => {
|
|
239
|
+
notify.error(message, { detail, path, source: "Convert" });
|
|
240
|
+
return null;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const { loadFormats } = await import("./format-host");
|
|
244
|
+
await loadFormats();
|
|
245
|
+
|
|
246
|
+
const targets = convertTargets(path);
|
|
247
|
+
if (targets.length === 0) {
|
|
248
|
+
return fail(`${path} cannot be converted to another format.`, CANNOT_CONVERT);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const { workspace } = await import("../workspace/workspace");
|
|
252
|
+
const { commitTabBuffers } = await import("../services/monaco-buffer");
|
|
253
|
+
const openTabEntry = [...workspace.tabs.values()].find((tab) => tab.documentPath === path);
|
|
254
|
+
if (openTabEntry) {
|
|
255
|
+
await commitTabBuffers(openTabEntry);
|
|
256
|
+
if (openTabEntry.doc.dirty) {
|
|
257
|
+
return fail(`${path} has unsaved changes.`, DIRTY_TAB);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const chosen = targetExt ?? (await pickTarget(path, targets));
|
|
262
|
+
if (chosen === null) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
if (!targets.some((target) => target.ext === chosen)) {
|
|
266
|
+
return fail(`${path} cannot be converted to ${chosen}.`, CANNOT_CONVERT);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { getPlatform } = await import("../platform");
|
|
270
|
+
const platform = getPlatform();
|
|
271
|
+
|
|
272
|
+
const targetPath = withExtension(path, chosen);
|
|
273
|
+
/* Fail CLOSED on a listing failure, unlike the creation dialog. There the destination may simply
|
|
274
|
+
not exist yet and the write is the authority; here the parent provably exists, and the operation
|
|
275
|
+
below is `rename(2)`, which replaces its destination without a word. */
|
|
276
|
+
try {
|
|
277
|
+
const listing = await platform.listDirectory(parentDir(path));
|
|
278
|
+
const taken = new Set(listing.map((entry) => entry.path.replaceAll("\\", "/").toLowerCase()));
|
|
279
|
+
if (taken.has(targetPath.toLowerCase())) {
|
|
280
|
+
return fail(`${targetPath} already exists.`, EXISTS);
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return fail(`Could not check whether ${targetPath} already exists.`, errorMessage(error));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let original: string;
|
|
287
|
+
try {
|
|
288
|
+
original = (await platform.readFile(path)) ?? "";
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return fail(`Could not read ${path}.`, errorMessage(error));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const plan = await buildPlan(path, original, chosen);
|
|
294
|
+
if (plan.blocker !== null) {
|
|
295
|
+
return fail(`${path} cannot be converted to ${chosen}.`, plan.blocker);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const { showConfirmDialog } = await import("../ui/layers");
|
|
299
|
+
const { html } = await import("lit-html");
|
|
300
|
+
const confirmed = await showConfirmDialog(
|
|
301
|
+
"Convert Format",
|
|
302
|
+
html`<span
|
|
303
|
+
>Convert <strong>${path.split("/").pop()}</strong> to
|
|
304
|
+
${targets.find((target) => target.ext === chosen)?.label ?? chosen}?</span
|
|
305
|
+
>${plan.lines.map((line) => html`<p class="dialog-consequence">${line}</p>`)}`,
|
|
306
|
+
{ confirmLabel: "Convert", destructive: true },
|
|
307
|
+
);
|
|
308
|
+
if (!confirmed) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { markLocalMutation } = await import("../files/fs-events");
|
|
313
|
+
const { notifyMoveOutcome, settleRename } = await import("../files/files");
|
|
314
|
+
markLocalMutation(path, plan.targetPath);
|
|
315
|
+
try {
|
|
316
|
+
await platform.writeFile(path, plan.text);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return fail(`Could not write the converted ${path}.`, errorMessage(error));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let report;
|
|
322
|
+
try {
|
|
323
|
+
report = await platform.renameFile(path, plan.targetPath);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
/* Put the file back. The rename is what makes a conversion visible; without it the author is
|
|
326
|
+
left with target-format bytes sitting at the source extension, which nothing can open. */
|
|
327
|
+
try {
|
|
328
|
+
markLocalMutation(path);
|
|
329
|
+
await platform.writeFile(path, original);
|
|
330
|
+
} catch {
|
|
331
|
+
// Reported below either way, and the original text is still in the failure's own detail.
|
|
332
|
+
}
|
|
333
|
+
return fail(`Could not convert ${path}.`, errorMessage(error));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (openTabEntry) {
|
|
337
|
+
await rebuildTab(path, plan.targetPath, plan.text);
|
|
338
|
+
}
|
|
339
|
+
await settleRename(path, plan.targetPath, report);
|
|
340
|
+
|
|
341
|
+
notifyMoveOutcome(convertStatus(plan.targetPath, report), report, plan.targetPath);
|
|
342
|
+
return plan.targetPath;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Move an open tab onto the converted file, rebuilding it rather than reloading it.
|
|
347
|
+
*
|
|
348
|
+
* Two calls, and both are load-bearing:
|
|
349
|
+
*
|
|
350
|
+
* - `renameTab` re-keys the tab's id, its pane slot, its MRU entry and its collab room. Without it
|
|
351
|
+
* the rebuild below opens a SECOND tab beside the stale one.
|
|
352
|
+
* - `openTab` under the new id then takes its `previous` branch: it disposes the re-keyed tab —
|
|
353
|
+
* freeing the collab session and the tab-keyed grid controller — and builds a fresh one whose
|
|
354
|
+
* `sourceFormat` is the target's, and therefore whose modes and whose SERIALIZER are too. It
|
|
355
|
+
* inherits `pinned` and keeps the same pane slot.
|
|
356
|
+
*
|
|
357
|
+
* `reloadFileInTab` is not enough: it sets the document and the frontmatter and never touches
|
|
358
|
+
* `sourceFormat`, so the tab would keep the old format's modes and the next save would write the
|
|
359
|
+
* wrong format into the file. `openFileInTab` is not enough either — it REVEALS a tab that already
|
|
360
|
+
* points at the path, which after the re-key is exactly the stale one. `closeTab` is worse still:
|
|
361
|
+
* it collapses a pane it empties, so converting the only file in a split pane would silently
|
|
362
|
+
* unsplit the workspace.
|
|
363
|
+
*/
|
|
364
|
+
async function rebuildTab(from: string, to: string, text: string): Promise<void> {
|
|
365
|
+
const { openTab, renameTab } = await import("../workspace/workspace");
|
|
366
|
+
const { formatForPath } = await import("./format-host");
|
|
367
|
+
renameTab(from, to, to);
|
|
368
|
+
const format = formatForPath(to);
|
|
369
|
+
let document: Record<string, unknown>;
|
|
370
|
+
let frontmatter: Record<string, unknown> | undefined;
|
|
371
|
+
try {
|
|
372
|
+
if (format) {
|
|
373
|
+
const { parseSourceForPath } = await import("../files/file-ops");
|
|
374
|
+
({ document, frontmatter } = await parseSourceForPath(to, text));
|
|
375
|
+
} else {
|
|
376
|
+
document = JSON.parse(text) as Record<string, unknown>;
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
// The bytes on disk are the ones just written and the conversion has already succeeded, so a
|
|
380
|
+
// Parse failure here leaves a stale tab rather than a failed convert. The re-keyed tab still
|
|
381
|
+
// Points at the file, and reopening it is one click.
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
openTab({
|
|
385
|
+
document,
|
|
386
|
+
documentPath: to,
|
|
387
|
+
...(frontmatter === undefined ? {} : { frontmatter }),
|
|
388
|
+
id: to,
|
|
389
|
+
sourceFormat: format?.name ?? null,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const CANNOT_CONVERT =
|
|
394
|
+
"Studio converts documents in pages/ and components/ between formats that can both read and " +
|
|
395
|
+
"write a Jx document. Layouts are JSON only, and a file inside a content collection keeps its " +
|
|
396
|
+
"collection's format.";
|
|
397
|
+
|
|
398
|
+
const DIRTY_TAB =
|
|
399
|
+
"It is open in the editor with unsaved changes, and converting it would write over them. Save " +
|
|
400
|
+
"or discard the changes first.";
|
|
401
|
+
|
|
402
|
+
const EXISTS = "Rename or delete the existing file first; converting would replace it.";
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The outcome line, in the same family as a rename's — the new name, and what moved with it.
|
|
406
|
+
*
|
|
407
|
+
* Handed to `notifyMoveOutcome`, which turns it into a success or, when the refactor could not
|
|
408
|
+
* rewrite every referrer, into a warning naming them. A conversion makes the strongest promise of
|
|
409
|
+
* the three moves — its dialog states both the reference count and what the file is becoming — so
|
|
410
|
+
* it is the one that can least afford to report a plain success over a partial repair.
|
|
411
|
+
*/
|
|
412
|
+
function convertStatus(
|
|
413
|
+
targetPath: string,
|
|
414
|
+
report: { references?: { refsUpdated: number; filesChanged: number } },
|
|
415
|
+
): string {
|
|
416
|
+
const refs = report.references;
|
|
417
|
+
const name = targetPath.split("/").pop() ?? targetPath;
|
|
418
|
+
return refs && refs.refsUpdated > 0
|
|
419
|
+
? `Converted to ${name}; updated ${refs.refsUpdated} reference(s) in ${refs.filesChanged} file(s)`
|
|
420
|
+
: `Converted to ${name}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Ask which format, when the command did not say. One target answers itself. */
|
|
424
|
+
async function pickTarget(
|
|
425
|
+
path: string,
|
|
426
|
+
targets: { ext: string; label: string }[],
|
|
427
|
+
): Promise<string | null> {
|
|
428
|
+
if (targets.length === 1) {
|
|
429
|
+
return targets[0]!.ext;
|
|
430
|
+
}
|
|
431
|
+
const { showPromptDialog } = await import("../ui/layers");
|
|
432
|
+
let picked = targets[0]!.ext;
|
|
433
|
+
const answer = await showPromptDialog("Convert Format", {
|
|
434
|
+
choice: {
|
|
435
|
+
initial: picked,
|
|
436
|
+
label: "Convert to",
|
|
437
|
+
onChange: (next: string) => {
|
|
438
|
+
picked = next;
|
|
439
|
+
},
|
|
440
|
+
options: () => targets.map((target) => ({ label: target.label, value: target.ext })),
|
|
441
|
+
},
|
|
442
|
+
confirmLabel: "Continue",
|
|
443
|
+
message: `Converting ${path}.`,
|
|
444
|
+
select: "none",
|
|
445
|
+
validate: () => "",
|
|
446
|
+
value: path.split("/").pop() ?? path,
|
|
447
|
+
});
|
|
448
|
+
return answer === null ? null : picked;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The convert command record. */
|
|
452
|
+
export function fileFormatCommands(): AnyCommand[] {
|
|
453
|
+
return [
|
|
454
|
+
{
|
|
455
|
+
/*
|
|
456
|
+
* `source`, not `path`.
|
|
457
|
+
*
|
|
458
|
+
* `files/files.ts`'s `fileRowFacts` is keyed by ARGUMENT NAME, and `path` already means "an
|
|
459
|
+
* entry of a content collection" there — which is what keeps "Open Entry Form" off
|
|
460
|
+
* `styles/main.css`. Reusing the key would state it for every convertible file and offer the
|
|
461
|
+
* entry form on all of them.
|
|
462
|
+
*
|
|
463
|
+
* `required` is passed EXPLICITLY: `argsSchema` defaults it to every key, and a required
|
|
464
|
+
* `format` would make this row silently never render, since a file row cannot state one.
|
|
465
|
+
*/
|
|
466
|
+
args: argsSchema(
|
|
467
|
+
{
|
|
468
|
+
format: derivedEnumProperty(
|
|
469
|
+
convertTargetExtensions,
|
|
470
|
+
"Extension to convert to. Omitted, Studio asks.",
|
|
471
|
+
),
|
|
472
|
+
source: stringProperty("Project-relative path of the file to convert."),
|
|
473
|
+
},
|
|
474
|
+
["source"],
|
|
475
|
+
),
|
|
476
|
+
category: "File",
|
|
477
|
+
id: "file.convertFormat",
|
|
478
|
+
level: "project",
|
|
479
|
+
// Not the palette: `panels/quick-search.ts`'s `paletteArgs` cannot prompt for a free-text
|
|
480
|
+
// Path, and a convert with no file named is not a verb anybody can mean.
|
|
481
|
+
menus: ["context/file"],
|
|
482
|
+
group: "1_file",
|
|
483
|
+
requires: "an open project",
|
|
484
|
+
when: (ctx) => ctx.project.open,
|
|
485
|
+
// Nothing restores it: the file has moved and its references have been rewritten with it.
|
|
486
|
+
// The confirmation carrying the count is what stands in for an undo.
|
|
487
|
+
undo: "none",
|
|
488
|
+
aiTool: {
|
|
489
|
+
description:
|
|
490
|
+
"Convert a document between formats in place — Markdown to JSON and back. The file is " +
|
|
491
|
+
"renamed to the new extension and every reference to it is rewritten. Refused for " +
|
|
492
|
+
"layouts, for anything inside a content collection, and while the file is open with " +
|
|
493
|
+
"unsaved changes.",
|
|
494
|
+
name: "convert_file_format",
|
|
495
|
+
},
|
|
496
|
+
run: async (_ctx, args: Record<string, unknown>) => {
|
|
497
|
+
const target = typeof args.format === "string" ? args.format : undefined;
|
|
498
|
+
await convertFile(
|
|
499
|
+
stringArg("file.convertFormat", args, "source"),
|
|
500
|
+
...(target === undefined ? [] : [target]),
|
|
501
|
+
);
|
|
502
|
+
},
|
|
503
|
+
title: "Convert Format…",
|
|
504
|
+
},
|
|
505
|
+
];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Register the convert command. */
|
|
509
|
+
export function registerFileFormatCommands(registry: CommandRegistry): void {
|
|
510
|
+
registry.registerAll(fileFormatCommands());
|
|
511
|
+
}
|