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