@jxsuite/studio 0.37.0 → 1.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.
Files changed (38) hide show
  1. package/dist/studio.js +77654 -76269
  2. package/dist/studio.js.map +128 -119
  3. package/package.json +44 -44
  4. package/src/account-status.ts +39 -0
  5. package/src/browse/browse.ts +10 -14
  6. package/src/canvas/iframe-host.ts +1 -1
  7. package/src/editor/context-menu.ts +1 -1
  8. package/src/editor/repeater-scope.ts +8 -13
  9. package/src/files/files.ts +3 -0
  10. package/src/format/format-host.ts +63 -1
  11. package/src/new-project/add-repo-modal.ts +183 -0
  12. package/src/new-project/new-project-modal.ts +22 -3
  13. package/src/page-params.ts +34 -8
  14. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  15. package/src/panels/ai-panel.ts +61 -4
  16. package/src/panels/data-grid.ts +619 -0
  17. package/src/panels/signals-panel.ts +102 -437
  18. package/src/panels/statusbar.ts +1 -1
  19. package/src/panels/welcome-screen.ts +50 -0
  20. package/src/platform-errors.ts +30 -0
  21. package/src/platforms/cloud.ts +172 -89
  22. package/src/platforms/devserver.ts +172 -0
  23. package/src/services/context-resolver.ts +73 -0
  24. package/src/services/data-service.ts +155 -0
  25. package/src/services/monaco-setup.ts +75 -26
  26. package/src/settings/contributed-section.ts +406 -0
  27. package/src/settings/extension-sections.ts +145 -0
  28. package/src/settings/schema-field-ui.ts +4 -2
  29. package/src/settings/settings-modal.ts +101 -42
  30. package/src/site-context.ts +10 -1
  31. package/src/studio.ts +24 -0
  32. package/src/tabs/transact.ts +1 -1
  33. package/src/types.ts +120 -1
  34. package/src/ui/form-controls.ts +322 -0
  35. package/src/ui/progress-modal.ts +2 -2
  36. package/src/ui/schema-form.ts +524 -0
  37. package/src/utils/studio-utils.ts +4 -3
  38. package/src/settings/content-types-editor.ts +0 -599
@@ -0,0 +1,524 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Schema-form — reusable JSON-Schema → form rendering engine, extracted from the signals panel.
4
+ *
5
+ * Maps schema property types to Spectrum controls (enum → picker, boolean → checkbox,
6
+ * number/integer → number-field, `json-schema` format → multiline JSON editor, array-of-objects →
7
+ * multi-row inline form, other array/object → JSON text field, default → textfield). Hosts commit
8
+ * edits through a single `onChange(patch)` callback; dynamic enum choices and `$ref` bindings
9
+ * resolve through a {@link SchemaFormContext}. Custom controls register by name via
10
+ * {@link registerFormControl} and are consulted first for `ui` overrides.
11
+ */
12
+
13
+ import { html, nothing } from "lit-html";
14
+ import { ifDefined } from "lit-html/directives/if-defined.js";
15
+ import { live } from "lit-html/directives/live.js";
16
+ import { styleMap } from "lit-html/directives/style-map.js";
17
+ import { isRef } from "@jxsuite/schema/guards";
18
+ import { renderFieldRow } from "./field-row";
19
+ import type { TemplateResult } from "lit-html";
20
+
21
+ /** A (possibly nested) JSON Schema node, covering both object and property level keys. */
22
+ export interface JsonSchema {
23
+ type?: string;
24
+ properties?: Record<string, JsonSchema>;
25
+ required?: string[];
26
+ description?: string;
27
+ enum?: unknown;
28
+ default?: unknown;
29
+ format?: string;
30
+ minimum?: number;
31
+ maximum?: number;
32
+ examples?: string[];
33
+ name?: string;
34
+ items?: JsonSchema;
35
+ }
36
+
37
+ /** Host-provided context threaded to every control. */
38
+ export interface SchemaFormContext {
39
+ /** Resolve a `#/$context/…` pointer (or legacy sentinel) against the host's project config. */
40
+ resolvePointer: (pointer: string, scope?: Record<string, unknown>) => unknown;
41
+ /** Route params available for `$ref` bindings (e.g. from the document path). */
42
+ params?: string[] | undefined;
43
+ /** Unique prefix for per-field ephemeral UI state (e.g. the binding control's custom mode). */
44
+ fieldKeyPrefix?: string | undefined;
45
+ /**
46
+ * Commit hook for the "secret" control: stores the VALUE in the platform's secret store (never
47
+ * project.json) and returns the derived env-var NAME, which the control persists to the field
48
+ * instead of the value.
49
+ */
50
+ commitSecret?: ((key: string, value: string) => string | Promise<string>) | undefined;
51
+ }
52
+
53
+ /** Arguments passed to a registered form control. */
54
+ export interface SchemaFormControlArgs {
55
+ key: string;
56
+ schema: JsonSchema;
57
+ value: unknown;
58
+ onChange: (next: unknown) => void;
59
+ ctx: SchemaFormContext;
60
+ rerender?: (() => void) | undefined;
61
+ }
62
+
63
+ export type SchemaFormControl = (args: SchemaFormControlArgs) => TemplateResult;
64
+
65
+ /** Options for {@link renderForm}. */
66
+ export interface RenderFormOptions {
67
+ onChange: (patch: Record<string, unknown>) => void;
68
+ context?: SchemaFormContext | undefined;
69
+ /**
70
+ * Per-field overrides from `$studio.settings.entry.ui`: a registered control name, and/or an
71
+ * `enum` source (choice list or `{ "$ref": "#/$context/<pointer>" }`) layered over the field
72
+ * schema — fragments stay valid JSON Schema while descriptors add dynamic choices.
73
+ */
74
+ ui?: Record<string, { control?: string; enum?: unknown }> | undefined;
75
+ rerender?: (() => void) | undefined;
76
+ }
77
+
78
+ // ─── Control registry ────────────────────────────────────────────────────────
79
+
80
+ const controlRegistry = new Map<string, SchemaFormControl>();
81
+
82
+ /** Register (or replace) a named form control. */
83
+ export function registerFormControl(name: string, control: SchemaFormControl): void {
84
+ controlRegistry.set(name, control);
85
+ }
86
+
87
+ /** Look up a registered form control by name. */
88
+ export function getFormControl(name: string): SchemaFormControl | undefined {
89
+ return controlRegistry.get(name);
90
+ }
91
+
92
+ /** Inert context used when a host renders a form without one. */
93
+ const NULL_CONTEXT: SchemaFormContext = {
94
+ resolvePointer: () => {
95
+ // Nothing to resolve against without a host context
96
+ },
97
+ };
98
+
99
+ // ─── Enum resolution ─────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Resolve a schema enum definition to concrete choices. Plain arrays pass through; `$ref` objects
103
+ * and sentinel strings resolve through the context's pointer resolver, applying object →
104
+ * `Object.keys` and string[] → itself.
105
+ *
106
+ * @param {unknown} enumDef
107
+ * @param {SchemaFormContext | undefined} ctx
108
+ * @param {Record<string, unknown>} [scope] - Scope for `{@param}` substitution (the form value)
109
+ * @returns {string[] | undefined}
110
+ */
111
+ export function resolveFormEnum(
112
+ enumDef: unknown,
113
+ ctx?: SchemaFormContext,
114
+ scope?: Record<string, unknown>,
115
+ ): string[] | undefined {
116
+ if (Array.isArray(enumDef)) {
117
+ return enumDef as string[];
118
+ }
119
+ let pointer: string | undefined;
120
+ if (enumDef && typeof enumDef === "object") {
121
+ const ref = (enumDef as Record<string, unknown>).$ref;
122
+ if (typeof ref === "string") {
123
+ pointer = ref;
124
+ }
125
+ } else if (typeof enumDef === "string") {
126
+ // Legacy sentinel strings (e.g. "$contentTypes") resolve through the host's pointer resolver
127
+ pointer = enumDef;
128
+ }
129
+ if (pointer === undefined || !ctx) {
130
+ return undefined;
131
+ }
132
+ const resolved = ctx.resolvePointer(pointer, scope);
133
+ if (Array.isArray(resolved)) {
134
+ return resolved.map(String);
135
+ }
136
+ if (resolved && typeof resolved === "object") {
137
+ return Object.keys(resolved);
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ // ─── Field helpers ───────────────────────────────────────────────────────────
143
+
144
+ /** Parse a numeric field value, returning NaN for blank input (so callers can treat it as unset). */
145
+ export function parseNumericField(raw: string, integer: boolean): number {
146
+ if (raw.trim() === "") {
147
+ return Number.NaN;
148
+ }
149
+ return integer ? Math.trunc(Number(raw)) : Number(raw);
150
+ }
151
+
152
+ /** Plain textfield editing a `{ $ref }` value directly — the fallback when no binding control. */
153
+ function refTextField(key: string, refVal: string, onChange: (next: unknown) => void) {
154
+ return html`<sp-textfield
155
+ size="s"
156
+ label=${key}
157
+ placeholder=${key}
158
+ .value=${live(refVal)}
159
+ @change=${(e: Event) => {
160
+ const v = (e.target as HTMLInputElement).value.trim();
161
+ onChange(v ? { $ref: v } : undefined);
162
+ }}
163
+ ></sp-textfield>`;
164
+ }
165
+
166
+ /**
167
+ * Render a single inline field within an array-of-objects row. Dispatches by schema type: enum →
168
+ * picker, boolean → switch, number → number-field, else → textfield.
169
+ *
170
+ * @param {string} key
171
+ * @param {JsonSchema} schema
172
+ * @param {unknown} value
173
+ * @param {(val: unknown) => void} onChange
174
+ * @param {SchemaFormContext | undefined} ctx
175
+ * @param {Record<string, unknown>} [scope] - Scope for dependent enum refs (the parent form value)
176
+ */
177
+ export function renderInlineField(
178
+ key: string,
179
+ schema: JsonSchema,
180
+ value: unknown,
181
+ onChange: (val: unknown) => void,
182
+ ctx?: SchemaFormContext,
183
+ scope?: Record<string, unknown>,
184
+ ) {
185
+ if (isRef(value)) {
186
+ return refTextField(key, value.$ref, onChange);
187
+ }
188
+ const enumValues = resolveFormEnum(schema.enum, ctx, scope);
189
+
190
+ if (enumValues) {
191
+ return html`<sp-picker
192
+ size="s"
193
+ label=${key}
194
+ value=${value !== undefined ? String(value) : "__none__"}
195
+ @change=${(e: Event) =>
196
+ onChange(
197
+ (e.target as HTMLInputElement).value === "__none__"
198
+ ? undefined
199
+ : (e.target as HTMLInputElement).value,
200
+ )}
201
+ >
202
+ <sp-menu-item value="__none__">—</sp-menu-item>
203
+ ${enumValues.map((v: string) => html`<sp-menu-item value=${v}>${v}</sp-menu-item>`)}
204
+ </sp-picker>`;
205
+ }
206
+ if (schema.type === "boolean") {
207
+ return html`<sp-switch
208
+ size="s"
209
+ ?checked=${Boolean(value)}
210
+ @change=${(e: Event) => onChange((e.target as HTMLInputElement).checked)}
211
+ >${key}</sp-switch
212
+ >`;
213
+ }
214
+ if (schema.type === "integer" || schema.type === "number") {
215
+ return html`<sp-number-field
216
+ size="s"
217
+ label=${key}
218
+ .value=${value !== undefined ? value : nothing}
219
+ step=${schema.type === "integer" ? "1" : nothing}
220
+ @change=${(e: Event) => {
221
+ const parsed = parseNumericField(
222
+ (e.target as HTMLInputElement).value,
223
+ schema.type === "integer",
224
+ );
225
+ onChange(Number.isNaN(parsed) ? undefined : parsed);
226
+ }}
227
+ ></sp-number-field>`;
228
+ }
229
+ return html`<sp-textfield
230
+ size="s"
231
+ label=${key}
232
+ placeholder=${key}
233
+ .value=${value ?? ""}
234
+ @input=${(e: Event) => onChange((e.target as HTMLInputElement).value || undefined)}
235
+ ></sp-textfield>`;
236
+ }
237
+
238
+ /** Render a debounced multiline JSON text field for array/object schema properties. */
239
+ function renderJsonTextField(currentValue: unknown, ps: JsonSchema, commit: (v: unknown) => void) {
240
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
241
+ let debounce: ReturnType<typeof setTimeout> | undefined;
242
+ return html`<sp-textfield
243
+ multiline
244
+ size="s"
245
+ style="min-height:40px"
246
+ .value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
247
+ placeholder=${ps.default !== undefined ? JSON.stringify(ps.default) : nothing}
248
+ @input=${(e: Event) => {
249
+ clearTimeout(debounce);
250
+ debounce = setTimeout(() => {
251
+ try {
252
+ commit(JSON.parse((e.target as HTMLInputElement).value) as unknown);
253
+ } catch {}
254
+ }, 500);
255
+ }}
256
+ ></sp-textfield>`;
257
+ }
258
+
259
+ // ─── Per-property control dispatch ───────────────────────────────────────────
260
+
261
+ /** Render the widget for one schema property, honoring registered-control overrides. */
262
+ function renderPropertyControl(
263
+ prop: string,
264
+ ps: JsonSchema,
265
+ value: Record<string, unknown>,
266
+ required: Set<string>,
267
+ opts: RenderFormOptions,
268
+ ctx: SchemaFormContext,
269
+ ): TemplateResult {
270
+ const currentValue = value[prop];
271
+ const commit = (next: unknown) => opts.onChange({ [prop]: next });
272
+ const controlArgs: SchemaFormControlArgs = {
273
+ ctx,
274
+ key: prop,
275
+ onChange: commit,
276
+ rerender: opts.rerender,
277
+ schema: ps,
278
+ value: currentValue,
279
+ };
280
+
281
+ // Explicit ui override → consult the control registry first
282
+ const overrideName = opts.ui?.[prop]?.control;
283
+ if (overrideName) {
284
+ const custom = controlRegistry.get(overrideName);
285
+ if (custom) {
286
+ return custom(controlArgs);
287
+ }
288
+ }
289
+
290
+ if (
291
+ isRef(currentValue) &&
292
+ ps.format !== "json-schema" &&
293
+ ps.type !== "object" &&
294
+ ps.type !== "array"
295
+ ) {
296
+ const binding = controlRegistry.get("binding");
297
+ if (binding) {
298
+ return binding(controlArgs);
299
+ }
300
+ return refTextField(prop, currentValue.$ref, commit);
301
+ }
302
+
303
+ const enumValues = resolveFormEnum(opts.ui?.[prop]?.enum ?? ps.enum, ctx, value);
304
+ if (enumValues) {
305
+ return html`
306
+ <sp-picker
307
+ size="s"
308
+ value=${currentValue !== undefined
309
+ ? String(currentValue)
310
+ : ps.default !== undefined
311
+ ? String(ps.default)
312
+ : "__none__"}
313
+ @change=${(e: Event) =>
314
+ commit(
315
+ (e.target as HTMLInputElement).value === "__none__"
316
+ ? undefined
317
+ : (e.target as HTMLInputElement).value,
318
+ )}
319
+ >
320
+ ${!required.has(prop) ? html`<sp-menu-item value="__none__">—</sp-menu-item>` : nothing}
321
+ ${enumValues.map((val: string) => html`<sp-menu-item value=${val}>${val}</sp-menu-item>`)}
322
+ </sp-picker>
323
+ `;
324
+ }
325
+ if (ps.type === "boolean") {
326
+ return html`<sp-checkbox
327
+ ?checked=${currentValue ?? ps.default ?? false}
328
+ @change=${(e: Event) => commit((e.target as HTMLInputElement).checked)}
329
+ ></sp-checkbox>`;
330
+ }
331
+ if (ps.type === "integer" || ps.type === "number") {
332
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
333
+ let debounce: ReturnType<typeof setTimeout> | undefined;
334
+ return html`<sp-number-field
335
+ size="s"
336
+ min=${ifDefined(ps.minimum)}
337
+ max=${ifDefined(ps.maximum)}
338
+ step=${ps.type === "integer" ? "1" : nothing}
339
+ .value=${currentValue !== undefined ? currentValue : nothing}
340
+ placeholder=${ps.default != null ? String(ps.default) : nothing}
341
+ @change=${(e: Event) => {
342
+ clearTimeout(debounce);
343
+ debounce = setTimeout(() => {
344
+ const parsed = parseNumericField(
345
+ (e.target as HTMLInputElement).value,
346
+ ps.type === "integer",
347
+ );
348
+ commit(Number.isNaN(parsed) ? undefined : parsed);
349
+ }, 400);
350
+ }}
351
+ ></sp-number-field>`;
352
+ }
353
+ if (ps.format === "json-schema") {
354
+ const hasValue =
355
+ currentValue && typeof currentValue === "object" && Object.keys(currentValue).length > 0;
356
+ const cv = currentValue as Record<string, unknown>;
357
+ const isSchemaRef = hasValue && cv.$ref;
358
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
359
+ let debounce: ReturnType<typeof setTimeout> | undefined;
360
+ return html`
361
+ <div class="schema-param-editor">
362
+ ${hasValue && !isSchemaRef && cv.properties
363
+ ? html`
364
+ <div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px">
365
+ ${Object.entries(cv.properties as Record<string, Record<string, unknown>>).map(
366
+ ([k, v]) => html`
367
+ <span
368
+ style="background:var(--bg);padding:1px 6px;border-radius:var(--radius);font-size:10px;color:var(--fg-dim)"
369
+ >${k}: ${v.type ?? "any"}</span
370
+ >
371
+ `,
372
+ )}
373
+ </div>
374
+ `
375
+ : nothing}
376
+ <sp-textfield
377
+ multiline
378
+ size="s"
379
+ style=${styleMap({
380
+ fontFamily: "monospace",
381
+ fontSize: "11px",
382
+ minHeight: hasValue ? "80px" : "40px",
383
+ })}
384
+ .value=${currentValue !== undefined ? JSON.stringify(currentValue, null, 2) : ""}
385
+ placeholder=${ps.description ?? "JSON Schema defining the data shape…"}
386
+ @input=${(e: Event) => {
387
+ clearTimeout(debounce);
388
+ debounce = setTimeout(() => {
389
+ try {
390
+ commit(JSON.parse((e.target as HTMLInputElement).value) as unknown);
391
+ } catch {}
392
+ }, 500);
393
+ }}
394
+ ></sp-textfield>
395
+ </div>
396
+ `;
397
+ }
398
+ if (ps.type === "array" && ps.items?.type === "object" && ps.items?.properties) {
399
+ // Array of objects with defined schema → multi-row inline form
400
+ const rows: Record<string, unknown>[] = Array.isArray(currentValue)
401
+ ? (currentValue as Record<string, unknown>[])
402
+ : [];
403
+ const itemProps = ps.items.properties;
404
+ return html`
405
+ <div class="array-object-field">
406
+ ${rows.map(
407
+ (row: Record<string, unknown>, idx: number) => html`
408
+ <div
409
+ class="array-object-row"
410
+ style="display:flex;gap:4px;align-items:center;margin-bottom:4px"
411
+ >
412
+ ${Object.entries(itemProps).map(([propKey, propSchema]) =>
413
+ renderInlineField(
414
+ propKey,
415
+ propSchema,
416
+ row[propKey],
417
+ (val) => {
418
+ const updated = [...rows];
419
+ updated[idx] = { ...updated[idx], [propKey]: val };
420
+ commit(updated);
421
+ },
422
+ ctx,
423
+ value,
424
+ ),
425
+ )}
426
+ <sp-action-button
427
+ quiet
428
+ size="s"
429
+ @click=${() => {
430
+ const updated = rows.filter((_: unknown, i: number) => i !== idx);
431
+ commit(updated.length > 0 ? updated : undefined);
432
+ opts.rerender?.();
433
+ }}
434
+ >
435
+ <sp-icon-delete slot="icon"></sp-icon-delete>
436
+ </sp-action-button>
437
+ </div>
438
+ `,
439
+ )}
440
+ <sp-action-button
441
+ quiet
442
+ size="s"
443
+ @click=${(e: Event) => {
444
+ e.stopPropagation();
445
+ const newRow: Record<string, unknown> = {};
446
+ for (const [k, v] of Object.entries(itemProps)) {
447
+ if (v.default !== undefined) {
448
+ newRow[k] = v.default;
449
+ }
450
+ }
451
+ commit([...rows, newRow]);
452
+ opts.rerender?.();
453
+ }}
454
+ >+ Add</sp-action-button
455
+ >
456
+ </div>
457
+ `;
458
+ }
459
+ if (ps.type === "array" || ps.type === "object") {
460
+ return renderJsonTextField(currentValue, ps, commit) as TemplateResult;
461
+ }
462
+
463
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
464
+ let debounce: ReturnType<typeof setTimeout> | undefined;
465
+ const params = ctx.params ?? [];
466
+ const ph = ps.default !== undefined ? String(ps.default) : (ps.examples?.[0] ?? "");
467
+ return html`<div style="display:flex;gap:4px;align-items:center">
468
+ <sp-textfield
469
+ size="s"
470
+ style="flex:1"
471
+ .value=${currentValue ?? ""}
472
+ placeholder=${ph || nothing}
473
+ title=${ps.description || nothing}
474
+ @input=${(e: Event) => {
475
+ clearTimeout(debounce);
476
+ debounce = setTimeout(() => commit((e.target as HTMLInputElement).value || undefined), 400);
477
+ }}
478
+ ></sp-textfield>
479
+ ${params.length > 0
480
+ ? html`<sp-action-button
481
+ quiet
482
+ size="s"
483
+ title="Bind to route param"
484
+ @click=${() => {
485
+ commit({ $ref: `#/$params/${params[0]}` });
486
+ opts.rerender?.();
487
+ }}
488
+ ><sp-icon-link slot="icon"></sp-icon-link
489
+ ></sp-action-button>`
490
+ : nothing}
491
+ </div>`;
492
+ }
493
+
494
+ // ─── Form rendering ──────────────────────────────────────────────────────────
495
+
496
+ /**
497
+ * Render form field rows for a JSON Schema's `properties`, committing edits through `opts.onChange`
498
+ * as single-key patches (`undefined` values mean "unset the key").
499
+ *
500
+ * @param {JsonSchema} schema
501
+ * @param {Record<string, unknown>} value - The record being edited
502
+ * @param {RenderFormOptions} opts
503
+ * @returns {TemplateResult}
504
+ */
505
+ export function renderForm(
506
+ schema: JsonSchema,
507
+ value: Record<string, unknown>,
508
+ opts: RenderFormOptions,
509
+ ): TemplateResult {
510
+ const required = new Set(schema.required);
511
+ const ctx = opts.context ?? NULL_CONTEXT;
512
+
513
+ const propertyFields = Object.entries(schema.properties ?? {}).map(([prop, ps]) => {
514
+ const labelText = prop + (required.has(prop) ? " *" : "");
515
+ return renderFieldRow({
516
+ hasValue: false,
517
+ label: labelText,
518
+ prop: ps.name || prop,
519
+ widget: renderPropertyControl(prop, ps, value, required, opts, ctx),
520
+ });
521
+ });
522
+
523
+ return html`${propertyFields}`;
524
+ }
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { defaultContentFormat, formatByName } from "../format/format-host";
8
- import type { ContentTypeDef, ProjectConfig } from "@jxsuite/schema/types";
8
+ import type { ContentSectionEntry } from "../types";
9
+ import type { ProjectConfig } from "@jxsuite/schema/types";
9
10
 
10
11
  /**
11
12
  * CamelCase → kebab-case for inline style attributes
@@ -165,14 +166,14 @@ export function findContentTypeSchema(
165
166
  documentPath: string | null,
166
167
  projectConfig: ProjectConfig | null | undefined,
167
168
  ) {
168
- if (!documentPath || !projectConfig?.contentTypes) {
169
+ if (!documentPath || !projectConfig?.content) {
169
170
  return null;
170
171
  }
171
172
  // Content-type `source` prefixes are always forward-slash. The desktop platform can hand us
172
173
  // OS-native backslash paths on Windows, so normalize before prefix matching.
173
174
  const docPath = documentPath.replaceAll("\\", "/");
174
175
  for (const [name, def] of Object.entries(
175
- projectConfig.contentTypes as Record<string, ContentTypeDef>,
176
+ projectConfig.content as Record<string, ContentSectionEntry>,
176
177
  )) {
177
178
  if (!def.source || !def.schema) {
178
179
  continue;