@cssints/devtools 0.0.0-stage → 0.1.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 +102 -2
- package/dist/overlay.js +8237 -0
- package/package.json +41 -4
- package/src/core/cascade.ts +379 -0
- package/src/core/css.ts +219 -0
- package/src/core/dom.ts +184 -0
- package/src/core/index.ts +24 -0
- package/src/core/inject.ts +45 -0
- package/src/core/model.ts +6 -0
- package/src/core/page.ts +55 -0
- package/src/core/prompt.ts +82 -0
- package/src/core/session.ts +80 -0
- package/src/core/table.ts +68 -0
package/src/core/dom.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The page of the cascade in a browser: the element's own document. At-rules are decided by the browser through a probe
|
|
2
|
+
// sheet, winners are checked on a childless clone, and the other sheets are walked for what beats cssints.
|
|
3
|
+
import { Effect, Layer, Result } from "effect";
|
|
4
|
+
|
|
5
|
+
import { type Foreign, type Observed, Page, type Probe } from "./page.ts";
|
|
6
|
+
|
|
7
|
+
const PROBE = "data-cssints-probe";
|
|
8
|
+
const isCssints = (sheet: CSSStyleSheet) =>
|
|
9
|
+
sheet.ownerNode instanceof Element &&
|
|
10
|
+
(sheet.ownerNode.getAttribute("data-vite-dev-id") ?? "").endsWith("virtual:cssints.css");
|
|
11
|
+
|
|
12
|
+
const matches = (el: Element, selector: string) =>
|
|
13
|
+
Result.getOrElse(
|
|
14
|
+
Result.try(() => el.matches(selector)),
|
|
15
|
+
() => false,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const label = (el: Element) => {
|
|
19
|
+
const data = [...el.attributes].find((a) => a.name.startsWith("data-") && a.name !== PROBE);
|
|
20
|
+
return `${el.localName}${el.id ? `#${el.id}` : ""}${data ? `[${data.name}${data.value ? `="${data.value}"` : ""}]` : ""}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const probe = (el: Element, probes: readonly Probe[]) => {
|
|
24
|
+
if (!probes.length) return [];
|
|
25
|
+
const style = el.ownerDocument.createElement("style");
|
|
26
|
+
style.textContent = probes
|
|
27
|
+
.map(({ wrap, selector }, i) => {
|
|
28
|
+
const target = selector ? `:is(${selector}):where([${PROBE}])` : `[${PROBE}]`;
|
|
29
|
+
return `${wrap.map((w) => `${w} {`).join(" ")} ${target} { --cssints-probe-${i}: 1 } ${"}".repeat(wrap.length)}`;
|
|
30
|
+
})
|
|
31
|
+
.join("\n");
|
|
32
|
+
el.ownerDocument.head.append(style);
|
|
33
|
+
el.setAttribute(PROBE, "");
|
|
34
|
+
const cs = getComputedStyle(el);
|
|
35
|
+
const out = probes.map((_, i) => cs.getPropertyValue(`--cssints-probe-${i}`).trim() === "1");
|
|
36
|
+
el.removeAttribute(PROBE);
|
|
37
|
+
style.remove();
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// The rules of the other sheets that match the element and set `prop`; @media and @supports are evaluated, the rest kept.
|
|
42
|
+
const foreign = (el: Element, prop: string): Foreign[] => {
|
|
43
|
+
const out: Foreign[] = [];
|
|
44
|
+
const view = el.ownerDocument.defaultView;
|
|
45
|
+
const walk = (rules: CSSRuleList, source: string, layer: string) => {
|
|
46
|
+
for (const r of rules) {
|
|
47
|
+
if (r instanceof CSSStyleRule) {
|
|
48
|
+
const value = r.style.getPropertyValue(prop);
|
|
49
|
+
if (value && matches(el, r.selectorText))
|
|
50
|
+
out.push({
|
|
51
|
+
selector: r.selectorText,
|
|
52
|
+
source,
|
|
53
|
+
layer,
|
|
54
|
+
value,
|
|
55
|
+
important: r.style.getPropertyPriority(prop) === "important",
|
|
56
|
+
});
|
|
57
|
+
walk(r.cssRules, source, layer);
|
|
58
|
+
} else if (r instanceof CSSLayerBlockRule)
|
|
59
|
+
walk(r.cssRules, source, layer ? `${layer}.${r.name}` : r.name);
|
|
60
|
+
else if (r instanceof CSSMediaRule) {
|
|
61
|
+
if (view?.matchMedia(r.conditionText).matches) walk(r.cssRules, source, layer);
|
|
62
|
+
} else if (r instanceof CSSSupportsRule) {
|
|
63
|
+
if (CSS.supports(r.conditionText)) walk(r.cssRules, source, layer);
|
|
64
|
+
} else if (r instanceof CSSGroupingRule) walk(r.cssRules, source, layer);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
for (const sheet of el.ownerDocument.styleSheets) {
|
|
68
|
+
if (isCssints(sheet)) continue;
|
|
69
|
+
const owner = sheet.ownerNode instanceof Element ? sheet.ownerNode : undefined;
|
|
70
|
+
const source =
|
|
71
|
+
sheet.href ?? owner?.getAttribute("data-vite-dev-id") ?? `<${owner?.localName ?? "style"}>`;
|
|
72
|
+
const rules = Result.getOrUndefined(Result.try(() => sheet.cssRules));
|
|
73
|
+
if (rules) walk(rules, source, "");
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const selectorText = (selector: string) =>
|
|
79
|
+
Result.getOrUndefined(
|
|
80
|
+
Result.try(() => {
|
|
81
|
+
const sheet = new CSSStyleSheet();
|
|
82
|
+
sheet.insertRule(`${selector} {}`);
|
|
83
|
+
const rule = sheet.cssRules[0];
|
|
84
|
+
return rule instanceof CSSStyleRule ? rule.selectorText : undefined;
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// The winner's declaration of a custom property as the page has it. A transformer between the server and the page (Vite's
|
|
89
|
+
// LightningCSS) reprints values, and an unregistered custom property computes to its tokens as written: `.04` is not
|
|
90
|
+
// `0.04`. Among the page's rules with the winner's selector, the last whose at-rules hold for the element.
|
|
91
|
+
const live = (el: Element, selector: string, prop: string) => {
|
|
92
|
+
const target = selectorText(selector);
|
|
93
|
+
if (target === undefined) return undefined;
|
|
94
|
+
const found: { wrap: string[]; value: string }[] = [];
|
|
95
|
+
const walk = (rules: CSSRuleList, wrap: string[]) => {
|
|
96
|
+
for (const r of rules) {
|
|
97
|
+
if (r instanceof CSSStyleRule) {
|
|
98
|
+
const value = r.style.getPropertyValue(prop);
|
|
99
|
+
if (value && r.selectorText === target) found.push({ wrap, value });
|
|
100
|
+
} else if (r instanceof CSSMediaRule)
|
|
101
|
+
walk(r.cssRules, [...wrap, `@media ${r.conditionText}`]);
|
|
102
|
+
else if (r instanceof CSSSupportsRule)
|
|
103
|
+
walk(r.cssRules, [...wrap, `@supports ${r.conditionText}`]);
|
|
104
|
+
else if (r instanceof CSSContainerRule)
|
|
105
|
+
walk(r.cssRules, [...wrap, `@container ${r.conditionText}`]);
|
|
106
|
+
// Firefox before 146 has no CSSScopeRule
|
|
107
|
+
else if (globalThis.CSSScopeRule && r instanceof CSSScopeRule)
|
|
108
|
+
walk(r.cssRules, [
|
|
109
|
+
...wrap,
|
|
110
|
+
`@scope${r.start ? ` (${r.start})` : ""}${r.end ? ` to (${r.end})` : ""}`,
|
|
111
|
+
]);
|
|
112
|
+
else if (r instanceof CSSGroupingRule) walk(r.cssRules, wrap);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const sheets = [...el.ownerDocument.styleSheets];
|
|
116
|
+
const own = sheets.filter(isCssints);
|
|
117
|
+
for (const sheet of own.length ? own : sheets) {
|
|
118
|
+
const rules = Result.getOrUndefined(Result.try(() => sheet.cssRules));
|
|
119
|
+
if (rules) walk(rules, []);
|
|
120
|
+
}
|
|
121
|
+
if (found.length < 2) return found[0]?.value;
|
|
122
|
+
const held = probe(
|
|
123
|
+
el,
|
|
124
|
+
found.map(({ wrap }) => ({ wrap })),
|
|
125
|
+
);
|
|
126
|
+
return found.findLast((_, i) => held[i])?.value ?? found.at(-1)!.value;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const VAR = /var\(\s*(--[\w-]+)/g;
|
|
130
|
+
|
|
131
|
+
const verify = (
|
|
132
|
+
el: Element,
|
|
133
|
+
prop: string,
|
|
134
|
+
selector: string,
|
|
135
|
+
declared: string,
|
|
136
|
+
longhands: readonly string[],
|
|
137
|
+
): Observed[] => {
|
|
138
|
+
const clone = el.cloneNode(false);
|
|
139
|
+
if (!(clone instanceof Element) || !(clone instanceof HTMLElement || clone instanceof SVGElement))
|
|
140
|
+
return [];
|
|
141
|
+
const value = (prop.startsWith("--") && live(el, selector, prop)) || declared;
|
|
142
|
+
const cs = getComputedStyle(el);
|
|
143
|
+
// the clone is not hovered or focused: the custom properties the value reads are copied from the element
|
|
144
|
+
for (const [, name] of value.matchAll(VAR))
|
|
145
|
+
clone.style.setProperty(name!, cs.getPropertyValue(name!));
|
|
146
|
+
clone.style.setProperty(prop, value, "important");
|
|
147
|
+
el.after(clone);
|
|
148
|
+
const ccs = getComputedStyle(clone);
|
|
149
|
+
const inline = el instanceof HTMLElement || el instanceof SVGElement ? el.style : undefined;
|
|
150
|
+
const animated = el.getAnimations().length > 0;
|
|
151
|
+
const out = longhands.map((l) => {
|
|
152
|
+
const computed = cs.getPropertyValue(l).trim();
|
|
153
|
+
const expected = ccs.getPropertyValue(l).trim();
|
|
154
|
+
return {
|
|
155
|
+
prop: l,
|
|
156
|
+
computed,
|
|
157
|
+
expected,
|
|
158
|
+
inline: inline?.getPropertyValue(l) ?? "",
|
|
159
|
+
foreign: computed === expected ? [] : foreign(el, l),
|
|
160
|
+
animated,
|
|
161
|
+
} satisfies Observed;
|
|
162
|
+
});
|
|
163
|
+
clone.remove();
|
|
164
|
+
return out;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** The page of an element in a browser. */
|
|
168
|
+
export const domPage = Layer.succeed(
|
|
169
|
+
Page,
|
|
170
|
+
Page.of({
|
|
171
|
+
classes: (el) => Effect.sync(() => [...el.classList]),
|
|
172
|
+
label: (el) => Effect.sync(() => label(el)),
|
|
173
|
+
parent: (el) => Effect.sync(() => el.parentElement ?? undefined),
|
|
174
|
+
matches: (el, selector) => Effect.sync(() => matches(el, selector)),
|
|
175
|
+
probe: (el, probes) => Effect.sync(() => probe(el, probes)),
|
|
176
|
+
custom: (el, names) =>
|
|
177
|
+
Effect.sync(() => {
|
|
178
|
+
const cs = getComputedStyle(el);
|
|
179
|
+
return names.map((n) => cs.getPropertyValue(n).trim());
|
|
180
|
+
}),
|
|
181
|
+
verify: (el, prop, selector, value, longhands) =>
|
|
182
|
+
Effect.sync(() => verify(el, prop, selector, value, longhands)),
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The inspector's core: the protocol with the dev server, the model of its table, the cascade of an element. No overlay
|
|
2
|
+
// in it: the overlay (src/ui) is one host, a DevTools extension could be another.
|
|
3
|
+
export { beats, inspect } from "./cascade.ts";
|
|
4
|
+
export type {
|
|
5
|
+
Candidate,
|
|
6
|
+
ClassRow,
|
|
7
|
+
Condition,
|
|
8
|
+
Group,
|
|
9
|
+
Inspection,
|
|
10
|
+
Row,
|
|
11
|
+
TokenRow,
|
|
12
|
+
Verdict,
|
|
13
|
+
} from "./cascade.ts";
|
|
14
|
+
export { parseSheet, specificity, stripStates } from "./css.ts";
|
|
15
|
+
export type { Decl, Sheet, SheetRule } from "./css.ts";
|
|
16
|
+
export { domPage } from "./dom.ts";
|
|
17
|
+
export { Page } from "./page.ts";
|
|
18
|
+
export { buildPrompt, relative } from "./prompt.ts";
|
|
19
|
+
export type { Foreign, Observed, Probe } from "./page.ts";
|
|
20
|
+
export { ASK, ASKED, session, STALE, TABLE, Transport } from "./session.ts";
|
|
21
|
+
export type { Asked } from "./session.ts";
|
|
22
|
+
export type { Session, TransportApi } from "./session.ts";
|
|
23
|
+
export { toModel, Table, TableError } from "./table.ts";
|
|
24
|
+
export type { ClassRecord, Model, Site } from "./table.ts";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The core in a page without the overlay: a host that drives a browser (@cssints/mcp through Playwright) bundles this file,
|
|
2
|
+
// adds it to the page, hands it the table it got from the dev server, and runs the overlay's cascade on an element.
|
|
3
|
+
import { Effect, Exit, Result } from "effect";
|
|
4
|
+
|
|
5
|
+
import { inspect, type Inspection } from "./cascade.ts";
|
|
6
|
+
import { domPage } from "./dom.ts";
|
|
7
|
+
import { toModel, type Model } from "./table.ts";
|
|
8
|
+
|
|
9
|
+
let model: Model | undefined;
|
|
10
|
+
|
|
11
|
+
const api = {
|
|
12
|
+
// decodes the table (`cssints:devtools`): an error message, or undefined
|
|
13
|
+
load: (table: unknown): string | undefined => {
|
|
14
|
+
const exit = Effect.runSyncExit(toModel(table));
|
|
15
|
+
if (Exit.isFailure(exit)) return String(exit.cause);
|
|
16
|
+
model = exit.value;
|
|
17
|
+
return undefined;
|
|
18
|
+
},
|
|
19
|
+
// the inspection of the first element that matches, how many match, its computed `prop`; or an error message
|
|
20
|
+
inspect: (
|
|
21
|
+
selector: string,
|
|
22
|
+
prop?: string,
|
|
23
|
+
): { inspection: Inspection; matches: number; computed: string } | string => {
|
|
24
|
+
if (!model) return "no table: call load first";
|
|
25
|
+
const all = Result.try(() => document.querySelectorAll(selector));
|
|
26
|
+
if (Result.isFailure(all)) return `not a CSS selector: ${selector}`;
|
|
27
|
+
const el = all.success[0];
|
|
28
|
+
if (!el) return `no element matches ${selector}`;
|
|
29
|
+
// @effect-diagnostics-next-line strictEffectProvide:off -- the host's edge: the one place the core meets the page
|
|
30
|
+
const exit = Effect.runSyncExit(inspect(model, el).pipe(Effect.provide(domPage)));
|
|
31
|
+
if (Exit.isFailure(exit)) return String(exit.cause);
|
|
32
|
+
return {
|
|
33
|
+
inspection: exit.value,
|
|
34
|
+
matches: all.success.length,
|
|
35
|
+
computed: prop ? getComputedStyle(el).getPropertyValue(prop).trim() : "",
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
// takes the focus from whatever has it
|
|
39
|
+
blur: (): void => {
|
|
40
|
+
const a = document.activeElement;
|
|
41
|
+
if (a instanceof HTMLElement || a instanceof SVGElement) a.blur();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
Object.assign(globalThis, { __cssintsDevtools: api });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The table and its sheet without the DOM, for a host outside the page (@cssints/mcp reads the table in Node).
|
|
2
|
+
export { parseSheet, specificity, splitTop, stripStates } from "./css.ts";
|
|
3
|
+
export type { Decl, Sheet, SheetRule } from "./css.ts";
|
|
4
|
+
export { STALE, TABLE } from "./session.ts";
|
|
5
|
+
export { toModel, Table, TableError } from "./table.ts";
|
|
6
|
+
export type { ClassRecord, Model, Site } from "./table.ts";
|
package/src/core/page.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// What the cascade asks of the page an element is in. The DOM implementation is dom.ts; the unit tests give one of their own.
|
|
2
|
+
import { type Effect, Context } from "effect";
|
|
3
|
+
|
|
4
|
+
/** A rule of another sheet that matches the element and sets the property (dom.ts walks the CSSOM for it). */
|
|
5
|
+
export interface Foreign {
|
|
6
|
+
selector: string;
|
|
7
|
+
/** the sheet: its href, Vite's dev id, or `<style>` */
|
|
8
|
+
source: string;
|
|
9
|
+
/** "" outside every layer */
|
|
10
|
+
layer: string;
|
|
11
|
+
value: string;
|
|
12
|
+
important: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What the browser says of one longhand, for a winner's declaration. */
|
|
16
|
+
export interface Observed {
|
|
17
|
+
prop: string;
|
|
18
|
+
/** getComputedStyle of the element */
|
|
19
|
+
computed: string;
|
|
20
|
+
/** getComputedStyle of a childless clone in its place with the winner's declaration inline and `!important` */
|
|
21
|
+
expected: string;
|
|
22
|
+
/** the element's own `style` for it */
|
|
23
|
+
inline: string;
|
|
24
|
+
foreign: Foreign[];
|
|
25
|
+
animated: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Probe {
|
|
29
|
+
wrap: readonly string[];
|
|
30
|
+
/** the selector inside the at-rules; none: the at-rules alone, for the element */
|
|
31
|
+
selector?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Page extends Context.Service<
|
|
35
|
+
Page,
|
|
36
|
+
{
|
|
37
|
+
readonly classes: (el: Element) => Effect.Effect<string[]>;
|
|
38
|
+
readonly label: (el: Element) => Effect.Effect<string>;
|
|
39
|
+
readonly parent: (el: Element) => Effect.Effect<Element | undefined>;
|
|
40
|
+
/** `Element.matches` */
|
|
41
|
+
readonly matches: (el: Element, selector: string) => Effect.Effect<boolean>;
|
|
42
|
+
/** whether each rule applies to the element now, at-rules and selector, as the browser decides */
|
|
43
|
+
readonly probe: (el: Element, probes: readonly Probe[]) => Effect.Effect<boolean[]>;
|
|
44
|
+
/** computed values of custom properties */
|
|
45
|
+
readonly custom: (el: Element, names: readonly string[]) => Effect.Effect<string[]>;
|
|
46
|
+
/** the winner's declaration `prop: value` of the rule `selector` */
|
|
47
|
+
readonly verify: (
|
|
48
|
+
el: Element,
|
|
49
|
+
prop: string,
|
|
50
|
+
selector: string,
|
|
51
|
+
value: string,
|
|
52
|
+
longhands: readonly string[],
|
|
53
|
+
) => Effect.Effect<Observed[]>;
|
|
54
|
+
}
|
|
55
|
+
>()("@cssints/devtools/core/page") {}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The prompt of "ask OpenCode": the picked element as cssints knows it (the css.* calls that style it, the winner of each
|
|
2
|
+
// property and what it beat, the tokens) plus the user's instruction. Every list is cut, so a page of a thousand rules sends
|
|
3
|
+
// a few kilobytes; the dev server cuts the whole once more (packages/cssints/src/devtools-server.ts).
|
|
4
|
+
import type { Candidate, Inspection } from "./cascade.ts";
|
|
5
|
+
|
|
6
|
+
const SITES = 8;
|
|
7
|
+
const ROWS = 24;
|
|
8
|
+
const BEATEN = 3;
|
|
9
|
+
const TOKENS = 12;
|
|
10
|
+
const CALL = 400;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A file name relative to the root of the app, when it is under it.
|
|
14
|
+
* @param {string} root the root of the app
|
|
15
|
+
* @param {string} file a file name
|
|
16
|
+
* @returns {string} the name under the root, or the name as given
|
|
17
|
+
*/
|
|
18
|
+
export const relative = (root: string, file: string) =>
|
|
19
|
+
file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
|
|
20
|
+
|
|
21
|
+
const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text);
|
|
22
|
+
const flat = (text: string | null) => (text ?? "").replace(/\s+/g, " ").trim();
|
|
23
|
+
|
|
24
|
+
// html > body > main > p: the element and its three nearest ancestors
|
|
25
|
+
const domPath = (el: Element) => {
|
|
26
|
+
const parent = el.parentElement;
|
|
27
|
+
const up2 = parent?.parentElement;
|
|
28
|
+
return [up2?.parentElement, up2, parent, el]
|
|
29
|
+
.flatMap((e) => (e ? [`${e.localName}${e.id ? `#${e.id}` : ""}`] : []))
|
|
30
|
+
.join(" > ");
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const where = (c: Candidate) => `${c.selector} (${c.layer || "unlayered"})`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The prompt for an element: the instruction first, then what cssints knows of the element.
|
|
37
|
+
* @param {Inspection} inspection the cascade of the element (`inspect`)
|
|
38
|
+
* @param {Element} el the picked element
|
|
39
|
+
* @param {string} instruction what the user asked for
|
|
40
|
+
* @returns {string} the text to send to the agent
|
|
41
|
+
*/
|
|
42
|
+
export const buildPrompt = (inspection: Inspection, el: Element, instruction: string) => {
|
|
43
|
+
const { root } = inspection;
|
|
44
|
+
const sites = inspection.groups.flatMap((g) => (g.site ? [g.site] : []));
|
|
45
|
+
const lines = [
|
|
46
|
+
"Change the UI of the running app as the instruction says.",
|
|
47
|
+
"The styles are written with cssints: each `css.*` call below produced the classes of the element,",
|
|
48
|
+
"so edit those calls (or the markup that uses them), not the generated class names.",
|
|
49
|
+
"",
|
|
50
|
+
`Instruction: ${instruction.trim()}`,
|
|
51
|
+
"",
|
|
52
|
+
`Element: <${inspection.element}>`,
|
|
53
|
+
`DOM path: ${domPath(el)}`,
|
|
54
|
+
`Text: ${cut(flat(el.textContent), 120) || "(none)"}`,
|
|
55
|
+
`Classes: ${[...el.classList].slice(0, 12).join(" ") || "(none)"}`,
|
|
56
|
+
"",
|
|
57
|
+
"Styled by (file:line, the call as written):",
|
|
58
|
+
];
|
|
59
|
+
if (!sites.length) lines.push("- no cssints call: the element is styled elsewhere");
|
|
60
|
+
for (const s of sites.slice(0, SITES))
|
|
61
|
+
lines.push(`- ${relative(root, s.file)}:${s.line}`, ` ${cut(flat(s.text ?? ""), CALL)}`);
|
|
62
|
+
if (sites.length > SITES) lines.push(`- … and ${sites.length - SITES} more`);
|
|
63
|
+
lines.push("", "Properties now (the winner, then what it beat):");
|
|
64
|
+
for (const r of inspection.rows.slice(0, ROWS)) {
|
|
65
|
+
if (!r.winner) continue;
|
|
66
|
+
const w = r.winner;
|
|
67
|
+
const beaten = r.overridden
|
|
68
|
+
.slice(0, BEATEN)
|
|
69
|
+
.map((c) => `${c.decl.prop}: ${c.decl.value} ${where(c)}`);
|
|
70
|
+
lines.push(
|
|
71
|
+
`- ${w.decl.prop}: ${w.decl.value}${w.decl.important ? " !important" : ""} from ${where(w)}` +
|
|
72
|
+
(beaten.length ? `; overridden: ${beaten.join("; ")}` : ""),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (inspection.rows.length > ROWS) lines.push(`- … and ${inspection.rows.length - ROWS} more`);
|
|
76
|
+
if (inspection.tokens.length) {
|
|
77
|
+
lines.push("", "Tokens it reads:");
|
|
78
|
+
for (const t of inspection.tokens.slice(0, TOKENS))
|
|
79
|
+
lines.push(`- ${t.name} = ${t.computed || "(not set)"}${t.path ? ` (${t.path})` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The protocol with the dev server and the state of the inspector: the last table, decoded into a model, and whether it is
|
|
2
|
+
// stale. The transport is whatever carries the two events (Vite's HMR channel in the overlay, a port in an extension).
|
|
3
|
+
import { Console, Context, Effect, Queue, Ref, Stream, SubscriptionRef } from "effect";
|
|
4
|
+
|
|
5
|
+
import { toModel, type Model } from "./table.ts";
|
|
6
|
+
|
|
7
|
+
export const TABLE = "cssints:devtools";
|
|
8
|
+
export const STALE = "cssints:devtools:stale";
|
|
9
|
+
/** the browser asks the dev server to start an OpenCode session: `{ ref, prompt }` */
|
|
10
|
+
export const ASK = "cssints:devtools:ask";
|
|
11
|
+
/** the dev server tells how that session goes: an `Asked` */
|
|
12
|
+
export const ASKED = "cssints:devtools:asked";
|
|
13
|
+
|
|
14
|
+
/** `unavailable`: no OpenCode service runs (or its client is not installed); `message` says what to do. */
|
|
15
|
+
export interface Asked {
|
|
16
|
+
readonly ref: number;
|
|
17
|
+
readonly status: "running" | "completed" | "failed" | "interrupted" | "unavailable";
|
|
18
|
+
readonly id?: string;
|
|
19
|
+
readonly message?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TransportApi {
|
|
23
|
+
/** asks the server for the table (`cssints:devtools:get`); it answers with TABLE */
|
|
24
|
+
readonly request: () => void;
|
|
25
|
+
readonly on: (event: string, listener: (data: unknown) => void) => void;
|
|
26
|
+
/** only when the server has `opencode` on: sends a prompt, the answers come as ASKED; the overlay shows its prompt box if it is set */
|
|
27
|
+
readonly ask?: (ref: number, prompt: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Transport extends Context.Service<Transport, TransportApi>()(
|
|
31
|
+
"@cssints/devtools/core/session/Transport",
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
export interface Session {
|
|
35
|
+
/** the model of the last table; undefined until the first one */
|
|
36
|
+
readonly model: SubscriptionRef.SubscriptionRef<Model | undefined>;
|
|
37
|
+
/** an open inspector refetches when the table goes stale; a closed one when it opens */
|
|
38
|
+
readonly setActive: (active: boolean) => Effect.Effect<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const events = (transport: TransportApi, event: string) =>
|
|
42
|
+
Stream.callback<unknown>((queue) =>
|
|
43
|
+
Effect.sync(() => transport.on(event, (data) => Queue.offerUnsafe(queue, data))),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/** Listens to the server for as long as the page lives; the first table is asked for when the inspector opens. */
|
|
47
|
+
export const session = Effect.fn("devtools.session")(function* () {
|
|
48
|
+
const transport = yield* Transport;
|
|
49
|
+
const model = yield* SubscriptionRef.make<Model | undefined>(undefined);
|
|
50
|
+
const active = yield* Ref.make(false);
|
|
51
|
+
const stale = yield* Ref.make(true);
|
|
52
|
+
const request = Ref.set(stale, false).pipe(
|
|
53
|
+
Effect.andThen(Effect.sync(() => transport.request())),
|
|
54
|
+
);
|
|
55
|
+
const refetch = Effect.gen(function* () {
|
|
56
|
+
if ((yield* Ref.get(active)) && (yield* Ref.get(stale))) yield* request;
|
|
57
|
+
});
|
|
58
|
+
yield* Effect.forkDetach(
|
|
59
|
+
Stream.runForEach(events(transport, TABLE), (data) =>
|
|
60
|
+
toModel(data).pipe(
|
|
61
|
+
Effect.flatMap((m) => SubscriptionRef.set(model, m)),
|
|
62
|
+
Effect.catchTag("TableError", (e) => Console.error(e.message)),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
// a cold page load transforms every module: one refetch once they are done
|
|
67
|
+
yield* Effect.forkDetach(
|
|
68
|
+
Stream.runForEach(
|
|
69
|
+
events(transport, STALE).pipe(
|
|
70
|
+
Stream.tap(() => Ref.set(stale, true)),
|
|
71
|
+
Stream.debounce("60 millis"),
|
|
72
|
+
),
|
|
73
|
+
() => refetch,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
return {
|
|
77
|
+
model,
|
|
78
|
+
setActive: (on: boolean) => Ref.set(active, on).pipe(Effect.andThen(refetch)),
|
|
79
|
+
} satisfies Session;
|
|
80
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The table the dev server sends (`cssints:devtools`, packages/cssints/src/devtools-server.ts `DevTable`), decoded at the
|
|
2
|
+
// boundary, and the model the cascade reads: the table with its sheet parsed and its layers ranked.
|
|
3
|
+
import { Data, Effect, Schema } from "effect";
|
|
4
|
+
|
|
5
|
+
import { parseSheet, type Sheet } from "./css.ts";
|
|
6
|
+
|
|
7
|
+
const Site = Schema.Struct({
|
|
8
|
+
file: Schema.String,
|
|
9
|
+
line: Schema.Finite,
|
|
10
|
+
column: Schema.Finite,
|
|
11
|
+
classes: Schema.Array(Schema.String),
|
|
12
|
+
text: Schema.optionalKey(Schema.String),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const ClassRecord = Schema.Struct({
|
|
16
|
+
kind: Schema.Literals(["class", "marker", "group"]),
|
|
17
|
+
layer: Schema.String,
|
|
18
|
+
wrap: Schema.Array(Schema.String),
|
|
19
|
+
prop: Schema.String,
|
|
20
|
+
css: Schema.String,
|
|
21
|
+
tokens: Schema.Array(Schema.String),
|
|
22
|
+
plugin: Schema.optionalKey(Schema.String),
|
|
23
|
+
sites: Schema.Array(Schema.Finite),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const Table = Schema.Struct({
|
|
27
|
+
root: Schema.String,
|
|
28
|
+
sheet: Schema.String,
|
|
29
|
+
layers: Schema.Array(Schema.String),
|
|
30
|
+
classes: Schema.Record(Schema.String, ClassRecord),
|
|
31
|
+
sites: Schema.Array(Site),
|
|
32
|
+
tokens: Schema.Record(
|
|
33
|
+
Schema.String,
|
|
34
|
+
Schema.Struct({ path: Schema.String, value: Schema.String }),
|
|
35
|
+
),
|
|
36
|
+
});
|
|
37
|
+
export type Table = typeof Table.Type;
|
|
38
|
+
export type ClassRecord = typeof ClassRecord.Type;
|
|
39
|
+
export type Site = typeof Site.Type;
|
|
40
|
+
|
|
41
|
+
export class TableError extends Data.TaggedError("TableError")<{ readonly message: string }> {}
|
|
42
|
+
|
|
43
|
+
export interface Model {
|
|
44
|
+
table: Table;
|
|
45
|
+
sheet: Sheet;
|
|
46
|
+
/** top-level layer -> rank; a rule outside every layer ranks above all of them */
|
|
47
|
+
rank: Map<string, number>;
|
|
48
|
+
/** class -> the indexes of the rules whose selector names it */
|
|
49
|
+
rulesOf: Map<string, number[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The model of a table as the server sent it: decoded, its sheet parsed. */
|
|
53
|
+
export const toModel = Effect.fn("devtools.toModel")(function* (payload: unknown) {
|
|
54
|
+
const table = yield* Schema.decodeUnknownEffect(Table)(payload).pipe(
|
|
55
|
+
Effect.mapError(
|
|
56
|
+
(e) => new TableError({ message: `cssints devtools: a bad table: ${e.message}` }),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
const sheet = yield* parseSheet(table.sheet);
|
|
60
|
+
const rank = new Map(table.layers.map((l, i) => [l, i]));
|
|
61
|
+
const rulesOf = new Map<string, number[]>();
|
|
62
|
+
for (const r of sheet.rules)
|
|
63
|
+
for (const [, cls] of r.selector.matchAll(/\.(_[\w-]+)/g)) {
|
|
64
|
+
const list = rulesOf.get(cls!) ?? rulesOf.set(cls!, []).get(cls!)!;
|
|
65
|
+
if (list.at(-1) !== r.index) list.push(r.index);
|
|
66
|
+
}
|
|
67
|
+
return { table, sheet, rank, rulesOf } satisfies Model;
|
|
68
|
+
});
|