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,81 @@
1
+ /**
2
+ * Best-effort selector → source line resolver for the CSS inspector's
3
+ * "open in editor" jump. Pure string-in/(line,col)-out — no fs, no deps — so it
4
+ * unit-tests like a patcher.
5
+ *
6
+ * The CSSOM the client reads exposes a rule's declarations but not its source
7
+ * position, so this scans the file text for the selector fragment. It's
8
+ * deliberately approximate: it finds the *first* place the class/id token
9
+ * appears as a selector and returns that line. A miss returns null (the caller
10
+ * opens the file at its top). For .astro files the search is confined to
11
+ * <style> blocks, so a selector like `.hero-title` isn't matched against a
12
+ * class attribute in the markup.
13
+ */
14
+
15
+ export interface LineCol {
16
+ /** 1-based line number. */
17
+ line: number;
18
+ /** 1-based column. */
19
+ col: number;
20
+ }
21
+
22
+ /** A [start, end) offset range within the source. */
23
+ interface Range {
24
+ start: number;
25
+ end: number;
26
+ }
27
+
28
+ function escapeRegExp(s: string): string {
29
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ }
31
+
32
+ /** Content ranges of every <style>…</style> block (the text between the tags). */
33
+ function styleRanges(source: string): Range[] {
34
+ const ranges: Range[] = [];
35
+ const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
36
+ for (const m of source.matchAll(re)) {
37
+ const openEnd = m.index + m[0].indexOf('>') + 1;
38
+ ranges.push({ start: openEnd, end: openEnd + m[1].length });
39
+ }
40
+ return ranges;
41
+ }
42
+
43
+ function offsetToLineCol(source: string, offset: number): LineCol {
44
+ let line = 1;
45
+ let lineStart = 0;
46
+ for (let i = 0; i < offset; i++) {
47
+ if (source.charCodeAt(i) === 10 /* \n */) {
48
+ line++;
49
+ lineStart = i + 1;
50
+ }
51
+ }
52
+ return { line, col: offset - lineStart + 1 };
53
+ }
54
+
55
+ /**
56
+ * Locate the earliest occurrence of `selector` (a fragment like ".hero-title"
57
+ * or "#masthead") used as a selector token. `isAstro` confines the search to
58
+ * <style> blocks. Returns the 1-based line/col, or null when not found.
59
+ */
60
+ export function locateSelector(
61
+ source: string,
62
+ selector: string,
63
+ isAstro: boolean,
64
+ ): LineCol | null {
65
+ if (!selector) return null;
66
+ // The fragment already carries its leading "." / "#". The negative lookahead
67
+ // keeps ".hero-title" from matching ".hero-titles" (an identifier char or "-"
68
+ // following the token means it's a longer name).
69
+ const re = new RegExp(escapeRegExp(selector) + '(?![\\w-])');
70
+ const ranges: Range[] = isAstro ? styleRanges(source) : [{ start: 0, end: source.length }];
71
+
72
+ let best: number | null = null;
73
+ for (const { start, end } of ranges) {
74
+ const m = re.exec(source.slice(start, end));
75
+ if (m) {
76
+ const idx = start + m.index;
77
+ if (best === null || idx < best) best = idx;
78
+ }
79
+ }
80
+ return best === null ? null : offsetToLineCol(source, best);
81
+ }
@@ -0,0 +1,94 @@
1
+ import type { AstroIntegrationLogger } from 'astro';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
+ import type { InspectOpenRequest } from '../shared/protocol.ts';
5
+ import { launchInEditor } from './editor.ts';
6
+ import type { OptionsResolver } from './options.ts';
7
+ import { checkEditablePath } from './paths.ts';
8
+ import type { Route } from './router.ts';
9
+
10
+ /**
11
+ * The CSS-inspector route group (/inspect/open) — the "open this rule in my
12
+ * editor" jump for the hover-pill class/ID inspector. A feature route module:
13
+ * it exports a `Route[]` the middleware concatenates, keeping middleware.ts a
14
+ * thin composition point.
15
+ *
16
+ * The inspector's *display* is entirely client-side (it reads document.style-
17
+ * Sheets), so this is the only server surface it needs: best-effort locate the
18
+ * selector in its source file, then launch the editor there. Read-only — no
19
+ * writes ever pass through here.
20
+ */
21
+
22
+ export interface InspectRouteDeps {
23
+ logger: AstroIntegrationLogger;
24
+ /** Project root (fsPath). Every served path is confined to this. */
25
+ root: string;
26
+ /** Live options — `cssInspector` gates the group, `openInEditor` gates the
27
+ * editor launch, and both can change without a dev-server restart. */
28
+ optionsResolver: OptionsResolver;
29
+ }
30
+
31
+ export function createInspectRoutes(deps: InspectRouteDeps): Route[] {
32
+ const { root, optionsResolver } = deps;
33
+
34
+ // A rule's source is commonly a .css file, which is never in the write-side
35
+ // editableExtensions. Broaden the *open* allowlist with .css only — the rest
36
+ // of the gate (realpath ∈ root ∈ contentRoots) is unchanged, so node_modules
37
+ // and external stylesheets are still excluded. (spec §8)
38
+ const openableExtensions = (editableExtensions: string[]): string[] =>
39
+ editableExtensions.includes('.css') ? editableExtensions : [...editableExtensions, '.css'];
40
+
41
+ // A rule's file arrives as the stylesheet URL's path. URLs under the public
42
+ // dir have that segment stripped (Astro serves `public/foo.css` at
43
+ // `/foo.css`), so a bare `styles/global.css` won't resolve on disk — retry it
44
+ // under `public/`. Absolute paths (Vite's `data-vite-dev-id`) resolve on the
45
+ // first candidate. First path that clears the gate wins. (spec §8)
46
+ async function resolveOpenable(
47
+ file: string,
48
+ contentRoots: string[],
49
+ openable: string[],
50
+ ): Promise<string> {
51
+ const candidates = file.startsWith('public/') ? [file] : [file, `public/${file}`];
52
+ let lastReason = `no such file: ${file}`;
53
+ for (const cand of candidates) {
54
+ const check = await checkEditablePath(root, contentRoots, openable, cand);
55
+ if (check.ok) return check.abs;
56
+ lastReason = check.reason;
57
+ }
58
+ throw new Error(lastReason);
59
+ }
60
+
61
+ return [
62
+ // Best-effort jump to a CSS rule's source line, then launch the editor.
63
+ {
64
+ method: 'POST',
65
+ path: '/inspect/open',
66
+ maxBytes: 64 * 1024,
67
+ label: 'inspect-open',
68
+ fallback: 'open failed',
69
+ handler: async (body) => {
70
+ const { options } = await optionsResolver.resolve();
71
+ if (!options.cssInspector) {
72
+ return { status: 403, body: { error: 'the CSS inspector is disabled by configuration' } };
73
+ }
74
+ if (!options.openInEditor) {
75
+ return { status: 403, body: { error: 'open-in-editor is disabled by configuration' } };
76
+ }
77
+ const { file, selector } = body as InspectOpenRequest;
78
+ if (!file || !selector) throw new Error('file and selector are required');
79
+ const abs = await resolveOpenable(
80
+ file,
81
+ options.contentRoots,
82
+ openableExtensions(options.editableExtensions),
83
+ );
84
+ // Lazy import so the pure locator can be unit-tested without fs.
85
+ const { locateSelector } = await import('./inspect-locate.ts');
86
+ const source = await readFile(abs, 'utf8');
87
+ const hit = locateSelector(source, selector, extname(abs).toLowerCase() === '.astro');
88
+ const loc = hit ? `${hit.line}:${hit.col}` : null;
89
+ await launchInEditor(loc ? `${abs}:${loc}` : abs);
90
+ return { status: 200, body: { ok: true, loc } };
91
+ },
92
+ },
93
+ ];
94
+ }
@@ -0,0 +1,480 @@
1
+ import { createTextWrites } from './text-writes.ts';
2
+ import type { AstroIntegrationLogger } from 'astro';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { basename, extname } from 'node:path';
5
+ import type { Connect } from 'vite';
6
+ import { patcherFor } from '../patcher/registry.ts';
7
+ import type {
8
+ ApplyRequestWire,
9
+ ClassifyRequest,
10
+ OpenRequest,
11
+ PeekRequest,
12
+ UploadRequest,
13
+ } from '../shared/protocol.ts';
14
+ import { dataUrlMime, listAssets, saveUpload } from './assets.ts';
15
+ import type { EntrySchemaProvider } from './content-config.ts';
16
+ import { launchInEditor } from './editor.ts';
17
+ import { createDetailRoutes } from './entry-detect.ts';
18
+ import { createEntryResolveRoutes } from './entry-resolve-routes.ts';
19
+ import { createEntryRoutes } from './entry-routes.ts';
20
+ import { createInspectRoutes } from './inspect-routes.ts';
21
+ import type { OptionsResolver, ResolvedOptions } from './options.ts';
22
+ import { createPageSourceRoutes } from './page-source-routes.ts';
23
+ import {
24
+ checkEditablePath,
25
+ isPackageOwned,
26
+ resolveAssetTarget,
27
+ validateEditablePath,
28
+ } from './paths.ts';
29
+ import { BASE, dispatch, json, type Route } from './router.ts';
30
+ import type { RouteManifest } from './route-manifest.ts';
31
+ import { createSchemaRoutes } from './schema-routes.ts';
32
+ import { createSettingsRoutes } from './settings-routes.ts';
33
+ import { createUnsplashRoutes, type UnsplashConfig } from './unsplash-routes.ts';
34
+
35
+ /**
36
+ * Dev-server middleware for astro-dev-edit — the composition point for every
37
+ * /__dev-edit route group. This file owns the core loc-based editing routes
38
+ * (health, assets, upload, open, peek, classify, apply) and the localhost gate;
39
+ * feature route groups (the /entry* CMS endpoints in entry-routes.ts, the
40
+ * page-source lookup in page-source-routes.ts, and the rest) export
41
+ * their own `Route[]` and are concatenated here. Every endpoint rejects
42
+ * non-localhost requests — this API is strictly for the developer's own
43
+ * machine. (spec §8)
44
+ */
45
+
46
+ interface MiddlewareDeps {
47
+ logger: AstroIntegrationLogger;
48
+ /** Project root (fsPath). Every served path is confined to this. */
49
+ root: string;
50
+ /**
51
+ * Astro's `publicDir`, root-relative — the one directory a *build* copies
52
+ * verbatim. It decides both the URL a listed or uploaded asset is reported at
53
+ * and whether that URL survives the build (`AssetInfo.servable`). Project
54
+ * config rather than an option, so it is a value and not a thunk: changing it
55
+ * needs a dev-server restart anyway. Defaults to `public`. */
56
+ publicDir?: string;
57
+ /**
58
+ * The live option resolver. **A thunk, not the values** — options come from
59
+ * `astro.config.mjs`, the settings file the Settings panel writes, and the
60
+ * defaults, in that order, and the panel can change the middle layer at any
61
+ * time. Resolving per request is what lets a saved option take effect without
62
+ * a dev-server restart; it is the same shape, for the same reason, as
63
+ * `unsplash.resolve`.
64
+ *
65
+ * The consequence for this table: a feature gate can no longer decide whether
66
+ * a route group is *registered*, so every group is registered unconditionally
67
+ * and each handler checks its own gate. That was already the pattern the
68
+ * Unsplash group used, so clients get an explicit `disabled` code rather than
69
+ * a 404 they would have to guess the meaning of.
70
+ */
71
+ optionsResolver: OptionsResolver;
72
+ /** Collection/schema lookup for the entry editor; null → inference only. */
73
+ schemaProvider: EntrySchemaProvider | null;
74
+ /** Astro's route manifest, for "which file is this page written in"; null
75
+ * when none is available (an Astro that never fired the routes hook, or a
76
+ * test) → the page-source route refuses rather than guessing. */
77
+ routeManifest: RouteManifest | null;
78
+ /** Unsplash photo source. Its access key and its per-page/appName settings
79
+ * both resolve lazily, per request; null → no key resolver is available at
80
+ * all (the feature can still be switched on from the panel). */
81
+ unsplash: UnsplashConfig | null;
82
+ }
83
+
84
+ const NO_PATCHER_REASON = 'Only .astro templates support in-place editing so far.';
85
+
86
+ /** Why an animated GIF can't back an `image()` field. */
87
+ const GIF_REFUSAL =
88
+ 'Astro optimises image() assets, which flattens an animated GIF to a single frame. ' +
89
+ 'Keep animated GIFs in public/ and reference them from a plain <img src> instead.';
90
+
91
+ /** Reasons for elements whose source file exists but isn't yours to edit.
92
+ * These are *verdicts*, not errors: /classify is advisory and runs on hover,
93
+ * so an out-of-root path must answer "not editable here" rather than throw —
94
+ * otherwise `astro:assets` <Image> (annotated to
95
+ * node_modules/astro/components/Image.astro) floods the log with warnings on
96
+ * any site using it. Widening contentRoots would be the wrong fix: it would
97
+ * make Astro's own internals writable. */
98
+ const PACKAGE_OWNED_REASON =
99
+ 'Rendered by a package component (e.g. the astro:assets <Image>), not your source. ' +
100
+ 'Edit where the component is used instead.';
101
+ const OUT_OF_ROOT_REASON =
102
+ 'This element comes from a file outside the editable content roots.';
103
+
104
+ /** Max lines of context on each side of the focus line in a /peek response.
105
+ * Deliberately generous — in practice the peek returns the whole file and
106
+ * the panel scrolls it; the cap only stops a pathological multi-thousand-line
107
+ * file from flooding the response and the panel's DOM. */
108
+ const PEEK_CONTEXT = 1000;
109
+
110
+ /** Whether the Unsplash source is usable: enabled AND a key resolves. Degrades
111
+ * to false rather than throwing — /health must answer even when a settings
112
+ * file is unreadable, and the key itself never reaches the response. */
113
+ async function hasUnsplashKey(cfg: UnsplashConfig | null): Promise<boolean> {
114
+ if (!cfg) return false;
115
+ try {
116
+ return Boolean((await cfg.resolve()).key.trim());
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /** Reject anything that isn't a same-machine request. (spec §8) */
123
+ function isLocalRequest(req: Connect.IncomingMessage): boolean {
124
+ const remote = req.socket.remoteAddress ?? '';
125
+ const localAddrs = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
126
+ if (!localAddrs.includes(remote)) return false;
127
+
128
+ // If an Origin header is present it must be a localhost origin — this blocks
129
+ // a page on another site from POSTing to our dev endpoints via the browser.
130
+ const origin = req.headers.origin;
131
+ if (origin) {
132
+ try {
133
+ const host = new URL(origin).hostname;
134
+ if (host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]') {
135
+ return false;
136
+ }
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+ return true;
142
+ }
143
+
144
+ export function createMiddleware(deps: MiddlewareDeps): Connect.NextHandleFunction {
145
+ const { logger, root, optionsResolver, routeManifest, schemaProvider, unsplash } = deps;
146
+ const publicDir = deps.publicDir ?? 'public';
147
+ const textWrites = createTextWrites({ root, optionsResolver, logger });
148
+ const writeText = textWrites.write;
149
+
150
+ /** The effective options for the request in hand. Every handler starts here
151
+ * rather than closing over values captured at setup time. */
152
+ const opts = (): Promise<ResolvedOptions> =>
153
+ optionsResolver.resolve().then((r) => r.options);
154
+
155
+ /** The only directories an asset write may be steered into. Without this, a
156
+ * client-supplied targetDir would be a "write a file anywhere in the project"
157
+ * capability rather than "put this image beside its siblings". (spec §8)
158
+ *
159
+ * Derived per request now, so widening `assetDirs` from the panel takes
160
+ * effect immediately — and, more importantly, so *narrowing* it does. */
161
+ const assetTargetDirs = (o: ResolvedOptions) => ({
162
+ uploadDir: o.uploadDir,
163
+ imageUploadDir: o.imageUploadDir,
164
+ allowedDirs: [...o.assetDirs, o.uploadDir, o.imageUploadDir],
165
+ });
166
+
167
+ const coreRoutes: Route[] = [
168
+ {
169
+ method: 'GET',
170
+ path: '/health',
171
+ label: 'health',
172
+ handler: async () => {
173
+ const o = await opts();
174
+ return {
175
+ status: 200,
176
+ body: {
177
+ ok: true,
178
+ name: 'astro-dev-edit',
179
+ milestone: 1,
180
+ cssInspector: o.cssInspector,
181
+ openInEditor: o.openInEditor,
182
+ entryEditor: o.entryEditor !== false,
183
+ root,
184
+ // Enabled *and* holding a usable key — the overlay uses this to
185
+ // decide whether to render the Unsplash tab at all, and a tab that
186
+ // errors on click is worse than no tab. Resolved here rather than
187
+ // cached so a key entered through Settings shows up on the next poll.
188
+ unsplash: o.unsplash !== false && (await hasUnsplashKey(unsplash)),
189
+ // Where the picker's size select starts. Read live like the rest,
190
+ // so changing it in Settings moves the select without a reload.
191
+ ...(o.unsplash === false ? {} : { unsplashImportWidth: o.unsplash.importWidth }),
192
+ },
193
+ };
194
+ },
195
+ },
196
+
197
+ // Read-only listing for the image-swap panel. No writes anywhere. (spec §6.3)
198
+ {
199
+ method: 'GET',
200
+ path: '/assets',
201
+ label: 'asset listing',
202
+ handler: async () => ({
203
+ status: 200,
204
+ body: {
205
+ files: await listAssets(root, (await opts()).assetDirs, publicDir),
206
+ publicDir,
207
+ },
208
+ }),
209
+ onError: () => ({ status: 500, body: { error: 'could not list assets' } }),
210
+ },
211
+
212
+ // Writes a NEW image file into the configured upload dir — a self-contained
213
+ // asset write, never a source-file patch. (spec §11)
214
+ {
215
+ method: 'POST',
216
+ path: '/upload',
217
+ maxBytes: 25 * 1024 * 1024, // 25 MB cap
218
+ label: 'upload',
219
+ handler: async (body) => {
220
+ const req = body as UploadRequest;
221
+ const relative = req.assetRef === 'relative';
222
+ // An image() asset is imported and optimised by Astro, which flattens
223
+ // an animated GIF to a still frame. Refuse rather than write a value
224
+ // that silently degrades the image — public/ + the swap panel is the
225
+ // path that preserves animation.
226
+ if (relative && (dataUrlMime(req.dataUrl) === 'image/gif' || /\.gif$/i.test(req.filename ?? ''))) {
227
+ return {
228
+ status: 422,
229
+ body: { error: GIF_REFUSAL, code: 'unsupported' },
230
+ };
231
+ }
232
+ // Relative fields fall back to the src-side dir; a requested target is
233
+ // honoured only if it sits inside a configured asset directory.
234
+ const { dir, redirected } = resolveAssetTarget(root, assetTargetDirs(await opts()), req);
235
+ if (redirected) {
236
+ logger.warn(
237
+ `upload targetDir "${req.targetDir}" is not inside a configured asset ` +
238
+ `directory — writing to "${dir}" instead`,
239
+ );
240
+ }
241
+ const { webPath } = await saveUpload(root, dir, req, publicDir);
242
+ logger.info(`uploaded image -> ${webPath}`);
243
+ return { status: 200, body: { webPath } };
244
+ },
245
+ },
246
+
247
+ // Opens a source location in the user's editor via launch-editor — a
248
+ // read-only side effect (spawns the editor).
249
+ {
250
+ method: 'POST',
251
+ path: '/open',
252
+ maxBytes: 64 * 1024,
253
+ label: 'open-in-editor',
254
+ fallback: 'open failed',
255
+ handler: async (body) => {
256
+ const o = await opts();
257
+ if (!o.openInEditor) {
258
+ return { status: 403, body: { error: 'open-in-editor is disabled by configuration' } };
259
+ }
260
+ const { file, loc } = body as OpenRequest;
261
+ if (!file) throw new Error('file is required');
262
+ // Same gate as /classify and /apply: realpath ∈ root ∈ contentRoots,
263
+ // allowed extension. /open only spawns an editor, but it takes the
264
+ // same client-supplied paths, and every legitimate caller targets a
265
+ // file that already passed this gate. (spec §8)
266
+ const abs = await validateEditablePath(root, o.contentRoots, o.editableExtensions, file);
267
+ const [line, col] = (loc ?? '').split(':');
268
+ const spec = line ? `${abs}:${line}${col ? ':' + col : ''}` : abs;
269
+ await launchInEditor(spec);
270
+ return { status: 200, body: { ok: true } };
271
+ },
272
+ },
273
+
274
+ // Read-only source peek: the file's lines (windowed only past the huge-
275
+ // file cap) plus focus metadata, so the overlay can show the code in the
276
+ // browser without launching an editor. Same path gate as every
277
+ // file-touching route; no writes.
278
+ {
279
+ method: 'POST',
280
+ path: '/peek',
281
+ maxBytes: 64 * 1024,
282
+ label: 'peek',
283
+ handler: async (body) => {
284
+ const o = await opts();
285
+ const { file, loc } = body as PeekRequest;
286
+ if (!file) throw new Error('file is required');
287
+ // Clicking the hover pill's file:loc label on an <Image> lands here
288
+ // with a package-owned path. Same call as /classify: explain rather
289
+ // than 400, and return no source — the point is that it isn't ours.
290
+ const check = await checkEditablePath(root, o.contentRoots, o.editableExtensions, file);
291
+ if (!check.ok) {
292
+ if (check.code !== 'outside-roots') throw new Error(check.reason);
293
+ return {
294
+ status: 200,
295
+ body: {
296
+ file,
297
+ startLine: 1,
298
+ focusLine: 1,
299
+ totalLines: 0,
300
+ lines: [],
301
+ refused:
302
+ check.abs && isPackageOwned(check.abs)
303
+ ? PACKAGE_OWNED_REASON
304
+ : OUT_OF_ROOT_REASON,
305
+ },
306
+ };
307
+ }
308
+ const abs = check.abs;
309
+ const source = await readFile(abs, 'utf8');
310
+ const all = source.split(/\r?\n/);
311
+ // A trailing newline yields a phantom empty last line — drop it.
312
+ if (all.length > 1 && all[all.length - 1] === '') all.pop();
313
+ const line = parseInt((loc ?? '').split(':')[0] ?? '', 10);
314
+ const focusLine = Math.min(Math.max(Number.isFinite(line) ? line : 1, 1), all.length);
315
+ const startLine = Math.max(1, focusLine - PEEK_CONTEXT);
316
+ const endLine = Math.min(all.length, focusLine + PEEK_CONTEXT);
317
+ return {
318
+ status: 200,
319
+ body: {
320
+ file,
321
+ startLine,
322
+ focusLine,
323
+ totalLines: all.length,
324
+ lines: all.slice(startLine - 1, endLine),
325
+ },
326
+ };
327
+ },
328
+ },
329
+
330
+ // Source-truth classification from the source AST. The client confirms
331
+ // with this on click before opening an editor — the DOM-side guess cannot
332
+ // tell a resolved {expression} from literal text. (spec §7.3, §16.1)
333
+ {
334
+ method: 'POST',
335
+ path: '/classify',
336
+ maxBytes: 64 * 1024,
337
+ label: 'classify',
338
+ handler: async (body) => {
339
+ const o = await opts();
340
+ const { file, loc, tag } = body as ClassifyRequest;
341
+ if (!file || !loc || !tag) throw new Error('file, loc and tag are required');
342
+ // Advisory and read-only — the hover tooltip calls this too, so a file
343
+ // that simply isn't ours to edit must answer with a verdict, not a
344
+ // thrown 400. Genuine anomalies (missing, escaping the root, wrong
345
+ // extension) still throw.
346
+ const check = await checkEditablePath(root, o.contentRoots, o.editableExtensions, file);
347
+ if (!check.ok) {
348
+ if (check.code !== 'outside-roots') throw new Error(check.reason);
349
+ return {
350
+ status: 200,
351
+ body: {
352
+ kind: 'dynamic',
353
+ reason:
354
+ check.abs && isPackageOwned(check.abs)
355
+ ? PACKAGE_OWNED_REASON
356
+ : OUT_OF_ROOT_REASON,
357
+ },
358
+ };
359
+ }
360
+ const abs = check.abs;
361
+ const patcher = patcherFor(extname(abs).toLowerCase());
362
+ if (!patcher) {
363
+ return { status: 200, body: { kind: 'dynamic', reason: NO_PATCHER_REASON } };
364
+ }
365
+ const source = await readFile(abs, 'utf8');
366
+ return { status: 200, body: await patcher.classify(source, { loc, tag }) };
367
+ },
368
+ },
369
+
370
+ // The real write path: resolve the element in the AST, verify the source
371
+ // still matches what the client saw, patch, write atomically. HMR does the
372
+ // visual refresh. (spec §5, §6.1, §7.5, §10)
373
+ {
374
+ method: 'POST',
375
+ path: '/apply',
376
+ maxBytes: 256 * 1024,
377
+ label: 'apply',
378
+ handler: async (body) => {
379
+ const { file, loc, tag, ops } = body as ApplyRequestWire;
380
+ if (!file || !loc || !tag) throw new Error('file, loc and tag are required');
381
+ if (!Array.isArray(ops) || ops.length === 0) {
382
+ throw new Error('ops must be a non-empty array');
383
+ }
384
+ for (const op of ops) {
385
+ if (!op || !['text', 'markup', 'expression', 'src', 'alt'].includes(op.targetType)) {
386
+ throw new Error('bad targetType');
387
+ }
388
+ if (typeof op.original !== 'string' || typeof op.newText !== 'string') {
389
+ throw new Error('original and newText must be strings');
390
+ }
391
+ }
392
+ const o = await opts();
393
+ const abs = await validateEditablePath(root, o.contentRoots, o.editableExtensions, file);
394
+ const patcher = patcherFor(extname(abs).toLowerCase());
395
+ if (!patcher) {
396
+ return { status: 422, body: { error: NO_PATCHER_REASON, code: 'unsupported' } };
397
+ }
398
+ // Verify-all-then-write-once: apply each op to an in-memory copy of the
399
+ // source (re-parsing each time, so a later op sees the earlier edit) and
400
+ // write only after every op verifies. A single refusal writes nothing,
401
+ // so a batch (e.g. an image's src+alt) can never half-update the file.
402
+ const source = await readFile(abs, 'utf8');
403
+ let working = source;
404
+ for (const op of ops) {
405
+ const result = await patcher.apply(working, {
406
+ loc,
407
+ tag,
408
+ targetType: op.targetType,
409
+ original: op.original,
410
+ newText: op.newText,
411
+ });
412
+ if (!result.ok) {
413
+ logger.warn(`apply refused (${result.code}): ${basename(abs)}:${loc} — ${result.error}`);
414
+ return { status: 422, body: { error: result.error, code: result.code } };
415
+ }
416
+ working = result.newSource;
417
+ }
418
+ await writeText(abs, working, source);
419
+ logger.info(`applied ${ops.map((o) => o.targetType).join('+')} edit -> ${basename(abs)}:${loc}`);
420
+ return { status: 200, body: { ok: true } };
421
+ },
422
+ },
423
+ ];
424
+
425
+ // Every group is registered unconditionally and gates inside its handlers —
426
+ // see `MiddlewareDeps.optionsResolver`. A client that asks about a disabled
427
+ // feature gets an explicit `disabled` refusal rather than a 404 it would have
428
+ // to guess the meaning of.
429
+ // Built here rather than injected: it is derived entirely from two deps this
430
+ // function already holds, and it owns a cache that should live as long as the
431
+ // route table does. Two groups share the one instance so they share the cache.
432
+ const detailRoutes = createDetailRoutes({ root, routeManifest });
433
+
434
+ const routes: Route[] = [
435
+ ...coreRoutes,
436
+ ...createInspectRoutes({ logger, root, optionsResolver }),
437
+ ...createPageSourceRoutes({ logger, optionsResolver, routeManifest }),
438
+ ...createEntryRoutes({ writeText, logger, root, optionsResolver, schemaProvider }),
439
+ ...createEntryResolveRoutes({
440
+ logger,
441
+ root,
442
+ optionsResolver,
443
+ schemaProvider,
444
+ routeManifest,
445
+ detailRoutes,
446
+ }),
447
+ ...createSchemaRoutes({ writeText, logger, root, optionsResolver, schemaProvider, detailRoutes }),
448
+ ...createSettingsRoutes({ writeText, logger, root, optionsResolver, unsplash }),
449
+ ...createUnsplashRoutes({
450
+ logger,
451
+ root,
452
+ publicDir,
453
+ dirs: async () => assetTargetDirs(await opts()),
454
+ unsplash,
455
+ }),
456
+ ];
457
+
458
+ const textMutationPaths = new Set([
459
+ '/apply', '/entry/apply', '/entry/create', '/entry/delete',
460
+ '/collection/schema/apply', '/collection/create', '/collection/page-editing', '/settings',
461
+ ]);
462
+ for (const route of routes) {
463
+ if (route.method !== 'POST' || !textMutationPaths.has(route.path)) continue;
464
+ const handler = route.handler;
465
+ route.handler = (body, req) => textWrites.run(() => handler(body, req));
466
+ }
467
+
468
+ return (req, res, next) => {
469
+ const url = req.url ?? '';
470
+ if (!url.startsWith(BASE)) {
471
+ next();
472
+ return;
473
+ }
474
+ if (!isLocalRequest(req)) {
475
+ json(res, 403, { error: 'text-edit endpoints accept localhost requests only' });
476
+ return;
477
+ }
478
+ void dispatch(routes, logger, req, res);
479
+ };
480
+ }