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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,250 @@
1
+ import type {
2
+ UnsplashErrorCode,
3
+ UnsplashOrientation,
4
+ UnsplashPhoto,
5
+ UnsplashSearchRequest,
6
+ UnsplashSearchResponse,
7
+ } from '../shared/protocol.ts';
8
+
9
+ /**
10
+ * The Unsplash pane's search logic, with **no DOM** — debouncing, paging, and
11
+ * the stale-response guarding that keeps a slow first request from overwriting
12
+ * a newer one's results.
13
+ *
14
+ * Extracted from the pane so it can be unit-tested: the client layer has almost
15
+ * no automated coverage, and "typing fast produces one request whose result is
16
+ * the one you see" is exactly the kind of rule that breaks silently. The timer
17
+ * functions are injected for the same reason.
18
+ *
19
+ * Stale-response guarding uses the monotonic-generation idiom from
20
+ * `classify-cache.ts`: a `seq` bumped per request, and a response applied only
21
+ * while it is still the newest one issued.
22
+ */
23
+
24
+ export interface SearchError {
25
+ code: UnsplashErrorCode | 'unknown';
26
+ message: string;
27
+ /** Whether offering a Retry makes sense. A bad key or a disabled feature
28
+ * needs a settings change, not another attempt at the same call. */
29
+ retryable: boolean;
30
+ }
31
+
32
+ export type SearchState =
33
+ /** Nothing asked for yet — a blank query never fetches. */
34
+ | { status: 'idle' }
35
+ | { status: 'loading'; query: string }
36
+ /** Results, possibly still growing via loadMore. */
37
+ | {
38
+ status: 'ready';
39
+ query: string;
40
+ photos: UnsplashPhoto[];
41
+ total: number;
42
+ totalPages: number;
43
+ page: number;
44
+ remaining?: number;
45
+ /** True while a loadMore is in flight, so the button can say so without
46
+ * the grid dropping back to a loading state. */
47
+ loadingMore: boolean;
48
+ /** A *page* that failed. Reported here rather than as `status: 'error'`
49
+ * because the results already on screen are still good — throwing them
50
+ * away to show one error message would be the wrong trade. */
51
+ moreError?: SearchError;
52
+ }
53
+ /** A search that succeeded and matched nothing — distinct from `ready` with
54
+ * an empty array, which the pane would render as a blank grid. */
55
+ | { status: 'empty'; query: string }
56
+ | { status: 'error'; query: string; error: SearchError };
57
+
58
+ export interface SearchController {
59
+ state(): SearchState;
60
+ /** Type-ahead entry point: debounced, and a blank query resets to idle. */
61
+ setQuery(query: string): void;
62
+ /** Changing orientation re-runs the current query immediately — it is a
63
+ * deliberate click, not a keystroke, so it should not wait out a debounce. */
64
+ setOrientation(orientation: UnsplashOrientation): void;
65
+ orientation(): UnsplashOrientation;
66
+ /** Append the next page. No-op unless there is one and nothing is in flight. */
67
+ loadMore(): void;
68
+ /** Re-run the current query now, bypassing the debounce. */
69
+ retry(): void;
70
+ /** Cancel any pending debounce; in-flight responses are ignored afterwards. */
71
+ dispose(): void;
72
+ }
73
+
74
+ export interface SearchControllerOptions {
75
+ /** Performs the request. Injected so tests need no network and no api.ts. */
76
+ search(req: UnsplashSearchRequest): Promise<UnsplashSearchResponse>;
77
+ /** Called on every state transition. */
78
+ onState(state: SearchState): void;
79
+ debounceMs?: number;
80
+ /** Timer injection, so tests drive time rather than wait for it. Matches the
81
+ * single-handle debounce idiom in `hover.ts` — no new generic utility. */
82
+ setTimer?: (fn: () => void, ms: number) => unknown;
83
+ clearTimer?: (handle: unknown) => void;
84
+ /** Maps a rejected `search` to a typed error. Injected because the mapping
85
+ * lives in `api.ts` (which owns `UnsplashError`), and this module must stay
86
+ * free of anything that touches the network. */
87
+ toError?: (err: unknown) => SearchError;
88
+ }
89
+
90
+ const DEFAULT_DEBOUNCE_MS = 350;
91
+
92
+ function defaultToError(err: unknown): SearchError {
93
+ return {
94
+ code: 'unknown',
95
+ message: err instanceof Error ? err.message : 'Search failed.',
96
+ retryable: true,
97
+ };
98
+ }
99
+
100
+ export function createSearchController(opts: SearchControllerOptions): SearchController {
101
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
102
+ const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
103
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
104
+ const toError = opts.toError ?? defaultToError;
105
+
106
+ let current: SearchState = { status: 'idle' };
107
+ let query = '';
108
+ let orientation: UnsplashOrientation = 'any';
109
+ let timer: unknown = null;
110
+ let disposed = false;
111
+ // Bumped for every request issued. A response is applied only while its own
112
+ // seq is still the newest — so a slow early request cannot overwrite a fast
113
+ // later one, and dispose() invalidates everything outstanding.
114
+ let seq = 0;
115
+
116
+ const emit = (next: SearchState): void => {
117
+ current = next;
118
+ opts.onState(next);
119
+ };
120
+
121
+ const cancelPending = (): void => {
122
+ if (timer !== null) {
123
+ clearTimer(timer);
124
+ timer = null;
125
+ }
126
+ };
127
+
128
+ /** Issue a request for page 1, replacing whatever is shown. */
129
+ const run = (): void => {
130
+ if (disposed) return;
131
+ const q = query.trim();
132
+ if (!q) {
133
+ emit({ status: 'idle' });
134
+ return;
135
+ }
136
+ const mine = ++seq;
137
+ const issuedOrientation = orientation;
138
+ emit({ status: 'loading', query: q });
139
+ opts.search({ query: q, page: 1, orientation: issuedOrientation }).then(
140
+ (res) => {
141
+ if (disposed || mine !== seq) return; // superseded
142
+ if (!res.photos.length) {
143
+ emit({ status: 'empty', query: q });
144
+ return;
145
+ }
146
+ emit({
147
+ status: 'ready',
148
+ query: q,
149
+ photos: res.photos,
150
+ total: res.total,
151
+ totalPages: res.totalPages,
152
+ page: res.page,
153
+ ...(res.remaining === undefined ? {} : { remaining: res.remaining }),
154
+ loadingMore: false,
155
+ });
156
+ },
157
+ (err: unknown) => {
158
+ if (disposed || mine !== seq) return;
159
+ emit({ status: 'error', query: q, error: toError(err) });
160
+ },
161
+ );
162
+ };
163
+
164
+ return {
165
+ state: () => current,
166
+
167
+ setQuery(next) {
168
+ query = next;
169
+ cancelPending();
170
+ if (disposed) return;
171
+ // A cleared box resets immediately — waiting out a debounce to show
172
+ // nothing would feel broken.
173
+ if (!next.trim()) {
174
+ seq++; // abandon anything outstanding
175
+ emit({ status: 'idle' });
176
+ return;
177
+ }
178
+ timer = setTimer(() => {
179
+ timer = null;
180
+ run();
181
+ }, debounceMs);
182
+ },
183
+
184
+ setOrientation(next) {
185
+ if (next === orientation) return;
186
+ orientation = next;
187
+ cancelPending();
188
+ run();
189
+ },
190
+
191
+ orientation: () => orientation,
192
+
193
+ loadMore() {
194
+ if (disposed) return;
195
+ const at = current;
196
+ if (at.status !== 'ready' || at.loadingMore) return;
197
+ if (at.page >= at.totalPages) return;
198
+
199
+ const mine = ++seq;
200
+ const issuedQuery = at.query;
201
+ const issuedOrientation = orientation;
202
+ const nextPage = at.page + 1;
203
+ emit({ ...at, loadingMore: true, moreError: undefined });
204
+
205
+ opts.search({ query: issuedQuery, page: nextPage, orientation: issuedOrientation }).then(
206
+ (res) => {
207
+ if (disposed || mine !== seq) return;
208
+ // Appending is only valid if nothing about the search changed while
209
+ // the page was in flight — otherwise these are results for a
210
+ // different question.
211
+ const now = current;
212
+ if (
213
+ now.status !== 'ready' ||
214
+ now.query !== issuedQuery ||
215
+ orientation !== issuedOrientation
216
+ ) {
217
+ return;
218
+ }
219
+ emit({
220
+ ...now,
221
+ photos: [...now.photos, ...res.photos],
222
+ page: res.page,
223
+ total: res.total,
224
+ totalPages: res.totalPages,
225
+ ...(res.remaining === undefined ? {} : { remaining: res.remaining }),
226
+ loadingMore: false,
227
+ });
228
+ },
229
+ (err: unknown) => {
230
+ if (disposed || mine !== seq) return;
231
+ const now = current;
232
+ if (now.status !== 'ready') return;
233
+ // The results already on screen survive; only the button reports.
234
+ emit({ ...now, loadingMore: false, moreError: toError(err) });
235
+ },
236
+ );
237
+ },
238
+
239
+ retry() {
240
+ cancelPending();
241
+ run();
242
+ },
243
+
244
+ dispose() {
245
+ disposed = true;
246
+ cancelPending();
247
+ seq++;
248
+ },
249
+ };
250
+ }
package/src/index.ts ADDED
@@ -0,0 +1,299 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import { createRequire } from 'node:module';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
+ import { UNSPLASH_DEFAULT_IMPORT_WIDTH } from './shared/unsplash.ts';
5
+ import { createAnnotatePlugin } from './server/annotate.ts';
6
+ import { createSchemaProvider } from './server/content-config.ts';
7
+ import { createMiddleware } from './server/middleware.ts';
8
+ import { createPrivateFilesPlugin } from './server/private-files.ts';
9
+ import { createRouteManifest, type ResolvedRouteLike } from './server/route-manifest.ts';
10
+ import {
11
+ createOptionsResolver,
12
+ DEFAULTS,
13
+ UNSPLASH_MAX_PER_PAGE,
14
+ type DevEditOptions,
15
+ } from './server/options.ts';
16
+ import { resolveUnsplashKey } from './server/settings.ts';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ /**
20
+ * astro-dev-edit — in-browser visual content editing for the local dev server.
21
+ *
22
+ * Click-to-edit for literal text and static img src/alt in .astro templates:
23
+ * the client confirms each target against the server-side AST classification,
24
+ * and commits patch the source file directly (verified, atomic).
25
+ *
26
+ * Dev-only. The integration registers nothing for builds, so it can never reach
27
+ * a production bundle. See spec §8.
28
+ *
29
+ * **The option vocabulary lives in `server/options.ts`,** not here — the
30
+ * Settings panel resolves options per request against the settings file, so the
31
+ * table that declares them has to sit where both the resolver and the routes can
32
+ * read it. This file passes what the project actually wrote to `devEdit()`
33
+ * through **unmerged**: `key in userOptions` is what tells the panel an option is
34
+ * config-owned, and collapsing it into `DEFAULTS` here would erase exactly that.
35
+ */
36
+
37
+ export type { DevEditOptions, UnsplashOptions, ResolvedOptions } from './server/options.ts';
38
+ export type { EntryEditorOptions, EntryFieldOverride } from './server/content-config.ts';
39
+
40
+ /** The project's installed Astro major, resolved from the project root (the
41
+ * integration's own tree has no astro). null when resolution fails. */
42
+ function detectAstroMajor(projectRoot: string): number | null {
43
+ try {
44
+ const req = createRequire(join(projectRoot, 'package.json'));
45
+ const version = (req('astro/package.json') as { version: string }).version;
46
+ const major = Number.parseInt(version.split('.')[0]!, 10);
47
+ return Number.isNaN(major) ? null : major;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export default function devEdit(userOptions: DevEditOptions = {}): AstroIntegration {
54
+ // Config-setup-time options only. Both are consumed before any dev server
55
+ // exists — `sourceAnnotations` registers a Vite plugin — so neither can come
56
+ // from the settings file, and both are reported to the panel as read-only.
57
+ // `enabled` is additionally config-only because storing `false` there would
58
+ // lock the user out of the UI that set it.
59
+ const enabled = userOptions.enabled ?? DEFAULTS.enabled;
60
+ const sourceAnnotations = userOptions.sourceAnnotations ?? DEFAULTS.sourceAnnotations;
61
+
62
+ // Captured in config:setup, consumed in server:setup. Only set when we're
63
+ // actually running in dev with the integration enabled.
64
+ let active = false;
65
+ let projectRoot = '';
66
+ let base = '/';
67
+ /** Astro's `publicDir`, root-relative and posix-shaped. The one directory a
68
+ * build copies verbatim, so it is what decides both the URL an asset is
69
+ * served at and whether that URL survives the build. Read from the config
70
+ * rather than assumed to be `public`. */
71
+ let publicDir = 'public';
72
+ /** Astro's own route table, for "which file is this page written in". Replaced
73
+ * wholesale on every `astro:routes:resolved` and read through a thunk, never
74
+ * captured: the hook re-fires on any change under `srcDir`, so a page added
75
+ * mid-session has to be visible without a restart. */
76
+ let resolvedRoutes: readonly ResolvedRouteLike[] = [];
77
+
78
+ return {
79
+ name: 'astro-dev-edit',
80
+ hooks: {
81
+ 'astro:config:setup': ({ command, config, injectScript, logger, updateConfig }) => {
82
+ // Dev server only. Bail for `astro build` / `astro preview` so nothing
83
+ // ships to production. (spec §4.1, §8)
84
+ if (command !== 'dev') return;
85
+
86
+ // Ahead of the `enabled` bail on purpose: a project that turned the
87
+ // editor off still has `.astro-dev-edit.json` sitting in a directory
88
+ // Vite serves, so "disabled" must mean no editor, not no protection.
89
+ // Like `createAnnotatePlugin` below this is an ordering problem, but in
90
+ // the middleware stack rather than the transform one — see the header
91
+ // of `private-files.ts` for why `configureServer` is the only seam and
92
+ // `server.fs.deny` is not.
93
+ updateConfig({ vite: { plugins: [createPrivateFilesPlugin()] } });
94
+
95
+ if (!enabled) {
96
+ logger.info('disabled via options.enabled — skipping');
97
+ return;
98
+ }
99
+ active = true;
100
+ projectRoot = fileURLToPath(config.root);
101
+ // Not normalized by Astro's schema — 'docs', '/docs' and '/docs/' are
102
+ // all possible, and route-manifest.ts tolerates all three.
103
+ base = config.base ?? '/';
104
+ publicDir =
105
+ relative(projectRoot, fileURLToPath(config.publicDir)).split(sep).join('/') || 'public';
106
+
107
+ // Upload-directory preflight. Only warns about what the *config* says:
108
+ // the panel enforces the same two rules on the value it stores, and a
109
+ // startup warning about a value the user is about to change from the UI
110
+ // would be noise.
111
+ warnAboutUploadDirs(projectRoot, publicDir, userOptions, logger);
112
+
113
+ // The whole feature rides on `data-astro-source-file` / `-loc`
114
+ // attributes. On Astro 5/6 the compiler emits them (dev toolbar on);
115
+ // on Astro ≥7 the Rust compiler doesn't
116
+ // (withastro/compiler-rs#96), so we inject them ourselves with a
117
+ // pre-compiler Vite transform. Unresolvable version → inject too:
118
+ // double annotation is harmless (identical values, browsers keep the
119
+ // first), while missing annotation kills the feature.
120
+ const astroMajor = detectAstroMajor(projectRoot);
121
+ const selfAnnotate =
122
+ sourceAnnotations === 'force' ||
123
+ (sourceAnnotations === 'auto' && (astroMajor === null || astroMajor >= 7));
124
+ if (selfAnnotate) {
125
+ updateConfig({ vite: { plugins: [createAnnotatePlugin()] } });
126
+ logger.info(
127
+ `injecting data-astro-source-* annotations (` +
128
+ (sourceAnnotations === 'force'
129
+ ? 'sourceAnnotations: "force"'
130
+ : `Astro ${astroMajor ?? 'unknown'} — its compiler does not emit them`) +
131
+ ')',
132
+ );
133
+ }
134
+
135
+ // Without self-annotation, only the dev toolbar makes Astro emit the
136
+ // attributes. If it's off, hover highlight and click-to-edit silently
137
+ // find nothing. Fail loud rather than mysteriously do nothing.
138
+ const toolbarEnabled = config.devToolbar?.enabled ?? true;
139
+ if (!toolbarEnabled && !selfAnnotate) {
140
+ logger.warn(
141
+ 'the Astro dev toolbar is DISABLED, so no data-astro-source-* ' +
142
+ 'attributes are emitted. astro-dev-edit needs them to locate ' +
143
+ 'editable elements and will find nothing. Re-enable the dev ' +
144
+ 'toolbar (devToolbar.enabled) or set sourceAnnotations: "force".',
145
+ );
146
+ }
147
+
148
+ // Injected on every page. `overlay.ts` is compiled by Vite because the
149
+ // injected code imports it by absolute path. (spec §4.1)
150
+ const overlayUrl = new URL('./client/overlay.ts', import.meta.url);
151
+ injectScript('page', `import ${JSON.stringify(fileURLToPath(overlayUrl))};`);
152
+
153
+ logger.info('edit mode available — toggle it from the admin bar at the top of the page');
154
+ },
155
+
156
+ // Astro's answer to "which file is this route written in", which the DOM
157
+ // cannot give: component tags carry no source annotation. This fires on
158
+ // every add/unlink/change under srcDir, so the body is an assignment and
159
+ // nothing else — no logging, no work per fire. The `active` guard is what
160
+ // keeps the integration dev-only: it is set only by a dev config:setup.
161
+ 'astro:routes:resolved': ({ routes }: { routes: readonly ResolvedRouteLike[] }) => {
162
+ if (!active) return;
163
+ resolvedRoutes = routes;
164
+ },
165
+
166
+ 'astro:server:setup': ({ server, logger }) => {
167
+ if (!active) return;
168
+
169
+ // The one seam every route reads options through. Per-request, so an
170
+ // option changed in the Settings panel applies to the very next call.
171
+ const optionsResolver = createOptionsResolver({
172
+ root: projectRoot,
173
+ configOptions: userOptions,
174
+ });
175
+
176
+ // Vite dev middleware exposes the edit API under /__dev-edit/. (spec §4.3)
177
+ server.middlewares.use(
178
+ createMiddleware({
179
+ logger,
180
+ root: projectRoot,
181
+ publicDir,
182
+ optionsResolver,
183
+ // Always constructed: the entry editor can now be switched on from
184
+ // the panel, so a provider built only when it started enabled would
185
+ // leave the feature schema-less until the next restart. The routes
186
+ // check the live gate themselves.
187
+ schemaProvider: createSchemaProvider(server, projectRoot, async () => {
188
+ const { options } = await optionsResolver.resolve();
189
+ return options.entryEditor === false ? {} : options.entryEditor;
190
+ }),
191
+ // Always constructed, like schemaProvider: the array is simply
192
+ // empty until the routes hook has fired, and the route then answers
193
+ // an explicit refusal rather than guessing at a file.
194
+ routeManifest: createRouteManifest({
195
+ root: projectRoot,
196
+ base,
197
+ routes: () => resolvedRoutes,
198
+ }),
199
+ unsplash: {
200
+ // Thunks, not values: both the key and the sub-options resolve
201
+ // per request, so anything entered through the Settings panel
202
+ // takes effect without a dev-server restart and nothing depends
203
+ // on hook ordering.
204
+ resolve: async () => {
205
+ const { options } = await optionsResolver.resolve();
206
+ const configKey =
207
+ options.unsplash === false ? undefined : options.unsplash.accessKey;
208
+ return resolveUnsplashKey(projectRoot, configKey);
209
+ },
210
+ appName: async () => {
211
+ const { options } = await optionsResolver.resolve();
212
+ const o = options.unsplash;
213
+ return (o === false ? '' : o.appName) || 'astro-dev-edit';
214
+ },
215
+ perPage: async () => {
216
+ const { options } = await optionsResolver.resolve();
217
+ const o = options.unsplash;
218
+ const raw = (o === false ? undefined : o.perPage) ?? 20;
219
+ return Math.min(Math.max(1, Math.trunc(raw)), UNSPLASH_MAX_PER_PAGE);
220
+ },
221
+ importWidth: async () => {
222
+ const { options } = await optionsResolver.resolve();
223
+ const o = options.unsplash;
224
+ // Already safelisted by the resolver; the fallback is only for
225
+ // the feature-off shape, which never reaches an import anyway.
226
+ return (o === false ? undefined : o.importWidth) ?? UNSPLASH_DEFAULT_IMPORT_WIDTH;
227
+ },
228
+ enabled: async () => {
229
+ const { options } = await optionsResolver.resolve();
230
+ return options.unsplash !== false;
231
+ },
232
+ },
233
+ }),
234
+ );
235
+
236
+ if (userOptions.unsplash) {
237
+ if (userOptions.unsplash.accessKey) {
238
+ logger.warn(
239
+ 'unsplash.accessKey is set in your Astro config. That file is ' +
240
+ 'committed and is read by `astro build`, so the key travels ' +
241
+ 'with the repo — prefer UNSPLASH_ACCESS_KEY in .env.local, ' +
242
+ 'which the overlay’s Settings panel writes for you.',
243
+ );
244
+ }
245
+ logger.info(
246
+ 'Unsplash photo source enabled' +
247
+ (userOptions.unsplash.accessKey
248
+ ? ''
249
+ : ' — add an access key from the admin bar’s Settings panel'),
250
+ );
251
+ }
252
+ },
253
+ },
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Warn when a *configured* upload directory cannot work, at startup where the
259
+ * user will see it.
260
+ *
261
+ * Uploads become a literal `<img src>` in the source, so anything outside
262
+ * `public/` is served by Vite in dev but absent from a production build and the
263
+ * reference 404s once deployed. The mirror-image rule holds for `image()`
264
+ * fields: Astro imports those assets through Vite, and `public/` files are
265
+ * copied verbatim rather than importable, so a `public/` target fails the
266
+ * collection's own schema.
267
+ */
268
+ function warnAboutUploadDirs(
269
+ projectRoot: string,
270
+ publicDir: string,
271
+ userOptions: DevEditOptions,
272
+ logger: { warn(message: string): void },
273
+ ): void {
274
+ const uploadDir = userOptions.uploadDir;
275
+ if (uploadDir !== undefined) {
276
+ const rel = relative(projectRoot, resolve(projectRoot, uploadDir));
277
+ const pub = relative(projectRoot, resolve(projectRoot, publicDir));
278
+ if (!(rel === pub || rel.startsWith(pub + sep))) {
279
+ logger.warn(
280
+ `uploadDir "${uploadDir}" is not under ${publicDir}/ — uploaded images ` +
281
+ 'are served in dev but will 404 in a production build. Point uploadDir ' +
282
+ `at a folder under ${publicDir}/.`,
283
+ );
284
+ }
285
+ }
286
+
287
+ const imageUploadDir = userOptions.imageUploadDir;
288
+ if (imageUploadDir !== undefined) {
289
+ const rel = relative(projectRoot, resolve(projectRoot, imageUploadDir));
290
+ if (!(rel === 'src' || rel.startsWith('src' + sep))) {
291
+ logger.warn(
292
+ `imageUploadDir "${imageUploadDir}" is not under src/ — Astro cannot ` +
293
+ 'import assets from there for an image() schema field, so uploads to ' +
294
+ 'it will fail the collection schema. Point imageUploadDir at a folder ' +
295
+ 'under src/.',
296
+ );
297
+ }
298
+ }
299
+ }