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,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch the user's editor at a file spec — the one place the `launch-editor`
|
|
3
|
+
* package is invoked, shared by the /open and /inspect/open routes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Spawn the editor for `spec` (an abs path, or "abs:line" / "abs:line:col"). */
|
|
7
|
+
export async function launchInEditor(spec: string, onError?: () => void): Promise<void> {
|
|
8
|
+
// launch-editor is CommonJS: the module IS the function. Interop may wrap it
|
|
9
|
+
// under .default depending on the loader, so handle both.
|
|
10
|
+
const mod = (await import('launch-editor')) as unknown as
|
|
11
|
+
| ((f: string, onError?: () => void) => void)
|
|
12
|
+
| { default: (f: string, onError?: () => void) => void };
|
|
13
|
+
const launch = typeof mod === 'function' ? mod : mod.default;
|
|
14
|
+
launch(spec, onError);
|
|
15
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { RouteManifest } from './route-manifest.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Which collection does this page route render?" — the layer between a URL's
|
|
7
|
+
* page file and an entry file.
|
|
8
|
+
*
|
|
9
|
+
* Matching a URL's last segment against entry ids alone is not enough to be
|
|
10
|
+
* safe: two collections holding a `hello-world.md` are indistinguishable that
|
|
11
|
+
* way, and the honest answer would be a refusal on a site where nothing is
|
|
12
|
+
* actually ambiguous. Astro's own route file says which collection it renders —
|
|
13
|
+
* `getCollection('blog')` is right there in the frontmatter — so we read it.
|
|
14
|
+
*
|
|
15
|
+
* **A scan, not a parse.** The same stance `patcher/content-config.ts` takes,
|
|
16
|
+
* for the same reason: Vite's `parseAst` throws on `import type`, `satisfies`
|
|
17
|
+
* and type annotations, all legitimate in a page's frontmatter, and a parser
|
|
18
|
+
* that refuses real files is worse than a scanner that sometimes finds nothing.
|
|
19
|
+
* Finding nothing is a supported outcome here — a route that fetches through a
|
|
20
|
+
* helper names no collection, and the caller falls back to matching against
|
|
21
|
+
* every collection the user has switched on.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here decides anything. It reports what the source says; the resolver
|
|
24
|
+
* decides, and refuses when the answer isn't unique.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Collection names a page's source names, in first-seen order.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately narrow: only the two calls that take a collection name as a
|
|
31
|
+
* string literal. `getCollection(name)` with a variable is not matched, and
|
|
32
|
+
* should not be — a name this cannot prove is a name it must not report.
|
|
33
|
+
*/
|
|
34
|
+
export function collectionsNamedIn(source: string): string[] {
|
|
35
|
+
const out: string[] = [];
|
|
36
|
+
const re = /\bget(?:Collection|Entry)\s*\(\s*(['"])([A-Za-z0-9_$-]+)\1/g;
|
|
37
|
+
for (const m of source.matchAll(re)) {
|
|
38
|
+
if (!out.includes(m[2])) out.push(m[2]);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DetailRoutes {
|
|
44
|
+
/** Collections the page at this root-relative path names. Empty when it names
|
|
45
|
+
* none — a route that fetches through a helper, or a file we can't read. */
|
|
46
|
+
collectionsIn(file: string): Promise<string[]>;
|
|
47
|
+
/** The first dynamic page route naming this collection, or null. What the
|
|
48
|
+
* Collections panel shows beside a row so the binding is visible. */
|
|
49
|
+
patternFor(collection: string): Promise<string | null>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DetailRoutesDeps {
|
|
53
|
+
/** Project root (fsPath). */
|
|
54
|
+
root: string;
|
|
55
|
+
/** Astro's route manifest, or null when there is none — then nothing is
|
|
56
|
+
* detected and every answer is empty, never a guess. */
|
|
57
|
+
routeManifest: RouteManifest | null;
|
|
58
|
+
/** Seam for tests. Defaults to reading the file; a throw reads as "names
|
|
59
|
+
* nothing", since a page we cannot read cannot be said to name anything. */
|
|
60
|
+
readSource?: (abs: string) => Promise<string>;
|
|
61
|
+
/** Seam for tests; the mtime a cached scan is keyed on. */
|
|
62
|
+
mtimeOf?: (abs: string) => Promise<number>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A scanner with a per-file cache keyed on mtime.
|
|
67
|
+
*
|
|
68
|
+
* The cache matters because both callers are on hot paths — the resolver runs
|
|
69
|
+
* on every page load, the panel on every open — while page frontmatter changes
|
|
70
|
+
* rarely. Keying on mtime rather than clearing on HMR keeps it correct without
|
|
71
|
+
* this module having to know when Astro re-fires anything.
|
|
72
|
+
*/
|
|
73
|
+
export function createDetailRoutes(deps: DetailRoutesDeps): DetailRoutes {
|
|
74
|
+
const { root, routeManifest } = deps;
|
|
75
|
+
const readSource = deps.readSource ?? ((abs: string) => readFile(abs, 'utf8'));
|
|
76
|
+
const mtimeOf = deps.mtimeOf ?? (async (abs: string) => (await stat(abs)).mtimeMs);
|
|
77
|
+
|
|
78
|
+
const cache = new Map<string, { mtime: number; names: string[] }>();
|
|
79
|
+
|
|
80
|
+
async function collectionsIn(file: string): Promise<string[]> {
|
|
81
|
+
const abs = resolve(root, file);
|
|
82
|
+
let mtime: number;
|
|
83
|
+
try {
|
|
84
|
+
mtime = await mtimeOf(abs);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const hit = cache.get(abs);
|
|
89
|
+
if (hit && hit.mtime === mtime) return hit.names;
|
|
90
|
+
let names: string[];
|
|
91
|
+
try {
|
|
92
|
+
names = collectionsNamedIn(await readSource(abs));
|
|
93
|
+
} catch {
|
|
94
|
+
names = [];
|
|
95
|
+
}
|
|
96
|
+
cache.set(abs, { mtime, names });
|
|
97
|
+
return names;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
collectionsIn,
|
|
102
|
+
|
|
103
|
+
async patternFor(collection) {
|
|
104
|
+
for (const page of routeManifest?.dynamicPages() ?? []) {
|
|
105
|
+
if ((await collectionsIn(page.file)).includes(collection)) return page.pattern;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { AstroIntegrationLogger } from 'astro';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { EntryResolveRefusal, EntryResolveRequest, EntryResolveResponse } from '../shared/protocol.ts';
|
|
4
|
+
import { entryId, inContentRoots, listEntryFiles } from './collection-entries.ts';
|
|
5
|
+
import type { EntryCollectionInfo, EntrySchemaProvider } from './content-config.ts';
|
|
6
|
+
import type { DetailRoutes } from './entry-detect.ts';
|
|
7
|
+
import { ENTRY_EXTENSIONS } from './entry-routes.ts';
|
|
8
|
+
import type { OptionsResolver } from './options.ts';
|
|
9
|
+
import type { RouteManifest } from './route-manifest.ts';
|
|
10
|
+
import type { Route } from './router.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The entry-resolve route group (`/entry/resolve`) — "which content entry backs
|
|
14
|
+
* the page I am looking at?", answered so a project need not emit the
|
|
15
|
+
* `astro-dev-edit:page-source` meta tag by hand.
|
|
16
|
+
*
|
|
17
|
+
* Its own module rather than a corner of `entry-routes.ts`, for the reason
|
|
18
|
+
* `page-source-routes.ts` is its own module: that group's routes all take a
|
|
19
|
+
* **file** and write it, while this one takes a **pathname** and writes nothing.
|
|
20
|
+
* It is resolve-only — the file it names is handed back to the client, which
|
|
21
|
+
* POSTs it to `/entry` like any other, and passes that group's own path gate
|
|
22
|
+
* there. Nothing here opens, reads or writes an entry.
|
|
23
|
+
*
|
|
24
|
+
* **A refusal is an answer.** Every failure path names why, and the client keeps
|
|
25
|
+
* the entry button hidden — exactly the behaviour a page with no meta tag has
|
|
26
|
+
* today. The one refusal that carries data is `not-enabled`: the entry was
|
|
27
|
+
* found and its collection is switched off, so the refusal notice can name what
|
|
28
|
+
* it is offering to switch on.
|
|
29
|
+
*
|
|
30
|
+
* ## Why three layers
|
|
31
|
+
*
|
|
32
|
+
* Matching a URL's tail against entry ids alone would answer, but not safely.
|
|
33
|
+
* Two collections holding a `hello-world.md` are indistinguishable that way, and
|
|
34
|
+
* a dynamic pattern matches far more paths than it generates — the bug behind
|
|
35
|
+
* issue #8, where a dead URL resolved to a real route file. So:
|
|
36
|
+
*
|
|
37
|
+
* 1. **The route manifest** maps the pathname to a page file and a pattern, and
|
|
38
|
+
* a pattern with no dynamic segment is refused outright: a listing route
|
|
39
|
+
* renders a set of entries with no single backing file.
|
|
40
|
+
* 2. **The page file is scanned** for `getCollection('x')`, which binds route
|
|
41
|
+
* to collection from the user's own source rather than by inference.
|
|
42
|
+
* 3. **The pathname's tail is matched** against those collections' entry ids.
|
|
43
|
+
*
|
|
44
|
+
* Layer 3 is what makes a dead URL safe: the id of a path nobody wrote is not a
|
|
45
|
+
* file on disk, so the answer is `no-entry` rather than someone else's entry.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface EntryResolveRouteDeps {
|
|
49
|
+
logger: AstroIntegrationLogger;
|
|
50
|
+
/** Project root (fsPath). */
|
|
51
|
+
root: string;
|
|
52
|
+
/** Live options — `entryEditor` gates the group, `contentRoots` confines every
|
|
53
|
+
* directory read, `editableExtensions` narrows which files count as entries. */
|
|
54
|
+
optionsResolver: OptionsResolver;
|
|
55
|
+
/** Collection lookup. Null → nothing resolves, answered as a refusal. */
|
|
56
|
+
schemaProvider: EntrySchemaProvider | null;
|
|
57
|
+
/** Astro's route manifest, or null when there is none. */
|
|
58
|
+
routeManifest: RouteManifest | null;
|
|
59
|
+
/** The route→collection scan. Null → every enabled collection stays a
|
|
60
|
+
* candidate, and layer 3 alone has to be unique. */
|
|
61
|
+
detailRoutes: DetailRoutes | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A refusal, as the wire carries it. */
|
|
65
|
+
function refuse(
|
|
66
|
+
refusal: EntryResolveRefusal,
|
|
67
|
+
extra: Partial<EntryResolveResponse> = {},
|
|
68
|
+
): { status: 200; body: EntryResolveResponse } {
|
|
69
|
+
return {
|
|
70
|
+
status: 200,
|
|
71
|
+
body: {
|
|
72
|
+
file: null,
|
|
73
|
+
collection: null,
|
|
74
|
+
entryFile: null,
|
|
75
|
+
pageEditingLocked: false,
|
|
76
|
+
pattern: null,
|
|
77
|
+
refusal,
|
|
78
|
+
...extra,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A browser pathname reduced to what an entry id could equal.
|
|
85
|
+
*
|
|
86
|
+
* Only the parts an id can carry: the base is already stripped by the route
|
|
87
|
+
* manifest's own matching, so what is left is percent-decoding, collapsing
|
|
88
|
+
* repeated slashes, and dropping the trailing one. An index route's pathname
|
|
89
|
+
* ends `/`, and after this it is the parent path — which is correct, since an
|
|
90
|
+
* entry named `index` sits at its parent's URL.
|
|
91
|
+
*/
|
|
92
|
+
function normalizePath(pathname: string): string {
|
|
93
|
+
let p = pathname;
|
|
94
|
+
const cut = p.search(/[?#]/);
|
|
95
|
+
if (cut !== -1) p = p.slice(0, cut);
|
|
96
|
+
try {
|
|
97
|
+
p = decodeURI(p);
|
|
98
|
+
} catch {
|
|
99
|
+
/* a malformed escape simply won't match anything */
|
|
100
|
+
}
|
|
101
|
+
p = p.normalize().replace(/\/{2,}/g, '/');
|
|
102
|
+
return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether a route pattern renders one of a set rather than exactly one page. */
|
|
106
|
+
function isDynamic(pattern: string): boolean {
|
|
107
|
+
return pattern.includes('[');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface Candidate {
|
|
111
|
+
collection: string;
|
|
112
|
+
file: string;
|
|
113
|
+
/** Length of the id that matched, so the most specific one can win. */
|
|
114
|
+
idLength: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createEntryResolveRoutes(deps: EntryResolveRouteDeps): Route[] {
|
|
118
|
+
const { logger, root, optionsResolver, schemaProvider, routeManifest, detailRoutes } = deps;
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
method: 'POST',
|
|
123
|
+
path: '/entry/resolve',
|
|
124
|
+
maxBytes: 4 * 1024,
|
|
125
|
+
label: 'entry resolve',
|
|
126
|
+
handler: async (body) => {
|
|
127
|
+
const { options } = await optionsResolver.resolve();
|
|
128
|
+
if (options.entryEditor === false) return refuse('disabled');
|
|
129
|
+
|
|
130
|
+
const { pathname } = (body ?? {}) as EntryResolveRequest;
|
|
131
|
+
if (typeof pathname !== 'string' || pathname === '') {
|
|
132
|
+
throw new Error('pathname is required');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- layer 1: which page renders this URL --------------------------
|
|
136
|
+
const hit = routeManifest?.forPathname(pathname);
|
|
137
|
+
if (!hit) return refuse('no-routes');
|
|
138
|
+
if (!hit.ok) return refuse(hit.refusal === 'no-routes' ? 'no-routes' : 'no-match');
|
|
139
|
+
if (!isDynamic(hit.pattern)) return refuse('not-detail', { pattern: hit.pattern });
|
|
140
|
+
|
|
141
|
+
const all = (await schemaProvider?.listCollections()) ?? [];
|
|
142
|
+
if (all.length === 0) return refuse('no-entry', { pattern: hit.pattern });
|
|
143
|
+
|
|
144
|
+
// --- layer 2: which collection that page renders --------------------
|
|
145
|
+
// A name the scan reports but the project doesn't declare is dropped
|
|
146
|
+
// rather than trusted; finding none leaves every collection a candidate,
|
|
147
|
+
// which is the honest state for a route that fetches through a helper.
|
|
148
|
+
const named = (await detailRoutes?.collectionsIn(hit.file)) ?? [];
|
|
149
|
+
const declared = new Set(all.map((c) => c.collection));
|
|
150
|
+
const scoped = named.filter((n) => declared.has(n));
|
|
151
|
+
const candidates: EntryCollectionInfo[] =
|
|
152
|
+
scoped.length > 0 ? all.filter((c) => scoped.includes(c.collection)) : all;
|
|
153
|
+
|
|
154
|
+
// --- layer 3: which entry ------------------------------------------
|
|
155
|
+
const target = normalizePath(pathname);
|
|
156
|
+
const extensions = ENTRY_EXTENSIONS.filter((e) => options.editableExtensions.includes(e));
|
|
157
|
+
const matches: Candidate[] = [];
|
|
158
|
+
for (const info of candidates) {
|
|
159
|
+
const dirAbs = resolve(root, info.dir);
|
|
160
|
+
// The same confinement `/collection/entries` applies. A collection
|
|
161
|
+
// pointed outside the content roots is not listed, not refused: another
|
|
162
|
+
// collection may still answer.
|
|
163
|
+
if (!inContentRoots(root, dirAbs, options.contentRoots)) continue;
|
|
164
|
+
const { names } = await listEntryFiles(dirAbs, extensions);
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
const id = entryId(name);
|
|
167
|
+
if (target === `/${id}` || target.endsWith(`/${id}`)) {
|
|
168
|
+
matches.push({ collection: info.collection, file: `${info.dir}/${name}`, idLength: id.length });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (matches.length === 0) return refuse('no-entry', { pattern: hit.pattern });
|
|
174
|
+
|
|
175
|
+
// The longest id wins, which is how a nested id (`2026/hello`) beats a
|
|
176
|
+
// leaf of the same name (`hello`) on the URL they share a tail of. A tie
|
|
177
|
+
// is a genuine ambiguity and refuses rather than picking.
|
|
178
|
+
matches.sort((a, b) => b.idLength - a.idLength);
|
|
179
|
+
if (matches.length > 1 && matches[1].idLength === matches[0].idLength) {
|
|
180
|
+
logger.debug(
|
|
181
|
+
`entry resolve: ${pathname} matches ${matches
|
|
182
|
+
.filter((m) => m.idLength === matches[0].idLength)
|
|
183
|
+
.map((m) => m.file)
|
|
184
|
+
.join(', ')}`,
|
|
185
|
+
);
|
|
186
|
+
return refuse('ambiguous', { pattern: hit.pattern });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const best = matches[0];
|
|
190
|
+
const info = candidates.find((c) => c.collection === best.collection);
|
|
191
|
+
if (info?.pageEditing !== true) {
|
|
192
|
+
// Found it, and the user hasn't switched this collection on. Both
|
|
193
|
+
// names travel so the notice can offer exactly this, by name.
|
|
194
|
+
return refuse('not-enabled', {
|
|
195
|
+
collection: best.collection,
|
|
196
|
+
entryFile: best.file,
|
|
197
|
+
// Offering a switch whose write would be refused is worse than not
|
|
198
|
+
// offering it, so the notice is told not to.
|
|
199
|
+
pageEditingLocked:
|
|
200
|
+
optionsResolver.entryEditorConfig()?.collections?.[best.collection]?.pageEditing !==
|
|
201
|
+
undefined,
|
|
202
|
+
pattern: hit.pattern,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const found: EntryResolveResponse = {
|
|
207
|
+
file: best.file,
|
|
208
|
+
collection: best.collection,
|
|
209
|
+
entryFile: best.file,
|
|
210
|
+
pageEditingLocked: false,
|
|
211
|
+
pattern: hit.pattern,
|
|
212
|
+
refusal: null,
|
|
213
|
+
};
|
|
214
|
+
return { status: 200, body: found };
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { directWrite, type TextWriter } from './text-writes.ts';
|
|
2
|
+
import type { AstroIntegrationLogger } from 'astro';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { readdir, readFile, realpath, unlink } from 'node:fs/promises';
|
|
6
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { applyEntryChanges, parseEntry, serializeEntry } from '../patcher/frontmatter.ts';
|
|
8
|
+
import type {
|
|
9
|
+
EntryApplyRequest,
|
|
10
|
+
EntryCreateRequest,
|
|
11
|
+
EntryDeleteRequest,
|
|
12
|
+
EntryRequest,
|
|
13
|
+
FieldDescriptor,
|
|
14
|
+
} from '../shared/protocol.ts';
|
|
15
|
+
import { slugify } from '../shared/slug.ts';
|
|
16
|
+
import type { EntryCollectionInfo, EntrySchemaProvider } from './content-config.ts';
|
|
17
|
+
import type { OptionsResolver } from './options.ts';
|
|
18
|
+
import { validateEditablePath } from './paths.ts';
|
|
19
|
+
import type { Route } from './router.ts';
|
|
20
|
+
import {
|
|
21
|
+
inferFields,
|
|
22
|
+
validateChanges,
|
|
23
|
+
validateFull,
|
|
24
|
+
zodToFields,
|
|
25
|
+
} from './schema-introspect.ts';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The entry-editor route group (/entry, /entry/apply, /entry/create,
|
|
29
|
+
* /entry/delete) — the CMS surface over content-collection entries. A feature
|
|
30
|
+
* route module: it exports a `Route[]` that the middleware concatenates into
|
|
31
|
+
* its table, so this file owns everything entry-specific (etag guards, schema
|
|
32
|
+
* validation, field assembly) and the middleware stays a thin composition
|
|
33
|
+
* point. Localhost rejection and dispatch plumbing stay with the middleware.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface EntryRouteDeps {
|
|
37
|
+
writeText?: TextWriter;
|
|
38
|
+
logger: AstroIntegrationLogger;
|
|
39
|
+
/** Project root (fsPath). Every served path is confined to this. */
|
|
40
|
+
root: string;
|
|
41
|
+
/** Live options — `entryEditor` gates the group, and `contentRoots` /
|
|
42
|
+
* `editableExtensions` are the confinement every path here passes through.
|
|
43
|
+
* Resolved per request so a change from the Settings panel applies without a
|
|
44
|
+
* dev-server restart. */
|
|
45
|
+
optionsResolver: OptionsResolver;
|
|
46
|
+
/** Collection/schema lookup; null → inference only. */
|
|
47
|
+
schemaProvider: EntrySchemaProvider | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extensions the entry editor treats as collection entries. */
|
|
51
|
+
export const ENTRY_EXTENSIONS = ['.md', '.mdx'];
|
|
52
|
+
|
|
53
|
+
function sha256(text: string): string {
|
|
54
|
+
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extension for a newly created entry: the collection's configured one wins;
|
|
58
|
+
* otherwise, when every existing entry in the dir shares one extension, new
|
|
59
|
+
* entries follow it; mixed or empty collections fall back to .md. */
|
|
60
|
+
async function pickEntryExtension(info: EntryCollectionInfo, dirAbs: string): Promise<string> {
|
|
61
|
+
if (info.extension) return info.extension;
|
|
62
|
+
try {
|
|
63
|
+
const files = await readdir(dirAbs, { recursive: true });
|
|
64
|
+
const seen = new Set<string>();
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
const ext = ENTRY_EXTENSIONS.find((e) => String(f).endsWith(e));
|
|
67
|
+
if (ext) seen.add(ext);
|
|
68
|
+
}
|
|
69
|
+
if (seen.size === 1) return [...seen][0];
|
|
70
|
+
} catch {
|
|
71
|
+
// Unreadable dir — the create itself will surface the real error.
|
|
72
|
+
}
|
|
73
|
+
return '.md';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Assemble the panel's field list: schema-derived when possible (with
|
|
77
|
+
* presence marked and extra file keys appended as inferred), else inferred
|
|
78
|
+
* entirely from the entry's values; config overrides applied last. */
|
|
79
|
+
function assembleFields(
|
|
80
|
+
info: EntryCollectionInfo | null,
|
|
81
|
+
data: Record<string, unknown>,
|
|
82
|
+
): FieldDescriptor[] {
|
|
83
|
+
let fields = info?.schema ? zodToFields(info.schema) : null;
|
|
84
|
+
if (fields) {
|
|
85
|
+
for (const f of fields) f.present = f.name in data;
|
|
86
|
+
const extras = Object.fromEntries(
|
|
87
|
+
Object.entries(data).filter(([k]) => !fields!.some((f) => f.name === k)),
|
|
88
|
+
);
|
|
89
|
+
fields = fields.concat(inferFields(extras));
|
|
90
|
+
} else {
|
|
91
|
+
fields = inferFields(data);
|
|
92
|
+
}
|
|
93
|
+
const overrides = info?.fieldConfig ?? {};
|
|
94
|
+
return fields
|
|
95
|
+
.filter((f) => !overrides[f.name]?.hidden)
|
|
96
|
+
.map((f) => {
|
|
97
|
+
const o = overrides[f.name];
|
|
98
|
+
if (!o) return f;
|
|
99
|
+
return { ...f, ...(o.widget ? { type: o.widget } : {}), ...(o.label ? { label: o.label } : {}) };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createEntryRoutes(deps: EntryRouteDeps): Route[] {
|
|
104
|
+
const { logger, root, optionsResolver, schemaProvider } = deps;
|
|
105
|
+
const writeText: TextWriter = deps.writeText ?? directWrite;
|
|
106
|
+
|
|
107
|
+
/** The effective options, plus the entry-specific extension allowlist derived
|
|
108
|
+
* from them: same confinement as edits, but only markdown-family files are
|
|
109
|
+
* collection entries. */
|
|
110
|
+
async function gate(): Promise<{
|
|
111
|
+
contentRoots: string[];
|
|
112
|
+
entryExtensions: string[];
|
|
113
|
+
enabled: boolean;
|
|
114
|
+
}> {
|
|
115
|
+
const { options } = await optionsResolver.resolve();
|
|
116
|
+
return {
|
|
117
|
+
contentRoots: options.contentRoots,
|
|
118
|
+
entryExtensions: ENTRY_EXTENSIONS.filter((e) => options.editableExtensions.includes(e)),
|
|
119
|
+
enabled: options.entryEditor !== false,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function validateEntryPath(file: string): Promise<string> {
|
|
124
|
+
const { contentRoots, entryExtensions, enabled } = await gate();
|
|
125
|
+
if (!enabled) throw new Error('the entry editor is disabled by configuration');
|
|
126
|
+
if (typeof file !== 'string' || !file) throw new Error('file is required');
|
|
127
|
+
return validateEditablePath(root, contentRoots, entryExtensions, file);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Paths coming back from validation are realpath'd; compare against the
|
|
131
|
+
// realpath'd root or symlinked roots (macOS /var → /private/var) mis-relativize.
|
|
132
|
+
let rootRealCache: string | null = null;
|
|
133
|
+
async function relToRoot(abs: string): Promise<string> {
|
|
134
|
+
rootRealCache ??= await realpath(root);
|
|
135
|
+
return relative(rootRealCache, abs).split(sep).join('/');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [
|
|
139
|
+
// Read a collection entry as fields + body.
|
|
140
|
+
{
|
|
141
|
+
method: 'POST',
|
|
142
|
+
path: '/entry',
|
|
143
|
+
maxBytes: 64 * 1024,
|
|
144
|
+
label: 'entry read',
|
|
145
|
+
handler: async (body) => {
|
|
146
|
+
const { file } = body as EntryRequest;
|
|
147
|
+
const abs = await validateEntryPath(file);
|
|
148
|
+
const source = await readFile(abs, 'utf8');
|
|
149
|
+
const parsed = parseEntry(source);
|
|
150
|
+
const rel = await relToRoot(abs);
|
|
151
|
+
const info = (await schemaProvider?.forFile(rel)) ?? null;
|
|
152
|
+
return {
|
|
153
|
+
status: 200,
|
|
154
|
+
body: {
|
|
155
|
+
file: rel,
|
|
156
|
+
etag: sha256(source),
|
|
157
|
+
collection: info?.collection ?? null,
|
|
158
|
+
collectionDir: info?.dir ?? null,
|
|
159
|
+
fields: assembleFields(info, parsed.data),
|
|
160
|
+
values: parsed.data,
|
|
161
|
+
body: parsed.body,
|
|
162
|
+
bodyEditable: true,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// Verified, atomic multi-field write: etag guards against a file that
|
|
169
|
+
// changed since the panel read it; changed keys are validated against the
|
|
170
|
+
// project's own zod schema before anything touches disk.
|
|
171
|
+
{
|
|
172
|
+
method: 'POST',
|
|
173
|
+
path: '/entry/apply',
|
|
174
|
+
maxBytes: 1024 * 1024,
|
|
175
|
+
label: 'entry apply',
|
|
176
|
+
handler: async (body) => {
|
|
177
|
+
const { file, etag, changes } = body as EntryApplyRequest;
|
|
178
|
+
const abs = await validateEntryPath(file);
|
|
179
|
+
if (typeof etag !== 'string' || !etag) throw new Error('etag is required');
|
|
180
|
+
if (!changes || typeof changes !== 'object') throw new Error('changes are required');
|
|
181
|
+
|
|
182
|
+
const source = await readFile(abs, 'utf8');
|
|
183
|
+
if (sha256(source) !== etag) {
|
|
184
|
+
return {
|
|
185
|
+
status: 409,
|
|
186
|
+
body: { error: 'file changed on disk since it was loaded', code: 'conflict' },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const fmChanges = changes.frontmatter ?? {};
|
|
191
|
+
if (Object.keys(fmChanges).length > 0 && schemaProvider) {
|
|
192
|
+
const rel = await relToRoot(abs);
|
|
193
|
+
const info = await schemaProvider.forFile(rel);
|
|
194
|
+
if (info?.schema) {
|
|
195
|
+
const fieldErrors = validateChanges(info.schema, fmChanges);
|
|
196
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
197
|
+
return {
|
|
198
|
+
status: 422,
|
|
199
|
+
body: { error: 'validation failed', code: 'validation', fieldErrors },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const result = applyEntryChanges(source, changes);
|
|
206
|
+
if (!result.ok) {
|
|
207
|
+
return { status: 422, body: { error: result.error, code: 'unsupported' } };
|
|
208
|
+
}
|
|
209
|
+
await writeText(abs, result.newSource, source);
|
|
210
|
+
logger.info(`entry saved -> ${basename(abs)}`);
|
|
211
|
+
return { status: 200, body: { ok: true } };
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
// Create a new entry in a collection's directory. Full-object validation,
|
|
216
|
+
// sanitized slug, never overwrites. Extension: configured per collection,
|
|
217
|
+
// else inferred from existing entries, else .md.
|
|
218
|
+
{
|
|
219
|
+
method: 'POST',
|
|
220
|
+
path: '/entry/create',
|
|
221
|
+
maxBytes: 1024 * 1024,
|
|
222
|
+
label: 'entry create',
|
|
223
|
+
handler: async (body) => {
|
|
224
|
+
const { contentRoots, entryExtensions, enabled } = await gate();
|
|
225
|
+
if (!enabled) throw new Error('the entry editor is disabled by configuration');
|
|
226
|
+
const { collection, slug, frontmatter, body: entryBody } = body as EntryCreateRequest;
|
|
227
|
+
if (!collection || typeof collection !== 'string') throw new Error('collection is required');
|
|
228
|
+
const cleanSlug = slugify(String(slug ?? ''));
|
|
229
|
+
if (!cleanSlug) throw new Error('slug is required');
|
|
230
|
+
if (!frontmatter || typeof frontmatter !== 'object') throw new Error('frontmatter is required');
|
|
231
|
+
|
|
232
|
+
const info = (await schemaProvider?.forCollection(collection)) ?? null;
|
|
233
|
+
if (!info) {
|
|
234
|
+
return { status: 422, body: { error: `unknown collection "${collection}"` } };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Confine the target dir exactly like an edit path would be.
|
|
238
|
+
const dirAbs = resolve(root, info.dir);
|
|
239
|
+
if (!existsSync(dirAbs)) {
|
|
240
|
+
return { status: 422, body: { error: `collection directory ${info.dir} does not exist` } };
|
|
241
|
+
}
|
|
242
|
+
const dirReal = await realpath(dirAbs);
|
|
243
|
+
const relDir = relative(await realpath(root), dirReal);
|
|
244
|
+
const inContentRoot = contentRoots.some(
|
|
245
|
+
(cr) => relDir === cr || relDir.startsWith(cr.endsWith(sep) ? cr : cr + sep),
|
|
246
|
+
);
|
|
247
|
+
if (relDir.startsWith('..') || !inContentRoot) {
|
|
248
|
+
throw new Error('collection directory is outside the editable content roots');
|
|
249
|
+
}
|
|
250
|
+
const ext = await pickEntryExtension(info, dirReal);
|
|
251
|
+
if (!entryExtensions.includes(ext)) {
|
|
252
|
+
return { status: 422, body: { error: `${ext} entries are not editable by configuration` } };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const abs = join(dirReal, `${cleanSlug}${ext}`);
|
|
256
|
+
if (existsSync(abs)) {
|
|
257
|
+
return { status: 409, body: { error: `${cleanSlug}${ext} already exists`, code: 'exists' } };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (info.schema) {
|
|
261
|
+
const fieldErrors = validateFull(info.schema, frontmatter);
|
|
262
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
263
|
+
return {
|
|
264
|
+
status: 422,
|
|
265
|
+
body: { error: 'validation failed', code: 'validation', fieldErrors },
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Drop empty-string values so schema defaults apply instead.
|
|
271
|
+
const values = Object.fromEntries(
|
|
272
|
+
Object.entries(frontmatter).filter(([, v]) => v !== '' && v !== null && v !== undefined),
|
|
273
|
+
);
|
|
274
|
+
await writeText(abs, serializeEntry(values, String(entryBody ?? '')), null);
|
|
275
|
+
const rel = await relToRoot(abs);
|
|
276
|
+
logger.info(`entry created -> ${rel}`);
|
|
277
|
+
return { status: 200, body: { file: rel } };
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
// Delete an entry. Etag-guarded; undo is git.
|
|
282
|
+
{
|
|
283
|
+
method: 'POST',
|
|
284
|
+
path: '/entry/delete',
|
|
285
|
+
maxBytes: 64 * 1024,
|
|
286
|
+
label: 'entry delete',
|
|
287
|
+
handler: async (body) => {
|
|
288
|
+
const { file, etag } = body as EntryDeleteRequest;
|
|
289
|
+
const abs = await validateEntryPath(file);
|
|
290
|
+
if (typeof etag !== 'string' || !etag) throw new Error('etag is required');
|
|
291
|
+
const source = await readFile(abs, 'utf8');
|
|
292
|
+
if (sha256(source) !== etag) {
|
|
293
|
+
return {
|
|
294
|
+
status: 409,
|
|
295
|
+
body: { error: 'file changed on disk since it was loaded', code: 'conflict' },
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
await unlink(abs);
|
|
299
|
+
logger.info(`entry deleted -> ${basename(abs)}`);
|
|
300
|
+
return { status: 200, body: { ok: true } };
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
}
|