@chr33s/solarflare 0.0.2
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/package.json +52 -0
- package/readme.md +183 -0
- package/src/ast.ts +316 -0
- package/src/build.bundle-client.ts +404 -0
- package/src/build.bundle-server.ts +131 -0
- package/src/build.bundle.ts +48 -0
- package/src/build.emit-manifests.ts +25 -0
- package/src/build.hmr-entry.ts +88 -0
- package/src/build.scan.ts +182 -0
- package/src/build.ts +227 -0
- package/src/build.validate.ts +63 -0
- package/src/client.hmr.ts +78 -0
- package/src/client.styles.ts +68 -0
- package/src/client.ts +190 -0
- package/src/codemod.ts +688 -0
- package/src/console-forward.ts +254 -0
- package/src/critical-css.ts +103 -0
- package/src/devtools-json.ts +52 -0
- package/src/diff-dom-streaming.ts +406 -0
- package/src/early-flush.ts +125 -0
- package/src/early-hints.ts +83 -0
- package/src/fetch.ts +44 -0
- package/src/fs.ts +11 -0
- package/src/head.ts +876 -0
- package/src/hmr.ts +647 -0
- package/src/hydration.ts +238 -0
- package/src/manifest.runtime.ts +25 -0
- package/src/manifest.ts +23 -0
- package/src/paths.ts +96 -0
- package/src/render-priority.ts +69 -0
- package/src/route-cache.ts +163 -0
- package/src/router-deferred.ts +85 -0
- package/src/router-stream.ts +65 -0
- package/src/router.ts +535 -0
- package/src/runtime.ts +32 -0
- package/src/serialize.ts +38 -0
- package/src/server.hmr.ts +67 -0
- package/src/server.styles.ts +42 -0
- package/src/server.ts +480 -0
- package/src/solarflare.d.ts +101 -0
- package/src/speculation-rules.ts +171 -0
- package/src/store.ts +78 -0
- package/src/stream-assets.ts +135 -0
- package/src/stylesheets.ts +222 -0
- package/src/worker.config.ts +243 -0
- package/src/worker.ts +542 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** Speculation Rules API types and utilities for prefetch/prerender. */
|
|
2
|
+
|
|
3
|
+
/** Eagerness levels for speculation. */
|
|
4
|
+
export type SpeculationEagerness = "immediate" | "eager" | "moderate" | "conservative";
|
|
5
|
+
|
|
6
|
+
/** Referrer policy for speculative requests. */
|
|
7
|
+
type SpeculationReferrerPolicy =
|
|
8
|
+
| "no-referrer"
|
|
9
|
+
| "no-referrer-when-downgrade"
|
|
10
|
+
| "origin"
|
|
11
|
+
| "origin-when-cross-origin"
|
|
12
|
+
| "same-origin"
|
|
13
|
+
| "strict-origin"
|
|
14
|
+
| "strict-origin-when-cross-origin"
|
|
15
|
+
| "unsafe-url";
|
|
16
|
+
|
|
17
|
+
/** Document-sourced rule matching anchors. */
|
|
18
|
+
export interface DocumentRule {
|
|
19
|
+
source: "document";
|
|
20
|
+
where?: {
|
|
21
|
+
href_matches?: string | string[];
|
|
22
|
+
selector_matches?: string;
|
|
23
|
+
and?: DocumentRule["where"][];
|
|
24
|
+
or?: DocumentRule["where"][];
|
|
25
|
+
not?: DocumentRule["where"];
|
|
26
|
+
};
|
|
27
|
+
eagerness?: SpeculationEagerness;
|
|
28
|
+
referrer_policy?: SpeculationReferrerPolicy;
|
|
29
|
+
expects_no_vary_search?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** List-sourced rule with explicit URLs. */
|
|
33
|
+
export interface ListRule {
|
|
34
|
+
source: "list";
|
|
35
|
+
urls: string[];
|
|
36
|
+
eagerness?: SpeculationEagerness;
|
|
37
|
+
referrer_policy?: SpeculationReferrerPolicy;
|
|
38
|
+
expects_no_vary_search?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Union type for speculation rules. */
|
|
42
|
+
export type SpeculationRule = DocumentRule | ListRule;
|
|
43
|
+
|
|
44
|
+
/** Complete speculation rules object. */
|
|
45
|
+
export interface SpeculationRules {
|
|
46
|
+
prefetch?: SpeculationRule[];
|
|
47
|
+
prerender?: SpeculationRule[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Checks if Speculation Rules API is supported. */
|
|
51
|
+
export function supportsSpeculationRules() {
|
|
52
|
+
return (
|
|
53
|
+
typeof HTMLScriptElement !== "undefined" &&
|
|
54
|
+
HTMLScriptElement.supports?.("speculationrules") === true
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Injects speculation rules into the document head. */
|
|
59
|
+
export function injectSpeculationRules(rules: SpeculationRules) {
|
|
60
|
+
if (typeof document === "undefined") return null;
|
|
61
|
+
|
|
62
|
+
const script = document.createElement("script");
|
|
63
|
+
script.type = "speculationrules";
|
|
64
|
+
script.textContent = JSON.stringify(rules);
|
|
65
|
+
const head = document.head;
|
|
66
|
+
const existing = head.querySelector('script[type="speculationrules"]');
|
|
67
|
+
if (existing) {
|
|
68
|
+
existing.replaceWith(script);
|
|
69
|
+
return script;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const range = document.createRange();
|
|
73
|
+
const firstManaged = head.querySelector("[data-sf-head]");
|
|
74
|
+
if (firstManaged) {
|
|
75
|
+
range.setStartBefore(firstManaged);
|
|
76
|
+
range.collapse(true);
|
|
77
|
+
} else {
|
|
78
|
+
range.selectNodeContents(head);
|
|
79
|
+
range.collapse(false);
|
|
80
|
+
}
|
|
81
|
+
range.insertNode(script);
|
|
82
|
+
return script;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Removes all speculation rules scripts from the document. */
|
|
86
|
+
export function clearSpeculationRules() {
|
|
87
|
+
if (typeof document === "undefined") return;
|
|
88
|
+
|
|
89
|
+
const scripts = document.querySelectorAll('script[type="speculationrules"]');
|
|
90
|
+
for (const script of scripts) {
|
|
91
|
+
script.remove();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Creates a list-based prefetch rule. */
|
|
96
|
+
export function createPrefetchListRule(
|
|
97
|
+
urls: string[],
|
|
98
|
+
options: Omit<ListRule, "source" | "urls"> = {},
|
|
99
|
+
): ListRule {
|
|
100
|
+
return { source: "list", urls, ...options };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Creates a list-based prerender rule. */
|
|
104
|
+
export function createPrerenderListRule(
|
|
105
|
+
urls: string[],
|
|
106
|
+
options: Omit<ListRule, "source" | "urls"> = {},
|
|
107
|
+
): ListRule {
|
|
108
|
+
return { source: "list", urls, ...options };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Creates a document-sourced rule matching href patterns. */
|
|
112
|
+
export function createDocumentRule(
|
|
113
|
+
patterns: string | string[],
|
|
114
|
+
options: Omit<DocumentRule, "source" | "where"> = {},
|
|
115
|
+
): DocumentRule {
|
|
116
|
+
return {
|
|
117
|
+
source: "document",
|
|
118
|
+
where: { href_matches: patterns },
|
|
119
|
+
...options,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Creates a document-sourced rule matching CSS selectors. */
|
|
124
|
+
export function createSelectorRule(
|
|
125
|
+
selector: string,
|
|
126
|
+
options: Omit<DocumentRule, "source" | "where"> = {},
|
|
127
|
+
): DocumentRule {
|
|
128
|
+
return {
|
|
129
|
+
source: "document",
|
|
130
|
+
where: { selector_matches: selector },
|
|
131
|
+
...options,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Builds speculation rules from route patterns. */
|
|
136
|
+
export function buildRouteSpeculationRules(
|
|
137
|
+
routes: { pattern: string; prerender?: boolean }[],
|
|
138
|
+
base = "",
|
|
139
|
+
): SpeculationRules {
|
|
140
|
+
const prefetchUrls: string[] = [];
|
|
141
|
+
const prerenderUrls: string[] = [];
|
|
142
|
+
|
|
143
|
+
for (const route of routes) {
|
|
144
|
+
// Skip dynamic routes (contain : or *)
|
|
145
|
+
if (/[:*]/.test(route.pattern)) continue;
|
|
146
|
+
|
|
147
|
+
const url = base + route.pattern;
|
|
148
|
+
if (route.prerender) {
|
|
149
|
+
prerenderUrls.push(url);
|
|
150
|
+
} else {
|
|
151
|
+
prefetchUrls.push(url);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rules: SpeculationRules = {};
|
|
156
|
+
|
|
157
|
+
if (prefetchUrls.length > 0) {
|
|
158
|
+
rules.prefetch = [createPrefetchListRule(prefetchUrls, { eagerness: "moderate" })];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (prerenderUrls.length > 0) {
|
|
162
|
+
rules.prerender = [createPrerenderListRule(prerenderUrls, { eagerness: "moderate" })];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return rules;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Generates inline script tag HTML for SSR. */
|
|
169
|
+
export function renderSpeculationRulesTag(rules: SpeculationRules) {
|
|
170
|
+
return `<script type="speculationrules">${JSON.stringify(rules)}</script>`;
|
|
171
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { signal, batch, type ReadonlySignal } from "@preact/signals";
|
|
2
|
+
|
|
3
|
+
export interface ServerData<T = unknown> {
|
|
4
|
+
data: T;
|
|
5
|
+
loading: boolean;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StoreConfig {
|
|
10
|
+
params?: Record<string, string>;
|
|
11
|
+
serverData?: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Route params signal. */
|
|
15
|
+
const _params = signal<Record<string, string>>({});
|
|
16
|
+
|
|
17
|
+
/** Server data signal. */
|
|
18
|
+
const _serverData = signal<ServerData<unknown>>({
|
|
19
|
+
data: null,
|
|
20
|
+
loading: false,
|
|
21
|
+
error: null,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Pathname signal. */
|
|
25
|
+
const _pathname = signal<string>("");
|
|
26
|
+
|
|
27
|
+
/** Route parameters. */
|
|
28
|
+
export const params: ReadonlySignal<Record<string, string>> = _params;
|
|
29
|
+
|
|
30
|
+
/** Server data. */
|
|
31
|
+
export const serverData: ReadonlySignal<ServerData<unknown>> = _serverData;
|
|
32
|
+
|
|
33
|
+
/** Current pathname. */
|
|
34
|
+
export const pathname: ReadonlySignal<string> = _pathname;
|
|
35
|
+
|
|
36
|
+
/** Sets route parameters. */
|
|
37
|
+
export function setParams(newParams: Record<string, string>) {
|
|
38
|
+
_params.value = newParams;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Sets server data. */
|
|
42
|
+
export function setServerData<T>(data: T) {
|
|
43
|
+
_serverData.value = {
|
|
44
|
+
data,
|
|
45
|
+
loading: false,
|
|
46
|
+
error: null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Sets current pathname. */
|
|
51
|
+
export function setPathname(path: string) {
|
|
52
|
+
_pathname.value = path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Initializes store with config. */
|
|
56
|
+
export function initStore(config: StoreConfig = {}) {
|
|
57
|
+
batch(() => {
|
|
58
|
+
if (config.params) {
|
|
59
|
+
_params.value = config.params;
|
|
60
|
+
}
|
|
61
|
+
if (config.serverData !== undefined) {
|
|
62
|
+
_serverData.value = {
|
|
63
|
+
data: config.serverData,
|
|
64
|
+
loading: false,
|
|
65
|
+
error: null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resets store to initial state. */
|
|
72
|
+
export function resetStore() {
|
|
73
|
+
batch(() => {
|
|
74
|
+
_params.value = {};
|
|
75
|
+
_serverData.value = { data: null, loading: false, error: null };
|
|
76
|
+
_pathname.value = "";
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { getHeadContext, HEAD_MARKER } from "./head.ts";
|
|
2
|
+
|
|
3
|
+
/** Marker for asset injection during streaming. */
|
|
4
|
+
export const BODY_MARKER = "<!--SOLARFLARE_BODY-->";
|
|
5
|
+
|
|
6
|
+
/** Generates asset HTML tags for injection. */
|
|
7
|
+
export function generateAssetTags(script?: string, styles?: string[], devScripts?: string[]) {
|
|
8
|
+
let html = "";
|
|
9
|
+
|
|
10
|
+
// Add stylesheet links
|
|
11
|
+
if (styles && styles.length > 0) {
|
|
12
|
+
for (const href of styles) {
|
|
13
|
+
html += /* html */ `<link rel="stylesheet" href="${href}">`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Add dev mode scripts (like console forwarding)
|
|
18
|
+
if (devScripts && devScripts.length > 0) {
|
|
19
|
+
for (const src of devScripts) {
|
|
20
|
+
html += /* html */ `<script src="${src}" async></script>`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Add script tag
|
|
25
|
+
if (script) {
|
|
26
|
+
html += /* html */ `<script type="module" src="${script}" async></script>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return html;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Transforms stream to inject assets, head tags, and store hydration. */
|
|
33
|
+
export function createAssetInjectionTransformer(
|
|
34
|
+
storeScript: string,
|
|
35
|
+
script?: string,
|
|
36
|
+
styles?: string[],
|
|
37
|
+
devScripts?: string[],
|
|
38
|
+
): TransformStream<Uint8Array, Uint8Array> {
|
|
39
|
+
const encoder = new TextEncoder();
|
|
40
|
+
const decoder = new TextDecoder();
|
|
41
|
+
let buffer = "";
|
|
42
|
+
let doctypeInjected = false;
|
|
43
|
+
let headInjected = false;
|
|
44
|
+
const bodyMarker = /* html */ `<solarflare-body>${BODY_MARKER}</solarflare-body>`;
|
|
45
|
+
const headMarker = HEAD_MARKER;
|
|
46
|
+
|
|
47
|
+
function replaceHeadMarker(html: string) {
|
|
48
|
+
const markerIndex = html.indexOf(headMarker);
|
|
49
|
+
if (markerIndex === -1) return { html, replaced: false };
|
|
50
|
+
|
|
51
|
+
const templateStart = html.lastIndexOf("<template", markerIndex);
|
|
52
|
+
if (templateStart !== -1) {
|
|
53
|
+
const tagEnd = html.indexOf(">", templateStart);
|
|
54
|
+
if (tagEnd !== -1) {
|
|
55
|
+
const openTag = html.slice(templateStart, tagEnd + 1);
|
|
56
|
+
const templateEnd = html.indexOf("</template>", markerIndex);
|
|
57
|
+
if (openTag.includes("data-sf-head") && templateEnd !== -1) {
|
|
58
|
+
const replaced =
|
|
59
|
+
html.slice(0, templateStart) +
|
|
60
|
+
getHeadContext().renderToString() +
|
|
61
|
+
html.slice(templateEnd + "</template>".length);
|
|
62
|
+
return { html: replaced, replaced: true };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { html: html.replace(headMarker, getHeadContext().renderToString()), replaced: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new TransformStream({
|
|
71
|
+
transform(chunk, controller) {
|
|
72
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
73
|
+
|
|
74
|
+
// Inject <!doctype html> before the root <html> tag (only once)
|
|
75
|
+
if (!doctypeInjected) {
|
|
76
|
+
const htmlIndex = buffer.indexOf("<html");
|
|
77
|
+
if (htmlIndex !== -1) {
|
|
78
|
+
buffer = buffer.slice(0, htmlIndex) + "<!doctype html>" + buffer.slice(htmlIndex);
|
|
79
|
+
doctypeInjected = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Inject head tags at Head marker (only once)
|
|
84
|
+
if (!headInjected) {
|
|
85
|
+
const result = replaceHeadMarker(buffer);
|
|
86
|
+
buffer = result.html;
|
|
87
|
+
headInjected = result.replaced;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if we have the complete body marker
|
|
91
|
+
const markerIndex = buffer.indexOf(bodyMarker);
|
|
92
|
+
if (markerIndex !== -1) {
|
|
93
|
+
// Generate replacement content
|
|
94
|
+
const assetTags = generateAssetTags(script, styles, devScripts);
|
|
95
|
+
|
|
96
|
+
// Replace marker with assets + store hydration
|
|
97
|
+
buffer = buffer.replace(bodyMarker, assetTags + storeScript);
|
|
98
|
+
|
|
99
|
+
// Flush everything before and including the replacement
|
|
100
|
+
controller.enqueue(encoder.encode(buffer));
|
|
101
|
+
buffer = "";
|
|
102
|
+
} else if (buffer.length > bodyMarker.length * 2) {
|
|
103
|
+
// If buffer is getting large and no marker found, flush safe portion
|
|
104
|
+
const safeLength = buffer.length - bodyMarker.length;
|
|
105
|
+
controller.enqueue(encoder.encode(buffer.slice(0, safeLength)));
|
|
106
|
+
buffer = buffer.slice(safeLength);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
flush(controller) {
|
|
110
|
+
// Flush any remaining content
|
|
111
|
+
if (buffer) {
|
|
112
|
+
// Inject doctype if not done yet (edge case: small document)
|
|
113
|
+
if (!doctypeInjected) {
|
|
114
|
+
const htmlIndex = buffer.indexOf("<html");
|
|
115
|
+
if (htmlIndex !== -1) {
|
|
116
|
+
buffer = buffer.slice(0, htmlIndex) + "<!doctype html>" + buffer.slice(htmlIndex);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Final check for head marker in remaining content
|
|
120
|
+
if (!headInjected) {
|
|
121
|
+
const result = replaceHeadMarker(buffer);
|
|
122
|
+
buffer = result.html;
|
|
123
|
+
headInjected = result.replaced;
|
|
124
|
+
}
|
|
125
|
+
// Final check for body marker in remaining content
|
|
126
|
+
const markerIndex = buffer.indexOf(bodyMarker);
|
|
127
|
+
if (markerIndex !== -1) {
|
|
128
|
+
const assetTags = generateAssetTags(script, styles, devScripts);
|
|
129
|
+
buffer = buffer.replace(bodyMarker, assetTags + storeScript);
|
|
130
|
+
}
|
|
131
|
+
controller.enqueue(encoder.encode(buffer));
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/** Feature detection for Constructable Stylesheets. */
|
|
2
|
+
export const supportsConstructableStylesheets = () => {
|
|
3
|
+
if (typeof window === "undefined") return false;
|
|
4
|
+
try {
|
|
5
|
+
new CSSStyleSheet();
|
|
6
|
+
return true;
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Stylesheet entry with metadata. */
|
|
13
|
+
interface StylesheetEntry {
|
|
14
|
+
sheet: CSSStyleSheet;
|
|
15
|
+
source: string;
|
|
16
|
+
hash: string;
|
|
17
|
+
consumers: Set<string>;
|
|
18
|
+
isGlobal: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Stylesheet manager for efficient CSS handling. */
|
|
22
|
+
class StylesheetManager {
|
|
23
|
+
#sheets = new Map<string, StylesheetEntry>();
|
|
24
|
+
#documentSheets: CSSStyleSheet[] = [];
|
|
25
|
+
|
|
26
|
+
/** Registers a stylesheet with the manager. */
|
|
27
|
+
register(id: string, css: string, options: { isGlobal?: boolean; consumer?: string } = {}) {
|
|
28
|
+
if (!this.#isSupported()) {
|
|
29
|
+
this.#injectStyleElement(id, css);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const existing = this.#sheets.get(id);
|
|
34
|
+
const hash = this.#hash(css);
|
|
35
|
+
|
|
36
|
+
if (existing) {
|
|
37
|
+
if (existing.hash !== hash) {
|
|
38
|
+
existing.sheet.replaceSync(css);
|
|
39
|
+
existing.source = css;
|
|
40
|
+
existing.hash = hash;
|
|
41
|
+
}
|
|
42
|
+
if (options.consumer) {
|
|
43
|
+
existing.consumers.add(options.consumer);
|
|
44
|
+
}
|
|
45
|
+
return existing.sheet;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sheet = new CSSStyleSheet();
|
|
49
|
+
sheet.replaceSync(css);
|
|
50
|
+
|
|
51
|
+
const entry: StylesheetEntry = {
|
|
52
|
+
sheet,
|
|
53
|
+
source: css,
|
|
54
|
+
hash,
|
|
55
|
+
consumers: new Set(options.consumer ? [options.consumer] : []),
|
|
56
|
+
isGlobal: options.isGlobal ?? false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this.#sheets.set(id, entry);
|
|
60
|
+
|
|
61
|
+
if (entry.isGlobal) {
|
|
62
|
+
this.#adoptToDocument(sheet);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return sheet;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Gets a registered stylesheet. */
|
|
69
|
+
get(id: string) {
|
|
70
|
+
return this.#sheets.get(id)?.sheet ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Gets all stylesheets for a route/consumer. */
|
|
74
|
+
getForConsumer(consumer: string) {
|
|
75
|
+
const sheets: CSSStyleSheet[] = [];
|
|
76
|
+
|
|
77
|
+
for (const entry of this.#sheets.values()) {
|
|
78
|
+
if (entry.isGlobal || entry.consumers.has(consumer)) {
|
|
79
|
+
sheets.push(entry.sheet);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return sheets;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Updates a stylesheet with new CSS (for HMR). */
|
|
87
|
+
update(id: string, css: string) {
|
|
88
|
+
const entry = this.#sheets.get(id);
|
|
89
|
+
if (!entry) return false;
|
|
90
|
+
|
|
91
|
+
const hash = this.#hash(css);
|
|
92
|
+
if (entry.hash === hash) return false; // No change
|
|
93
|
+
|
|
94
|
+
// For small changes, try incremental update
|
|
95
|
+
if (this.#canIncrementalUpdate(entry.source, css)) {
|
|
96
|
+
this.#incrementalUpdate(entry.sheet, entry.source, css);
|
|
97
|
+
} else {
|
|
98
|
+
// Full replace for larger changes
|
|
99
|
+
entry.sheet.replaceSync(css);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
entry.source = css;
|
|
103
|
+
entry.hash = hash;
|
|
104
|
+
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Inserts a single rule into a stylesheet. */
|
|
109
|
+
insertRule(id: string, rule: string, index?: number) {
|
|
110
|
+
const entry = this.#sheets.get(id);
|
|
111
|
+
if (!entry) return -1;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const insertIndex = index ?? entry.sheet.cssRules.length;
|
|
115
|
+
return entry.sheet.insertRule(rule, insertIndex);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.warn(`[stylesheets] Failed to insert rule: ${rule}`, e);
|
|
118
|
+
return -1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Deletes a rule from a stylesheet. */
|
|
123
|
+
deleteRule(id: string, index: number) {
|
|
124
|
+
const entry = this.#sheets.get(id);
|
|
125
|
+
if (!entry) return false;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
entry.sheet.deleteRule(index);
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Adopts stylesheets to a Shadow Root. */
|
|
136
|
+
adoptToShadowRoot(shadowRoot: ShadowRoot, stylesheetIds: string[]) {
|
|
137
|
+
if (!this.#isSupported()) return;
|
|
138
|
+
|
|
139
|
+
const sheets = stylesheetIds
|
|
140
|
+
.map((id) => this.#sheets.get(id)?.sheet)
|
|
141
|
+
.filter((s): s is CSSStyleSheet => s !== null);
|
|
142
|
+
|
|
143
|
+
// Include global sheets
|
|
144
|
+
const globalSheets = [...this.#sheets.values()].filter((e) => e.isGlobal).map((e) => e.sheet);
|
|
145
|
+
|
|
146
|
+
shadowRoot.adoptedStyleSheets = [...globalSheets, ...sheets];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Removes a consumer from all its stylesheets. */
|
|
150
|
+
removeConsumer(consumer: string) {
|
|
151
|
+
for (const [id, entry] of this.#sheets.entries()) {
|
|
152
|
+
entry.consumers.delete(consumer);
|
|
153
|
+
|
|
154
|
+
// Clean up orphaned non-global stylesheets
|
|
155
|
+
if (!entry.isGlobal && entry.consumers.size === 0) {
|
|
156
|
+
this.#sheets.delete(id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Clears all stylesheets. */
|
|
162
|
+
clear() {
|
|
163
|
+
this.#sheets.clear();
|
|
164
|
+
if (this.#isSupported()) {
|
|
165
|
+
document.adoptedStyleSheets = [];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ========== Private Methods ==========
|
|
170
|
+
|
|
171
|
+
#adoptToDocument(sheet: CSSStyleSheet) {
|
|
172
|
+
if (!this.#documentSheets.includes(sheet)) {
|
|
173
|
+
this.#documentSheets.push(sheet);
|
|
174
|
+
document.adoptedStyleSheets = [...this.#documentSheets];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#isSupported() {
|
|
179
|
+
return supportsConstructableStylesheets();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#hash(css: string) {
|
|
183
|
+
let hash = 0;
|
|
184
|
+
for (let i = 0; i < css.length; i++) {
|
|
185
|
+
const char = css.charCodeAt(i);
|
|
186
|
+
hash = ((hash << 5) - hash + char) | 0;
|
|
187
|
+
}
|
|
188
|
+
return hash.toString(36);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#canIncrementalUpdate(oldCss: string, newCss: string) {
|
|
192
|
+
// Only do incremental updates for small changes
|
|
193
|
+
const sizeDiff = Math.abs(newCss.length - oldCss.length);
|
|
194
|
+
return sizeDiff < 500;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#incrementalUpdate(sheet: CSSStyleSheet, _oldCss: string, newCss: string) {
|
|
198
|
+
// Simple diff: find changed rules
|
|
199
|
+
// In practice, you'd use a proper CSS parser for this
|
|
200
|
+
// For now, fall back to replaceSync
|
|
201
|
+
sheet.replaceSync(newCss);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#injectStyleElement(id: string, css: string) {
|
|
205
|
+
// Fallback for browsers without Constructable Stylesheets
|
|
206
|
+
let style = document.getElementById(`sf-style-${id}`) as HTMLStyleElement;
|
|
207
|
+
|
|
208
|
+
if (!style) {
|
|
209
|
+
style = document.createElement("style");
|
|
210
|
+
style.id = `sf-style-${id}`;
|
|
211
|
+
document.head.appendChild(style);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
style.textContent = css;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Global stylesheet manager instance. */
|
|
219
|
+
export const stylesheets = new StylesheetManager();
|
|
220
|
+
|
|
221
|
+
// Export for testing
|
|
222
|
+
export { StylesheetManager };
|