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,73 @@
1
+ import { clearHighlight } from '../hover.ts';
2
+ import { trapFocus } from '../focus.ts';
3
+ import * as state from '../state.ts';
4
+ import { buildBackdrop, buildPanel, footButton, inputEl, styled, toast } from '../ui.ts';
5
+ import { mount } from '../shadow.ts';
6
+
7
+ /**
8
+ * Clipboard fallback for the hover pill's `copy ⧉`: the gathered context shown
9
+ * in a read-only, preselected textarea with its own Copy button.
10
+ *
11
+ * Needed because `navigator.clipboard` is not always there to write to. A dev
12
+ * server opened over the network (http://192.168.x.x:4321, for a phone or a
13
+ * second machine) is not a secure context, so the API is simply absent; a
14
+ * permission policy or the lost user-activation after our `await` can also
15
+ * reject the write. The panel's own button is a fresh user gesture, and
16
+ * ⌘A/⌘C works even where every programmatic path is blocked.
17
+ */
18
+
19
+ /** Show the context text for manual copying. `title` names the element. */
20
+ export function openCopyPanel(title: string, text: string): void {
21
+ clearHighlight();
22
+ const panel = buildPanel(`Element context — ${title}`, undefined, { width: 'min(640px, 94vw)' });
23
+ const body = panel.querySelector('[data-body]') as HTMLElement;
24
+
25
+ const note = styled('p', 'atx-copy-note');
26
+ note.textContent =
27
+ 'Your browser would not let the page write to the clipboard — over a network address the dev server is not a secure context. Copy it from here instead:';
28
+
29
+ const area = inputEl('textarea', 'atx-copy-text');
30
+ area.readOnly = true;
31
+ area.value = text;
32
+ body.append(note, area);
33
+
34
+ const close = (): void => {
35
+ state.releaseIf(token);
36
+ releaseFocus();
37
+ panel.remove();
38
+ backdrop.remove();
39
+ };
40
+ const backdrop = buildBackdrop(close);
41
+ const token = state.begin({ kind: 'panel', close });
42
+
43
+ /** Try both paths from a real click: the modern API first, then the legacy
44
+ * command, which is the one that still works in an insecure context. */
45
+ const copyNow = async (): Promise<void> => {
46
+ area.focus();
47
+ area.select();
48
+ try {
49
+ await navigator.clipboard.writeText(text);
50
+ } catch {
51
+ if (!document.execCommand('copy')) {
52
+ toast('Still blocked — select the text and press ⌘C / Ctrl+C', 'err');
53
+ return;
54
+ }
55
+ }
56
+ toast('Copied element context', 'ok');
57
+ close();
58
+ };
59
+
60
+ const foot = panel.querySelector('[data-foot]') as HTMLElement;
61
+ foot.append(
62
+ footButton('Close', 'outline', close),
63
+ footButton('Copy', 'default', () => void copyNow()),
64
+ );
65
+
66
+ mount(backdrop, panel);
67
+ const releaseFocus = trapFocus(panel);
68
+ // Preselected, so ⌘C works the moment the panel opens.
69
+ requestAnimationFrame(() => {
70
+ area.focus();
71
+ area.select();
72
+ });
73
+ }
@@ -0,0 +1,95 @@
1
+ import { trapFocus } from '../focus.ts';
2
+ import * as state from '../state.ts';
3
+ import { buildBackdrop, buildDrawer } from '../ui.ts';
4
+ import { mount } from '../shadow.ts';
5
+
6
+ /**
7
+ * Drawer lifecycle scaffold: shell + backdrop + interaction-state token +
8
+ * dirty-checked close, owned once so every drawer behaves identically. Callers
9
+ * fill `body`/`foot`/`actions` and wire their buttons to `close` (dirty check)
10
+ * or `teardown` (unconditional). ui.ts stays pure DOM; the state coupling
11
+ * lives here.
12
+ */
13
+
14
+ export interface DrawerShell {
15
+ /** Scrollable content slot. */
16
+ body: HTMLElement;
17
+ /** Sticky footer slot. */
18
+ foot: HTMLElement;
19
+ /** Title-bar action slot. */
20
+ actions: HTMLElement;
21
+ /**
22
+ * Dirty-checked close — backdrop click, Escape and Cancel all end up here.
23
+ * Returns false when the user kept the drawer open at the discard prompt, so a
24
+ * caller that meant to *hand off* to another surface can stay put.
25
+ */
26
+ close(): boolean;
27
+ /** Remove the drawer unconditionally (after a successful save/create). */
28
+ teardown(): void;
29
+ }
30
+
31
+ export interface DrawerOpenOptions {
32
+ isDirty(): boolean;
33
+ /** window.confirm prompt shown when closing dirty. */
34
+ discardMessage: string;
35
+ /** CSS width override; see `ui.ts::DrawerOptions`. */
36
+ width?: string;
37
+ /** Second line under the title; see `ui.ts::DrawerOptions`. */
38
+ description?: string;
39
+ /** Chip beside the title; see `ui.ts::DrawerOptions`. */
40
+ badge?: HTMLElement;
41
+ /** Stacking layer. Needed when a drawer opens above something already
42
+ * raised — the Settings drawer reached from the media modal's "no key
43
+ * configured" card. */
44
+ layer?: number;
45
+ /** Run after the drawer is gone, however it closed. Lets the caller that
46
+ * raised it resume — the Unsplash pane re-runs its search once a key
47
+ * exists. */
48
+ onClose?(): void;
49
+ /**
50
+ * Hand the interaction slot back to whatever held it, instead of clearing it.
51
+ * Required when this drawer opened above another modal surface, which would
52
+ * otherwise stop owning the page's clicks once this one closes.
53
+ */
54
+ restoreState?: boolean;
55
+ }
56
+
57
+ export function openDrawer(title: string, opts: DrawerOpenOptions): DrawerShell {
58
+ const drawer = buildDrawer(title, {
59
+ ...(opts.width ? { width: opts.width } : {}),
60
+ ...(opts.description ? { description: opts.description } : {}),
61
+ ...(opts.badge ? { badge: opts.badge } : {}),
62
+ ...(opts.layer !== undefined ? { layer: opts.layer } : {}),
63
+ });
64
+ const body = drawer.querySelector('[data-body]') as HTMLElement;
65
+ const foot = drawer.querySelector('[data-foot]') as HTMLElement;
66
+ const actions = drawer.querySelector('[data-actions]') as HTMLElement;
67
+
68
+ const heldBefore = opts.restoreState ? state.get() : null;
69
+ const teardown = (): void => {
70
+ if (heldBefore) state.releaseTo(token, heldBefore);
71
+ else state.releaseIf(token);
72
+ releaseFocus();
73
+ drawer.remove();
74
+ backdrop.remove();
75
+ opts.onClose?.();
76
+ };
77
+ const close = (): boolean => {
78
+ if (opts.isDirty() && !window.confirm(opts.discardMessage)) {
79
+ // The slot may already be cleared (Escape path goes through dismiss);
80
+ // re-claim it so the drawer stays the active interaction.
81
+ token = state.begin({ kind: 'panel', close });
82
+ return false;
83
+ }
84
+ teardown();
85
+ return true;
86
+ };
87
+ const backdrop = buildBackdrop(close, opts.layer !== undefined ? opts.layer - 1 : undefined);
88
+ let token = state.begin({ kind: 'panel', close });
89
+
90
+ mount(backdrop, drawer);
91
+ // After mounting: a trap focuses its first control, and nothing in a drawer
92
+ // that is not in the document yet can take focus.
93
+ const releaseFocus = trapFocus(drawer);
94
+ return { body, foot, actions, close, teardown };
95
+ }
@@ -0,0 +1,433 @@
1
+ import type { EntryResponse, FieldDescriptor } from '../../shared/protocol.ts';
2
+ import * as api from '../api.ts';
3
+ import { EntryApplyError } from '../api.ts';
4
+ import { slugify } from '../../shared/slug.ts';
5
+ import { pageSource } from '../page-source.ts';
6
+ import * as state from '../state.ts';
7
+ import { basename, footButton, toast } from '../ui.ts';
8
+ import { card, fieldGroup } from '../group.ts';
9
+ import { icon } from '../icons.ts';
10
+ import { buildBodyEditor } from './body-editor.ts';
11
+ import { openDrawer } from './drawer.ts';
12
+ import { applyFieldErrors, buildControl, collectChanges, type FieldControl } from './fields.ts';
13
+
14
+ /**
15
+ * The CMS entry drawers (edit + create): schema-driven forms over a collection
16
+ * entry's frontmatter plus its markdown body. Fields come from the server
17
+ * (/entry) — derived from the project's own zod schema when resolvable,
18
+ * inferred from the file's values otherwise — and render through the field
19
+ * registry in fields.ts; the drawer shell/lifecycle comes from drawer.ts.
20
+ * Saves are atomic and etag-guarded; Astro HMR refreshes the page afterwards.
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Shared pieces
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Where the field list came from, said plainly. The drawer is schema-driven
29
+ * when the project's `content.config.ts` resolves and value-inferred when it
30
+ * does not, and that difference decides whether a missing field is a gap in
31
+ * the file or a gap in what the tool could work out — worth a line rather than
32
+ * something the user infers from which fields happen to be present.
33
+ */
34
+ function fieldsOrigin(fields: readonly FieldDescriptor[]): string {
35
+ return fields.some((f) => f.source === 'schema')
36
+ ? "From the collection's schema."
37
+ : "Inferred from the file's own values.";
38
+ }
39
+
40
+ /** A card holding one run of fields. The drawer body is a stack of these, so
41
+ * every group states what it is instead of running into the next. */
42
+ function fieldCard(title: string, description: string, controls: readonly FieldControl[]): HTMLElement {
43
+ const c = card({ title, description });
44
+ const group = fieldGroup();
45
+ for (const ctl of controls) group.append(ctl.root);
46
+ c.body.append(group);
47
+ return c.root;
48
+ }
49
+
50
+ /** The header's corner action: one size down, and iconned, because it leaves
51
+ * the drawer rather than completing it. */
52
+ function newEntryButton(onClick: () => void): HTMLButtonElement {
53
+ const btn = footButton('New', 'outline', onClick);
54
+ btn.classList.add('atx-btn-sm');
55
+ btn.prepend(icon('plus', 16));
56
+ return btn;
57
+ }
58
+
59
+ /** URL of the listing above the current detail page (…/articles/x → …/articles). */
60
+ function parentPath(): string {
61
+ const p = location.pathname.replace(/\/+$/, '');
62
+ const i = p.lastIndexOf('/');
63
+ return i <= 0 ? '/' : p.slice(0, i);
64
+ }
65
+
66
+ /** URL a sibling entry with `slug` would live at, by route convention. */
67
+ function siblingPath(slug: string): string {
68
+ const parent = parentPath();
69
+ return (parent === '/' ? '' : parent) + '/' + slug;
70
+ }
71
+
72
+ /**
73
+ * Where a just-created entry is waiting to be opened, remembered across the
74
+ * reload that creating it causes.
75
+ *
76
+ * Writing the file makes Astro resync its content layer, which full-reloads the
77
+ * page — the very reload the poll below is waiting for the *result* of. A
78
+ * promise cannot survive that: the document it belongs to is gone, and with it
79
+ * the only record that anyone asked to go anywhere. The same `sessionStorage`
80
+ * trick that carries edit mode across a save carries the destination.
81
+ */
82
+ const NAV_KEY = 'astroDevEditPendingNav';
83
+
84
+ /** How long the poll runs, measured from the create — not from the boot that
85
+ * resumed it, so a reload cannot extend the wait indefinitely. */
86
+ const NAV_BUDGET_MS = 10_000;
87
+
88
+ /** After this a remembered destination is stale — a poll that never finished
89
+ * must not hijack an unrelated visit minutes later. */
90
+ const NAV_TTL_MS = 30_000;
91
+
92
+ function rememberNavigation(url: string | null, at = Date.now()): void {
93
+ try {
94
+ if (url) sessionStorage.setItem(NAV_KEY, JSON.stringify({ url, at }));
95
+ else sessionStorage.removeItem(NAV_KEY);
96
+ } catch {
97
+ // sessionStorage unavailable — the poll just won't survive a reload.
98
+ }
99
+ }
100
+
101
+ function readPendingNavigation(): { url: string; at: number } | null {
102
+ try {
103
+ const raw = sessionStorage.getItem(NAV_KEY);
104
+ if (!raw) return null;
105
+ const { url, at } = JSON.parse(raw) as { url?: string; at?: number };
106
+ if (!url || typeof at !== 'number' || Date.now() - at > NAV_TTL_MS) {
107
+ sessionStorage.removeItem(NAV_KEY);
108
+ return null;
109
+ }
110
+ return { url, at };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ const samePath = (a: string, b: string): boolean =>
117
+ a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
118
+
119
+ /**
120
+ * Pick a create's navigation back up after Astro's reload interrupted it.
121
+ * Called from the overlay's boot; does nothing when nothing is pending, and
122
+ * drops the record when this *is* the page it named.
123
+ */
124
+ export function resumePendingNavigation(): void {
125
+ const pending = readPendingNavigation();
126
+ if (!pending) return;
127
+ if (samePath(location.pathname, pending.url)) {
128
+ rememberNavigation(null);
129
+ return;
130
+ }
131
+ void navigateWhenReady(pending.url, pending.at);
132
+ }
133
+
134
+ /** Navigate to a freshly created route once the content layer has synced it:
135
+ * poll until it stops 404ing, then go. After ~10s give up and navigate
136
+ * anyway, so a non-conventional detail route degrades to a visible 404
137
+ * (reload once the sync lands) instead of stranding the user here. */
138
+ async function navigateWhenReady(url: string, since = Date.now()): Promise<void> {
139
+ rememberNavigation(url, since);
140
+ const deadline = since + NAV_BUDGET_MS;
141
+ while (Date.now() < deadline) {
142
+ if (await api.routeExists(url)) break;
143
+ await new Promise((r) => setTimeout(r, 250));
144
+ }
145
+ // Consumed *before* the jump: arriving must never re-arm the poll, and a
146
+ // give-up landing on a route that still 404s must not loop on it either.
147
+ rememberNavigation(null);
148
+ location.assign(url);
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Edit drawer
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** Fetch an entry and open the edit drawer for it. */
156
+ export async function openEntryPanel(file: string): Promise<void> {
157
+ const busy = state.begin({ kind: 'busy' });
158
+ let entry: EntryResponse;
159
+ try {
160
+ entry = await api.getEntry({ file });
161
+ } catch (err) {
162
+ toast(`Could not load entry — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
163
+ return;
164
+ } finally {
165
+ state.releaseIf(busy);
166
+ }
167
+ showEditDrawer(entry);
168
+ }
169
+
170
+ function showEditDrawer(entry: EntryResponse): void {
171
+ const controls = entry.fields.map((f) =>
172
+ buildControl(f, entry.values[f.name], entry.file),
173
+ );
174
+ const bodyEditor = entry.bodyEditable ? buildBodyEditor(entry.body) : null;
175
+
176
+ const shell = openDrawer('Edit entry', {
177
+ description: entry.file,
178
+ isDirty: () => controls.some((c) => c.dirty()) || (bodyEditor?.dirty() ?? false),
179
+ discardMessage: 'Discard unsaved changes?',
180
+ // The body editor's writing surface is light-DOM (slotted in), so closing
181
+ // the drawer does not take it with it.
182
+ onClose: () => bodyEditor?.destroy(),
183
+ });
184
+
185
+ shell.body.append(fieldCard('Frontmatter', fieldsOrigin(entry.fields), controls));
186
+ if (bodyEditor) {
187
+ const bodyCard = card({ title: 'Body', description: 'Markdown, written straight to the file.' });
188
+ bodyCard.body.append(bodyEditor.root);
189
+ shell.body.append(bodyCard.root);
190
+ }
191
+
192
+ // "New" — only when the file maps to a known collection.
193
+ if (entry.collection) {
194
+ shell.actions.append(
195
+ newEntryButton(() => {
196
+ // Hand off through the dirty gate, not around it: teardown() is
197
+ // unconditional, so a draft in this drawer would go without being
198
+ // asked about — and there is no in-app undo to get it back.
199
+ if (!shell.close()) return;
200
+ showCreateDrawer(entry);
201
+ }),
202
+ );
203
+ }
204
+
205
+ const save = async (): Promise<void> => {
206
+ const changes: { frontmatter?: Record<string, unknown>; body?: string } = {};
207
+ const fm = collectChanges(controls);
208
+ if (Object.keys(fm).length > 0) changes.frontmatter = fm;
209
+ if (bodyEditor?.dirty()) changes.body = bodyEditor.value();
210
+ if (!changes.frontmatter && changes.body === undefined) {
211
+ shell.teardown();
212
+ return;
213
+ }
214
+ saveBtn.disabled = true;
215
+ saveBtn.textContent = 'Saving…';
216
+ try {
217
+ await api.applyEntry({ file: entry.file, etag: entry.etag, changes });
218
+ toast(`Saved ${basename(entry.file)}`, 'ok');
219
+ shell.teardown();
220
+ } catch (err) {
221
+ saveBtn.disabled = false;
222
+ saveBtn.textContent = 'Save';
223
+ if (err instanceof EntryApplyError && err.code === 'validation' && err.fieldErrors) {
224
+ applyFieldErrors(controls, err.fieldErrors);
225
+ toast('Fix the highlighted fields', 'err');
226
+ } else if (err instanceof EntryApplyError && err.code === 'conflict') {
227
+ toast('File changed on disk — reloading its current state', 'err');
228
+ shell.teardown();
229
+ void openEntryPanel(entry.file);
230
+ } else {
231
+ toast(`Save failed — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
232
+ }
233
+ }
234
+ };
235
+
236
+ const del = async (): Promise<void> => {
237
+ if (!window.confirm(`Delete ${basename(entry.file)}? (Undo is git.)`)) return;
238
+ try {
239
+ await api.deleteEntry({ file: entry.file, etag: entry.etag });
240
+ toast(`Deleted ${basename(entry.file)}`, 'ok');
241
+ shell.teardown();
242
+ // This page is about to 404 — land on the listing above it.
243
+ location.assign(parentPath());
244
+ } catch (err) {
245
+ toast(`Delete failed — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
246
+ }
247
+ };
248
+
249
+ const saveBtn = footButton('Save', 'default', () => void save());
250
+ const delBtn = footButton('Delete…', 'destructive', () => void del());
251
+ delBtn.prepend(icon('trash', 16));
252
+ // Delete first in the DOM is what puts it at the far end of the band — see
253
+ // the `:first-child` rule in styles.ts. Cancel is an outline rather than a
254
+ // ghost so the pair the user is choosing between reads as a pair.
255
+ shell.foot.append(delBtn, footButton('Cancel', 'outline', shell.close), saveBtn);
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Create drawer
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * Everything the create drawer needs, independent of a loaded entry — so the
264
+ * collection designer's Items view can open it for a collection the user hasn't
265
+ * navigated to.
266
+ */
267
+ export interface EntrySeed {
268
+ collection: string;
269
+ /** Repo-relative collection dir; relative asset values resolve against it. */
270
+ collectionDir: string | null;
271
+ /** Stand-in path for asset resolution when there is no collection dir. */
272
+ file: string;
273
+ /** Only `source: 'schema'` fields are offered — a new entry has no values to
274
+ * infer from. */
275
+ fields: FieldDescriptor[];
276
+ /**
277
+ * What to do once the file exists, **including saying so** — a hook owns the
278
+ * whole outcome, because two toasts would stack on top of each other.
279
+ *
280
+ * The default navigates to the sibling detail route, which is right when the
281
+ * create started from a rendered page and wrong when it started anywhere
282
+ * else — the Collections tab, or an Items drawer showing another
283
+ * collection's entry. Hence the hook.
284
+ */
285
+ afterCreate?(file: string, slug: string): void;
286
+ }
287
+
288
+ /**
289
+ * Whether a sibling route is a defensible guess for a new entry alongside this
290
+ * one.
291
+ *
292
+ * The default post-create destination is derived from the browser's current
293
+ * path, on the assumption that the entry being created is a sibling of the
294
+ * page you are looking at. That holds only while the drawer is showing the
295
+ * entry that *backs* this page. Opened through Collections → Items it is
296
+ * showing an entry in some other collection entirely, and the "sibling" would
297
+ * be a route in the current page's family — a 404 for a create that fully
298
+ * succeeded, which reads as a failure and sends the author hunting for a file
299
+ * already on disk.
300
+ *
301
+ * A page that declares no backing entry can only have reached this drawer
302
+ * through Items, so a null reading is the same answer.
303
+ */
304
+ function backsCurrentPage(file: string): boolean {
305
+ return pageSource() === file;
306
+ }
307
+
308
+ function showCreateDrawer(entry: EntryResponse): void {
309
+ openEntryCreatePanel({
310
+ collection: entry.collection!,
311
+ collectionDir: entry.collectionDir,
312
+ file: entry.file,
313
+ fields: entry.fields,
314
+ // Same rule the server keeps for Open page source: a destination that
315
+ // cannot be justified is refused, not guessed. Say where the file landed
316
+ // and stay put.
317
+ ...(backsCurrentPage(entry.file)
318
+ ? {}
319
+ : { afterCreate: (file: string) => toast(`Created ${file}`, 'ok') }),
320
+ });
321
+ }
322
+
323
+ /** The create drawer, opened from a seed rather than from a loaded entry. */
324
+ export function openEntryCreatePanel(entry: EntrySeed): void {
325
+ const collection = entry.collection;
326
+
327
+ // Slug first: filename of the new entry, auto-suggested from the title
328
+ // while untouched.
329
+ const slugField: FieldDescriptor = {
330
+ name: 'slug', label: 'Slug (filename)', type: 'text',
331
+ required: true, present: false, source: 'schema',
332
+ };
333
+ const slugControl = buildControl(slugField, '');
334
+ const slugInput = slugControl.root.querySelector('input') as HTMLInputElement;
335
+ slugInput.placeholder = 'my-new-entry';
336
+
337
+ // A new entry has no path yet, but relative asset values only depend on the
338
+ // *directory* it will land in — which is the collection dir the create route
339
+ // writes to. Resolve against a placeholder sibling there.
340
+ const entryFile = entry.collectionDir ? `${entry.collectionDir}/_new.md` : entry.file;
341
+
342
+ // Only schema fields make sense for a brand-new entry. The descriptors
343
+ // describe the entry this drawer was opened from, where a key may well be
344
+ // present; in a file that does not exist yet none of them is, and `present`
345
+ // is what tells a control to offer the schema's default rather than to
346
+ // present its own idle state as a value.
347
+ const controls: FieldControl[] = entry.fields
348
+ .filter((f) => f.source === 'schema' && f.type !== 'json')
349
+ .map((f) => buildControl({ ...f, present: false }, undefined, entryFile));
350
+
351
+ const bodyEditor = buildBodyEditor('');
352
+
353
+ const shell = openDrawer('New entry', {
354
+ description: `in ${collection}`,
355
+ isDirty: () =>
356
+ slugInput.value !== '' || bodyEditor.dirty() || controls.some((c) => c.dirty()),
357
+ discardMessage: 'Discard this new entry?',
358
+ onClose: () => bodyEditor.destroy(),
359
+ });
360
+
361
+ // Three concerns, three cards. The slug is not frontmatter — it is the
362
+ // filename, and therefore the URL — so it gets said separately rather than
363
+ // sitting at the top of the field list looking like a key.
364
+ shell.body.append(
365
+ fieldCard('File', 'The filename, and the path it will be served at.', [slugControl]),
366
+ fieldCard('Frontmatter', fieldsOrigin(entry.fields), controls),
367
+ );
368
+ const bodyCard = card({ title: 'Body', description: 'Markdown, written straight to the file.' });
369
+ bodyCard.body.append(bodyEditor.root);
370
+ shell.body.append(bodyCard.root);
371
+
372
+ let slugTouched = false;
373
+ slugInput.addEventListener('input', () => (slugTouched = true));
374
+ const titleControl = controls.find((c) => c.field.name === 'title');
375
+ if (titleControl) {
376
+ const titleInput = titleControl.root.querySelector('input, textarea') as HTMLInputElement | null;
377
+ titleInput?.addEventListener('input', () => {
378
+ // The server re-runs the same slugify as the authority on create.
379
+ if (!slugTouched) slugInput.value = slugify(titleInput.value);
380
+ });
381
+ }
382
+
383
+ const create = async (): Promise<void> => {
384
+ const slug = slugInput.value.trim();
385
+ if (!slug) {
386
+ slugControl.setError('required');
387
+ return;
388
+ }
389
+ slugControl.setError(null);
390
+ const frontmatter: Record<string, unknown> = {};
391
+ for (const c of controls) {
392
+ // Only what was actually filled in. An untouched control has no value to
393
+ // contribute — it has an idle state, which is not the same thing, and
394
+ // writing it would override the schema's own default. A checkbox is
395
+ // where that bites: nobody chose Off, the box simply starts empty, and
396
+ // `published: false` in the file beats `.default(true)` in the schema.
397
+ if (!c.dirty()) continue;
398
+ const v = c.value();
399
+ const empty = v === '' || v === undefined || (Array.isArray(v) && v.length === 0);
400
+ if (!empty) frontmatter[c.field.name] = v;
401
+ }
402
+ createBtn.disabled = true;
403
+ createBtn.textContent = 'Creating…';
404
+ try {
405
+ const { file } = await api.createEntry({ collection, slug, frontmatter, body: bodyEditor.value() });
406
+ shell.teardown();
407
+ if (entry.afterCreate) {
408
+ entry.afterCreate(file, slug);
409
+ } else {
410
+ toast(`Created ${basename(file)}`, 'ok');
411
+ // Detail routes are conventionally siblings of the current page; the
412
+ // fresh route 404s until Astro's content layer syncs the new file.
413
+ void navigateWhenReady(siblingPath(slug));
414
+ }
415
+ } catch (err) {
416
+ createBtn.disabled = false;
417
+ createBtn.textContent = 'Create';
418
+ if (err instanceof EntryApplyError && err.code === 'validation' && err.fieldErrors) {
419
+ applyFieldErrors(controls, err.fieldErrors);
420
+ toast('Fix the highlighted fields', 'err');
421
+ } else if (err instanceof EntryApplyError && err.code === 'exists') {
422
+ slugControl.setError(err.message);
423
+ toast('That slug is taken', 'err');
424
+ } else {
425
+ toast(`Create failed — ${err instanceof Error ? err.message : 'unknown error'}`, 'err');
426
+ }
427
+ }
428
+ };
429
+
430
+ const createBtn = footButton('Create', 'default', () => void create());
431
+ shell.foot.append(footButton('Cancel', 'outline', shell.close), createBtn);
432
+ slugInput.focus();
433
+ }
@@ -0,0 +1,77 @@
1
+ import type { SourceLoc } from '../../shared/protocol.ts';
2
+ import * as api from '../api.ts';
3
+ import { clearHighlight } from '../hover.ts';
4
+ import * as state from '../state.ts';
5
+ import { basename, lockElement, toast } from '../ui.ts';
6
+ import { openSourcePopup } from './source-popup.ts';
7
+
8
+ /**
9
+ * Value popup: for text the page renders through an `{expression}` — a
10
+ * frontmatter const, or one item of an array a `.map()` loops over. The words
11
+ * are edited here and written back to that string in the frontmatter; the
12
+ * template itself is never touched.
13
+ *
14
+ * Deliberately a popup rather than inline editing, and deliberately titled
15
+ * with where the value lives (`benefits[].title`): what you are changing is a
16
+ * constant that may be rendered in more than one place, which is worth knowing
17
+ * before you type.
18
+ *
19
+ * **Plain text only, no markup palette.** `{value}` renders escaped in Astro,
20
+ * so a `<br>` typed here would show as visible punctuation rather than a line
21
+ * break. Offering the palette would promise something the template can't do.
22
+ *
23
+ * The text sent is the text the page showed, and for a loop it is the *only*
24
+ * thing that says which item was clicked — every card shares one source loc.
25
+ * Two items reading the same way refuse rather than guess.
26
+ */
27
+
28
+ export function beginExpressionEdit(
29
+ el: HTMLElement,
30
+ src: SourceLoc,
31
+ info: { property: string; label: string },
32
+ openSource: (src: SourceLoc) => void,
33
+ ): void {
34
+ clearHighlight();
35
+ const original = el.textContent ?? '';
36
+ openSourcePopup({
37
+ title: `Value · ${info.label}`,
38
+ label: `Text of ${info.property}, in ${basename(src.file)}`,
39
+ value: original,
40
+ minHeight: '90px',
41
+ // The element's own loc: that is where the {expression} sits, which is the
42
+ // way in to both the loop and the const it reads.
43
+ openSource: () => openSource(src),
44
+ save: (value) => commitExpressionEdit(el, src, original, value),
45
+ });
46
+ }
47
+
48
+ /** Writes the edit. Resolves to null on success, or the refusal message. */
49
+ async function commitExpressionEdit(
50
+ el: HTMLElement,
51
+ src: SourceLoc,
52
+ original: string,
53
+ newText: string,
54
+ ): Promise<string | null> {
55
+ const busy = state.begin({ kind: 'busy' });
56
+ const release = lockElement(el);
57
+ state.setSavePhase('saving');
58
+ try {
59
+ await api.apply({
60
+ file: src.file,
61
+ loc: src.loc,
62
+ tag: el.tagName.toLowerCase(),
63
+ ops: [{ targetType: 'expression', original, newText }],
64
+ });
65
+ state.setSavePhase('saved');
66
+ toast(`Saved — ${basename(src.file)}`, 'ok');
67
+ // HMR re-renders from the frontmatter; every place that value appears
68
+ // updates with it, which is exactly why the popup names where it lives.
69
+ return null;
70
+ } catch (err) {
71
+ state.setSavePhase('error');
72
+ return err instanceof Error ? err.message : 'The edit could not be saved.';
73
+ } finally {
74
+ release();
75
+ state.releaseIf(busy);
76
+ }
77
+ }