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