@jxsuite/studio 3.0.0 → 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-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
- 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-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
- package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
- package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
- package/dist/chunks/file-ops-rskmhxyq.js +48 -0
- package/dist/chunks/files-0cratsr0.js +92 -0
- package/dist/chunks/{format-host-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
- 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-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
- package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
- package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
- package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -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-xntfdy1x.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-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
- package/dist/chunks/{studio-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-hcxzyk3x.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-7p35254k.js → studio-ex8zyp98.js} +30 -26
- package/dist/chunks/studio-gv8vvshc.js +118 -0
- package/dist/chunks/studio-hbtp0eh1.js +1315 -0
- package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
- package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
- 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-g5gdqnyd.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-p5dja96s.js → studio-p8j86wag.js} +2 -2
- package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
- 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-k4jvjcfx.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-5efrtn1m.js → studio-wx5609xd.js} +21 -17
- package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
- package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/workspace-xpres40v.js +89 -0
- package/dist/iframe-entry.js +108 -24
- package/dist/studio.js +2362 -1695
- package/package.json +16 -15
- 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 +485 -59
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- 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/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +332 -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 -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 +20 -0
- package/src/platforms/devserver.ts +67 -33
- 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-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 +89 -0
- 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 +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-dialog.ts +18 -41
- 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 +103 -5
- 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/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 +111 -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-c31yb4t6.js +0 -55
- package/src/canvas/site-style-css.ts +0 -117
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection-match — the ONE answer to "which content collection owns this path".
|
|
3
|
+
*
|
|
4
|
+
* Three matchers already existed and disagreed: `utils/studio-utils.ts`'s `findContentTypeSchema`,
|
|
5
|
+
* `grid/sources/content-source.ts`'s `collectionInfo` and `browse/library-model.ts`'s
|
|
6
|
+
* `contentTypeFor`. Each was right about the question it was written for and wrong about the
|
|
7
|
+
* others', which was tolerable while the consumers were a form and a grid. It stopped being
|
|
8
|
+
* tolerable when the Files tree began asking, because a mismatch there does not draw the wrong
|
|
9
|
+
* field — it CONSTRAINS or REROUTES a creation, and both are wrong answers the author cannot see.
|
|
10
|
+
*
|
|
11
|
+
* Four things this states that no predecessor did:
|
|
12
|
+
*
|
|
13
|
+
* 1. **A locale segment is one of the project's DECLARED locales** wherever it declares any. The bare
|
|
14
|
+
* `[^/]+` wildcard binds `content/exhibitions/images` — the co-located media directory
|
|
15
|
+
* `site-architecture.md` §6.5 blesses — as if it were a locale root. The wildcard survives only
|
|
16
|
+
* as the fallback for a project that declares a `{locale}` source and no `i18n` section at all,
|
|
17
|
+
* where it is the sole thing that can match.
|
|
18
|
+
* 2. **`dir` is the directory that MATCHED**, not the declared `source`. `collectionInfo` hands back a
|
|
19
|
+
* literal `content/exhibitions/{locale}`, which is a path nobody has; a caller that creates a
|
|
20
|
+
* file there creates a directory named `{locale}`.
|
|
21
|
+
* 3. **The longest source wins**, not the first declared. With sources `./content/` and
|
|
22
|
+
* `./content/blog/`, a file in the latter belongs to the latter.
|
|
23
|
+
* 4. **An unregistered format is `null`, and says so.** `ext` falling back to `.json` when the
|
|
24
|
+
* declared format class is missing produces a locked picker asserting an extension the
|
|
25
|
+
* collection does not use — a stated lie plus an enforced refusal, which is worse than no
|
|
26
|
+
* constraint at all.
|
|
27
|
+
*
|
|
28
|
+
* Pure over `projectState.projectConfig` and the format registry, so every rule is assertable with
|
|
29
|
+
* no DOM and no platform.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { defaultContentFormat, formatByName } from "../format/format-host";
|
|
33
|
+
import { projectState } from "../store";
|
|
34
|
+
import { resolveI18n } from "@jxsuite/schema/locale";
|
|
35
|
+
import type { ContentSectionEntry } from "../types";
|
|
36
|
+
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
37
|
+
|
|
38
|
+
/** The placeholder a localized `source` carries, standing for one directory per declared locale. */
|
|
39
|
+
export const LOCALE_PLACEHOLDER = "{locale}";
|
|
40
|
+
|
|
41
|
+
/** What a path's collection is, and everything a caller needs in order to act on it. */
|
|
42
|
+
export interface CollectionMatch {
|
|
43
|
+
name: string;
|
|
44
|
+
def: ContentSectionEntry;
|
|
45
|
+
/**
|
|
46
|
+
* The extension this collection's entries carry, or `null` when its declared `format` names a
|
|
47
|
+
* class the project has not registered. Never a silent `.json`.
|
|
48
|
+
*/
|
|
49
|
+
ext: string | null;
|
|
50
|
+
/** The declared-but-missing format name behind a `null` `ext`, for the message that says so. */
|
|
51
|
+
unresolvedFormat: string | null;
|
|
52
|
+
/** The REAL directory that matched: `{locale}` substituted, a subdirectory preserved. */
|
|
53
|
+
dir: string;
|
|
54
|
+
/** True when `dir` is the collection's own source root rather than a directory beneath it. */
|
|
55
|
+
isSourceRoot: boolean;
|
|
56
|
+
/** True when `source` names a single file (a CSV catalogue) — its entries are rows, not files. */
|
|
57
|
+
fileBacked: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Escape a literal for embedding in a RegExp — `sites/jxsuite.com` ships `source: "../../docs"`. */
|
|
61
|
+
function escapeLiteral(part: string): string {
|
|
62
|
+
return part.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Normalise a project-relative path: forward slashes, no `./` prefix, no trailing slash. */
|
|
66
|
+
function normalize(path: string): string {
|
|
67
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The config a lookup reads: the caller's, or the open project's. */
|
|
71
|
+
function configOf(config: ProjectConfig | null | undefined): ProjectConfig | null {
|
|
72
|
+
return config ?? (projectState?.projectConfig as ProjectConfig | null | undefined) ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The `content` section of a config, or an empty one. */
|
|
76
|
+
function contentSection(config: ProjectConfig | null): Record<string, ContentSectionEntry> {
|
|
77
|
+
return (config?.content ?? {}) as Record<string, ContentSectionEntry>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The locales a `{locale}` segment may be, canonicalised — or `null` when the project declares none
|
|
82
|
+
* and the segment must fall back to matching anything.
|
|
83
|
+
*/
|
|
84
|
+
function declaredLocales(config: ProjectConfig | null): string[] | null {
|
|
85
|
+
const locales = resolveI18n(config ?? {}).i18n?.locales ?? [];
|
|
86
|
+
return locales.length > 0 ? [...locales] : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether `source` names a single file rather than a directory.
|
|
91
|
+
*
|
|
92
|
+
* A trailing slash settles it outright; otherwise the BASENAME must carry an extension. Testing the
|
|
93
|
+
* whole string — which `collectionDirs` does — calls `./content/v1.2/` a file, and testing merely
|
|
94
|
+
* for a dot calls `../../docs` one.
|
|
95
|
+
*/
|
|
96
|
+
function isFileBacked(source: string): boolean {
|
|
97
|
+
if (source.endsWith("/")) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const base = source.slice(source.lastIndexOf("/") + 1);
|
|
101
|
+
return /\.[a-z\d]+$/i.test(base);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The extension a collection's entries carry, and the format name that failed when there is none.
|
|
106
|
+
*
|
|
107
|
+
* `json` is the one native collection shape (`site-architecture.md` §6.2) and has no format class
|
|
108
|
+
* to ask; a collection declaring no format at all is resolved from the source extension by the
|
|
109
|
+
* loader, which `defaultContentFormat()` approximates. Anything else must resolve through the
|
|
110
|
+
* registry, and when it does not the answer is `null` plus the name, never a fallback.
|
|
111
|
+
*/
|
|
112
|
+
function resolveExt(def: ContentSectionEntry): Pick<CollectionMatch, "ext" | "unresolvedFormat"> {
|
|
113
|
+
if (def.format === "json") {
|
|
114
|
+
return { ext: ".json", unresolvedFormat: null };
|
|
115
|
+
}
|
|
116
|
+
if (def.format === undefined || def.format === "") {
|
|
117
|
+
return { ext: defaultContentFormat()?.extensions[0] ?? ".json", unresolvedFormat: null };
|
|
118
|
+
}
|
|
119
|
+
const format = formatByName(def.format);
|
|
120
|
+
return format?.extensions[0] === undefined
|
|
121
|
+
? { ext: null, unresolvedFormat: def.format }
|
|
122
|
+
: { ext: format.extensions[0], unresolvedFormat: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The pattern a declared `source` matches paths with — the root itself, or anything beneath it.
|
|
127
|
+
*
|
|
128
|
+
* Locale segments come from the project's own `i18n.locales`, which is what keeps
|
|
129
|
+
* `content/exhibitions/images` out; the bare wildcard survives only when the project declares no
|
|
130
|
+
* locales at all, where it is the sole thing that can match a `{locale}` source. Case-insensitive,
|
|
131
|
+
* because a `fr-FR` declaration and an `fr-fr` directory are the same locale — and because APFS and
|
|
132
|
+
* NTFS are case-insensitive too.
|
|
133
|
+
*/
|
|
134
|
+
function sourcePattern(source: string, locales: string[] | null): RegExp {
|
|
135
|
+
const segment =
|
|
136
|
+
locales === null ? "[^/]+" : `(?:${locales.map((locale) => escapeLiteral(locale)).join("|")})`;
|
|
137
|
+
const body = normalize(source)
|
|
138
|
+
.split(LOCALE_PLACEHOLDER)
|
|
139
|
+
.map((part) => escapeLiteral(part))
|
|
140
|
+
.join(segment);
|
|
141
|
+
return new RegExp(`^${body}(?:/|$)`, "i");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The matched source ROOT inside `target`, or null.
|
|
146
|
+
*
|
|
147
|
+
* Returned rather than merely tested, because `dir` must be the real directory and `isSourceRoot`
|
|
148
|
+
* must know where the root ended — neither is recoverable from a `source` that still carries a
|
|
149
|
+
* placeholder.
|
|
150
|
+
*/
|
|
151
|
+
function matchedRoot(source: string, target: string, locales: string[] | null): string | null {
|
|
152
|
+
const hit = sourcePattern(source, locales).exec(target);
|
|
153
|
+
if (!hit) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
// The match includes the trailing "/" when the target is beneath the root; trim it back.
|
|
157
|
+
return hit[0].replace(/\/$/, "");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* How specific a source is, for "longest wins". Measured on the LITERAL text with the placeholder
|
|
162
|
+
* removed: two sources cannot be compared on their expansions, because a localized one expands
|
|
163
|
+
* differently per locale.
|
|
164
|
+
*/
|
|
165
|
+
function sourceWeight(source: string): number {
|
|
166
|
+
return normalize(source).replaceAll(LOCALE_PLACEHOLDER, "").length;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The collection whose source tree contains `dir` — the source root itself, or any directory under
|
|
171
|
+
* it.
|
|
172
|
+
*
|
|
173
|
+
* Entry discovery is RECURSIVE (`Markdown.discover` walks with `readdirSync(recursive: true)`, and
|
|
174
|
+
* the grid's `listEntryFiles` walks subdirectories), so a document in `content/posts/2026/` is an
|
|
175
|
+
* entry of `posts`. `isSourceRoot` is what lets a caller treat the two depths differently without
|
|
176
|
+
* consulting a second matcher.
|
|
177
|
+
*
|
|
178
|
+
* File-backed collections are skipped: a CSV catalogue's directory is an ordinary directory, and
|
|
179
|
+
* its entries are rows rather than files. {@link collectionForFile} answers for the catalogue
|
|
180
|
+
* itself.
|
|
181
|
+
*
|
|
182
|
+
* @param dir - Project-relative directory. `"."` is the project root.
|
|
183
|
+
* @param config - Config to read; defaults to the open project's.
|
|
184
|
+
* @returns The most specific matching collection, or null.
|
|
185
|
+
*/
|
|
186
|
+
export function collectionForDirectory(
|
|
187
|
+
dir: string | null | undefined,
|
|
188
|
+
config?: ProjectConfig | null,
|
|
189
|
+
): CollectionMatch | null {
|
|
190
|
+
if (dir === null || dir === undefined || dir === "") {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const resolved = configOf(config);
|
|
194
|
+
const locales = declaredLocales(resolved);
|
|
195
|
+
const target = normalize(dir);
|
|
196
|
+
let best: CollectionMatch | null = null;
|
|
197
|
+
let bestWeight = -1;
|
|
198
|
+
for (const [name, def] of Object.entries(contentSection(resolved))) {
|
|
199
|
+
const { source } = def;
|
|
200
|
+
if (source === undefined || source === "" || isFileBacked(source)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const weight = sourceWeight(source);
|
|
204
|
+
if (weight <= bestWeight) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const root = matchedRoot(source, target, locales);
|
|
208
|
+
if (root === null) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
best = {
|
|
212
|
+
def,
|
|
213
|
+
dir: target,
|
|
214
|
+
fileBacked: false,
|
|
215
|
+
isSourceRoot: target.length === root.length,
|
|
216
|
+
name,
|
|
217
|
+
...resolveExt(def),
|
|
218
|
+
};
|
|
219
|
+
bestWeight = weight;
|
|
220
|
+
}
|
|
221
|
+
return best;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The collection a FILE belongs to: the collection of the directory holding it, or — for a
|
|
226
|
+
* file-backed collection — the one whose `source` the file IS.
|
|
227
|
+
*
|
|
228
|
+
* The file's own extension is NOT consulted, deliberately. A `.json` sitting in a Markdown
|
|
229
|
+
* collection's folder is still that collection's business: it is exactly the file a convert must
|
|
230
|
+
* refuse to produce, and answering "no collection" for it is how it would slip through.
|
|
231
|
+
*
|
|
232
|
+
* @param path - Project-relative file path.
|
|
233
|
+
* @param config - Config to read; defaults to the open project's.
|
|
234
|
+
* @returns The collection, or null.
|
|
235
|
+
*/
|
|
236
|
+
export function collectionForFile(
|
|
237
|
+
path: string | null | undefined,
|
|
238
|
+
config?: ProjectConfig | null,
|
|
239
|
+
): CollectionMatch | null {
|
|
240
|
+
if (path === null || path === undefined || path === "") {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const resolved = configOf(config);
|
|
244
|
+
const target = normalize(path);
|
|
245
|
+
const lower = target.toLowerCase();
|
|
246
|
+
for (const [name, def] of Object.entries(contentSection(resolved))) {
|
|
247
|
+
const { source } = def;
|
|
248
|
+
if (source === undefined || source === "" || !isFileBacked(source)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const src = normalize(source).toLowerCase();
|
|
252
|
+
if (lower === src || lower.endsWith(`/${src}`)) {
|
|
253
|
+
return {
|
|
254
|
+
def,
|
|
255
|
+
dir: target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : ".",
|
|
256
|
+
fileBacked: true,
|
|
257
|
+
isSourceRoot: false,
|
|
258
|
+
name,
|
|
259
|
+
...resolveExt(def),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return collectionForDirectory(
|
|
264
|
+
target.includes("/") ? target.slice(0, target.lastIndexOf("/")) : ".",
|
|
265
|
+
resolved,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
argsSchema,
|
|
15
15
|
booleanArg,
|
|
16
16
|
booleanProperty,
|
|
17
|
+
derivedEnumProperty,
|
|
17
18
|
enumArg,
|
|
18
19
|
stringArg,
|
|
19
20
|
stringProperty,
|
|
@@ -31,6 +32,7 @@ import { invalidateReferenceEntries } from "../ui/form-controls";
|
|
|
31
32
|
import { reloadDraftAwareGrids } from "../grid/sources/content-source";
|
|
32
33
|
import { setIncludeDrafts } from "./draft-state";
|
|
33
34
|
import { collectionOfPath, entryCollection, entryCollections, seedEntry } from "./entry-model";
|
|
35
|
+
import { LOCALE_PLACEHOLDER, collectionForDirectory } from "./collection-match";
|
|
34
36
|
import { openEntryEditor, setEntryDraft } from "./entry-editor";
|
|
35
37
|
import { errorMessage } from "@jxsuite/schema/parse";
|
|
36
38
|
import type { EntryCollection } from "./entry-model";
|
|
@@ -42,32 +44,6 @@ function creatableCollections(): string[] {
|
|
|
42
44
|
return entryCollections().map((collection) => collection.name);
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
/**
|
|
46
|
-
* An enum property whose values are derived WHEN THE SCHEMA IS READ, not when the record is
|
|
47
|
-
* defined.
|
|
48
|
-
*
|
|
49
|
-
* `enumProperty(creatableCollections(), …)` looks identical and is wrong here, and the difference
|
|
50
|
-
* is invisible in a unit test: a command record is built at module scope —
|
|
51
|
-
* `commands/app-commands.ts` and `studio.ts` both call {@link contentCommands} before a project is
|
|
52
|
-
* open — so the array is frozen at `[]`, and `panels/quick-search.ts`'s `paletteArgs` offered an
|
|
53
|
-
* empty choice list forever after. `run` re-derived the list, which is why the programmatic call
|
|
54
|
-
* worked and the palette did not: two answers to "which collections exist", one of them a snapshot
|
|
55
|
-
* of the empty boot state.
|
|
56
|
-
*
|
|
57
|
-
* A getter is the whole fix — the palette reads `property.enum` when it opens the prompt, the AI
|
|
58
|
-
* tool's parameters serialise it when the tool list is built, and `scripts/check-shot-contract.ts`
|
|
59
|
-
* reads it in a bare Bun process where the honest answer really is "none declared".
|
|
60
|
-
*/
|
|
61
|
-
function derivedEnumProperty(declared: () => readonly string[], description: string): object {
|
|
62
|
-
return {
|
|
63
|
-
description,
|
|
64
|
-
get enum() {
|
|
65
|
-
return [...declared()];
|
|
66
|
-
},
|
|
67
|
-
type: "string",
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
47
|
/**
|
|
72
48
|
* The source text a new entry starts as.
|
|
73
49
|
*
|
|
@@ -106,9 +82,17 @@ async function seedText(collection: EntryCollection): Promise<string> {
|
|
|
106
82
|
* created in, and a **body seeded from the schema**, so the entry is valid the moment it exists
|
|
107
83
|
* instead of being a pile of absent required fields.
|
|
108
84
|
*
|
|
85
|
+
* `opts.dir` names a directory INSIDE the collection — the Files tree creating in a subdirectory of
|
|
86
|
+
* the source root. It is checked by MEMBERSHIP (`collectionForDirectory` must answer with this same
|
|
87
|
+
* collection) rather than by string prefix, because `collection.dir` may still carry a `{locale}`
|
|
88
|
+
* placeholder, and a prefix test against a template rejects every real directory it stands for.
|
|
89
|
+
*
|
|
109
90
|
* @returns The created path, or null when the author cancelled or the write failed.
|
|
110
91
|
*/
|
|
111
|
-
export async function createEntry(
|
|
92
|
+
export async function createEntry(
|
|
93
|
+
collectionName: string,
|
|
94
|
+
opts: { dir?: string } = {},
|
|
95
|
+
): Promise<string | null> {
|
|
112
96
|
const collection = entryCollection(collectionName);
|
|
113
97
|
if (!collection) {
|
|
114
98
|
notify.error(`No content collection named "${collectionName}" to create an entry in.`, {
|
|
@@ -135,11 +119,36 @@ export async function createEntry(collectionName: string): Promise<string | null
|
|
|
135
119
|
return null;
|
|
136
120
|
}
|
|
137
121
|
|
|
122
|
+
/*
|
|
123
|
+
* Where the entry lands.
|
|
124
|
+
*
|
|
125
|
+
* A localized collection's `dir` is `content/exhibitions/{locale}` — a path nobody has. Creating
|
|
126
|
+
* there makes a directory literally named `{locale}`, which is what `content.newEntry` from the
|
|
127
|
+
* palette and the `new_content_entry` tool have been doing. There is no default locale to pick on
|
|
128
|
+
* the author's behalf (choosing one silently files the entry under a language), so this refuses
|
|
129
|
+
* and names the gesture that works.
|
|
130
|
+
*/
|
|
131
|
+
const requested = opts.dir;
|
|
132
|
+
const destination =
|
|
133
|
+
requested !== undefined && collectionForDirectory(requested)?.name === collection.name
|
|
134
|
+
? requested
|
|
135
|
+
: collection.dir;
|
|
136
|
+
if (destination.includes(LOCALE_PLACEHOLDER)) {
|
|
137
|
+
notify.error(`"${collection.name}" is a localized collection.`, {
|
|
138
|
+
detail:
|
|
139
|
+
`Its entries live in one directory per language (${collection.dir}). Open the folder for ` +
|
|
140
|
+
"the language you are writing in and create the entry there.",
|
|
141
|
+
path: collection.dir,
|
|
142
|
+
source: "Content",
|
|
143
|
+
});
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
138
147
|
const { createFileIn } = await import("../files/files");
|
|
139
148
|
const created = await createFileIn({
|
|
140
149
|
content,
|
|
141
|
-
dir:
|
|
142
|
-
ext: collection.ext,
|
|
150
|
+
dir: destination,
|
|
151
|
+
format: { ext: collection.ext, kind: "fixed" },
|
|
143
152
|
source: "Content",
|
|
144
153
|
suggestedName: "untitled",
|
|
145
154
|
title: `New ${collection.name} entry`,
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { cloneValue } from "@jxsuite/collab/ops";
|
|
32
|
+
import { notePreviewOverlayEdit } from "../preview/preview-overlay";
|
|
32
33
|
import { recordDocOp } from "../tabs/patch-ops";
|
|
33
34
|
import { mutateUpdateFrontmatter, transactDoc } from "../tabs/transact";
|
|
34
35
|
import type { Tab } from "../tabs/tab";
|
|
@@ -85,6 +86,11 @@ function mutateDocumentField(tab: Tab, field: string, value?: JsonValue): void {
|
|
|
85
86
|
inverse: { key: field, op: "set-key", path: [], value: before },
|
|
86
87
|
});
|
|
87
88
|
tab.doc.dirty = true;
|
|
89
|
+
/* The document's ROOT REFERENCE is untouched here — the key is written in place — so the preview
|
|
90
|
+
overlay's effect, which reads that reference, sees nothing. `transactDoc` and the Monaco source
|
|
91
|
+
commit both assign a fresh one and need no such call; this path is the exception, and without
|
|
92
|
+
the note an entry field edited twice would stop reaching a live preview after the first. */
|
|
93
|
+
notePreviewOverlayEdit();
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
/**
|
package/src/editor/shortcuts.ts
CHANGED
|
@@ -123,6 +123,7 @@ export interface StudioCommandHooks {
|
|
|
123
123
|
*/
|
|
124
124
|
openProject: (target: ProjectOpenTarget) => Promise<ProjectOpenOutcome>;
|
|
125
125
|
openInBrowser: () => void | Promise<void>;
|
|
126
|
+
buildSite: () => void | Promise<void>;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
// ─── Shared predicates ────────────────────────────────────────────────────────
|
|
@@ -832,6 +833,7 @@ export function registerStudioCommands(
|
|
|
832
833
|
duplicateSelection,
|
|
833
834
|
focusInspectorTab: (tabId) => focusInspectorTab(registry, tabId),
|
|
834
835
|
focusPanel,
|
|
836
|
+
buildSite: () => hooks.buildSite(),
|
|
835
837
|
openInBrowser: () => hooks.openInBrowser(),
|
|
836
838
|
openPalette: (mode) => {
|
|
837
839
|
openQuickSearch(mode);
|
|
@@ -958,6 +960,9 @@ const SELF_SCROLLING_MODES: ReadonlySet<string> = new Set([
|
|
|
958
960
|
"entry",
|
|
959
961
|
"grid",
|
|
960
962
|
"manage",
|
|
963
|
+
/* `media` — the Media viewer's own scrolling column (`media/media-pane.ts`). There is no artboard
|
|
964
|
+
here either: a wheel over an image is a scroll down the page it sits on, not a zoom. */
|
|
965
|
+
"media",
|
|
961
966
|
"preview",
|
|
962
967
|
"settings",
|
|
963
968
|
]);
|
package/src/files/file-ops.ts
CHANGED
|
@@ -29,12 +29,13 @@ import { getGridController } from "../grid/grid-controller";
|
|
|
29
29
|
import { activeTab, openTab } from "../workspace/workspace";
|
|
30
30
|
import { collabReadOnly, collabSave } from "../collab/collab-session";
|
|
31
31
|
import { flushCanvasEdits } from "../canvas/iframe-host";
|
|
32
|
+
import { flushPreviewOverlay } from "../preview/preview-overlay";
|
|
33
|
+
import { serializeDocument } from "./serialize-document";
|
|
32
34
|
import {
|
|
33
35
|
defaultContentFormat,
|
|
34
36
|
formatByName,
|
|
35
37
|
formatForPath,
|
|
36
38
|
formatParse,
|
|
37
|
-
formatSerialize,
|
|
38
39
|
getFormats,
|
|
39
40
|
loadFormats,
|
|
40
41
|
noFormatError,
|
|
@@ -254,6 +255,10 @@ export async function saveFile(tab: Tab | null = activeTab.value): Promise<boole
|
|
|
254
255
|
const platform = getPlatform();
|
|
255
256
|
await platform.writeFile(tab.documentPath, output);
|
|
256
257
|
tab.doc.dirty = false;
|
|
258
|
+
/* Retract the unsaved bytes NOW rather than a debounce later. The write also reaches the
|
|
259
|
+
backend's filesystem watcher, and a live preview coalesces both into one reload only if
|
|
260
|
+
they arrive together — otherwise a save costs two. */
|
|
261
|
+
void flushPreviewOverlay();
|
|
257
262
|
reportSaved(tab);
|
|
258
263
|
return true;
|
|
259
264
|
}
|
|
@@ -352,44 +357,6 @@ export async function exportFile() {
|
|
|
352
357
|
}
|
|
353
358
|
}
|
|
354
359
|
|
|
355
|
-
/**
|
|
356
|
-
* Serialize the current document to its output format. Format tabs round-trip through the format
|
|
357
|
-
* class's serialize capability; everything else is native JSON.
|
|
358
|
-
*
|
|
359
|
-
* @param {import("../tabs/tab.js").Tab} tab
|
|
360
|
-
* @returns {Promise<string>}
|
|
361
|
-
*/
|
|
362
|
-
export async function serializeDocument(tab: Tab): Promise<string> {
|
|
363
|
-
// Grid tabs serialize through their source (pending edits included) — e.g. the Monaco source
|
|
364
|
-
// View of a CSV grid tab shows the live file text.
|
|
365
|
-
const grid = getGridController(tab);
|
|
366
|
-
const gridText = grid?.serializeForSource();
|
|
367
|
-
if (gridText) {
|
|
368
|
-
return gridText;
|
|
369
|
-
}
|
|
370
|
-
await loadFormats();
|
|
371
|
-
const sourceFormat = formatByName(tab.doc.sourceFormat);
|
|
372
|
-
if (sourceFormat?.capabilities.serialize) {
|
|
373
|
-
const fm = tab.doc.content?.frontmatter || {};
|
|
374
|
-
const doc = tab.doc.document;
|
|
375
|
-
const fullDoc = { ...fm, ...doc, children: doc.children ?? [] };
|
|
376
|
-
return formatSerialize(sourceFormat.name, fullDoc, { mode: "roundtrip" });
|
|
377
|
-
}
|
|
378
|
-
if (tab.doc.mode === "content") {
|
|
379
|
-
const format = defaultContentFormat();
|
|
380
|
-
if (format) {
|
|
381
|
-
const fm = tab.doc.content?.frontmatter ?? {};
|
|
382
|
-
const hasFrontmatter = Object.keys(fm).length > 0;
|
|
383
|
-
const fullDoc = { ...fm, ...tab.doc.document };
|
|
384
|
-
return formatSerialize(format.name, fullDoc, {
|
|
385
|
-
frontmatter: hasFrontmatter,
|
|
386
|
-
mode: "roundtrip",
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
return JSON.stringify(tab.doc.document, null, 2);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
360
|
// ─── Destructive confirmations ───────────────────────────────────────────────
|
|
394
361
|
|
|
395
362
|
/**
|
|
@@ -412,7 +379,7 @@ export async function serializeDocument(tab: Tab): Promise<string> {
|
|
|
412
379
|
* @param path — the file about to be deleted or renamed.
|
|
413
380
|
* @param verb — which way the references go. A rename repairs them; a delete breaks them.
|
|
414
381
|
*/
|
|
415
|
-
async function usageLine(path: string, verb: "delete" | "rename") {
|
|
382
|
+
async function usageLine(path: string, verb: "delete" | "rename" | "convert") {
|
|
416
383
|
const state = isMediaFile(path) ? await loadMediaUsages(path) : await loadUsages({ path });
|
|
417
384
|
const sentence = usageWarning(state, verb);
|
|
418
385
|
return sentence === null ? nothing : html`<p class="dialog-consequence">${sentence}</p>`;
|
|
@@ -445,8 +412,9 @@ export async function confirmFileDelete(file: { name: string; path: string }): P
|
|
|
445
412
|
* is there to say how much work is silently being done on the user's behalf.
|
|
446
413
|
*
|
|
447
414
|
* @param path — the file about to be renamed.
|
|
415
|
+
* @param verb — `"rename"`, or `"convert"` when the bytes change with the name.
|
|
448
416
|
*/
|
|
449
|
-
export async function renamePromptMessage(path: string) {
|
|
450
|
-
const consequence = await usageLine(path,
|
|
417
|
+
export async function renamePromptMessage(path: string, verb: "rename" | "convert" = "rename") {
|
|
418
|
+
const consequence = await usageLine(path, verb);
|
|
451
419
|
return consequence === nothing ? undefined : html`${consequence}`;
|
|
452
420
|
}
|