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,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hover-pill CSS inspector's client core: read which CSS rules an element
|
|
3
|
+
* matches through a given class/ID, straight from the browser's CSSOM, and
|
|
4
|
+
* build the little rules card the pill shows on chip hover.
|
|
5
|
+
*
|
|
6
|
+
* All of this is client-only — `document.styleSheets` already knows the applied
|
|
7
|
+
* rules and their declarations. The one thing the CSSOM does NOT expose is a
|
|
8
|
+
* rule's source line, so the card's "open ↗" defers to the server
|
|
9
|
+
* (/inspect/open) to jump the editor there. See src/server/inspect-locate.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { icon } from './icons.ts';
|
|
13
|
+
import { FONT, Z, basename, isolateScroll, pillButton, styled } from './ui.ts';
|
|
14
|
+
|
|
15
|
+
/** One applied rule, distilled for display. */
|
|
16
|
+
export interface MatchedRule {
|
|
17
|
+
/** The comma-joined sub-selectors (of this rule) the element actually matched. */
|
|
18
|
+
selectorText: string;
|
|
19
|
+
/** The declaration block, one `prop: value;` per line. */
|
|
20
|
+
declarations: string;
|
|
21
|
+
/** Source file the rule came from (root-relative or absolute), or null when
|
|
22
|
+
* it can't be resolved (inline <style>, cross-origin) — no open link then. */
|
|
23
|
+
sourceFile: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function escapeRegExp(s: string): string {
|
|
27
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when `sub` contains the class/id token as a whole selector token
|
|
31
|
+
* (so `.foo` doesn't match `.foobar`). Works on Astro's scoped selectors too,
|
|
32
|
+
* where the token still appears literally alongside `:where([data-astro-cid-…])`. */
|
|
33
|
+
function referencesToken(sub: string, token: string, kind: 'class' | 'id'): boolean {
|
|
34
|
+
const prefix = kind === 'class' ? '\\.' : '#';
|
|
35
|
+
return new RegExp(prefix + escapeRegExp(token) + '(?![\\w-])').test(sub);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Split a selector list on top-level commas — commas inside `()` / `[]`
|
|
39
|
+
* (e.g. `:is(.a, .b)`, attribute selectors) don't separate selectors. */
|
|
40
|
+
function splitSelectorList(selectorText: string): string[] {
|
|
41
|
+
const out: string[] = [];
|
|
42
|
+
let depth = 0;
|
|
43
|
+
let cur = '';
|
|
44
|
+
for (const ch of selectorText) {
|
|
45
|
+
if (ch === '(' || ch === '[') depth++;
|
|
46
|
+
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
|
|
47
|
+
if (ch === ',' && depth === 0) {
|
|
48
|
+
out.push(cur);
|
|
49
|
+
cur = '';
|
|
50
|
+
} else {
|
|
51
|
+
cur += ch;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (cur.trim()) out.push(cur);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Which sub-selectors a scan cares about: one token's (the chips card) or all
|
|
59
|
+
* of them (the copy-context collector). Matching against `el` happens either
|
|
60
|
+
* way — this only narrows what's worth testing. */
|
|
61
|
+
type SelectorFilter = (sub: string) => boolean;
|
|
62
|
+
|
|
63
|
+
/** Selectors made of nothing but `*` and combinators (`*`, `* > *`). They match
|
|
64
|
+
* every element, so a whole-element scan would drag in every reset rule. */
|
|
65
|
+
const UNIVERSAL_ONLY = /^[\s*>+~]*$/;
|
|
66
|
+
|
|
67
|
+
/** The sub-selectors of `rule` that pass `accept` AND that `el` matches. */
|
|
68
|
+
function matchingSubSelectors(rule: CSSStyleRule, el: Element, accept: SelectorFilter): string[] {
|
|
69
|
+
const matched: string[] = [];
|
|
70
|
+
for (const sub of splitSelectorList(rule.selectorText)) {
|
|
71
|
+
if (!accept(sub)) continue;
|
|
72
|
+
try {
|
|
73
|
+
if (el.matches(sub)) matched.push(sub.trim());
|
|
74
|
+
} catch {
|
|
75
|
+
// Invalid/unsupported sub-selector — skip it rather than fail the scan.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return matched;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Split a declaration block on top-level `;` — semicolons inside `()` (url,
|
|
82
|
+
* var, data URIs) or quotes don't separate declarations. */
|
|
83
|
+
function splitDeclarations(cssText: string): string[] {
|
|
84
|
+
const out: string[] = [];
|
|
85
|
+
let depth = 0;
|
|
86
|
+
let quote = '';
|
|
87
|
+
let cur = '';
|
|
88
|
+
for (const ch of cssText) {
|
|
89
|
+
if (quote) {
|
|
90
|
+
if (ch === quote) quote = '';
|
|
91
|
+
cur += ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
95
|
+
else if (ch === '(') depth++;
|
|
96
|
+
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
97
|
+
if (ch === ';' && depth === 0) {
|
|
98
|
+
if (cur.trim()) out.push(cur.trim());
|
|
99
|
+
cur = '';
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
cur += ch;
|
|
103
|
+
}
|
|
104
|
+
if (cur.trim()) out.push(cur.trim());
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The rule's declarations as `prop: value;` lines, one per line. Uses cssText
|
|
109
|
+
* (not item()/getPropertyValue) so shorthands driven by CSS variables — which
|
|
110
|
+
* expand to empty longhands in the CSSOM — keep their authored value. */
|
|
111
|
+
function formatDeclarations(rule: CSSStyleRule): string {
|
|
112
|
+
return splitDeclarations(rule.style.cssText)
|
|
113
|
+
.map((d) => `${d};`)
|
|
114
|
+
.join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Best-effort map a stylesheet to a source path the server can open. */
|
|
118
|
+
function sheetSource(sheet: CSSStyleSheet): string | null {
|
|
119
|
+
// <link> / @import / Astro's `…/index.astro?astro&type=style` hrefs. The
|
|
120
|
+
// pathname is dev-server-root-relative; strip the leading slash so the server
|
|
121
|
+
// resolves it against the project root (an absolute "/src/…" would escape it).
|
|
122
|
+
if (sheet.href) {
|
|
123
|
+
try {
|
|
124
|
+
const u = new URL(sheet.href, location.href);
|
|
125
|
+
if (u.origin !== location.origin) return null; // external / CDN
|
|
126
|
+
return decodeURIComponent(u.pathname).replace(/^\/+/, '') || null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Vite injects imported CSS as `<style data-vite-dev-id="/abs/path?…">`; that
|
|
132
|
+
// absolute path is passed through as-is (the server confines it to the root).
|
|
133
|
+
const viteId = (sheet.ownerNode as HTMLElement | null)?.dataset?.viteDevId;
|
|
134
|
+
if (viteId) return viteId.split('?')[0];
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function walkRules(
|
|
139
|
+
rules: CSSRuleList,
|
|
140
|
+
el: Element,
|
|
141
|
+
accept: SelectorFilter,
|
|
142
|
+
source: string | null,
|
|
143
|
+
out: MatchedRule[],
|
|
144
|
+
seen: Set<string>,
|
|
145
|
+
): void {
|
|
146
|
+
for (const rule of Array.from(rules)) {
|
|
147
|
+
if (rule instanceof CSSStyleRule) {
|
|
148
|
+
const matched = matchingSubSelectors(rule, el, accept);
|
|
149
|
+
if (matched.length === 0) continue;
|
|
150
|
+
const declarations = formatDeclarations(rule);
|
|
151
|
+
const key = `${matched.join(',')}|${declarations}|${source ?? ''}`;
|
|
152
|
+
if (seen.has(key)) continue; // dev often injects the same rule twice (HMR)
|
|
153
|
+
seen.add(key);
|
|
154
|
+
out.push({ selectorText: matched.join(', '), declarations, sourceFile: source });
|
|
155
|
+
} else if ('cssRules' in rule) {
|
|
156
|
+
// @media / @supports / @layer / @container — recurse into the group.
|
|
157
|
+
walkRules((rule as CSSGroupingRule).cssRules, el, accept, source, out, seen);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Scan every readable stylesheet for rules `el` matches through an accepted
|
|
163
|
+
* sub-selector. Cross-origin sheets are skipped, not fatal. */
|
|
164
|
+
function scan(el: Element, accept: SelectorFilter): MatchedRule[] {
|
|
165
|
+
const out: MatchedRule[] = [];
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
168
|
+
let rules: CSSRuleList;
|
|
169
|
+
try {
|
|
170
|
+
rules = sheet.cssRules;
|
|
171
|
+
} catch {
|
|
172
|
+
continue; // cross-origin stylesheet — CSSOM access throws; skip it
|
|
173
|
+
}
|
|
174
|
+
walkRules(rules, el, accept, sheetSource(sheet), out, seen);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every rule `el` matches through `token`, across all readable stylesheets. */
|
|
180
|
+
export function rulesForToken(el: Element, token: string, kind: 'class' | 'id'): MatchedRule[] {
|
|
181
|
+
return scan(el, (sub) => referencesToken(sub, token, kind));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Every rule `el` matches, whatever the selector — the chips card's scan
|
|
186
|
+
* widened from "through this one class/ID" to "at all", for the copy-context
|
|
187
|
+
* collector. Rules that apply only to an *ancestor* are not included: this is
|
|
188
|
+
* what the browser matched against this element.
|
|
189
|
+
*/
|
|
190
|
+
export function rulesForElement(el: Element): MatchedRule[] {
|
|
191
|
+
return scan(el, (sub) => !UNIVERSAL_ONLY.test(sub));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Narrow a matched-rule list to `max`, dropping the rules that say least about
|
|
196
|
+
* this element first.
|
|
197
|
+
*
|
|
198
|
+
* A selector naming one of the element's own classes or its id was written for
|
|
199
|
+
* something like this element. `a { color: inherit }` matches an anchor
|
|
200
|
+
* without naming it — a site-wide default, and a page's worth of those crowds
|
|
201
|
+
* out the one rule the reader is actually asking about.
|
|
202
|
+
*
|
|
203
|
+
* Survivors keep their original order. Ranking them would misreport the
|
|
204
|
+
* cascade, which among equal specificity is decided by document order, and a
|
|
205
|
+
* reader of the result has no way to know the list was resorted.
|
|
206
|
+
*/
|
|
207
|
+
export function narrowRules(el: Element, rules: MatchedRule[], max: number): MatchedRule[] {
|
|
208
|
+
if (rules.length <= max) return rules;
|
|
209
|
+
const tokens: { token: string; kind: 'class' | 'id' }[] = [
|
|
210
|
+
...(el.id ? [{ token: el.id, kind: 'id' as const }] : []),
|
|
211
|
+
...Array.from(el.classList).map((token) => ({ token, kind: 'class' as const })),
|
|
212
|
+
];
|
|
213
|
+
const names = (rule: MatchedRule): boolean =>
|
|
214
|
+
splitSelectorList(rule.selectorText).some((sub) =>
|
|
215
|
+
tokens.some(({ token, kind }) => referencesToken(sub, token, kind)));
|
|
216
|
+
|
|
217
|
+
const specific = rules.filter(names);
|
|
218
|
+
if (specific.length >= max) return specific.slice(0, max);
|
|
219
|
+
const keep = new Set(specific);
|
|
220
|
+
for (const rule of rules) {
|
|
221
|
+
if (keep.size >= max) break;
|
|
222
|
+
keep.add(rule);
|
|
223
|
+
}
|
|
224
|
+
return rules.filter((r) => keep.has(r));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Card DOM ----------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/** A small "open" button matching the pill's own, for a rule's source jump. */
|
|
230
|
+
function openButton(onClick: () => void): HTMLButtonElement {
|
|
231
|
+
const btn = pillButton(
|
|
232
|
+
'atx-tooltip-rule-open',
|
|
233
|
+
'open',
|
|
234
|
+
'Open this rule in your editor',
|
|
235
|
+
{ font: `600 10.5px ${FONT.ui}`, flex: '0 0 auto' },
|
|
236
|
+
icon('external', 12),
|
|
237
|
+
);
|
|
238
|
+
btn.addEventListener('click', onClick);
|
|
239
|
+
return btn;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The parts of a declaration the tokenizer can tell apart. What each one
|
|
243
|
+
* looks like is `[data-css]` in styles.ts, beside the source-peek palette it
|
|
244
|
+
* is deliberately the same as. */
|
|
245
|
+
type CssTokenKind = 'prop' | 'value' | 'string' | 'number' | 'variable' | 'keyword' | 'punct';
|
|
246
|
+
|
|
247
|
+
// One pass over a declaration's value: strings, hex colors, custom-property
|
|
248
|
+
// refs, !important, numbers-with-units, identifiers, whitespace, punctuation.
|
|
249
|
+
const VALUE_TOKEN =
|
|
250
|
+
/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(#[0-9a-fA-F]{3,8}\b)|(--[A-Za-z0-9-]+)|(!important\b)|(-?\d*\.?\d+[a-z%]*)|([A-Za-z][\w-]*)|(\s+)|([^\s])/g;
|
|
251
|
+
|
|
252
|
+
/** One syntax-tinted run of a declaration. The kind is the attribute, not a
|
|
253
|
+
* colour: the seven kinds are a fixed vocabulary, so the palette belongs in
|
|
254
|
+
* the stylesheet beside every other one. */
|
|
255
|
+
function span(text: string, kind: CssTokenKind): HTMLElement {
|
|
256
|
+
const s = styled('span', 'atx-css-token');
|
|
257
|
+
s.dataset.css = kind;
|
|
258
|
+
s.textContent = text;
|
|
259
|
+
return s;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Append syntax-colored spans for one `prop: value;` line onto `pre`. Anything
|
|
263
|
+
* unrecognized stays a plain value span — a wrong guess is never wrong code. */
|
|
264
|
+
function appendDeclLine(pre: HTMLElement, line: string): void {
|
|
265
|
+
const colon = line.indexOf(':');
|
|
266
|
+
if (colon === -1) {
|
|
267
|
+
pre.append(span(line, 'value'));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const prop = line.slice(0, colon);
|
|
271
|
+
pre.append(span(prop, /^\s*--/.test(prop) ? 'variable' : 'prop'));
|
|
272
|
+
pre.append(span(':', 'punct'));
|
|
273
|
+
for (const m of line.slice(colon + 1).matchAll(VALUE_TOKEN)) {
|
|
274
|
+
const [text, str, hex, variable, imp, num, ident, ws] = m;
|
|
275
|
+
const kind: CssTokenKind = str
|
|
276
|
+
? 'string'
|
|
277
|
+
: hex || num
|
|
278
|
+
? 'number'
|
|
279
|
+
: variable
|
|
280
|
+
? 'variable'
|
|
281
|
+
: imp
|
|
282
|
+
? 'keyword'
|
|
283
|
+
: ident || ws
|
|
284
|
+
? 'value'
|
|
285
|
+
: 'punct';
|
|
286
|
+
pre.append(span(text, kind));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Fill the declarations `<pre>` with syntax-highlighted lines. */
|
|
291
|
+
function renderDeclarations(pre: HTMLElement, declarations: string): void {
|
|
292
|
+
declarations.split('\n').forEach((line, i) => {
|
|
293
|
+
if (i > 0) pre.append(document.createTextNode('\n'));
|
|
294
|
+
appendDeclLine(pre, line);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ruleBlock(
|
|
299
|
+
rule: MatchedRule,
|
|
300
|
+
selector: string,
|
|
301
|
+
openRule: (file: string, selector: string) => void,
|
|
302
|
+
): HTMLElement {
|
|
303
|
+
const block = styled('div', 'atx-tooltip-rule');
|
|
304
|
+
|
|
305
|
+
const sel = styled('div', 'atx-tooltip-rule-sel');
|
|
306
|
+
sel.textContent = rule.selectorText;
|
|
307
|
+
|
|
308
|
+
const decl = styled('pre', 'atx-tooltip-rule-decl');
|
|
309
|
+
renderDeclarations(decl, rule.declarations);
|
|
310
|
+
block.append(sel, decl);
|
|
311
|
+
|
|
312
|
+
if (rule.sourceFile) {
|
|
313
|
+
const foot = styled('div', 'atx-tooltip-rule-foot');
|
|
314
|
+
const src = styled('span', 'atx-tooltip-rule-src');
|
|
315
|
+
src.textContent = basename(rule.sourceFile);
|
|
316
|
+
src.title = rule.sourceFile;
|
|
317
|
+
foot.append(src, openButton(() => openRule(rule.sourceFile!, selector)));
|
|
318
|
+
block.append(foot);
|
|
319
|
+
}
|
|
320
|
+
return block;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build the (detached, fixed-position) rules card for one class/ID chip. The
|
|
325
|
+
* caller appends it to the body and positions it; `selector` is the fragment
|
|
326
|
+
* (".hero-title" / "#masthead") passed to the open jump.
|
|
327
|
+
*/
|
|
328
|
+
export function buildRulesCard(
|
|
329
|
+
selector: string,
|
|
330
|
+
rules: MatchedRule[],
|
|
331
|
+
openRule: (file: string, selector: string) => void,
|
|
332
|
+
): HTMLElement {
|
|
333
|
+
// The card's own position is written by the caller once it is measured.
|
|
334
|
+
const card = styled('div', 'atx-tooltip-rules', { zIndex: String(Z + 1) });
|
|
335
|
+
isolateScroll(card);
|
|
336
|
+
|
|
337
|
+
if (rules.length === 0) {
|
|
338
|
+
const empty = styled('div', 'atx-tooltip-rules-empty');
|
|
339
|
+
empty.textContent = `No applied rules for ${selector}`;
|
|
340
|
+
card.append(empty);
|
|
341
|
+
return card;
|
|
342
|
+
}
|
|
343
|
+
for (const rule of rules) card.append(ruleBlock(rule, selector, openRule));
|
|
344
|
+
return card;
|
|
345
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { entryAssetDir, entryRelativeToWeb, webToEntryRelative } from '../../shared/asset-path.ts';
|
|
2
|
+
import { inputEl, setFreshSrc, styled, toast } from '../ui.ts';
|
|
3
|
+
import { openMediaModal } from './media-modal.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `buildImageField` — the entry panel's image form control: a preview, a path
|
|
7
|
+
* input, and a Browse button that opens the shared media modal.
|
|
8
|
+
*
|
|
9
|
+
* Browsing, uploading and searching all live in `media-modal.ts` now; what
|
|
10
|
+
* stays here is what is specific to a *field*. Chiefly the path conversion:
|
|
11
|
+
*
|
|
12
|
+
* Two path universes meet in this file. By default a value is a **web-servable
|
|
13
|
+
* path** (`/images/hero.png`) — the shape a plain `<img src>` needs. With
|
|
14
|
+
* `assetRef: 'relative'` the field instead stores a path relative to its entry
|
|
15
|
+
* file, because it is backed by Astro's `image()` helper (see
|
|
16
|
+
* shared/asset-path.ts). The modal deals only in web paths, so this control is
|
|
17
|
+
* the single place that converts between the two — and the two candidate sets
|
|
18
|
+
* are disjoint, so a field can never be handed a path of the wrong shape.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a value handed to `onChange` came from.
|
|
23
|
+
*
|
|
24
|
+
* A `picked` value describes a **file** — it comes out of the media picker, so
|
|
25
|
+
* it carries that filename's own characters, a space among them. A `stored`
|
|
26
|
+
* value is the field's initial value or one typed into the path input: already
|
|
27
|
+
* in whatever form it is meant to be written in.
|
|
28
|
+
*
|
|
29
|
+
* A caller that has to turn the value into a URL can only tell the two apart if
|
|
30
|
+
* the field says which it was — `webPathToUrl` is deliberately not idempotent,
|
|
31
|
+
* so encoding a value that was already encoded turns `%20` into `%2520`.
|
|
32
|
+
*/
|
|
33
|
+
export type ImageValueOrigin = 'picked' | 'stored';
|
|
34
|
+
|
|
35
|
+
export interface ImageFieldOptions {
|
|
36
|
+
/** The field's current stored value (a web path, or entry-relative). */
|
|
37
|
+
initial: string;
|
|
38
|
+
/** Called with the new stored value on every change. */
|
|
39
|
+
onChange: (value: string, origin: ImageValueOrigin) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Set for a field backed by Astro's `image()` helper: values are paths
|
|
42
|
+
* relative to `entryFile`, so previews resolve through it and picks are
|
|
43
|
+
* converted back into that shape.
|
|
44
|
+
*/
|
|
45
|
+
assetRef?: 'relative';
|
|
46
|
+
/** Repo-relative path of the entry being edited. Required for relative mode. */
|
|
47
|
+
entryFile?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compact image-field control for the entry panel: current-value preview, path
|
|
52
|
+
* input, and a Browse button that opens the media modal. In relative mode this
|
|
53
|
+
* control is the *only* place that converts between the stored entry-relative
|
|
54
|
+
* value and the web path the browser and the modal speak.
|
|
55
|
+
*/
|
|
56
|
+
export function buildImageField(opts: ImageFieldOptions): HTMLElement {
|
|
57
|
+
const { initial, onChange } = opts;
|
|
58
|
+
const entryFile = opts.entryFile ?? '';
|
|
59
|
+
const relative = opts.assetRef === 'relative' && entryFile !== '';
|
|
60
|
+
let value = initial;
|
|
61
|
+
|
|
62
|
+
/** The stored value as something an <img> can load. */
|
|
63
|
+
const previewSrc = (raw: string): string =>
|
|
64
|
+
relative ? (entryRelativeToWeb(entryFile, raw) ?? '') : raw;
|
|
65
|
+
|
|
66
|
+
const wrap = styled('div', 'atx-image-field');
|
|
67
|
+
|
|
68
|
+
// Preview above the path input; clicking it opens the browse list too.
|
|
69
|
+
const thumbWrap = styled('button', 'atx-image-field-preview');
|
|
70
|
+
thumbWrap.type = 'button';
|
|
71
|
+
thumbWrap.title = 'Browse images';
|
|
72
|
+
const thumb = styled('img', 'atx-image-field-thumb');
|
|
73
|
+
thumb.toggleAttribute('data-hidden', true);
|
|
74
|
+
thumb.alt = '';
|
|
75
|
+
const thumbEmpty = styled('span', 'atx-image-field-empty');
|
|
76
|
+
thumbEmpty.textContent = 'No image — click to browse';
|
|
77
|
+
/** The preview shows exactly one of the two: the image, or the placeholder. */
|
|
78
|
+
const showThumb = (on: boolean): void => {
|
|
79
|
+
thumb.toggleAttribute('data-hidden', !on);
|
|
80
|
+
thumbEmpty.toggleAttribute('data-hidden', on);
|
|
81
|
+
};
|
|
82
|
+
// A path that fails to load falls back to the placeholder, never a broken icon.
|
|
83
|
+
thumb.addEventListener('error', () => showThumb(false));
|
|
84
|
+
thumb.addEventListener('load', () => showThumb(true));
|
|
85
|
+
thumbWrap.append(thumb, thumbEmpty);
|
|
86
|
+
wrap.append(thumbWrap);
|
|
87
|
+
|
|
88
|
+
const row = styled('div', 'atx-image-field-row');
|
|
89
|
+
const pathInput = inputEl('input', 'atx-image-field-path');
|
|
90
|
+
const browse = styled('button', 'atx-btn atx-btn-outline atx-image-field-browse');
|
|
91
|
+
browse.type = 'button';
|
|
92
|
+
browse.textContent = 'Browse…';
|
|
93
|
+
row.append(pathInput, browse);
|
|
94
|
+
wrap.append(row);
|
|
95
|
+
|
|
96
|
+
// A relative value is meaningless without knowing what it is relative to.
|
|
97
|
+
if (relative) {
|
|
98
|
+
const hint = styled('div', 'atx-image-field-hint');
|
|
99
|
+
hint.textContent = `relative to ${entryFile}`;
|
|
100
|
+
wrap.append(hint);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const showImage = (src: string, fresh = false): void => {
|
|
104
|
+
if (src) {
|
|
105
|
+
// A just-written file may still be inside Vite's brief 404 window, so it
|
|
106
|
+
// gets the retrying loader; the *stored* value stays the clean path.
|
|
107
|
+
if (fresh) setFreshSrc(thumb, src);
|
|
108
|
+
else thumb.src = src;
|
|
109
|
+
} else {
|
|
110
|
+
thumb.removeAttribute('src');
|
|
111
|
+
showThumb(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const set = (next: string, origin: ImageValueOrigin, fresh = false): void => {
|
|
115
|
+
value = next;
|
|
116
|
+
pathInput.value = next;
|
|
117
|
+
showImage(previewSrc(next), fresh);
|
|
118
|
+
onChange(next, origin);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Browsing opens the shared media modal rather than an inline list. This is
|
|
123
|
+
* the one place that converts a picked web path back into the stored
|
|
124
|
+
* entry-relative value — the modal deals only in web paths.
|
|
125
|
+
*/
|
|
126
|
+
const browseImages = async (): Promise<void> => {
|
|
127
|
+
const dir = relative ? entryAssetDir(entryFile, value) : undefined;
|
|
128
|
+
const pick = await openMediaModal({
|
|
129
|
+
title: 'Choose an image',
|
|
130
|
+
...(relative ? { assetRef: 'relative' as const } : {}),
|
|
131
|
+
...(previewSrc(value) ? { currentWebPath: previewSrc(value) } : {}),
|
|
132
|
+
...(dir ? { scopeDir: dir, targetDir: dir } : {}),
|
|
133
|
+
});
|
|
134
|
+
if (!pick) return; // cancelled — nothing staged, nothing written
|
|
135
|
+
const next = relative ? webToEntryRelative(entryFile, pick.webPath) : pick.webPath;
|
|
136
|
+
if (!next) {
|
|
137
|
+
toast('That image cannot back an image() field — it must live under src/.', 'err');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
set(next, 'picked', pick.origin !== 'existing');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
set(initial, 'stored');
|
|
144
|
+
|
|
145
|
+
pathInput.addEventListener('input', () => {
|
|
146
|
+
value = pathInput.value;
|
|
147
|
+
showImage(previewSrc(value));
|
|
148
|
+
onChange(value, 'stored');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
browse.addEventListener('click', () => void browseImages());
|
|
152
|
+
thumbWrap.addEventListener('click', () => void browseImages());
|
|
153
|
+
|
|
154
|
+
return wrap;
|
|
155
|
+
}
|