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,778 @@
|
|
|
1
|
+
import type { FieldType, UnsplashImportWidth } from '../shared/protocol.ts';
|
|
2
|
+
import {
|
|
3
|
+
UNSPLASH_DEFAULT_IMPORT_WIDTH,
|
|
4
|
+
UNSPLASH_IMPORT_WIDTH_CHOICES,
|
|
5
|
+
coerceImportWidth,
|
|
6
|
+
} from '../shared/unsplash.ts';
|
|
7
|
+
import type { EntryEditorOptions } from './content-config.ts';
|
|
8
|
+
import { readStoredOptions } from './settings.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The integration's option vocabulary, and the resolver that turns it into the
|
|
12
|
+
* effective values a request runs against.
|
|
13
|
+
*
|
|
14
|
+
* **Why options resolve per request.** Everything here used to be collapsed
|
|
15
|
+
* once in `astro:config:setup` and captured by the middleware, so changing an
|
|
16
|
+
* option meant editing `astro.config.mjs` and restarting the dev server. The
|
|
17
|
+
* Settings panel needs to change them live, so resolution moved behind a thunk
|
|
18
|
+
* — the same shape `unsplash.resolve` already had, and for the same reason:
|
|
19
|
+
* nothing depends on hook ordering, and a saved change takes effect on the next
|
|
20
|
+
* request.
|
|
21
|
+
*
|
|
22
|
+
* **Precedence, highest first: `astro.config.mjs` → the settings file →
|
|
23
|
+
* {@link DEFAULTS}.** The config wins because it is code the user wrote
|
|
24
|
+
* deliberately, is committed, and is read by `astro build`. An option set there
|
|
25
|
+
* is reported `locked` and the panel renders it read-only rather than storing a
|
|
26
|
+
* value that resolution would ignore — the refusal `settings-panel.ts` already
|
|
27
|
+
* makes for a config-supplied Unsplash key, generalized.
|
|
28
|
+
*
|
|
29
|
+
* **{@link OPTION_SPECS} is the registry.** One entry per option carries its
|
|
30
|
+
* default, its wire label/help, the control it renders as, and how to read it
|
|
31
|
+
* out of a partial config. That single table drives resolution, the `/settings`
|
|
32
|
+
* response, and the panel's controls — so adding an option is one entry here,
|
|
33
|
+
* with no client change at all.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Options a consuming project passes to `devEdit()`. */
|
|
37
|
+
export interface DevEditOptions {
|
|
38
|
+
/** Kill switch. When false the integration does nothing at all. */
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
/** Directories scanned for replacement images offered in the swap panel. */
|
|
41
|
+
assetDirs?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Directory new image uploads are written to, relative to the project root.
|
|
44
|
+
* Must be a web-servable location — files here become a plain `<img src>` in
|
|
45
|
+
* the source, so anything outside `public/` works in dev but 404s in a
|
|
46
|
+
* production build. Defaults to `public`.
|
|
47
|
+
*/
|
|
48
|
+
uploadDir?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Fallback directory for uploads that back an `image()` schema field, relative
|
|
51
|
+
* to the project root. Those assets are *imported* by Astro rather than served
|
|
52
|
+
* verbatim, so they must live under `src/` — `public/` files can't be
|
|
53
|
+
* imported. Only used when the field has no existing value to sit beside;
|
|
54
|
+
* otherwise the upload lands in that value's own directory. Defaults to
|
|
55
|
+
* `src/assets`.
|
|
56
|
+
*/
|
|
57
|
+
imageUploadDir?: string;
|
|
58
|
+
/** Extensions the patcher is allowed to write. */
|
|
59
|
+
editableExtensions?: string[];
|
|
60
|
+
/** Directories that writes are confined to. */
|
|
61
|
+
contentRoots?: string[];
|
|
62
|
+
/** Expose the click-to-source fallback. */
|
|
63
|
+
openInEditor?: boolean;
|
|
64
|
+
/** Reveal text-write destinations in the external editor. Default false. */
|
|
65
|
+
revealWrites?: boolean;
|
|
66
|
+
/** Best-effort pause before existing-file writes, 0–10000 ms. Default 1000. */
|
|
67
|
+
revealWriteDelayMs?: number;
|
|
68
|
+
/**
|
|
69
|
+
* The hover-pill CSS inspector: on hover, list an element's classes and ID,
|
|
70
|
+
* and reveal the CSS rules each one applies (read from the browser, no server
|
|
71
|
+
* round-trip) with a link to open the defining file at the rule. The
|
|
72
|
+
* open-at-rule jump additionally requires `openInEditor`. `false` disables the
|
|
73
|
+
* whole surface (no chips render).
|
|
74
|
+
*/
|
|
75
|
+
cssInspector?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Who emits the `data-astro-source-*` attributes the feature rides on.
|
|
78
|
+
* `'auto'` (default): Astro's own compiler on Astro 5/6; injected by this
|
|
79
|
+
* integration on Astro ≥7, whose Rust compiler doesn't emit them
|
|
80
|
+
* (withastro/compiler-rs#96). `'force'` always injects (also lifts the
|
|
81
|
+
* dev-toolbar requirement on 5/6); `'off'` never injects.
|
|
82
|
+
*/
|
|
83
|
+
sourceAnnotations?: 'auto' | 'force' | 'off';
|
|
84
|
+
/**
|
|
85
|
+
* The CMS-style entry panel for content-collection pages that emit the
|
|
86
|
+
* `astro-dev-edit:page-source` meta tag. Zero-config for conventional
|
|
87
|
+
* `src/content/<name>/` layouts; `false` disables the whole surface.
|
|
88
|
+
*/
|
|
89
|
+
entryEditor?: false | EntryEditorOptions;
|
|
90
|
+
/**
|
|
91
|
+
* The collection designer's **schema writes**. When true (the default) the
|
|
92
|
+
* Collections tab can add, retype and remove fields and append collections,
|
|
93
|
+
* which patches the project's own `src/content.config.ts`. `false` keeps the
|
|
94
|
+
* tab read-only: collections and their fields are still listed, and the
|
|
95
|
+
* editor-only overrides (widget, label, hidden) still save, since those go to
|
|
96
|
+
* `.astro-dev-edit.json` rather than to committed source.
|
|
97
|
+
*/
|
|
98
|
+
schemaEditor?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* The Unsplash photo source in the media picker. `unsplash: {}` turns it on
|
|
101
|
+
* with defaults; omitted (the default) leaves it off entirely, and the media
|
|
102
|
+
* modal renders as a single-source project-asset grid.
|
|
103
|
+
*
|
|
104
|
+
* Every user brings their own access key. The recommended way to give one is
|
|
105
|
+
* the overlay's own Settings panel (admin bar → Settings), which writes it to
|
|
106
|
+
* a gitignored `.env.local` — see `accessKey` for why not here.
|
|
107
|
+
*/
|
|
108
|
+
unsplash?: false | UnsplashOptions;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Options for the Unsplash photo source. See `DevEditOptions.unsplash`. */
|
|
112
|
+
export interface UnsplashOptions {
|
|
113
|
+
/**
|
|
114
|
+
* Access key, as an escape hatch for programmatic config. **Not the
|
|
115
|
+
* recommended path:** `astro.config.mjs` is committed *and* is read by
|
|
116
|
+
* `astro build`, so a key here travels with the repo. Prefer the Settings
|
|
117
|
+
* panel, which writes `UNSPLASH_ACCESS_KEY` into `.env.local` — the same
|
|
118
|
+
* place you would put it by hand. When set here it wins over every other
|
|
119
|
+
* source, and the Settings panel says so rather than accepting a value that
|
|
120
|
+
* would do nothing.
|
|
121
|
+
*/
|
|
122
|
+
accessKey?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Application name sent as `utm_source` on every photographer credit link,
|
|
125
|
+
* as the Unsplash API guidelines require. Should match the application name
|
|
126
|
+
* registered at unsplash.com/oauth/applications. Defaults to
|
|
127
|
+
* `astro-dev-edit`.
|
|
128
|
+
*/
|
|
129
|
+
appName?: string;
|
|
130
|
+
/** Results per search page. Clamped to Unsplash's own maximum of 30.
|
|
131
|
+
* Defaults to 20. */
|
|
132
|
+
perPage?: number;
|
|
133
|
+
/**
|
|
134
|
+
* How wide an imported photo is fetched from Unsplash's CDN. `'original'`
|
|
135
|
+
* asks for the raw file at full resolution; every other value only ever
|
|
136
|
+
* shrinks, since the request carries `fit=max`. Defaults to `2400`.
|
|
137
|
+
*
|
|
138
|
+
* Project-wide, and overridable per import from the picker's size select —
|
|
139
|
+
* the right size is a property of the slot the image goes in, not of the
|
|
140
|
+
* project. See `shared/unsplash.ts` for the safelist.
|
|
141
|
+
*/
|
|
142
|
+
importWidth?: UnsplashImportWidth;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every option's effective value for one request — no optionals left. */
|
|
146
|
+
export type ResolvedOptions = Required<DevEditOptions>;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What the Settings panel stores. `Partial<DevEditOptions>` for every option
|
|
150
|
+
* with a single value, plus explicit on/off flags for the two *features*.
|
|
151
|
+
*
|
|
152
|
+
* The flags exist because `DevEditOptions` encodes a feature as
|
|
153
|
+
* `false | { …detail }`, which cannot hold "switched off" and "configured like
|
|
154
|
+
* this" at the same time. In a config file that is fine — the user retypes the
|
|
155
|
+
* object. In a store the panel writes, switching a feature off would silently
|
|
156
|
+
* discard the per-collection widget overrides or the Unsplash app name, and
|
|
157
|
+
* switching it back on would come up empty. So the stored document keeps the
|
|
158
|
+
* detail under `entryEditor` / `unsplash` unconditionally and the on/off bit
|
|
159
|
+
* beside it.
|
|
160
|
+
*/
|
|
161
|
+
export interface StoredOptions extends Partial<DevEditOptions> {
|
|
162
|
+
entryEditorEnabled?: boolean;
|
|
163
|
+
unsplashEnabled?: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const DEFAULTS: ResolvedOptions = {
|
|
167
|
+
enabled: true,
|
|
168
|
+
assetDirs: ['src/assets', 'public'],
|
|
169
|
+
uploadDir: 'public',
|
|
170
|
+
imageUploadDir: 'src/assets',
|
|
171
|
+
editableExtensions: ['.astro', '.md', '.mdx'],
|
|
172
|
+
contentRoots: ['src', 'public'],
|
|
173
|
+
openInEditor: true,
|
|
174
|
+
revealWrites: false,
|
|
175
|
+
revealWriteDelayMs: 1000,
|
|
176
|
+
cssInspector: true,
|
|
177
|
+
sourceAnnotations: 'auto',
|
|
178
|
+
entryEditor: {},
|
|
179
|
+
schemaEditor: true,
|
|
180
|
+
// Off unless asked for: the feature reaches a third-party API and needs a key
|
|
181
|
+
// the user has to supply, so opting in is deliberate.
|
|
182
|
+
unsplash: false,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Unsplash's own ceiling on `per_page`. */
|
|
186
|
+
export const UNSPLASH_MAX_PER_PAGE = 30;
|
|
187
|
+
|
|
188
|
+
/** Which Settings tab an option is grouped under. */
|
|
189
|
+
export type OptionGroup = 'general' | 'editing' | 'media' | 'unsplash';
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One option, as both a resolution rule and a control the panel renders.
|
|
193
|
+
*
|
|
194
|
+
* `read` returns `undefined` for "this source is silent about the option",
|
|
195
|
+
* which is what separates a deliberate `false` from an absent key — and
|
|
196
|
+
* therefore what {@link OptionDescriptor.locked} means.
|
|
197
|
+
*/
|
|
198
|
+
interface OptionSpec {
|
|
199
|
+
/** Flat wire key. Also the settings-file key for everything but the
|
|
200
|
+
* `unsplash*` trio, which flattens a nested config object. */
|
|
201
|
+
key: string;
|
|
202
|
+
label: string;
|
|
203
|
+
help: string;
|
|
204
|
+
type: FieldType;
|
|
205
|
+
group: OptionGroup;
|
|
206
|
+
/** Enum values, for `type: 'select'`. */
|
|
207
|
+
choices?: string[];
|
|
208
|
+
/**
|
|
209
|
+
* Consumed in `astro:config:setup`, before any dev server exists — so it can
|
|
210
|
+
* only come from the config, and changing it needs a restart. The panel
|
|
211
|
+
* renders these read-only. `enabled` is additionally config-only because
|
|
212
|
+
* storing `false` here would lock the user out of the UI that set it.
|
|
213
|
+
*/
|
|
214
|
+
configOnly?: boolean;
|
|
215
|
+
fallback: unknown;
|
|
216
|
+
/** Read from the config layer. `undefined` = this layer is silent. */
|
|
217
|
+
read(o: Partial<DevEditOptions>): unknown;
|
|
218
|
+
/**
|
|
219
|
+
* Read from the stored layer, when it encodes the option differently — only
|
|
220
|
+
* the two feature toggles do, and only because the store separates a toggle
|
|
221
|
+
* from its detail. Defaults to {@link read}.
|
|
222
|
+
*/
|
|
223
|
+
readStored?(o: StoredOptions): unknown;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The registry. Adding an option is one entry — resolution, the `/settings`
|
|
227
|
+
* response and the panel's control all follow from it. */
|
|
228
|
+
const OPTION_SPECS: readonly OptionSpec[] = [
|
|
229
|
+
{
|
|
230
|
+
key: 'enabled',
|
|
231
|
+
label: 'Integration enabled',
|
|
232
|
+
help: 'The kill switch. Set in astro.config.mjs only — turning it off from here would remove the UI that turns it back on.',
|
|
233
|
+
type: 'boolean',
|
|
234
|
+
group: 'general',
|
|
235
|
+
configOnly: true,
|
|
236
|
+
fallback: DEFAULTS.enabled,
|
|
237
|
+
read: (o) => o.enabled,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
key: 'sourceAnnotations',
|
|
241
|
+
label: 'Source annotations',
|
|
242
|
+
help: 'Who emits the data-astro-source-* attributes everything rides on. "auto" uses Astro\'s compiler on 5/6 and injects them on 7+. Registers a Vite plugin, so it is config-only.',
|
|
243
|
+
type: 'select',
|
|
244
|
+
choices: ['auto', 'force', 'off'],
|
|
245
|
+
group: 'general',
|
|
246
|
+
configOnly: true,
|
|
247
|
+
fallback: DEFAULTS.sourceAnnotations,
|
|
248
|
+
read: (o) => o.sourceAnnotations,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
key: 'contentRoots',
|
|
252
|
+
label: 'Content roots',
|
|
253
|
+
help: 'Writes are confined to these directories, resolved through symlinks. Narrowing this is the main way to limit what the editor can touch.',
|
|
254
|
+
type: 'tags',
|
|
255
|
+
group: 'general',
|
|
256
|
+
fallback: DEFAULTS.contentRoots,
|
|
257
|
+
read: (o) => o.contentRoots,
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
key: 'editableExtensions',
|
|
261
|
+
label: 'Editable extensions',
|
|
262
|
+
help: 'File extensions the patcher may write. Only .astro supports in-place element editing today; .md and .mdx are collection entries.',
|
|
263
|
+
type: 'tags',
|
|
264
|
+
group: 'general',
|
|
265
|
+
fallback: DEFAULTS.editableExtensions,
|
|
266
|
+
read: (o) => o.editableExtensions,
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
key: 'openInEditor',
|
|
270
|
+
label: 'Open in editor',
|
|
271
|
+
help: 'Expose the "Open source" buttons and the jump-to-file links that launch your editor at a source location.',
|
|
272
|
+
type: 'boolean',
|
|
273
|
+
group: 'editing',
|
|
274
|
+
fallback: DEFAULTS.openInEditor,
|
|
275
|
+
read: (o) => o.openInEditor,
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
key: 'revealWrites',
|
|
279
|
+
label: 'Show changed files in editor',
|
|
280
|
+
help: 'Reveal text files before saving, with a best-effort delay. New files open after creation. Independent of manual Open controls; excludes uploads and deletions. Settings files may contain your Unsplash key.',
|
|
281
|
+
type: 'boolean',
|
|
282
|
+
group: 'editing',
|
|
283
|
+
fallback: DEFAULTS.revealWrites,
|
|
284
|
+
read: (o) => o.revealWrites,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
key: 'revealWriteDelayMs',
|
|
288
|
+
label: 'Delay before writing (ms)',
|
|
289
|
+
help: 'Wait 0–10000 milliseconds after requesting the editor to open an existing file. This cannot confirm that the file is visible.',
|
|
290
|
+
type: 'number',
|
|
291
|
+
group: 'editing',
|
|
292
|
+
fallback: DEFAULTS.revealWriteDelayMs,
|
|
293
|
+
read: (o) => o.revealWriteDelayMs,
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
key: 'cssInspector',
|
|
297
|
+
label: 'CSS inspector',
|
|
298
|
+
help: 'The hover pill\'s class and ID chips, and the CSS rules each one applies. Off means no chips render at all.',
|
|
299
|
+
type: 'boolean',
|
|
300
|
+
group: 'editing',
|
|
301
|
+
fallback: DEFAULTS.cssInspector,
|
|
302
|
+
read: (o) => o.cssInspector,
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
key: 'entryEditor',
|
|
306
|
+
label: 'Entry editor',
|
|
307
|
+
help: 'The CMS drawer for content-collection entries. Off disables every /entry endpoint and the admin bar button.',
|
|
308
|
+
type: 'boolean',
|
|
309
|
+
group: 'editing',
|
|
310
|
+
fallback: DEFAULTS.entryEditor !== false,
|
|
311
|
+
// An object means the feature is on and configured; only an explicit
|
|
312
|
+
// `false` is a config-level kill. A configured object therefore locks the
|
|
313
|
+
// toggle on without locking the per-collection detail, which merges.
|
|
314
|
+
read: (o) => (o.entryEditor === undefined ? undefined : o.entryEditor !== false),
|
|
315
|
+
readStored: (o) => o.entryEditorEnabled,
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
key: 'schemaEditor',
|
|
319
|
+
label: 'Schema editing',
|
|
320
|
+
help: 'Let the Collections tab write your src/content.config.ts — add, retype and remove schema fields, and append collections. Off leaves the tab read-only.',
|
|
321
|
+
type: 'boolean',
|
|
322
|
+
group: 'editing',
|
|
323
|
+
fallback: DEFAULTS.schemaEditor,
|
|
324
|
+
read: (o) => o.schemaEditor,
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
key: 'assetDirs',
|
|
328
|
+
label: 'Asset directories',
|
|
329
|
+
help: 'Directories scanned for the images offered in the media picker\'s project grid.',
|
|
330
|
+
type: 'tags',
|
|
331
|
+
group: 'media',
|
|
332
|
+
fallback: DEFAULTS.assetDirs,
|
|
333
|
+
read: (o) => o.assetDirs,
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
key: 'uploadDir',
|
|
337
|
+
label: 'Upload directory',
|
|
338
|
+
help: 'Where new uploads are written. Must sit under public/ — files here become a plain <img src>, which 404s in a production build from anywhere else.',
|
|
339
|
+
type: 'text',
|
|
340
|
+
group: 'media',
|
|
341
|
+
fallback: DEFAULTS.uploadDir,
|
|
342
|
+
read: (o) => o.uploadDir,
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
key: 'imageUploadDir',
|
|
346
|
+
label: 'image() upload directory',
|
|
347
|
+
help: 'Fallback for uploads backing an image() schema field. Must sit under src/ — Astro imports those assets, and public/ files cannot be imported.',
|
|
348
|
+
type: 'text',
|
|
349
|
+
group: 'media',
|
|
350
|
+
fallback: DEFAULTS.imageUploadDir,
|
|
351
|
+
read: (o) => o.imageUploadDir,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
key: 'unsplashEnabled',
|
|
355
|
+
label: 'Unsplash photo source',
|
|
356
|
+
help: 'Adds an Unsplash tab to the media picker. Needs an access key, below.',
|
|
357
|
+
type: 'boolean',
|
|
358
|
+
group: 'unsplash',
|
|
359
|
+
fallback: DEFAULTS.unsplash !== false,
|
|
360
|
+
read: (o) => (o.unsplash === undefined ? undefined : o.unsplash !== false),
|
|
361
|
+
readStored: (o) => o.unsplashEnabled,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: 'unsplashAppName',
|
|
365
|
+
label: 'Application name',
|
|
366
|
+
help: 'Sent as utm_source on every photographer credit link, as the Unsplash API guidelines require. Should match the name you registered.',
|
|
367
|
+
type: 'text',
|
|
368
|
+
group: 'unsplash',
|
|
369
|
+
fallback: 'astro-dev-edit',
|
|
370
|
+
read: (o) => (o.unsplash ? o.unsplash.appName : undefined),
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
key: 'unsplashPerPage',
|
|
374
|
+
label: 'Results per page',
|
|
375
|
+
help: `How many photos each search returns. Clamped to Unsplash's own maximum of ${UNSPLASH_MAX_PER_PAGE}.`,
|
|
376
|
+
type: 'number',
|
|
377
|
+
group: 'unsplash',
|
|
378
|
+
fallback: 20,
|
|
379
|
+
read: (o) => (o.unsplash ? o.unsplash.perPage : undefined),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
key: 'unsplashImportWidth',
|
|
383
|
+
label: 'Import width',
|
|
384
|
+
help: 'How wide an imported photo is downloaded. Where the picker\u2019s size select starts \u2014 change it there for one import. "original" asks for the full-resolution file; the others only ever shrink, never upscale.',
|
|
385
|
+
type: 'select',
|
|
386
|
+
group: 'unsplash',
|
|
387
|
+
// A select's values are strings, and the settings file is JSON a human may
|
|
388
|
+
// hand-edit, so a width travels as text and is parsed once, by
|
|
389
|
+
// `coerceImportWidth`. `read` stringifies so a numeric config value still
|
|
390
|
+
// matches a choice.
|
|
391
|
+
choices: [...UNSPLASH_IMPORT_WIDTH_CHOICES],
|
|
392
|
+
fallback: String(UNSPLASH_DEFAULT_IMPORT_WIDTH),
|
|
393
|
+
read: (o) =>
|
|
394
|
+
o.unsplash && o.unsplash.importWidth !== undefined
|
|
395
|
+
? String(o.unsplash.importWidth)
|
|
396
|
+
: undefined,
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
/** Keys the Settings panel may write — everything the config does not own
|
|
401
|
+
* outright. Exported so the route can refuse anything else by name. */
|
|
402
|
+
export const WRITABLE_OPTION_KEYS: readonly string[] = OPTION_SPECS.filter(
|
|
403
|
+
(s) => !s.configOnly,
|
|
404
|
+
).map((s) => s.key);
|
|
405
|
+
|
|
406
|
+
/** Where an effective value came from. */
|
|
407
|
+
export type OptionSource = 'default' | 'file' | 'config';
|
|
408
|
+
|
|
409
|
+
/** One option as the panel sees it: the control to render, the effective value,
|
|
410
|
+
* and whether the config owns it. Mirrors `OptionDescriptor` in protocol.ts. */
|
|
411
|
+
export interface ResolvedOption {
|
|
412
|
+
key: string;
|
|
413
|
+
label: string;
|
|
414
|
+
help: string;
|
|
415
|
+
type: FieldType;
|
|
416
|
+
group: OptionGroup;
|
|
417
|
+
choices?: string[];
|
|
418
|
+
value: unknown;
|
|
419
|
+
source: OptionSource;
|
|
420
|
+
locked: boolean;
|
|
421
|
+
restartRequired: boolean;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export interface OptionsResolution {
|
|
425
|
+
/** The effective options this request runs against. */
|
|
426
|
+
options: ResolvedOptions;
|
|
427
|
+
/** The same values, described for the Settings panel. */
|
|
428
|
+
described: ResolvedOption[];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export interface OptionsResolverDeps {
|
|
432
|
+
/** Project root (fsPath) — where the settings file lives. */
|
|
433
|
+
root: string;
|
|
434
|
+
/** Exactly what the project passed to `devEdit()`, **not** merged with
|
|
435
|
+
* DEFAULTS: `key in configOptions` is what makes an option `locked`. */
|
|
436
|
+
configOptions: Partial<DevEditOptions>;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export interface OptionsResolver {
|
|
440
|
+
/** Resolve for one request. Cheap — one small JSON read, the same cost
|
|
441
|
+
* `resolveUnsplashKey` already pays per request. */
|
|
442
|
+
resolve(): Promise<OptionsResolution>;
|
|
443
|
+
/**
|
|
444
|
+
* The **config layer's** `entryEditor` alone, unmerged — what the project
|
|
445
|
+
* literally wrote in `astro.config.mjs`, or undefined when it wrote nothing.
|
|
446
|
+
*
|
|
447
|
+
* `resolve()` deliberately hands back only effective values, which cannot
|
|
448
|
+
* answer "does the config own this?" for a leaf inside `entryEditor`: a config
|
|
449
|
+
* and a stored value that happen to agree are indistinguishable there. The
|
|
450
|
+
* collection designer needs that answer per collection — a switch it renders
|
|
451
|
+
* as writable but resolution would ignore is worse than a locked one — so the
|
|
452
|
+
* layer is exposed rather than inferred. Synchronous: this is the value the
|
|
453
|
+
* dev server started with, and it cannot change without a restart.
|
|
454
|
+
*/
|
|
455
|
+
entryEditorConfig(): EntryEditorOptions | undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Deep-merge `entryEditor`, config leaf winning over stored leaf.
|
|
459
|
+
*
|
|
460
|
+
* `entryEditor` is the one option that is not a single value: a project may
|
|
461
|
+
* configure `collections.blog.fields.excerpt.widget` in code while the panel
|
|
462
|
+
* adds `collections.notes` at runtime, and both must apply. Merging per leaf
|
|
463
|
+
* keeps the config authoritative exactly where it speaks. */
|
|
464
|
+
function mergeEntryEditor(
|
|
465
|
+
stored: EntryEditorOptions | undefined,
|
|
466
|
+
config: EntryEditorOptions | undefined,
|
|
467
|
+
): EntryEditorOptions {
|
|
468
|
+
if (!stored) return config ?? {};
|
|
469
|
+
if (!config) return stored;
|
|
470
|
+
const names = new Set([
|
|
471
|
+
...Object.keys(stored.collections ?? {}),
|
|
472
|
+
...Object.keys(config.collections ?? {}),
|
|
473
|
+
]);
|
|
474
|
+
const collections: NonNullable<EntryEditorOptions['collections']> = {};
|
|
475
|
+
for (const name of names) {
|
|
476
|
+
const s = stored.collections?.[name] ?? {};
|
|
477
|
+
const c = config.collections?.[name] ?? {};
|
|
478
|
+
const fieldNames = new Set([...Object.keys(s.fields ?? {}), ...Object.keys(c.fields ?? {})]);
|
|
479
|
+
const fields: NonNullable<typeof s.fields> = {};
|
|
480
|
+
for (const f of fieldNames) fields[f] = { ...s.fields?.[f], ...c.fields?.[f] };
|
|
481
|
+
collections[name] = {
|
|
482
|
+
...s,
|
|
483
|
+
...c,
|
|
484
|
+
...(fieldNames.size > 0 ? { fields } : {}),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
...stored,
|
|
489
|
+
...config,
|
|
490
|
+
...(names.size > 0 ? { collections } : {}),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Rebuild the nested option shape from resolved flat values. Explicit rather
|
|
495
|
+
* than driven by per-spec writers, because the `unsplash*` trio collapses to a
|
|
496
|
+
* single `false` and order-of-assignment bugs there are invisible. */
|
|
497
|
+
function toResolvedOptions(
|
|
498
|
+
flat: Map<string, unknown>,
|
|
499
|
+
entryEditor: EntryEditorOptions,
|
|
500
|
+
accessKeyFromConfig: string | undefined,
|
|
501
|
+
): ResolvedOptions {
|
|
502
|
+
const unsplashOn = flat.get('unsplashEnabled') === true;
|
|
503
|
+
return {
|
|
504
|
+
enabled: flat.get('enabled') as boolean,
|
|
505
|
+
assetDirs: flat.get('assetDirs') as string[],
|
|
506
|
+
uploadDir: flat.get('uploadDir') as string,
|
|
507
|
+
imageUploadDir: flat.get('imageUploadDir') as string,
|
|
508
|
+
editableExtensions: flat.get('editableExtensions') as string[],
|
|
509
|
+
contentRoots: flat.get('contentRoots') as string[],
|
|
510
|
+
openInEditor: flat.get('openInEditor') === true,
|
|
511
|
+
revealWrites: flat.get('revealWrites') === true,
|
|
512
|
+
revealWriteDelayMs: validRevealDelay(flat.get('revealWriteDelayMs')) ? flat.get('revealWriteDelayMs') as number : DEFAULTS.revealWriteDelayMs,
|
|
513
|
+
schemaEditor: flat.get('schemaEditor') === true,
|
|
514
|
+
cssInspector: flat.get('cssInspector') === true,
|
|
515
|
+
sourceAnnotations: flat.get('sourceAnnotations') as 'auto' | 'force' | 'off',
|
|
516
|
+
entryEditor: flat.get('entryEditor') === true ? entryEditor : false,
|
|
517
|
+
unsplash: unsplashOn
|
|
518
|
+
? {
|
|
519
|
+
// The key never travels through the option table — it resolves
|
|
520
|
+
// separately, through settings.ts, and is only ever read there.
|
|
521
|
+
...(accessKeyFromConfig ? { accessKey: accessKeyFromConfig } : {}),
|
|
522
|
+
appName: flat.get('unsplashAppName') as string,
|
|
523
|
+
perPage: flat.get('unsplashPerPage') as number,
|
|
524
|
+
// Off-safelist can only mean a hand-edited settings file; fall back
|
|
525
|
+
// rather than resolve to a width the import route would refuse.
|
|
526
|
+
importWidth:
|
|
527
|
+
coerceImportWidth(flat.get('unsplashImportWidth')) ?? UNSPLASH_DEFAULT_IMPORT_WIDTH,
|
|
528
|
+
}
|
|
529
|
+
: false,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function createOptionsResolver(deps: OptionsResolverDeps): OptionsResolver {
|
|
534
|
+
const { root, configOptions } = deps;
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
entryEditorConfig() {
|
|
538
|
+
// `false` is the kill switch, not a detail — it owns no per-collection leaf.
|
|
539
|
+
return configOptions.entryEditor === false ? undefined : configOptions.entryEditor;
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
async resolve() {
|
|
543
|
+
// Every failure inside degrades to "nothing stored", so a corrupt or
|
|
544
|
+
// unreadable settings file falls back to config + defaults rather than
|
|
545
|
+
// breaking every endpoint.
|
|
546
|
+
const stored = await readStoredOptions(root);
|
|
547
|
+
|
|
548
|
+
const flat = new Map<string, unknown>();
|
|
549
|
+
const described: ResolvedOption[] = [];
|
|
550
|
+
|
|
551
|
+
for (const spec of OPTION_SPECS) {
|
|
552
|
+
const fromConfig = spec.read(configOptions);
|
|
553
|
+
const fromFile = spec.configOnly
|
|
554
|
+
? undefined
|
|
555
|
+
: (spec.readStored ?? spec.read)(stored);
|
|
556
|
+
|
|
557
|
+
let value: unknown;
|
|
558
|
+
let source: OptionSource;
|
|
559
|
+
if (fromConfig !== undefined) {
|
|
560
|
+
value = fromConfig;
|
|
561
|
+
source = 'config';
|
|
562
|
+
} else if (fromFile !== undefined) {
|
|
563
|
+
value = fromFile;
|
|
564
|
+
source = 'file';
|
|
565
|
+
} else {
|
|
566
|
+
value = spec.fallback;
|
|
567
|
+
source = 'default';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
flat.set(spec.key, value);
|
|
571
|
+
described.push({
|
|
572
|
+
key: spec.key,
|
|
573
|
+
label: spec.label,
|
|
574
|
+
help: spec.help,
|
|
575
|
+
type: spec.type,
|
|
576
|
+
group: spec.group,
|
|
577
|
+
...(spec.choices ? { choices: spec.choices } : {}),
|
|
578
|
+
value,
|
|
579
|
+
source,
|
|
580
|
+
// Config-only options are always locked; the rest lock only when the
|
|
581
|
+
// config actually speaks about them.
|
|
582
|
+
locked: Boolean(spec.configOnly) || source === 'config',
|
|
583
|
+
restartRequired: Boolean(spec.configOnly),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const entryEditor = mergeEntryEditor(
|
|
588
|
+
// The stored side holds detail only — its on/off bit is
|
|
589
|
+
// `entryEditorEnabled`, already folded into `flat` above.
|
|
590
|
+
stored.entryEditor === false ? undefined : stored.entryEditor,
|
|
591
|
+
configOptions.entryEditor === false ? undefined : configOptions.entryEditor,
|
|
592
|
+
);
|
|
593
|
+
const configUnsplash = configOptions.unsplash;
|
|
594
|
+
const accessKey = configUnsplash ? configUnsplash.accessKey : undefined;
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
options: toResolvedOptions(flat, entryEditor, accessKey),
|
|
598
|
+
described,
|
|
599
|
+
};
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// --- The write side ----------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
/** Look a spec up by wire key. */
|
|
607
|
+
function specFor(key: string): OptionSpec | undefined {
|
|
608
|
+
return OPTION_SPECS.find((s) => s.key === key);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function validRevealDelay(value: unknown): value is number {
|
|
612
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 10000;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export interface CoercedPatch {
|
|
616
|
+
/** Wire key → validated value, for keys that passed. */
|
|
617
|
+
values: Map<string, unknown>;
|
|
618
|
+
/** Wire key → message, for keys that did not. */
|
|
619
|
+
errors: Record<string, string>;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Validate a flat patch from the Settings panel. The client is typed but not
|
|
624
|
+
* trusted — these values become the project's write confinement and upload
|
|
625
|
+
* targets, so every one is checked against its spec's declared type, and an
|
|
626
|
+
* unknown or config-only key is an error rather than a silent no-op.
|
|
627
|
+
*
|
|
628
|
+
* `locked` is deliberately **not** checked here: it depends on the resolution
|
|
629
|
+
* this patch is about to change, so the route checks it against a fresh resolve.
|
|
630
|
+
*/
|
|
631
|
+
export function coerceOptionPatch(patch: Record<string, unknown>): CoercedPatch {
|
|
632
|
+
const values = new Map<string, unknown>();
|
|
633
|
+
const errors: Record<string, string> = {};
|
|
634
|
+
|
|
635
|
+
for (const key of Object.keys(patch)) {
|
|
636
|
+
if (!specFor(key)) errors[key] = 'unknown option';
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Table order, not the client's JSON key order: `applyOptionPatch` relies on
|
|
640
|
+
// `unsplashEnabled` being applied before the sub-options it gates.
|
|
641
|
+
for (const spec of OPTION_SPECS) {
|
|
642
|
+
const key = spec.key;
|
|
643
|
+
if (!(key in patch)) continue;
|
|
644
|
+
const raw = patch[key];
|
|
645
|
+
if (spec.configOnly) {
|
|
646
|
+
errors[key] = `${spec.label} can only be set in astro.config.mjs`;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
// `collectChanges` maps an emptied optional field to `null` (= remove the
|
|
650
|
+
// key), which is right for frontmatter and meaningless here: an option
|
|
651
|
+
// always has an effective value, and there is no "absent" state to fall back
|
|
652
|
+
// to. Caught up front so the message is about being empty rather than about
|
|
653
|
+
// the type.
|
|
654
|
+
if (raw === null) {
|
|
655
|
+
errors[key] = 'cannot be empty';
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
switch (spec.type) {
|
|
659
|
+
case 'boolean': {
|
|
660
|
+
if (typeof raw !== 'boolean') {
|
|
661
|
+
errors[key] = 'expected true or false';
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
values.set(key, raw);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
case 'number': {
|
|
668
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
669
|
+
if (key === 'revealWriteDelayMs' && !validRevealDelay(n)) {
|
|
670
|
+
errors[key] = 'expected an integer from 0 to 10000';
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if (!Number.isFinite(n)) {
|
|
674
|
+
errors[key] = 'expected a number';
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
values.set(key, Math.trunc(n));
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
case 'select': {
|
|
681
|
+
if (typeof raw !== 'string' || !spec.choices?.includes(raw)) {
|
|
682
|
+
errors[key] = `expected one of ${spec.choices?.join(', ')}`;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
values.set(key, raw);
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
case 'tags': {
|
|
689
|
+
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : null;
|
|
690
|
+
if (!list) {
|
|
691
|
+
errors[key] = 'expected a list';
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
const clean = list.map((v) => String(v).trim()).filter(Boolean);
|
|
695
|
+
if (clean.length === 0) {
|
|
696
|
+
errors[key] = 'at least one entry is required';
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
values.set(key, clean);
|
|
700
|
+
break;
|
|
701
|
+
}
|
|
702
|
+
default: {
|
|
703
|
+
// 'text' and anything a future spec adds: a trimmed non-empty string.
|
|
704
|
+
if (typeof raw !== 'string') {
|
|
705
|
+
errors[key] = 'expected text';
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const trimmed = raw.trim();
|
|
709
|
+
if (!trimmed) {
|
|
710
|
+
errors[key] = 'cannot be empty';
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
values.set(key, trimmed);
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return { values, errors };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Merge a validated flat patch into the stored option document.
|
|
724
|
+
*
|
|
725
|
+
* Merge-not-replace, like `saveUnsplashKey` — the panel sends only what changed,
|
|
726
|
+
* so anything absent from the patch must survive. The `unsplash*` trio folds
|
|
727
|
+
* back into one nested object, and `accessKey` is **stripped**: the key lives at
|
|
728
|
+
* the document's top level, resolved by `settings.ts`, and must never be written
|
|
729
|
+
* anywhere the option table can echo back to the browser.
|
|
730
|
+
*/
|
|
731
|
+
export function applyOptionPatch(
|
|
732
|
+
current: StoredOptions,
|
|
733
|
+
values: Map<string, unknown>,
|
|
734
|
+
): StoredOptions {
|
|
735
|
+
const next: StoredOptions = { ...current };
|
|
736
|
+
|
|
737
|
+
for (const [key, value] of values) {
|
|
738
|
+
switch (key) {
|
|
739
|
+
// Both feature toggles set the flag beside the detail, never the detail
|
|
740
|
+
// itself — see {@link StoredOptions} for why they are separate.
|
|
741
|
+
case 'entryEditor': {
|
|
742
|
+
next.entryEditorEnabled = value === true;
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
case 'unsplashEnabled': {
|
|
746
|
+
next.unsplashEnabled = value === true;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
case 'unsplashAppName':
|
|
750
|
+
case 'unsplashPerPage':
|
|
751
|
+
case 'unsplashImportWidth': {
|
|
752
|
+
// Stored whether or not the feature is on: in this document the object
|
|
753
|
+
// is detail, not the on/off bit, so writing it enables nothing.
|
|
754
|
+
const base = next.unsplash === false || next.unsplash === undefined ? {} : next.unsplash;
|
|
755
|
+
const merged: UnsplashOptions = { ...base };
|
|
756
|
+
if (key === 'unsplashAppName') merged.appName = value as string;
|
|
757
|
+
else if (key === 'unsplashPerPage') merged.perPage = value as number;
|
|
758
|
+
// Stored parsed, so the file reads `"importWidth": 800` rather than a
|
|
759
|
+
// stringly-typed `"800"`; `read` stringifies it back for the wire.
|
|
760
|
+
else merged.importWidth = coerceImportWidth(value) ?? UNSPLASH_DEFAULT_IMPORT_WIDTH;
|
|
761
|
+
next.unsplash = merged;
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
default: {
|
|
765
|
+
(next as Record<string, unknown>)[key] = value;
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (next.unsplash) {
|
|
772
|
+
// Never persist the secret through this path. Copied rather than deleted
|
|
773
|
+
// in place, because `next.unsplash` can still be `current`'s own object.
|
|
774
|
+
const { accessKey: _secret, ...rest } = next.unsplash;
|
|
775
|
+
next.unsplash = rest;
|
|
776
|
+
}
|
|
777
|
+
return next;
|
|
778
|
+
}
|