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,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
|
+
}
|