astro-dev-edit 0.11.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/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { directWrite, type TextWriter } from './text-writes.ts';
|
|
2
|
+
import type { AstroIntegrationLogger } from 'astro';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { mkdir, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
6
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { parseEntry } from '../patcher/frontmatter.ts';
|
|
8
|
+
import {
|
|
9
|
+
addCollection,
|
|
10
|
+
addField,
|
|
11
|
+
readCollectionBlocks,
|
|
12
|
+
removeField,
|
|
13
|
+
setSchemaForm,
|
|
14
|
+
updateField,
|
|
15
|
+
type CollectionBlock,
|
|
16
|
+
type SchemaField,
|
|
17
|
+
} from '../patcher/content-config.ts';
|
|
18
|
+
import type {
|
|
19
|
+
CollectionApplyResponse,
|
|
20
|
+
CollectionCreateRequest,
|
|
21
|
+
CollectionEntriesRequest,
|
|
22
|
+
CollectionEntryItem,
|
|
23
|
+
CollectionOpenRequest,
|
|
24
|
+
CollectionPageEditingRequest,
|
|
25
|
+
CollectionPageEditingResponse,
|
|
26
|
+
CollectionRefusal,
|
|
27
|
+
CollectionSchemaApplyRequest,
|
|
28
|
+
CollectionSummary,
|
|
29
|
+
FieldDescriptor,
|
|
30
|
+
FieldOverride,
|
|
31
|
+
FieldType,
|
|
32
|
+
} from '../shared/protocol.ts';
|
|
33
|
+
import type {
|
|
34
|
+
EntryCollectionInfo,
|
|
35
|
+
EntryEditorOptions,
|
|
36
|
+
EntrySchemaProvider,
|
|
37
|
+
} from './content-config.ts';
|
|
38
|
+
import {
|
|
39
|
+
MAX_ENTRIES_LISTED,
|
|
40
|
+
inContentRoots,
|
|
41
|
+
listEntryFiles,
|
|
42
|
+
} from './collection-entries.ts';
|
|
43
|
+
import type { DetailRoutes } from './entry-detect.ts';
|
|
44
|
+
import { launchInEditor } from './editor.ts';
|
|
45
|
+
import { ENTRY_EXTENSIONS } from './entry-routes.ts';
|
|
46
|
+
import type { OptionsResolver, StoredOptions } from './options.ts';
|
|
47
|
+
import { insideRoot } from './paths.ts';
|
|
48
|
+
import type { Route, RouteResult } from './router.ts';
|
|
49
|
+
import { FIELD_TYPES, inferFields, zodToFields } from './schema-introspect.ts';
|
|
50
|
+
import { readStoredOptions, saveStoredOptions } from './settings.ts';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The collection-designer route group (`/collections`,
|
|
54
|
+
* `/collection/schema/apply`, `/collection/create`) — the Collections tab's
|
|
55
|
+
* server side. A feature route module: it exports a `Route[]` the middleware
|
|
56
|
+
* concatenates, so everything collection-shaped lives here.
|
|
57
|
+
*
|
|
58
|
+
* **This is the third class of write in the codebase, and the widest.** The
|
|
59
|
+
* others target content (confined by `paths.ts::validateEditablePath`) or a fixed
|
|
60
|
+
* settings file. This one patches the project's own `src/content.config.ts` —
|
|
61
|
+
* TypeScript the dev server executes. Four rules hold it in:
|
|
62
|
+
*
|
|
63
|
+
* 1. **The path is discovered server-side**, from `EntrySchemaProvider.configPath`
|
|
64
|
+
* (the conventional candidates, or `entryEditor.configPath` from the config).
|
|
65
|
+
* A request names a *collection*, never a path, so there is no path to
|
|
66
|
+
* smuggle. `configTarget` re-checks the resolved realpath is inside the
|
|
67
|
+
* project root and carries a config-shaped extension anyway — the same
|
|
68
|
+
* fixed-target reasoning `settings.ts` documents, plus a belt.
|
|
69
|
+
* 2. **Every string that reaches generated source is validated**, not escaped:
|
|
70
|
+
* collection and field names must be plain identifiers, a directory and a glob
|
|
71
|
+
* pattern must match a conservative character class. A name that would need
|
|
72
|
+
* quoting is refused instead of quoted, which keeps every expression this
|
|
73
|
+
* module writes a shape it can read back.
|
|
74
|
+
* 3. **Writes are etag-guarded and all-or-nothing.** Each field edit is applied
|
|
75
|
+
* to one in-memory copy and the file is written once at the end, so a refusal
|
|
76
|
+
* anywhere leaves the config untouched and a stale panel fails safe. Same
|
|
77
|
+
* property `/apply` has.
|
|
78
|
+
* 4. **`schemaEditor: false` refuses before touching the filesystem**, so a
|
|
79
|
+
* project can keep the rest of the tool and forbid schema writes outright.
|
|
80
|
+
*
|
|
81
|
+
* The editor half of a field (widget, label, hidden) is not a schema write at
|
|
82
|
+
* all: it goes to `.astro-dev-edit.json` through `saveStoredOptions`. The two
|
|
83
|
+
* stores are deliberately visible as two in the response, because one is
|
|
84
|
+
* committed source and the other is local.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
export interface SchemaRouteDeps {
|
|
88
|
+
writeText?: TextWriter;
|
|
89
|
+
logger: AstroIntegrationLogger;
|
|
90
|
+
/** Project root (fsPath). */
|
|
91
|
+
root: string;
|
|
92
|
+
/** Live options — `entryEditor` gates the group, `schemaEditor` the writes,
|
|
93
|
+
* and `contentRoots` confines a new collection's directory. */
|
|
94
|
+
optionsResolver: OptionsResolver;
|
|
95
|
+
/** Collection/schema lookup. Null → the panel is told there is no config. */
|
|
96
|
+
schemaProvider: EntrySchemaProvider | null;
|
|
97
|
+
/** Which dynamic route renders which collection, for the page-editing status.
|
|
98
|
+
* Null → the panel shows no detail route rather than a wrong one. */
|
|
99
|
+
detailRoutes: DetailRoutes | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Extensions a content config may have — the tail of `CONFIG_CANDIDATES`. */
|
|
103
|
+
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.js', '.mjs'];
|
|
104
|
+
|
|
105
|
+
/** A repo-relative directory, conservatively. No quotes, no `..`, no backslash —
|
|
106
|
+
* it is written verbatim into a single-quoted string in generated source. */
|
|
107
|
+
const DIR_RE = /^[A-Za-z0-9_\-./]+$/;
|
|
108
|
+
|
|
109
|
+
/** A glob pattern, conservatively. Same reason as {@link DIR_RE}. */
|
|
110
|
+
const PATTERN_RE = /^[A-Za-z0-9_\-./*{}[\],!()]+$/;
|
|
111
|
+
|
|
112
|
+
/** Frontmatter keys tried, in order, for an entry's display title. */
|
|
113
|
+
const TITLE_KEYS = ['title', 'name', 'heading', 'label'];
|
|
114
|
+
|
|
115
|
+
function sha256(text: string): string {
|
|
116
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** HTTP status for a designer refusal. */
|
|
120
|
+
const STATUS: Record<CollectionRefusal, number> = {
|
|
121
|
+
conflict: 409,
|
|
122
|
+
disabled: 403,
|
|
123
|
+
unrecognized: 422,
|
|
124
|
+
missing: 422,
|
|
125
|
+
exists: 409,
|
|
126
|
+
unsupported: 422,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function refuse(code: CollectionRefusal, error: string): RouteResult {
|
|
130
|
+
const body: CollectionApplyResponse = {
|
|
131
|
+
ok: false,
|
|
132
|
+
schemaWritten: false,
|
|
133
|
+
overridesWritten: false,
|
|
134
|
+
error,
|
|
135
|
+
code,
|
|
136
|
+
};
|
|
137
|
+
return { status: STATUS[code], body };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createSchemaRoutes(deps: SchemaRouteDeps): Route[] {
|
|
141
|
+
const { logger, root, optionsResolver, schemaProvider, detailRoutes } = deps;
|
|
142
|
+
const writeText: TextWriter = deps.writeText ?? directWrite;
|
|
143
|
+
|
|
144
|
+
/** The gate every route in this group starts at. */
|
|
145
|
+
async function gate(): Promise<{
|
|
146
|
+
enabled: boolean;
|
|
147
|
+
schemaEditor: boolean;
|
|
148
|
+
contentRoots: string[];
|
|
149
|
+
}> {
|
|
150
|
+
const { options } = await optionsResolver.resolve();
|
|
151
|
+
return {
|
|
152
|
+
enabled: options.entryEditor !== false,
|
|
153
|
+
schemaEditor: options.schemaEditor,
|
|
154
|
+
contentRoots: options.contentRoots,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let rootRealCache: string | null = null;
|
|
159
|
+
async function rootReal(): Promise<string> {
|
|
160
|
+
rootRealCache ??= await realpath(root);
|
|
161
|
+
return rootRealCache;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The content config this group may read and write, or null when there isn't
|
|
166
|
+
* one. Discovered server-side; the confinement check is defence in depth
|
|
167
|
+
* against a hand-written `entryEditor.configPath` rather than against a
|
|
168
|
+
* request, which never carries a path.
|
|
169
|
+
*/
|
|
170
|
+
async function configTarget(): Promise<{ rel: string; abs: string } | null> {
|
|
171
|
+
const rel = (await schemaProvider?.configPath()) ?? null;
|
|
172
|
+
if (!rel) return null;
|
|
173
|
+
const abs = resolve(root, rel);
|
|
174
|
+
if (!CONFIG_EXTENSIONS.includes(extname(abs))) return null;
|
|
175
|
+
try {
|
|
176
|
+
const real = await realpath(abs);
|
|
177
|
+
if (!insideRoot(await rootReal(), real)) return null;
|
|
178
|
+
return { rel, abs: real };
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Entry files in a collection's directory. Counting only — nothing here is
|
|
185
|
+
* ever opened. */
|
|
186
|
+
async function countEntries(dir: string): Promise<{ exists: boolean; count: number }> {
|
|
187
|
+
const abs = resolve(root, dir);
|
|
188
|
+
if (!insideRoot(root, abs) || !existsSync(abs)) return { exists: false, count: 0 };
|
|
189
|
+
try {
|
|
190
|
+
const files = await readdir(abs, { recursive: true });
|
|
191
|
+
return {
|
|
192
|
+
exists: true,
|
|
193
|
+
count: files.filter((f) => ENTRY_EXTENSIONS.some((e) => String(f).endsWith(e))).length,
|
|
194
|
+
};
|
|
195
|
+
} catch {
|
|
196
|
+
return { exists: true, count: 0 };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The panel's row for one collection: its live fields (schema-derived when the
|
|
201
|
+
* schema resolved) joined to what the config source actually says. */
|
|
202
|
+
async function summarize(
|
|
203
|
+
info: EntryCollectionInfo,
|
|
204
|
+
block: CollectionBlock | undefined,
|
|
205
|
+
/** The stored overrides for this collection, to tell apart what the panel
|
|
206
|
+
* wrote from what the project's config owns. */
|
|
207
|
+
storedFields: Record<string, FieldOverride>,
|
|
208
|
+
): Promise<CollectionSummary> {
|
|
209
|
+
// Read off the config layer directly rather than inferred by comparing
|
|
210
|
+
// effective against stored: a config and a stored value that agree are
|
|
211
|
+
// indistinguishable that way, and a switch the panel draws as writable while
|
|
212
|
+
// resolution ignores it is worse than one drawn locked.
|
|
213
|
+
const pageEditingLocked =
|
|
214
|
+
optionsResolver.entryEditorConfig()?.collections?.[info.collection]?.pageEditing !==
|
|
215
|
+
undefined;
|
|
216
|
+
const { exists, count } = await countEntries(info.dir);
|
|
217
|
+
// No resolvable schema: fall back to the field names the *source* names, so
|
|
218
|
+
// the row isn't empty. Types are unknown, which `json` is the honest answer
|
|
219
|
+
// for — the same degradation the entry panel makes — and `fieldSource` tells
|
|
220
|
+
// the panel to say where the list came from.
|
|
221
|
+
const derived = info.schema ? zodToFields(info.schema) : null;
|
|
222
|
+
const fields: FieldDescriptor[] =
|
|
223
|
+
derived ?? inferFields(Object.fromEntries((block?.fields ?? []).map((f) => [f.name, null])));
|
|
224
|
+
return {
|
|
225
|
+
name: info.collection,
|
|
226
|
+
dir: info.dir,
|
|
227
|
+
dirExists: exists,
|
|
228
|
+
entryCount: count,
|
|
229
|
+
fields,
|
|
230
|
+
fieldSource: derived ? 'schema' : 'source',
|
|
231
|
+
expressions: Object.fromEntries((block?.fields ?? []).map((f) => [f.name, f.expr])),
|
|
232
|
+
schemaForm: block?.schemaForm ?? null,
|
|
233
|
+
...(block?.unrecognized
|
|
234
|
+
? { unrecognized: block.unrecognized }
|
|
235
|
+
: block
|
|
236
|
+
? {}
|
|
237
|
+
: { unrecognized: 'this collection is not declared in the content config' }),
|
|
238
|
+
registered: block?.registered ?? false,
|
|
239
|
+
...(block ? { configLine: block.line } : {}),
|
|
240
|
+
overrides: info.fieldConfig,
|
|
241
|
+
// The effective override merges config over store, config winning key by
|
|
242
|
+
// key. Anything that doesn't match the store is therefore the config's, and
|
|
243
|
+
// storing a value for it from the panel would do nothing.
|
|
244
|
+
lockedFields: Object.entries(info.fieldConfig)
|
|
245
|
+
.filter(([name, effective]) => !sameOverride(effective, storedFields[name]))
|
|
246
|
+
.map(([name]) => name),
|
|
247
|
+
pageEditing: info.pageEditing === true,
|
|
248
|
+
pageEditingLocked,
|
|
249
|
+
// Null is a real answer, not a missing one: a data collection no dynamic
|
|
250
|
+
// route renders has no detail route, and the panel says so rather than
|
|
251
|
+
// implying the switch will produce a button somewhere.
|
|
252
|
+
detailRoute: (await detailRoutes?.patternFor(info.collection)) ?? null,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Read the config source with its etag, or the refusal that stands in for it. */
|
|
257
|
+
async function readConfig(): Promise<
|
|
258
|
+
{ ok: true; rel: string; abs: string; source: string; etag: string } | RouteResult
|
|
259
|
+
> {
|
|
260
|
+
const target = await configTarget();
|
|
261
|
+
if (!target) {
|
|
262
|
+
return refuse(
|
|
263
|
+
'missing',
|
|
264
|
+
'This project has no content config the designer can read. Create src/content.config.ts first.',
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const source = await readFile(target.abs, 'utf8');
|
|
268
|
+
return { ok: true, ...target, source, etag: sha256(source) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return [
|
|
272
|
+
// The Collections tab's read: every collection, its fields, and whether the
|
|
273
|
+
// designer can patch its schema.
|
|
274
|
+
{
|
|
275
|
+
method: 'POST',
|
|
276
|
+
path: '/collections',
|
|
277
|
+
maxBytes: 1024,
|
|
278
|
+
label: 'collections list',
|
|
279
|
+
handler: async () => {
|
|
280
|
+
const { enabled, schemaEditor } = await gate();
|
|
281
|
+
if (!enabled) {
|
|
282
|
+
return {
|
|
283
|
+
status: 403,
|
|
284
|
+
body: { error: 'the entry editor is disabled by configuration', code: 'disabled' },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const target = await configTarget();
|
|
288
|
+
let source: string | null = null;
|
|
289
|
+
if (target) {
|
|
290
|
+
try {
|
|
291
|
+
source = await readFile(target.abs, 'utf8');
|
|
292
|
+
} catch {
|
|
293
|
+
source = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const blocks = source ? readCollectionBlocks(source) : [];
|
|
297
|
+
const infos = (await schemaProvider?.listCollections()) ?? [];
|
|
298
|
+
const storedOptions: StoredOptions = await readStoredOptions(root).catch(() => ({}));
|
|
299
|
+
const stored: EntryEditorOptions = storedOptions.entryEditor || {};
|
|
300
|
+
const collections: CollectionSummary[] = [];
|
|
301
|
+
for (const info of infos) {
|
|
302
|
+
collections.push(
|
|
303
|
+
await summarize(
|
|
304
|
+
info,
|
|
305
|
+
blocks.find((b) => b.name === info.collection),
|
|
306
|
+
stored.collections?.[info.collection]?.fields ?? {},
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
// A collection the config *declares* but the provider couldn't report —
|
|
311
|
+
// which is what happens when the config module fails to load at all, and
|
|
312
|
+
// is exactly when the designer is most useful. Listing it from the source
|
|
313
|
+
// alone beats letting it vanish from the panel with no explanation.
|
|
314
|
+
const covered = new Set(collections.map((c) => c.name));
|
|
315
|
+
for (const block of blocks) {
|
|
316
|
+
if (covered.has(block.name)) continue;
|
|
317
|
+
collections.push(
|
|
318
|
+
await summarize(
|
|
319
|
+
{
|
|
320
|
+
collection: block.name,
|
|
321
|
+
dir: `src/content/${block.name}`,
|
|
322
|
+
schema: null,
|
|
323
|
+
// The provider returns nothing for this name only when the
|
|
324
|
+
// config module failed to load *and* nothing configures it
|
|
325
|
+
// explicitly — so the stored layer is the whole answer here.
|
|
326
|
+
pageEditing: stored.collections?.[block.name]?.pageEditing === true,
|
|
327
|
+
fieldConfig: {},
|
|
328
|
+
},
|
|
329
|
+
block,
|
|
330
|
+
{},
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
status: 200,
|
|
336
|
+
body: {
|
|
337
|
+
configPath: target?.rel ?? null,
|
|
338
|
+
etag: source === null ? null : sha256(source),
|
|
339
|
+
schemaEditor,
|
|
340
|
+
collections,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
// Schema edits and/or editor overrides for one collection. Schema first, so a
|
|
347
|
+
// refused patch never leaves overrides pointing at fields that don't exist.
|
|
348
|
+
{
|
|
349
|
+
method: 'POST',
|
|
350
|
+
path: '/collection/schema/apply',
|
|
351
|
+
maxBytes: 64 * 1024,
|
|
352
|
+
label: 'collection schema apply',
|
|
353
|
+
handler: async (body) => {
|
|
354
|
+
const { enabled, schemaEditor } = await gate();
|
|
355
|
+
if (!enabled) return refuse('disabled', 'The entry editor is disabled by configuration.');
|
|
356
|
+
const req = (body ?? {}) as CollectionSchemaApplyRequest;
|
|
357
|
+
if (!req.collection || typeof req.collection !== 'string') {
|
|
358
|
+
throw new Error('collection is required');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const edits = req.schema ?? {};
|
|
362
|
+
if (edits.form !== undefined && edits.form !== 'object' && edits.form !== 'function') {
|
|
363
|
+
throw new Error('schema.form must be "object" or "function"');
|
|
364
|
+
}
|
|
365
|
+
const hasSchemaEdits =
|
|
366
|
+
(edits.add?.length ?? 0) + (edits.update?.length ?? 0) + (edits.remove?.length ?? 0) > 0 ||
|
|
367
|
+
edits.form !== undefined;
|
|
368
|
+
const hasOverrides = req.overrides && Object.keys(req.overrides).length > 0;
|
|
369
|
+
if (!hasSchemaEdits && !hasOverrides) {
|
|
370
|
+
return { status: 400, body: { error: 'nothing to save' } };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
let schemaWritten = false;
|
|
374
|
+
let etag: string | undefined;
|
|
375
|
+
if (hasSchemaEdits) {
|
|
376
|
+
if (!schemaEditor) {
|
|
377
|
+
return refuse(
|
|
378
|
+
'disabled',
|
|
379
|
+
'Schema editing is off. Turn on "Schema editing" to let the designer write your content config.',
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const config = await readConfig();
|
|
383
|
+
if (!('ok' in config)) return config;
|
|
384
|
+
if (typeof req.etag !== 'string' || req.etag !== config.etag) {
|
|
385
|
+
return refuse(
|
|
386
|
+
'conflict',
|
|
387
|
+
`${config.rel} changed on disk since this panel read it. Reopen the tab and try again.`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// One in-memory copy, verified all the way through, written once.
|
|
392
|
+
let next = config.source;
|
|
393
|
+
// The form goes first: turning image support on and adding the image
|
|
394
|
+
// field it was turned on for arrive as a single save, and the field
|
|
395
|
+
// can't render until the helper is in scope. A form already matching
|
|
396
|
+
// is a no-op, so an unchanged switch costs nothing.
|
|
397
|
+
if (edits.form !== undefined) {
|
|
398
|
+
const step = setSchemaForm(next, req.collection, edits.form);
|
|
399
|
+
if (!step.ok) return refuse(step.code, step.error);
|
|
400
|
+
next = step.newSource;
|
|
401
|
+
}
|
|
402
|
+
for (const name of edits.remove ?? []) {
|
|
403
|
+
const step = removeField(next, req.collection, String(name));
|
|
404
|
+
if (!step.ok) return refuse(step.code, step.error);
|
|
405
|
+
next = step.newSource;
|
|
406
|
+
}
|
|
407
|
+
for (const raw of edits.update ?? []) {
|
|
408
|
+
const field = coerceField(raw);
|
|
409
|
+
if (!field.ok) return refuse('unsupported', field.error);
|
|
410
|
+
const step = updateField(next, req.collection, field.field);
|
|
411
|
+
if (!step.ok) return refuse(step.code, step.error);
|
|
412
|
+
next = step.newSource;
|
|
413
|
+
}
|
|
414
|
+
for (const raw of edits.add ?? []) {
|
|
415
|
+
const field = coerceField(raw);
|
|
416
|
+
if (!field.ok) return refuse('unsupported', field.error);
|
|
417
|
+
const step = addField(next, req.collection, field.field);
|
|
418
|
+
if (!step.ok) return refuse(step.code, step.error);
|
|
419
|
+
next = step.newSource;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (next !== config.source) {
|
|
423
|
+
await writeText(config.abs, next, config.source);
|
|
424
|
+
logger.info(`${req.collection} schema updated -> ${config.rel}`);
|
|
425
|
+
etag = sha256(next);
|
|
426
|
+
}
|
|
427
|
+
schemaWritten = next !== config.source;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let overridesWritten = false;
|
|
431
|
+
let error: string | undefined;
|
|
432
|
+
if (hasOverrides) {
|
|
433
|
+
const result = await writeOverrides(req.collection, req.overrides!);
|
|
434
|
+
if (result.ok) overridesWritten = result.changed;
|
|
435
|
+
else error = result.error;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const response: CollectionApplyResponse = {
|
|
439
|
+
ok: !error,
|
|
440
|
+
schemaWritten,
|
|
441
|
+
overridesWritten,
|
|
442
|
+
...(etag ? { etag } : {}),
|
|
443
|
+
...(error ? { error, code: 'unsupported' as const } : {}),
|
|
444
|
+
};
|
|
445
|
+
return { status: error ? 422 : 200, body: response };
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
// The Items view's read: one collection's entry files. Frontmatter only — a
|
|
450
|
+
// listing never reads a body — and capped, with the cap reported rather than
|
|
451
|
+
// silently truncating.
|
|
452
|
+
{
|
|
453
|
+
method: 'POST',
|
|
454
|
+
path: '/collection/entries',
|
|
455
|
+
maxBytes: 1024,
|
|
456
|
+
label: 'collection entries',
|
|
457
|
+
handler: async (body) => {
|
|
458
|
+
const { enabled, contentRoots } = await gate();
|
|
459
|
+
if (!enabled) return refuse('disabled', 'The entry editor is disabled by configuration.');
|
|
460
|
+
const { collection } = (body ?? {}) as CollectionEntriesRequest;
|
|
461
|
+
if (!collection || typeof collection !== 'string') {
|
|
462
|
+
throw new Error('collection is required');
|
|
463
|
+
}
|
|
464
|
+
const info = await schemaProvider?.forCollection(collection);
|
|
465
|
+
// A collection the config declares but the provider can't resolve (a
|
|
466
|
+
// config that fails to load) still has entries on disk; fall back to the
|
|
467
|
+
// conventional directory rather than answering "no such collection".
|
|
468
|
+
const dir = info?.dir ?? `src/content/${collection}`;
|
|
469
|
+
const dirAbs = resolve(root, dir);
|
|
470
|
+
if (!inContentRoots(root, dirAbs, contentRoots)) {
|
|
471
|
+
return refuse('unsupported', `${dir} is outside the editable content roots.`);
|
|
472
|
+
}
|
|
473
|
+
if (!existsSync(dirAbs)) {
|
|
474
|
+
return { status: 200, body: { collection, dir, entries: [] } };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const { options } = await optionsResolver.resolve();
|
|
478
|
+
const extensions = ENTRY_EXTENSIONS.filter((e) => options.editableExtensions.includes(e));
|
|
479
|
+
const { names, truncated } = await listEntryFiles(dirAbs, extensions);
|
|
480
|
+
const entries: CollectionEntryItem[] = [];
|
|
481
|
+
for (const name of names) {
|
|
482
|
+
entries.push(await describeEntry(dir, dirAbs, name));
|
|
483
|
+
}
|
|
484
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
485
|
+
if (truncated) {
|
|
486
|
+
logger.info(`${collection}: listing the first ${MAX_ENTRIES_LISTED} entries`);
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
status: 200,
|
|
490
|
+
body: { collection, dir, entries, ...(truncated ? { truncated: true } : {}) },
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
// Switch one collection's in-page entry drawer on or off. Its own route
|
|
496
|
+
// rather than a corner of /collection/schema/apply, whose `overrides` are
|
|
497
|
+
// keyed per field while this is one flag about the collection — and it saves
|
|
498
|
+
// on the flip, because the list view it is drawn in has no Save button.
|
|
499
|
+
//
|
|
500
|
+
// Gated on `entryEditor` and deliberately NOT on `schemaEditor`: this writes
|
|
501
|
+
// `.astro-dev-edit.json`, the local gitignored half, exactly as a widget or
|
|
502
|
+
// label does. No committed source is touched.
|
|
503
|
+
{
|
|
504
|
+
method: 'POST',
|
|
505
|
+
path: '/collection/page-editing',
|
|
506
|
+
maxBytes: 1024,
|
|
507
|
+
label: 'collection page editing',
|
|
508
|
+
handler: async (body) => {
|
|
509
|
+
const { enabled: on } = await gate();
|
|
510
|
+
if (!on) return refuse('disabled', 'The entry editor is disabled by configuration.');
|
|
511
|
+
const { collection, enabled } = (body ?? {}) as CollectionPageEditingRequest;
|
|
512
|
+
if (!collection || typeof collection !== 'string') {
|
|
513
|
+
throw new Error('collection is required');
|
|
514
|
+
}
|
|
515
|
+
if (typeof enabled !== 'boolean') throw new Error('enabled must be a boolean');
|
|
516
|
+
|
|
517
|
+
// Refusing to store a value resolution would ignore, the same stance
|
|
518
|
+
// `/settings` takes for a config-owned option and `writeAccessKey` takes
|
|
519
|
+
// for a config-supplied key.
|
|
520
|
+
if (
|
|
521
|
+
optionsResolver.entryEditorConfig()?.collections?.[collection]?.pageEditing !== undefined
|
|
522
|
+
) {
|
|
523
|
+
return refuse(
|
|
524
|
+
'disabled',
|
|
525
|
+
`Page editing for ${collection} is set in astro.config.mjs, which takes precedence. ` +
|
|
526
|
+
'Remove it there to manage it from this panel.',
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const result = await writePageEditing(collection, enabled);
|
|
531
|
+
if (!result.ok) return refuse('unsupported', result.error);
|
|
532
|
+
const response: CollectionPageEditingResponse = { ok: true, pageEditing: enabled };
|
|
533
|
+
return { status: 200, body: response };
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
// Launch the editor on the content config. Read-only, and the *only* path it
|
|
538
|
+
// can reach is the one this group discovered — the request carries a
|
|
539
|
+
// collection name at most, so there is nothing to confine beyond what
|
|
540
|
+
// `configTarget` already did.
|
|
541
|
+
{
|
|
542
|
+
method: 'POST',
|
|
543
|
+
path: '/collection/open',
|
|
544
|
+
maxBytes: 1024,
|
|
545
|
+
label: 'collection open',
|
|
546
|
+
handler: async (body) => {
|
|
547
|
+
const { enabled } = await gate();
|
|
548
|
+
const { options } = await optionsResolver.resolve();
|
|
549
|
+
if (!enabled || !options.openInEditor) {
|
|
550
|
+
return refuse('disabled', 'Open-in-editor is disabled by configuration.');
|
|
551
|
+
}
|
|
552
|
+
const config = await readConfig();
|
|
553
|
+
if (!('ok' in config)) return config;
|
|
554
|
+
const { collection } = (body ?? {}) as CollectionOpenRequest;
|
|
555
|
+
const block = collection
|
|
556
|
+
? readCollectionBlocks(config.source).find((b) => b.name === collection)
|
|
557
|
+
: undefined;
|
|
558
|
+
await launchInEditor(block ? `${config.abs}:${block.line}:1` : config.abs);
|
|
559
|
+
return { status: 200, body: { ok: true } };
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
// Append a collection: its defineCollection block, its registry entry, and
|
|
564
|
+
// its entry directory.
|
|
565
|
+
{
|
|
566
|
+
method: 'POST',
|
|
567
|
+
path: '/collection/create',
|
|
568
|
+
maxBytes: 64 * 1024,
|
|
569
|
+
label: 'collection create',
|
|
570
|
+
handler: async (body) => {
|
|
571
|
+
const { enabled, schemaEditor, contentRoots } = await gate();
|
|
572
|
+
if (!enabled) return refuse('disabled', 'The entry editor is disabled by configuration.');
|
|
573
|
+
if (!schemaEditor) {
|
|
574
|
+
return refuse(
|
|
575
|
+
'disabled',
|
|
576
|
+
'Schema editing is off. Turn on "Schema editing" to create collections from here.',
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
const req = (body ?? {}) as CollectionCreateRequest;
|
|
580
|
+
const name = String(req.name ?? '');
|
|
581
|
+
const dir = String(req.dir || `src/content/${name}`);
|
|
582
|
+
const pattern = req.pattern ? String(req.pattern) : undefined;
|
|
583
|
+
|
|
584
|
+
if (!DIR_RE.test(dir) || dir.includes('..')) {
|
|
585
|
+
return refuse('unsupported', `"${dir}" is not a usable collection directory.`);
|
|
586
|
+
}
|
|
587
|
+
if (pattern && !PATTERN_RE.test(pattern)) {
|
|
588
|
+
return refuse('unsupported', `"${pattern}" is not a usable glob pattern.`);
|
|
589
|
+
}
|
|
590
|
+
// The directory is client-supplied, so confine it exactly as an edit path
|
|
591
|
+
// would be: inside the root, and inside a configured content root.
|
|
592
|
+
const dirAbs = resolve(root, dir);
|
|
593
|
+
const relDir = relative(root, dirAbs);
|
|
594
|
+
const inRoot = contentRoots.some(
|
|
595
|
+
(cr) => relDir === cr || relDir.startsWith(cr.endsWith(sep) ? cr : cr + sep),
|
|
596
|
+
);
|
|
597
|
+
if (!insideRoot(root, dirAbs) || !inRoot) {
|
|
598
|
+
return refuse('unsupported', `${dir} is outside the editable content roots.`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const config = await readConfig();
|
|
602
|
+
if (!('ok' in config)) return config;
|
|
603
|
+
if (typeof req.etag !== 'string' || req.etag !== config.etag) {
|
|
604
|
+
return refuse(
|
|
605
|
+
'conflict',
|
|
606
|
+
`${config.rel} changed on disk since this panel read it. Reopen the tab and try again.`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const fields: SchemaField[] = [];
|
|
611
|
+
for (const raw of req.fields ?? []) {
|
|
612
|
+
const field = coerceField(raw);
|
|
613
|
+
if (!field.ok) return refuse('unsupported', field.error);
|
|
614
|
+
fields.push(field.field);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (req.schemaForm !== undefined && req.schemaForm !== 'object' && req.schemaForm !== 'function') {
|
|
618
|
+
throw new Error('schemaForm must be "object" or "function"');
|
|
619
|
+
}
|
|
620
|
+
const patched = addCollection(config.source, {
|
|
621
|
+
name,
|
|
622
|
+
dir,
|
|
623
|
+
...(pattern ? { pattern } : {}),
|
|
624
|
+
...(req.schemaForm ? { schemaForm: req.schemaForm } : {}),
|
|
625
|
+
fields,
|
|
626
|
+
});
|
|
627
|
+
if (!patched.ok) return refuse(patched.code, patched.error);
|
|
628
|
+
|
|
629
|
+
// Directory first: a registered collection whose directory is missing is
|
|
630
|
+
// a build error, while a directory with no collection is inert.
|
|
631
|
+
await mkdir(dirAbs, { recursive: true });
|
|
632
|
+
await writeText(config.abs, patched.newSource, config.source);
|
|
633
|
+
logger.info(`collection created -> ${name} (${dir})`);
|
|
634
|
+
return { status: 200, body: { name, dir, etag: sha256(patched.newSource) } };
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
];
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Merge editor-only overrides into the settings file. Never touches the config:
|
|
641
|
+
* these are widget/label/hidden, which only the entry drawer reads.
|
|
642
|
+
*
|
|
643
|
+
* An override that says nothing (no widget, no label, `hidden: false`) is
|
|
644
|
+
* *removed* rather than stored, so reverting a row in the panel leaves the file
|
|
645
|
+
* as it was instead of accumulating no-op entries.
|
|
646
|
+
*/
|
|
647
|
+
/**
|
|
648
|
+
* Switch a collection's page editing on or off in the stored options.
|
|
649
|
+
*
|
|
650
|
+
* Shaped exactly like {@link writeOverrides} — read-modify-write at every
|
|
651
|
+
* level, never replacing a sibling — with one addition: **off is written by
|
|
652
|
+
* removing the key**, not by storing `false`. Off is already the default, so a
|
|
653
|
+
* stored `false` would be a husk that says nothing, and the collection entry
|
|
654
|
+
* itself is dropped when nothing else is left in it.
|
|
655
|
+
*/
|
|
656
|
+
async function writePageEditing(
|
|
657
|
+
collection: string,
|
|
658
|
+
enabled: boolean,
|
|
659
|
+
): Promise<{ ok: true; changed: boolean } | { ok: false; error: string }> {
|
|
660
|
+
try {
|
|
661
|
+
const stored = await readStoredOptions(root);
|
|
662
|
+
const detail: EntryEditorOptions = stored.entryEditor || {};
|
|
663
|
+
const collections = { ...detail.collections };
|
|
664
|
+
const { pageEditing: _was, ...rest } = { ...collections[collection] };
|
|
665
|
+
if (enabled) collections[collection] = { ...rest, pageEditing: true };
|
|
666
|
+
else if (Object.keys(rest).length > 0) collections[collection] = rest;
|
|
667
|
+
else delete collections[collection];
|
|
668
|
+
|
|
669
|
+
const before = JSON.stringify(detail.collections ?? {});
|
|
670
|
+
if (before === JSON.stringify(collections)) return { ok: true, changed: false };
|
|
671
|
+
|
|
672
|
+
await saveStoredOptions(root, {
|
|
673
|
+
...stored,
|
|
674
|
+
entryEditor: { ...detail, collections },
|
|
675
|
+
}, deps.writeText);
|
|
676
|
+
logger.info(`page editing ${enabled ? 'on' : 'off'}: ${collection}`);
|
|
677
|
+
return { ok: true, changed: true };
|
|
678
|
+
} catch (err) {
|
|
679
|
+
return { ok: false, error: `could not save page editing: ${String(err)}` };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function writeOverrides(
|
|
684
|
+
collection: string,
|
|
685
|
+
patch: Record<string, FieldOverride | null>,
|
|
686
|
+
): Promise<{ ok: true; changed: boolean } | { ok: false; error: string }> {
|
|
687
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
688
|
+
const widget = value?.widget;
|
|
689
|
+
if (widget !== undefined && !FIELD_TYPES.includes(widget)) {
|
|
690
|
+
return { ok: false, error: `"${widget}" is not a widget this editor knows (${field}).` };
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
try {
|
|
694
|
+
const stored = await readStoredOptions(root);
|
|
695
|
+
// `false` here is the config-level kill switch, not a detail — the stored
|
|
696
|
+
// document keeps the detail beside its own on/off flag (see StoredOptions).
|
|
697
|
+
const detail: EntryEditorOptions = stored.entryEditor || {};
|
|
698
|
+
const collections = { ...detail.collections };
|
|
699
|
+
const one = { ...collections[collection] };
|
|
700
|
+
const fields = { ...one.fields };
|
|
701
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
702
|
+
const merged = normalizeOverride({ ...fields[field], ...(value ?? {}) });
|
|
703
|
+
if (value === null || !merged) delete fields[field];
|
|
704
|
+
else fields[field] = merged;
|
|
705
|
+
}
|
|
706
|
+
const before = JSON.stringify(one.fields ?? {});
|
|
707
|
+
if (before === JSON.stringify(fields)) return { ok: true, changed: false };
|
|
708
|
+
|
|
709
|
+
if (Object.keys(fields).length > 0) {
|
|
710
|
+
collections[collection] = { ...one, fields };
|
|
711
|
+
} else {
|
|
712
|
+
// No overrides left. Keep the entry only if it carries something else
|
|
713
|
+
// (a configured dir or extension); otherwise drop it, so reverting every
|
|
714
|
+
// field in the panel leaves the file as it was rather than holding an
|
|
715
|
+
// empty husk.
|
|
716
|
+
const { fields: _drop, ...rest } = one;
|
|
717
|
+
if (Object.keys(rest).length > 0) collections[collection] = rest;
|
|
718
|
+
else delete collections[collection];
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
await saveStoredOptions(root, {
|
|
722
|
+
...stored,
|
|
723
|
+
entryEditor: { ...detail, collections },
|
|
724
|
+
}, deps.writeText);
|
|
725
|
+
logger.info(`field overrides saved: ${collection}`);
|
|
726
|
+
return { ok: true, changed: true };
|
|
727
|
+
} catch (err) {
|
|
728
|
+
return { ok: false, error: `could not save field overrides: ${String(err)}` };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** One entry's listing row. Reads the file's frontmatter only; a parse failure
|
|
734
|
+
* degrades to "no title" rather than dropping the entry, since an entry with
|
|
735
|
+
* broken YAML is exactly one the user needs to find. */
|
|
736
|
+
async function describeEntry(
|
|
737
|
+
dir: string,
|
|
738
|
+
dirAbs: string,
|
|
739
|
+
name: string,
|
|
740
|
+
): Promise<CollectionEntryItem> {
|
|
741
|
+
const abs = join(dirAbs, name);
|
|
742
|
+
const posix = name.split(sep).join('/');
|
|
743
|
+
const slug = posix.replace(/\.(md|mdx)$/i, '');
|
|
744
|
+
let title: string | null = null;
|
|
745
|
+
let draft = false;
|
|
746
|
+
let mtime = 0;
|
|
747
|
+
try {
|
|
748
|
+
mtime = (await stat(abs)).mtimeMs;
|
|
749
|
+
const parsed = parseEntry(await readFile(abs, 'utf8'));
|
|
750
|
+
for (const key of TITLE_KEYS) {
|
|
751
|
+
const v = parsed.data[key];
|
|
752
|
+
if (typeof v === 'string' && v.trim()) {
|
|
753
|
+
title = v.trim();
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
draft = parsed.data.draft === true;
|
|
758
|
+
} catch {
|
|
759
|
+
// Unreadable or unparseable — still listed, just without its metadata.
|
|
760
|
+
}
|
|
761
|
+
return { file: `${dir}/${posix}`, slug, title, mtime, draft };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Whether two overrides say the same thing. Absent counts as empty. */
|
|
765
|
+
function sameOverride(a: FieldOverride, b: FieldOverride | undefined): boolean {
|
|
766
|
+
const norm = (o: FieldOverride | undefined): string =>
|
|
767
|
+
JSON.stringify(normalizeOverride(o ?? {}) ?? {});
|
|
768
|
+
return norm(a) === norm(b);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Strip an override down to what it actually says; null when it says nothing. */
|
|
772
|
+
function normalizeOverride(o: FieldOverride): FieldOverride | null {
|
|
773
|
+
const out: FieldOverride = {};
|
|
774
|
+
if (o.widget) out.widget = o.widget;
|
|
775
|
+
if (o.label) out.label = o.label;
|
|
776
|
+
if (o.hidden) out.hidden = true;
|
|
777
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Validate an incoming field spec. Names must be plain identifiers and types must
|
|
782
|
+
* be known — the patcher refuses anything else anyway, but refusing here keeps the
|
|
783
|
+
* message about the request rather than about the source.
|
|
784
|
+
*/
|
|
785
|
+
function coerceField(
|
|
786
|
+
raw: unknown,
|
|
787
|
+
): { ok: true; field: SchemaField } | { ok: false; error: string } {
|
|
788
|
+
const r = (raw ?? {}) as Partial<SchemaField>;
|
|
789
|
+
const name = String(r.name ?? '');
|
|
790
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
|
|
791
|
+
return { ok: false, error: `"${name}" is not a usable field name.` };
|
|
792
|
+
}
|
|
793
|
+
const type = r.type as FieldType;
|
|
794
|
+
if (!FIELD_TYPES.includes(type)) {
|
|
795
|
+
return { ok: false, error: `"${String(r.type)}" is not a field type this editor knows.` };
|
|
796
|
+
}
|
|
797
|
+
const options = Array.isArray(r.options) ? r.options.map((o) => String(o)).filter(Boolean) : undefined;
|
|
798
|
+
return {
|
|
799
|
+
ok: true,
|
|
800
|
+
field: {
|
|
801
|
+
name,
|
|
802
|
+
type,
|
|
803
|
+
required: r.required === true,
|
|
804
|
+
...(r.defaultValue !== undefined ? { defaultValue: r.defaultValue } : {}),
|
|
805
|
+
...(options ? { options } : {}),
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
}
|