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,160 @@
1
+ import type { SourceLoc } from '../../shared/protocol.ts';
2
+ import { clearHighlight } from '../hover.ts';
3
+ import { pageEntryInfo, pageSource, resolvePageSource } from '../page-source.ts';
4
+ import { trapFocus } from '../focus.ts';
5
+ import * as state from '../state.ts';
6
+ import { basename, buildBackdrop, buildPanel, styled, toast, wirePanelButtons } from '../ui.ts';
7
+ import * as api from '../api.ts';
8
+ import { openEntryPanel } from './entry.ts';
9
+ import { mount } from '../shadow.ts';
10
+
11
+ /**
12
+ * Refusal notice for content that can't be edited in place (expressions,
13
+ * nested markup, components). Offers "Open source" — and, when the page
14
+ * declares a backing content file, a primary "Edit page content" action that
15
+ * opens the CMS entry drawer for it.
16
+ *
17
+ * There is a third case between those two, and it is the one worth explaining.
18
+ * The server can often name the entry backing a page whose collection the user
19
+ * has **not** switched on — it knows the file, it knows the collection, and the
20
+ * only thing missing is permission. Saying nothing there would be the old
21
+ * silence this feature exists to end: you clicked text that plainly comes from
22
+ * somewhere, and the tool knows where. So the notice offers the switch by name.
23
+ */
24
+
25
+ export interface NoticeOptions {
26
+ /**
27
+ * The tag the pointer actually landed on, when that element carries no
28
+ * source annotation and the loc below therefore belongs to an **ancestor**.
29
+ *
30
+ * Astro annotates only elements written in the file, so a `<Button>`'s
31
+ * rendered `<a>` and every element slotted in from MDX have none of their
32
+ * own, and the router classifies the nearest annotated ancestor instead. The
33
+ * refusal is right either way — none of it is editable here — but the
34
+ * *reason* then describes a different element than the one that was clicked,
35
+ * which reads as a puzzle: "contains nested markup" over a button that
36
+ * plainly holds one word. Naming the substitution is the whole fix.
37
+ */
38
+ clickedTag?: string;
39
+ }
40
+
41
+ export function showDynamicNotice(
42
+ src: SourceLoc,
43
+ reason: string,
44
+ openSource: (src: SourceLoc) => void,
45
+ openPeek: (src: SourceLoc) => void,
46
+ opts: NoticeOptions = {},
47
+ ): void {
48
+ clearHighlight();
49
+ const panel = buildPanel('Can’t edit this here');
50
+ const body = panel.querySelector('[data-body]') as HTMLElement;
51
+
52
+ if (opts.clickedTag) {
53
+ const lead = styled('p', 'atx-notice-lead');
54
+ lead.textContent =
55
+ `The <${opts.clickedTag}> you clicked isn’t written in this file — a component or ` +
56
+ 'slotted content rendered it. The nearest element that is written here is the one ' +
57
+ 'described below.';
58
+ body.append(lead);
59
+ }
60
+
61
+ const msg = styled('p', 'atx-notice-reason');
62
+ msg.textContent = reason;
63
+
64
+ // The location line opens the in-browser source peek — often all that's
65
+ // needed to see *why* this content refused, without leaving the page.
66
+ const where = styled('p', 'atx-notice-loc');
67
+ where.textContent = `${basename(src.file)}:${src.loc}`;
68
+ where.title = 'Peek at the source code';
69
+ where.addEventListener('click', () => {
70
+ close();
71
+ openPeek(src);
72
+ });
73
+
74
+ body.append(msg, where);
75
+
76
+ // On a detail page whose content lives in a markdown/MDX file, that file is
77
+ // almost always the *right* place to edit this text — not the template line
78
+ // the source loc points at. Offer a direct jump to it. (spec §16.2)
79
+ const contentFile = pageSource();
80
+ if (contentFile) {
81
+ const hint = styled('p', 'atx-notice-hint');
82
+ hint.textContent = `This page's content comes from ${basename(contentFile)} — that's where its title and body text are edited.`;
83
+ body.append(hint);
84
+ }
85
+
86
+ // Found the entry, but its collection is switched off — and not by the config,
87
+ // which would make the offer a button that refuses.
88
+ const found = pageEntryInfo();
89
+ const offer =
90
+ !contentFile &&
91
+ found?.refusal === 'not-enabled' &&
92
+ found.collection &&
93
+ found.entryFile &&
94
+ !found.pageEditingLocked
95
+ ? { collection: found.collection, file: found.entryFile }
96
+ : null;
97
+ if (offer) {
98
+ const hint = styled('p', 'atx-notice-hint');
99
+ hint.textContent =
100
+ `This page's content is in ${basename(offer.file)}, from the ${offer.collection} ` +
101
+ 'collection. Page editing is off for it — switch it on and this text is editable ' +
102
+ 'as form fields.';
103
+ body.append(hint);
104
+ }
105
+
106
+ /** Switch the collection on, then open the drawer the user was after. The
107
+ * re-resolve is what puts Edit entry in the admin bar, through the
108
+ * page-source subscription. */
109
+ const enableAndEdit = async (collection: string, file: string): Promise<void> => {
110
+ try {
111
+ await api.setCollectionPageEditing({ collection, enabled: true });
112
+ } catch (err) {
113
+ toast(err instanceof Error ? err.message : 'Could not switch page editing on', 'err');
114
+ return;
115
+ }
116
+ await resolvePageSource();
117
+ toast(`Page editing on for ${collection}`, 'ok');
118
+ void openEntryPanel(file);
119
+ };
120
+
121
+ const close = (): void => {
122
+ state.releaseIf(token);
123
+ releaseFocus();
124
+ panel.remove();
125
+ backdrop.remove();
126
+ };
127
+ const backdrop = buildBackdrop(close);
128
+ const token = state.begin({ kind: 'panel', close });
129
+
130
+ // cancel = close, "Open template" = jump to the .astro loc, and (when the
131
+ // page declares a content file) a primary "Edit page content" that opens
132
+ // the entry drawer for it. The drawer claims the state slot itself, so the
133
+ // notice just closes first.
134
+ wirePanelButtons(
135
+ panel,
136
+ close,
137
+ () => {
138
+ close();
139
+ if (contentFile) {
140
+ void openEntryPanel(contentFile);
141
+ } else if (offer) {
142
+ void enableAndEdit(offer.collection, offer.file);
143
+ } else {
144
+ openSource(src);
145
+ }
146
+ },
147
+ contentFile || offer
148
+ ? {
149
+ confirmLabel: contentFile ? 'Edit page content' : `Turn on for ${offer!.collection}`,
150
+ secondaryLabel: 'Open template',
151
+ onSecondary: () => {
152
+ openSource(src);
153
+ close();
154
+ },
155
+ }
156
+ : { confirmLabel: 'Open source' },
157
+ );
158
+ mount(backdrop, panel);
159
+ const releaseFocus = trapFocus(panel);
160
+ }
@@ -0,0 +1,135 @@
1
+ import type { PeekResponse, SourceLoc } from '../../shared/protocol.ts';
2
+ import * as api from '../api.ts';
3
+ import { tokenizeLines } from '../highlight.ts';
4
+ import { clearHighlight } from '../hover.ts';
5
+ import { trapFocus } from '../focus.ts';
6
+ import * as state from '../state.ts';
7
+ import { basename, buildBackdrop, buildPanel, footButton, isolateScroll, styled } from '../ui.ts';
8
+ import { mount } from '../shadow.ts';
9
+
10
+ /**
11
+ * Source-peek panel: a read-only, syntax-tinted view of the source file,
12
+ * scrolled to the element's line — "what does this element look like in the
13
+ * file?" without leaving the browser. The /peek endpoint sends the whole file
14
+ * (a "⋯ N more lines" marker appears only when its huge-file cap trimmed the
15
+ * ends), so full context is a scroll away. Opened from the hover pill's
16
+ * file:loc label and the refusal notice's location line; the "Open in editor"
17
+ * button in its footer is the jump-out.
18
+ */
19
+
20
+ /** A muted "⋯ N more lines" marker row for content the huge-file cap cut. */
21
+ function moreRow(count: number, where: 'above' | 'below'): HTMLElement {
22
+ const row = styled('div', 'atx-peek-more');
23
+ row.textContent = `⋯ ${count} more line${count === 1 ? '' : 's'} ${where}`;
24
+ return row;
25
+ }
26
+
27
+ /** Render the fetched lines as gutter-numbered, token-tinted rows. Returns
28
+ * the scroll container and the focused row (for centering). */
29
+ function renderCode(peeked: PeekResponse): { container: HTMLElement; focusRow: HTMLElement | null } {
30
+ const container = styled('div', 'atx-peek-code');
31
+ // The peek is the tallest scroller in the overlay and the one most likely to
32
+ // be opened on a page driving its own scroll (Lenis and friends).
33
+ isolateScroll(container);
34
+
35
+ const gutterWidth = `${String(peeked.startLine + peeked.lines.length - 1).length + 1}ch`;
36
+ const tokenized = tokenizeLines(peeked.lines);
37
+ let focusRow: HTMLElement | null = null;
38
+
39
+ if (peeked.startLine > 1) container.append(moreRow(peeked.startLine - 1, 'above'));
40
+
41
+ tokenized.forEach((tokens, idx) => {
42
+ const lineNo = peeked.startLine + idx;
43
+ const isFocus = lineNo === peeked.focusLine;
44
+ const row = styled('div', `atx-peek-line${isFocus ? ' atx-peek-focus' : ''}`);
45
+ // The gutter's width is the widest line number in this file, so it is the
46
+ // one thing about a row a stylesheet cannot know.
47
+ const gutter = styled('span', 'atx-peek-gutter', { width: gutterWidth });
48
+ gutter.textContent = String(lineNo);
49
+ const code = styled('span', 'atx-peek-text');
50
+ for (const token of tokens) {
51
+ const span = styled('span', 'atx-peek-token');
52
+ span.dataset.token = token.kind;
53
+ span.textContent = token.text;
54
+ code.append(span);
55
+ }
56
+ if (tokens.length === 0) code.textContent = ' '; // keep empty lines their height
57
+ row.append(gutter, code);
58
+ container.append(row);
59
+ if (isFocus) focusRow = row;
60
+ });
61
+
62
+ const below = peeked.totalLines - (peeked.startLine + peeked.lines.length - 1);
63
+ if (below > 0) container.append(moreRow(below, 'below'));
64
+
65
+ return { container, focusRow };
66
+ }
67
+
68
+ /** Open the peek panel for a source loc. `openSource` powers the footer's
69
+ * "Open in editor" jump-out. */
70
+ export function openPeekPanel(src: SourceLoc, openSource: (src: SourceLoc) => void): void {
71
+ clearHighlight();
72
+ // Code wants room: much wider than the default 420px panel, and the code
73
+ // area runs edge-to-edge (its own padding) instead of the body's 16px.
74
+ const panel = buildPanel(`${basename(src.file)}:${src.loc}`, undefined, {
75
+ width: 'min(780px, 94vw)',
76
+ });
77
+ const body = panel.querySelector('[data-body]') as HTMLElement;
78
+ body.toggleAttribute('data-flush', true);
79
+
80
+ const loading = styled('div', 'atx-peek-loading');
81
+ loading.textContent = 'Loading source…';
82
+ body.append(loading);
83
+
84
+ let closed = false;
85
+ const close = (): void => {
86
+ closed = true;
87
+ state.releaseIf(token);
88
+ releaseFocus();
89
+ panel.remove();
90
+ backdrop.remove();
91
+ };
92
+ const backdrop = buildBackdrop(close);
93
+ const token = state.begin({ kind: 'panel', close });
94
+
95
+ const foot = panel.querySelector('[data-foot]') as HTMLElement;
96
+ foot.append(
97
+ footButton('Close', 'outline', close),
98
+ footButton('Open in editor', 'default', () => {
99
+ close();
100
+ openSource(src);
101
+ }),
102
+ );
103
+
104
+ mount(backdrop, panel);
105
+ const releaseFocus = trapFocus(panel);
106
+
107
+ void (async () => {
108
+ try {
109
+ const peeked = await api.peek({ file: src.file, loc: src.loc });
110
+ if (closed) return;
111
+ // Not an error: the file is real but package-owned or out of the content
112
+ // roots, so the server explains instead of returning source. Show the
113
+ // sentence rather than an empty code pane. (Reached by clicking the
114
+ // hover pill on an `astro:assets` <Image>.)
115
+ if (peeked.refused) {
116
+ loading.textContent = peeked.refused;
117
+ loading.dataset.tone = 'warn';
118
+ return;
119
+ }
120
+ const { container, focusRow } = renderCode(peeked);
121
+ loading.replaceWith(container);
122
+ // Center the focused line in the scroll window once it has a layout.
123
+ if (focusRow) {
124
+ requestAnimationFrame(() => {
125
+ container.scrollTop =
126
+ focusRow.offsetTop - container.clientHeight / 2 + focusRow.clientHeight / 2;
127
+ });
128
+ }
129
+ } catch (err) {
130
+ if (closed) return;
131
+ loading.textContent = `Could not load source — ${err instanceof Error ? err.message : 'unknown error'}`;
132
+ loading.dataset.tone = 'err';
133
+ }
134
+ })();
135
+ }