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