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,308 @@
|
|
|
1
|
+
import type { ClassifyResult, PeekResponse, SourceLoc } from '../shared/protocol.ts';
|
|
2
|
+
import * as api from './api.ts';
|
|
3
|
+
import { classifyCached } from './classify-cache.ts';
|
|
4
|
+
import { narrowRules, rulesForElement, type MatchedRule } from './css-inspect.ts';
|
|
5
|
+
import { pageSource } from './page-source.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* "Everything we know about this element", assembled into one markdown block to
|
|
9
|
+
* paste into an AI assistant — what the hover pill's `copy ⧉` button produces.
|
|
10
|
+
*
|
|
11
|
+
* The knowledge is deliberately scattered and has to be gathered from three
|
|
12
|
+
* places, none of which can answer for the others:
|
|
13
|
+
* - the **source loc** comes from source-map.ts's snapshot cache (Astro's dev
|
|
14
|
+
* toolbar strips the annotations out of the live DOM, so it can't be read
|
|
15
|
+
* from the element),
|
|
16
|
+
* - the **CSS** comes from the browser's own CSSOM (which knows what applies
|
|
17
|
+
* but not where it was authored), and
|
|
18
|
+
* - the **source text** is only on disk, so it needs the /peek round-trip.
|
|
19
|
+
* Any of the three may come up empty; a section is then dropped or replaced
|
|
20
|
+
* with the reason, and the copy still happens. Never fail the whole payload
|
|
21
|
+
* because one part degraded.
|
|
22
|
+
*
|
|
23
|
+
* Split in two on purpose: `formatContext` is pure (an ElementContext in, a
|
|
24
|
+
* string out) and unit-tested, while `collectContext` is the DOM + fetch half
|
|
25
|
+
* that only the browser can run.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Cap on the copied outerHTML. Big enough for a real component, small enough
|
|
29
|
+
* that hovering a page wrapper doesn't paste a whole document. */
|
|
30
|
+
const HTML_MAX = 4000;
|
|
31
|
+
/** Cap on copied CSS rules. A heavily-styled element can match dozens, most of
|
|
32
|
+
* them site-wide defaults it merely happens to match — `narrowRules` drops
|
|
33
|
+
* those first, so what survives the cap is what names this element. */
|
|
34
|
+
const RULES_MAX = 12;
|
|
35
|
+
/** Source lines kept either side of the element's own line. /peek returns the
|
|
36
|
+
* whole file (its own cap is ~1000 lines each way); this is the paste-sized
|
|
37
|
+
* window cut out of it.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately tight. The payload's job is to identify *one* element, and a
|
|
40
|
+
* wide window buries it: at ±30 a one-line `<a>` on line 30 of a 107-line file
|
|
41
|
+
* quoted more than half the file, comment blocks and unrelated arrays
|
|
42
|
+
* included, with the three lines that matter near the top. What the window
|
|
43
|
+
* leaves out is named on the section heading, and the whole file is one
|
|
44
|
+
* `/peek` away. */
|
|
45
|
+
const SOURCE_CONTEXT = 5;
|
|
46
|
+
/** Deepest DOM-path segments kept, counting from the element itself. */
|
|
47
|
+
const PATH_MAX = 8;
|
|
48
|
+
|
|
49
|
+
/** The source window actually quoted in the payload. */
|
|
50
|
+
export interface SourceWindow {
|
|
51
|
+
file: string;
|
|
52
|
+
/** 1-based line number of `lines[0]`. */
|
|
53
|
+
startLine: number;
|
|
54
|
+
/** 1-based line the element sits on — marked in the quote. */
|
|
55
|
+
focusLine: number;
|
|
56
|
+
/** Lines in the whole file, so the payload can say what it left out. */
|
|
57
|
+
totalLines: number;
|
|
58
|
+
lines: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Everything the overlay knows about one element, before formatting. */
|
|
62
|
+
export interface ElementContext {
|
|
63
|
+
loc: SourceLoc;
|
|
64
|
+
/** The opening tag as rendered, e.g. `<h1 class="hero-title">`. */
|
|
65
|
+
openTag: string;
|
|
66
|
+
/** Short label for toasts/titles, e.g. `h1.hero-title`. */
|
|
67
|
+
label: string;
|
|
68
|
+
/** What a click would do, per the server's AST classification; null when it
|
|
69
|
+
* couldn't be determined. Deliberately *not* rendered into the copied
|
|
70
|
+
* markdown: it describes what this overlay can edit, not the element, and
|
|
71
|
+
* an LLM reads it as a constraint on what it may change. */
|
|
72
|
+
verdict: string | null;
|
|
73
|
+
pageUrl: string;
|
|
74
|
+
/** Content-collection file backing the page, when it declares one. */
|
|
75
|
+
entryFile: string | null;
|
|
76
|
+
domPath: string;
|
|
77
|
+
html: string;
|
|
78
|
+
/** Characters the cap dropped from `html`; 0 when it's complete. */
|
|
79
|
+
htmlDropped: number;
|
|
80
|
+
rules: MatchedRule[];
|
|
81
|
+
/** Rules the cap dropped; 0 when all matched rules are present. */
|
|
82
|
+
rulesDropped: number;
|
|
83
|
+
source: SourceWindow | null;
|
|
84
|
+
/** Why `source` is null (a server refusal or a failed read), when it is. */
|
|
85
|
+
sourceUnavailable: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Formatting (pure) -------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Fence language for the source quote, by extension. */
|
|
91
|
+
function fenceLang(file: string): string {
|
|
92
|
+
const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
|
|
93
|
+
if (ext === 'astro') return 'astro';
|
|
94
|
+
if (ext === 'md' || ext === 'mdx' || ext === 'markdown') return 'markdown';
|
|
95
|
+
if (ext === 'html') return 'html';
|
|
96
|
+
if (ext === 'ts' || ext === 'tsx') return 'ts';
|
|
97
|
+
if (ext === 'js' || ext === 'jsx') return 'js';
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `>` gutter-marks the element's own line, so the model knows which of the
|
|
102
|
+
* quoted lines is the subject without a marker polluting the code text. */
|
|
103
|
+
function quoteSource(source: SourceWindow): string {
|
|
104
|
+
const last = source.startLine + source.lines.length - 1;
|
|
105
|
+
const width = String(last).length;
|
|
106
|
+
return source.lines
|
|
107
|
+
.map((line, i) => {
|
|
108
|
+
const no = source.startLine + i;
|
|
109
|
+
const mark = no === source.focusLine ? '>' : ' ';
|
|
110
|
+
return `${mark} ${String(no).padStart(width)} | ${line}`;
|
|
111
|
+
})
|
|
112
|
+
.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ruleBlock(rule: MatchedRule): string {
|
|
116
|
+
const body = rule.declarations
|
|
117
|
+
.split('\n')
|
|
118
|
+
.map((d) => ` ${d}`)
|
|
119
|
+
.join('\n');
|
|
120
|
+
const from = rule.sourceFile ? `/* ${rule.sourceFile} */\n` : '';
|
|
121
|
+
return `${from}${rule.selectorText} {\n${body}\n}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Render an ElementContext as the markdown that lands on the clipboard.
|
|
125
|
+
* Every section is optional — an element with no classes has no CSS block, a
|
|
126
|
+
* refused /peek has no source block. */
|
|
127
|
+
export function formatContext(ctx: ElementContext): string {
|
|
128
|
+
const where = `${ctx.loc.file}:${ctx.loc.loc}`;
|
|
129
|
+
const out: string[] = [`# Element context — ${where}`, ''];
|
|
130
|
+
|
|
131
|
+
out.push(`- **Element** \`${ctx.openTag}\``);
|
|
132
|
+
out.push(`- **Source** ${where}`);
|
|
133
|
+
out.push(`- **Page** ${ctx.pageUrl}`);
|
|
134
|
+
if (ctx.entryFile) out.push(`- **Content entry** ${ctx.entryFile}`);
|
|
135
|
+
out.push(`- **DOM path** ${ctx.domPath}`, '');
|
|
136
|
+
|
|
137
|
+
out.push('## Rendered HTML', '```html', ctx.html, '```');
|
|
138
|
+
if (ctx.htmlDropped > 0) {
|
|
139
|
+
out.push(`_Truncated — ${ctx.htmlDropped} more characters of markup._`);
|
|
140
|
+
}
|
|
141
|
+
out.push('');
|
|
142
|
+
|
|
143
|
+
if (ctx.source) {
|
|
144
|
+
const { startLine, lines, totalLines, file } = ctx.source;
|
|
145
|
+
const last = startLine + lines.length - 1;
|
|
146
|
+
const range = lines.length === totalLines ? `all ${totalLines} lines` : `lines ${startLine}–${last} of ${totalLines}`;
|
|
147
|
+
out.push(`## Source — ${file} (${range}, \`>\` marks the element)`);
|
|
148
|
+
const lang = fenceLang(file);
|
|
149
|
+
out.push(`\`\`\`${lang}`, quoteSource(ctx.source), '```', '');
|
|
150
|
+
} else if (ctx.sourceUnavailable) {
|
|
151
|
+
out.push('## Source', `_Not available — ${ctx.sourceUnavailable}_`, '');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (ctx.rules.length > 0) {
|
|
155
|
+
const shown = ctx.rules.length;
|
|
156
|
+
const total = shown + ctx.rulesDropped;
|
|
157
|
+
const count = ctx.rulesDropped > 0 ? `${shown} of ${total} rules` : `${shown} rule${shown === 1 ? '' : 's'}`;
|
|
158
|
+
out.push(`## CSS that applies (${count})`, '```css');
|
|
159
|
+
out.push(ctx.rules.map(ruleBlock).join('\n\n'));
|
|
160
|
+
out.push('```');
|
|
161
|
+
if (ctx.rulesDropped > 0) out.push(`_Truncated — ${ctx.rulesDropped} further matching rules._`);
|
|
162
|
+
out.push('');
|
|
163
|
+
} else {
|
|
164
|
+
out.push('## CSS that applies', '_No stylesheet rule matches this element directly (it may inherit from an ancestor, or its stylesheets are cross-origin)._', '');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return `${out.join('\n').trimEnd()}\n`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Source paths repo-relative, the shape a person (or an assistant) can act on.
|
|
172
|
+
*
|
|
173
|
+
* Astro's `data-astro-source-file` annotations are absolute fsPaths — the pill
|
|
174
|
+
* only ever showed their basename, so it never mattered before, but
|
|
175
|
+
* "/Users/you/projects/site/src/pages/index.astro:12:3" is noise in a paste.
|
|
176
|
+
* `root` comes from /health; without it the path is left as-is rather than
|
|
177
|
+
* guessed at.
|
|
178
|
+
*/
|
|
179
|
+
export function relativize(file: string, root: string | null): string {
|
|
180
|
+
const path = file.replace(/\\/g, '/');
|
|
181
|
+
if (!root) return path;
|
|
182
|
+
const base = root.replace(/\\/g, '/').replace(/\/+$/, '') + '/';
|
|
183
|
+
return path.startsWith(base) ? path.slice(base.length) : path;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Cut the paste-sized window out of a /peek response (which is normally the
|
|
187
|
+
* whole file). Exported for its own test — the arithmetic is 1-based and easy
|
|
188
|
+
* to get wrong by one. */
|
|
189
|
+
export function windowAround(peeked: PeekResponse, context = SOURCE_CONTEXT): SourceWindow {
|
|
190
|
+
const first = peeked.startLine;
|
|
191
|
+
const last = first + peeked.lines.length - 1;
|
|
192
|
+
const from = Math.max(first, peeked.focusLine - context);
|
|
193
|
+
const to = Math.min(last, peeked.focusLine + context);
|
|
194
|
+
return {
|
|
195
|
+
file: peeked.file,
|
|
196
|
+
startLine: from,
|
|
197
|
+
focusLine: peeked.focusLine,
|
|
198
|
+
totalLines: peeked.totalLines,
|
|
199
|
+
lines: peeked.lines.slice(from - first, to - first + 1),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- Collection (DOM + server) -----------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** Astro's own scoping class is machine-generated noise in a path label. */
|
|
206
|
+
function authoredClass(el: Element): string | undefined {
|
|
207
|
+
return Array.from(el.classList).find((c) => !/^astro-[\w-]+$/.test(c));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `h1.hero-title` / `section#masthead` / `div` — one path segment. */
|
|
211
|
+
function describe(el: Element): string {
|
|
212
|
+
const tag = el.tagName.toLowerCase();
|
|
213
|
+
if (el.id) return `${tag}#${el.id}`;
|
|
214
|
+
const cls = authoredClass(el);
|
|
215
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function domPathOf(el: HTMLElement): string {
|
|
219
|
+
const parts: string[] = [];
|
|
220
|
+
let cur: HTMLElement | null = el;
|
|
221
|
+
while (cur && cur !== document.documentElement) {
|
|
222
|
+
parts.unshift(describe(cur));
|
|
223
|
+
cur = cur.parentElement;
|
|
224
|
+
}
|
|
225
|
+
if (parts.length > PATH_MAX) return `… > ${parts.slice(-PATH_MAX).join(' > ')}`;
|
|
226
|
+
return parts.join(' > ');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The opening tag only, attributes included, as the browser renders it. */
|
|
230
|
+
function openTagOf(el: HTMLElement): string {
|
|
231
|
+
const shallow = el.cloneNode(false) as HTMLElement;
|
|
232
|
+
const end = shallow.outerHTML.indexOf('>');
|
|
233
|
+
return end === -1 ? `<${el.tagName.toLowerCase()}>` : shallow.outerHTML.slice(0, end + 1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** outerHTML, capped. Nothing of the overlay's can appear in it: every panel,
|
|
237
|
+
* outline and veil lives in the shadow root, whose host is a child of <body>
|
|
238
|
+
* and never of the element being copied. */
|
|
239
|
+
function renderedHtml(el: HTMLElement): { html: string; dropped: number } {
|
|
240
|
+
const full = el.outerHTML;
|
|
241
|
+
if (full.length <= HTML_MAX) return { html: full, dropped: 0 };
|
|
242
|
+
return { html: full.slice(0, HTML_MAX), dropped: full.length - HTML_MAX };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Phrase the AST classification the way the payload reads best. */
|
|
246
|
+
function verdictOf(result: ClassifyResult): string {
|
|
247
|
+
const what =
|
|
248
|
+
result.kind === 'text'
|
|
249
|
+
? 'editable text'
|
|
250
|
+
: result.kind === 'image'
|
|
251
|
+
? 'image (src/alt)'
|
|
252
|
+
: `not editable in place (${result.kind})`;
|
|
253
|
+
return `${what} — ${result.reason}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function sourceFor(
|
|
257
|
+
src: SourceLoc,
|
|
258
|
+
root: string | null,
|
|
259
|
+
): Promise<Pick<ElementContext, 'source' | 'sourceUnavailable'>> {
|
|
260
|
+
try {
|
|
261
|
+
const peeked = await api.peek({ file: src.file, loc: src.loc });
|
|
262
|
+
// Not an error: the file is real but package-owned (an astro:assets
|
|
263
|
+
// <Image>, say), so the server explains instead of returning source.
|
|
264
|
+
if (peeked.refused) return { source: null, sourceUnavailable: peeked.refused };
|
|
265
|
+
const window = windowAround(peeked);
|
|
266
|
+
return { source: { ...window, file: relativize(window.file, root) }, sourceUnavailable: null };
|
|
267
|
+
} catch (err) {
|
|
268
|
+
return {
|
|
269
|
+
source: null,
|
|
270
|
+
sourceUnavailable: err instanceof Error ? err.message : 'the source file could not be read',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Gather everything for one element. Only the /peek read can be slow; the
|
|
276
|
+
* classification is served from the hover cache in the normal case. `root` is
|
|
277
|
+
* the project root from /health, used to render source paths repo-relative. */
|
|
278
|
+
export async function collectContext(
|
|
279
|
+
el: HTMLElement,
|
|
280
|
+
src: SourceLoc,
|
|
281
|
+
root: string | null,
|
|
282
|
+
): Promise<ElementContext> {
|
|
283
|
+
const { html, dropped } = renderedHtml(el);
|
|
284
|
+
const matched = rulesForElement(el);
|
|
285
|
+
const kept = narrowRules(el, matched, RULES_MAX);
|
|
286
|
+
|
|
287
|
+
let verdict: string | null = null;
|
|
288
|
+
try {
|
|
289
|
+
verdict = verdictOf(await classifyCached({ file: src.file, loc: src.loc, tag: el.tagName.toLowerCase() }));
|
|
290
|
+
} catch {
|
|
291
|
+
// Classification is a nicety here — the rest of the context still stands.
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
loc: { file: relativize(src.file, root), loc: src.loc },
|
|
296
|
+
openTag: openTagOf(el),
|
|
297
|
+
label: describe(el),
|
|
298
|
+
verdict,
|
|
299
|
+
pageUrl: location.href,
|
|
300
|
+
entryFile: pageSource(),
|
|
301
|
+
domPath: domPathOf(el),
|
|
302
|
+
html,
|
|
303
|
+
htmlDropped: dropped,
|
|
304
|
+
rules: kept,
|
|
305
|
+
rulesDropped: matched.length - kept.length,
|
|
306
|
+
...(await sourceFor(src, root)),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { HealthResponse, UnsplashImportWidth } from '../shared/protocol.ts';
|
|
2
|
+
import { UNSPLASH_DEFAULT_IMPORT_WIDTH, coerceImportWidth } from '../shared/unsplash.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Server-derived feature state, set at boot from `/health` (and refreshed by a
|
|
6
|
+
* Settings save) — the on/off flags, plus the one resolved value a surface
|
|
7
|
+
* needs before its own endpoint has answered.
|
|
8
|
+
*
|
|
9
|
+
* A **leaf module** on purpose: the media modal needs to know whether the
|
|
10
|
+
* Unsplash source is available, and reading that from `overlay.ts` would create
|
|
11
|
+
* an `overlay → router → image → media-modal → overlay` import cycle. Anything
|
|
12
|
+
* that only needs to *read* a flag imports this instead.
|
|
13
|
+
*
|
|
14
|
+
* Flags default to off, so a server that predates a flag (or a failed health
|
|
15
|
+
* check) degrades to the feature being absent rather than to a surface that
|
|
16
|
+
* errors when touched.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface Features {
|
|
20
|
+
/** The Unsplash photo source is enabled AND the server holds a usable key.
|
|
21
|
+
* False means the media modal renders single-source, with no tab strip. */
|
|
22
|
+
unsplash: boolean;
|
|
23
|
+
/** The resolved `unsplash.importWidth`, where the picker's size select starts.
|
|
24
|
+
* The one non-boolean here: it is server-derived and refreshed by the same
|
|
25
|
+
* two calls, so a separate channel for it would be a second thing to keep in
|
|
26
|
+
* step for no gain. */
|
|
27
|
+
unsplashImportWidth: UnsplashImportWidth;
|
|
28
|
+
/** The hover pill's class/ID chips and their CSS rules. */
|
|
29
|
+
cssInspector: boolean;
|
|
30
|
+
/** The "Open source" buttons and jump-to-file links. */
|
|
31
|
+
openInEditor: boolean;
|
|
32
|
+
/** The CMS entry drawer and the admin bar's entry button. */
|
|
33
|
+
entryEditor: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const features: Features = {
|
|
37
|
+
unsplash: false,
|
|
38
|
+
unsplashImportWidth: UNSPLASH_DEFAULT_IMPORT_WIDTH,
|
|
39
|
+
cssInspector: false,
|
|
40
|
+
openInEditor: false,
|
|
41
|
+
entryEditor: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Called once from `overlay.ts`'s boot, with the /health payload. */
|
|
45
|
+
export function setFeatures(info: HealthResponse): void {
|
|
46
|
+
features.unsplash = info.unsplash === true;
|
|
47
|
+
// A server that predates the option says nothing — keep the default rather
|
|
48
|
+
// than resolving to a width it would not honour.
|
|
49
|
+
features.unsplashImportWidth =
|
|
50
|
+
coerceImportWidth(info.unsplashImportWidth) ?? UNSPLASH_DEFAULT_IMPORT_WIDTH;
|
|
51
|
+
features.cssInspector = info.cssInspector === true;
|
|
52
|
+
features.openInEditor = info.openInEditor === true;
|
|
53
|
+
features.entryEditor = info.entryEditor === true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Re-read a single flag — used after the Settings panel stores a key, so the
|
|
57
|
+
* Unsplash tab appears without a page reload. */
|
|
58
|
+
export function updateFeature<K extends keyof Features>(key: K, value: Features[K]): void {
|
|
59
|
+
features[key] = value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hasUnsplash(): boolean {
|
|
63
|
+
return features.unsplash;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Where the picker's size select starts — `has()` is boolean-typed, so the one
|
|
67
|
+
* non-boolean gets its own reader. */
|
|
68
|
+
export function unsplashImportWidth(): UnsplashImportWidth {
|
|
69
|
+
return features.unsplashImportWidth;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Keys of {@link Features} that are on/off. Narrowed rather than left as
|
|
73
|
+
* `keyof Features` so `has('unsplashImportWidth')` is a type error instead of
|
|
74
|
+
* a truthiness test on a width. */
|
|
75
|
+
type FeatureFlag = {
|
|
76
|
+
[K in keyof Features]: Features[K] extends boolean ? K : never;
|
|
77
|
+
}[keyof Features];
|
|
78
|
+
|
|
79
|
+
export function has(key: FeatureFlag): boolean {
|
|
80
|
+
return features[key];
|
|
81
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { overlayActiveElement } from './shadow.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal focus containment — the keyboard half of what a backdrop does for the
|
|
5
|
+
* pointer.
|
|
6
|
+
*
|
|
7
|
+
* Every panel and drawer here is a `<div>` over a click-swallowing backdrop.
|
|
8
|
+
* That stops the pointer and nothing else: Tab walked straight out of the
|
|
9
|
+
* open panel into the site's navigation, the page's own links, Astro's dev
|
|
10
|
+
* toolbar and the admin bar behind it, with the panel still up. It reads as a
|
|
11
|
+
* modal and behaves as an overlay.
|
|
12
|
+
*
|
|
13
|
+
* `trapFocus` gives a shell the semantics (`role="dialog"`, `aria-modal`) and
|
|
14
|
+
* the behaviour (Tab cycles inside it; focus that lands outside is pulled
|
|
15
|
+
* back; the opener gets focus again on release).
|
|
16
|
+
*
|
|
17
|
+
* Two things make it more than a `querySelectorAll` loop:
|
|
18
|
+
*
|
|
19
|
+
* - **The composed tree, not the shadow tree.** The rich-text editor's
|
|
20
|
+
* `contenteditable` is a light-DOM node `<slot>`-ed into the drawer (see
|
|
21
|
+
* `shadow.ts::mountLight`), so `shell.querySelectorAll` cannot see it and a
|
|
22
|
+
* trap built on that query would lock the user out of the body they came to
|
|
23
|
+
* write. Slots are followed, in slot order, so Tab order matches what the eye
|
|
24
|
+
* sees.
|
|
25
|
+
* - **Traps stack.** The media modal opens over the CMS drawer, and the
|
|
26
|
+
* Settings drawer over the media modal. Only the innermost trap acts; the one
|
|
27
|
+
* underneath resumes when it is released, exactly like `state.releaseTo`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const FOCUSABLE =
|
|
31
|
+
'a[href], button, input, select, textarea, [contenteditable], [tabindex]';
|
|
32
|
+
|
|
33
|
+
const stack: Trap[] = [];
|
|
34
|
+
|
|
35
|
+
interface Trap {
|
|
36
|
+
shell: HTMLElement;
|
|
37
|
+
opener: Element | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function focusable(el: Element): el is HTMLElement {
|
|
41
|
+
if (!el.matches(FOCUSABLE)) return false;
|
|
42
|
+
if ((el as HTMLInputElement).disabled) return false;
|
|
43
|
+
if (el.getAttribute('tabindex') === '-1') return false;
|
|
44
|
+
if (el.hasAttribute('contenteditable') && !(el as HTMLElement).isContentEditable) return false;
|
|
45
|
+
// Hidden things are not in the tab order; a zero-size box is the only
|
|
46
|
+
// reliable read of that from here (the panel itself is never display:none).
|
|
47
|
+
return (el as HTMLElement).getClientRects().length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Focusable descendants in composed order, following `<slot>`s. */
|
|
51
|
+
function focusablesIn(node: Element, out: HTMLElement[] = []): HTMLElement[] {
|
|
52
|
+
for (const child of Array.from(node.children)) {
|
|
53
|
+
if (child instanceof HTMLSlotElement) {
|
|
54
|
+
for (const assigned of child.assignedElements()) {
|
|
55
|
+
if (focusable(assigned)) out.push(assigned);
|
|
56
|
+
focusablesIn(assigned, out);
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (focusable(child)) out.push(child);
|
|
61
|
+
focusablesIn(child, out);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether an event happened inside `shell`, slotted content included. */
|
|
67
|
+
function inShell(shell: HTMLElement, e: Event): boolean {
|
|
68
|
+
return e.composedPath().includes(shell);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function top(): Trap | undefined {
|
|
72
|
+
return stack[stack.length - 1];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function onKeyDown(e: KeyboardEvent): void {
|
|
76
|
+
if (e.key !== 'Tab') return;
|
|
77
|
+
const trap = top();
|
|
78
|
+
if (!trap) return;
|
|
79
|
+
const items = focusablesIn(trap.shell);
|
|
80
|
+
if (items.length === 0) {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const first = items[0] as HTMLElement;
|
|
85
|
+
const last = items[items.length - 1] as HTMLElement;
|
|
86
|
+
const active = e.composedPath()[0];
|
|
87
|
+
const here = inShell(trap.shell, e);
|
|
88
|
+
if (!here) {
|
|
89
|
+
// Focus is already outside (a stray click on the page, say) — Tab brings
|
|
90
|
+
// it back rather than continuing the page's own order.
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
first.focus();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (e.shiftKey && active === first) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
last.focus();
|
|
98
|
+
} else if (!e.shiftKey && active === last) {
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
first.focus();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onFocusIn(e: FocusEvent): void {
|
|
105
|
+
const trap = top();
|
|
106
|
+
if (!trap) return;
|
|
107
|
+
if (inShell(trap.shell, e)) return;
|
|
108
|
+
// Focus reached the page behind the backdrop — from the browser's own
|
|
109
|
+
// address-bar cycle, or a script. Take it back.
|
|
110
|
+
const items = focusablesIn(trap.shell);
|
|
111
|
+
(items[0] ?? trap.shell).focus();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function listen(on: boolean): void {
|
|
115
|
+
const fn = on ? document.addEventListener : document.removeEventListener;
|
|
116
|
+
fn.call(document, 'keydown', onKeyDown as EventListener, true);
|
|
117
|
+
fn.call(document, 'focusin', onFocusIn as EventListener, true);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface TrapOptions {
|
|
121
|
+
/** Accessible name for the dialog. Defaults to the shell's own title text. */
|
|
122
|
+
label?: string;
|
|
123
|
+
/** Focus this instead of the first focusable when the trap opens. Pass null
|
|
124
|
+
* to leave focus where the caller already put it. */
|
|
125
|
+
initial?: HTMLElement | null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Make `shell` a modal dialog and hold focus inside it. Returns the release,
|
|
130
|
+
* which restores focus to whatever was focused when the trap opened.
|
|
131
|
+
*/
|
|
132
|
+
export function trapFocus(shell: HTMLElement, opts: TrapOptions = {}): () => void {
|
|
133
|
+
shell.setAttribute('role', 'dialog');
|
|
134
|
+
shell.setAttribute('aria-modal', 'true');
|
|
135
|
+
const label =
|
|
136
|
+
opts.label ??
|
|
137
|
+
shell.querySelector('.atx-panel-heading, .atx-drawer-title-text')?.textContent ??
|
|
138
|
+
'';
|
|
139
|
+
if (label) shell.setAttribute('aria-label', label);
|
|
140
|
+
// So focus has somewhere to land in a shell whose controls are all disabled
|
|
141
|
+
// mid-save, without putting the panel itself in the tab order.
|
|
142
|
+
if (!shell.hasAttribute('tabindex')) shell.tabIndex = -1;
|
|
143
|
+
|
|
144
|
+
// `document.activeElement` reports the host for anything inside the overlay,
|
|
145
|
+
// so the opener has to be read through the root to be restorable.
|
|
146
|
+
const trap: Trap = { shell, opener: overlayActiveElement() ?? document.activeElement };
|
|
147
|
+
stack.push(trap);
|
|
148
|
+
if (stack.length === 1) listen(true);
|
|
149
|
+
|
|
150
|
+
if (opts.initial !== null) {
|
|
151
|
+
const target = opts.initial ?? focusablesIn(shell)[0] ?? shell;
|
|
152
|
+
target.focus();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let released = false;
|
|
156
|
+
return () => {
|
|
157
|
+
if (released) return;
|
|
158
|
+
released = true;
|
|
159
|
+
const i = stack.indexOf(trap);
|
|
160
|
+
if (i >= 0) stack.splice(i, 1);
|
|
161
|
+
if (stack.length === 0) listen(false);
|
|
162
|
+
const opener = trap.opener;
|
|
163
|
+
if (opener instanceof HTMLElement && opener.isConnected) opener.focus();
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|