@k2b/ssr 0.12.0-rc.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 +402 -0
- package/package.json +69 -0
- package/src/adapter/bun.ts +51 -0
- package/src/adapter/client.js +421 -0
- package/src/adapter/elysia.ts +39 -0
- package/src/adapter/hono.ts +190 -0
- package/src/adapter/utils.ts +190 -0
- package/src/build.ts +230 -0
- package/src/index.ts +246 -0
- package/src/island-id.ts +78 -0
- package/src/island-resolve.ts +39 -0
- package/src/nav.ts +308 -0
- package/src/transform.ts +157 -0
package/src/island-id.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable island ID generation based on canonical file paths.
|
|
3
|
+
* IDs are deterministic for file-based imports and robust in monorepos.
|
|
4
|
+
*/
|
|
5
|
+
import { realpathSync } from "fs";
|
|
6
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
7
|
+
|
|
8
|
+
/** Length of hashed island IDs used in data-id and generated chunk filenames */
|
|
9
|
+
export const ISLAND_ID_LENGTH = 12;
|
|
10
|
+
|
|
11
|
+
const canonicalFileCache = new Map<string, string>();
|
|
12
|
+
const canonicalRootCache = new Map<string, string>();
|
|
13
|
+
|
|
14
|
+
const toPosixPath = (value: string): string => value.replace(/\\/g, "/");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Short deterministic hash helper used for island IDs.
|
|
18
|
+
*/
|
|
19
|
+
export const hash = (value: string): string =>
|
|
20
|
+
new Bun.CryptoHasher("md5").update(value).digest("hex").slice(0, ISLAND_ID_LENGTH);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Canonicalize file path for stable hashing.
|
|
24
|
+
* Falls back to resolve() when realpath cannot be resolved.
|
|
25
|
+
*/
|
|
26
|
+
export const canonicalFilePath = (file: string): string => {
|
|
27
|
+
const absolutePath = resolve(file);
|
|
28
|
+
const cached = canonicalFileCache.get(absolutePath);
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
|
|
31
|
+
let canonical = absolutePath;
|
|
32
|
+
try {
|
|
33
|
+
canonical = realpathSync(absolutePath);
|
|
34
|
+
} catch {
|
|
35
|
+
// Keep resolved absolute path when the file does not exist yet.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalized = toPosixPath(canonical);
|
|
39
|
+
canonicalFileCache.set(absolutePath, normalized);
|
|
40
|
+
return normalized;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const canonicalRootPath = (rootDir: string): string => {
|
|
44
|
+
const absolutePath = resolve(rootDir);
|
|
45
|
+
const cached = canonicalRootCache.get(absolutePath);
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
|
|
48
|
+
let canonical = absolutePath;
|
|
49
|
+
try {
|
|
50
|
+
canonical = realpathSync(absolutePath);
|
|
51
|
+
} catch {
|
|
52
|
+
// Keep resolved absolute path when the directory does not exist yet.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const normalized = toPosixPath(canonical);
|
|
56
|
+
canonicalRootCache.set(absolutePath, normalized);
|
|
57
|
+
return normalized;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert file path to a stable key:
|
|
62
|
+
* - relative to rootDir when inside rootDir
|
|
63
|
+
* - otherwise canonical absolute path
|
|
64
|
+
*/
|
|
65
|
+
export const toStableKey = (file: string, rootDir: string): string => {
|
|
66
|
+
const canonicalFile = canonicalFilePath(file);
|
|
67
|
+
const root = canonicalRootPath(rootDir);
|
|
68
|
+
const rel = toPosixPath(relative(root, canonicalFile));
|
|
69
|
+
const isWithinRoot = !isAbsolute(rel) && rel !== ".." && !rel.startsWith("../");
|
|
70
|
+
|
|
71
|
+
return isWithinRoot ? rel : canonicalFile;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build final stable island ID from canonical path key.
|
|
76
|
+
*/
|
|
77
|
+
export const islandIdFromFile = (file: string, rootDir: string): string =>
|
|
78
|
+
hash(toStableKey(file, rootDir));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { dirname, isAbsolute, resolve } from "path";
|
|
2
|
+
|
|
3
|
+
const hasScriptExtension = (value: string): boolean => /\.(tsx|jsx|ts|js)$/.test(value);
|
|
4
|
+
|
|
5
|
+
export const withDefaultIslandExtension = (specifier: string): string =>
|
|
6
|
+
hasScriptExtension(specifier) ? specifier : `${specifier}.tsx`;
|
|
7
|
+
|
|
8
|
+
const formatResolveError = (specifier: string, resolveDir: string, importer?: string, reason?: string): Error => {
|
|
9
|
+
const importerHint = importer ? ` from "${importer}"` : "";
|
|
10
|
+
const detail = reason ? `\nReason: ${reason}` : "";
|
|
11
|
+
return new Error(
|
|
12
|
+
`[ssr] Failed to resolve island/client import "${specifier}"${importerHint} using resolveDir "${resolveDir}".` +
|
|
13
|
+
`\nCheck your tsconfig paths/baseUrl (for aliases) or use a relative/absolute file import.${detail}`,
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const resolveIslandImport = (specifier: string, resolveDir: string, importer?: string): string => {
|
|
18
|
+
const normalizedSpecifier = withDefaultIslandExtension(specifier);
|
|
19
|
+
const normalizedResolveDir = resolveDir || (importer ? dirname(importer) : process.cwd());
|
|
20
|
+
|
|
21
|
+
if (specifier.startsWith(".")) {
|
|
22
|
+
return resolve(normalizedResolveDir, normalizedSpecifier);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isAbsolute(specifier)) {
|
|
26
|
+
return resolve(normalizedSpecifier);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const resolved = Bun.resolveSync(normalizedSpecifier, normalizedResolveDir);
|
|
31
|
+
if (!isAbsolute(resolved)) {
|
|
32
|
+
throw formatResolveError(specifier, normalizedResolveDir, importer, `resolved to non-absolute path "${resolved}"`);
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
37
|
+
throw formatResolveError(specifier, normalizedResolveDir, importer, reason);
|
|
38
|
+
}
|
|
39
|
+
};
|
package/src/nav.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in browser navigation helpers for SSR islands.
|
|
3
|
+
*
|
|
4
|
+
* This is not a router. Links remain real anchors and apps decide whether an
|
|
5
|
+
* enhanced click can update client state before committing browser history.
|
|
6
|
+
*/
|
|
7
|
+
import { mergeProps, splitProps, type JSX } from "solid-js";
|
|
8
|
+
import { createDynamic } from "solid-js/web";
|
|
9
|
+
|
|
10
|
+
type AnchorProps = JSX.AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
11
|
+
|
|
12
|
+
export type NavigationScrollMode = "top" | "preserve" | "manual";
|
|
13
|
+
|
|
14
|
+
export type ScrollSnapshot = {
|
|
15
|
+
window: { x: number; y: number };
|
|
16
|
+
regions: Array<{
|
|
17
|
+
key: string;
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type EnhancedNavigateOptions = {
|
|
24
|
+
replace?: boolean;
|
|
25
|
+
scroll?: NavigationScrollMode;
|
|
26
|
+
scrollSnapshot?: ScrollSnapshot;
|
|
27
|
+
state?: unknown;
|
|
28
|
+
viewTransition?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PopStateNavigationEvent = {
|
|
32
|
+
event: PopStateEvent;
|
|
33
|
+
url: URL;
|
|
34
|
+
state: unknown;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type LinkNavigateEvent = {
|
|
38
|
+
event: MouseEvent;
|
|
39
|
+
href: string;
|
|
40
|
+
url: URL;
|
|
41
|
+
replace: boolean;
|
|
42
|
+
scroll: NavigationScrollMode;
|
|
43
|
+
push: (href?: string, options?: EnhancedNavigateOptions) => void;
|
|
44
|
+
replaceWith: (href?: string, options?: Omit<EnhancedNavigateOptions, "replace">) => void;
|
|
45
|
+
fallback: (href?: string) => void;
|
|
46
|
+
scrollSnapshot: ScrollSnapshot;
|
|
47
|
+
captureScroll: (selector?: string) => ScrollSnapshot;
|
|
48
|
+
restoreScroll: typeof restoreScroll;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type LinkProps = Omit<AnchorProps, "href" | "onClick"> & {
|
|
52
|
+
href: string;
|
|
53
|
+
replace?: boolean;
|
|
54
|
+
scroll?: NavigationScrollMode;
|
|
55
|
+
onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
|
|
56
|
+
onNavigate?: (event: LinkNavigateEvent) => void | Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const SCROLL_PRESERVE_SELECTOR = "[data-scroll-preserve]";
|
|
60
|
+
|
|
61
|
+
type ViewTransitionDocument = Document & {
|
|
62
|
+
startViewTransition?: (callback: () => void | Promise<void>) => unknown;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns the current URL path + query, without the hash.
|
|
67
|
+
*/
|
|
68
|
+
export const currentPathWithQuery = (): string => {
|
|
69
|
+
const url = new URL(window.location.href);
|
|
70
|
+
return `${url.pathname}${url.search}`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Navigates to the current path + query with a full document navigation.
|
|
75
|
+
*/
|
|
76
|
+
export const refreshCurrentPath = (): void => {
|
|
77
|
+
window.location.assign(currentPathWithQuery());
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Navigates with a full document navigation.
|
|
82
|
+
*/
|
|
83
|
+
export const navigateTo = (href: string): void => {
|
|
84
|
+
window.location.assign(href);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const startViewTransition = (callback: () => void | Promise<void>): void => {
|
|
88
|
+
const doc = document as ViewTransitionDocument;
|
|
89
|
+
if (!doc.startViewTransition) {
|
|
90
|
+
void callback();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
doc.startViewTransition(callback);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const restoreRegionScroll = (snapshot: ScrollSnapshot): void => {
|
|
97
|
+
for (const region of snapshot.regions) {
|
|
98
|
+
const selector = `[data-scroll-preserve="${CSS.escape(region.key)}"]`;
|
|
99
|
+
const el = document.querySelector<HTMLElement>(selector);
|
|
100
|
+
if (!el) continue;
|
|
101
|
+
el.scrollLeft = region.x;
|
|
102
|
+
el.scrollTop = region.y;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Captures window scroll and keyed `[data-scroll-preserve]` regions.
|
|
108
|
+
*/
|
|
109
|
+
export const captureScroll = (selector = SCROLL_PRESERVE_SELECTOR): ScrollSnapshot => ({
|
|
110
|
+
window: { x: window.scrollX, y: window.scrollY },
|
|
111
|
+
regions: Array.from(document.querySelectorAll<HTMLElement>(selector))
|
|
112
|
+
.map((el) => ({
|
|
113
|
+
key: el.dataset.scrollPreserve ?? "",
|
|
114
|
+
x: el.scrollLeft,
|
|
115
|
+
y: el.scrollTop,
|
|
116
|
+
}))
|
|
117
|
+
.filter((region) => region.key.length > 0),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Restores a captured scroll snapshot.
|
|
122
|
+
*/
|
|
123
|
+
export const restoreScroll = (snapshot: ScrollSnapshot, options: { window?: boolean } = {}): void => {
|
|
124
|
+
restoreRegionScroll(snapshot);
|
|
125
|
+
if (options.window === false) return;
|
|
126
|
+
window.scrollTo(snapshot.window.x, snapshot.window.y);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Subscribes to browser Back/Forward navigation.
|
|
131
|
+
*
|
|
132
|
+
* The application remains responsible for reconciling its island state with
|
|
133
|
+
* the URL. This helper intentionally does not perform route matching.
|
|
134
|
+
*/
|
|
135
|
+
export const listenPopState = (handler: (navigation: PopStateNavigationEvent) => void): (() => void) => {
|
|
136
|
+
const listener = (event: PopStateEvent) => {
|
|
137
|
+
handler({ event, url: new URL(window.location.href), state: event.state });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
window.addEventListener("popstate", listener);
|
|
141
|
+
return () => window.removeEventListener("popstate", listener);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const resolveNavigationUrl = (href: string): URL =>
|
|
145
|
+
new URL(href, document.baseURI || window.location.href);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Updates browser history without a document reload.
|
|
149
|
+
*
|
|
150
|
+
* Use this after the current island has already updated its UI or when it is
|
|
151
|
+
* intentionally preserving the current DOM. It does not match routes, load
|
|
152
|
+
* data, or re-render server pages.
|
|
153
|
+
*/
|
|
154
|
+
export const navigate = (href: string, options: EnhancedNavigateOptions = {}): void => {
|
|
155
|
+
const url = resolveNavigationUrl(href);
|
|
156
|
+
if (url.origin !== window.location.origin) {
|
|
157
|
+
documentNavigate(url.href, { replace: options.replace });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const scroll = options.scroll ?? "top";
|
|
162
|
+
const snapshot = scroll === "manual" ? null : (options.scrollSnapshot ?? captureScroll());
|
|
163
|
+
const target = `${url.pathname}${url.search}${url.hash}`;
|
|
164
|
+
|
|
165
|
+
const commit = () => {
|
|
166
|
+
const hasExplicitState = Object.prototype.hasOwnProperty.call(options, "state");
|
|
167
|
+
const state = hasExplicitState ? options.state : options.replace ? window.history.state : null;
|
|
168
|
+
|
|
169
|
+
if (options.replace) window.history.replaceState(state, "", target);
|
|
170
|
+
else window.history.pushState(state, "", target);
|
|
171
|
+
|
|
172
|
+
if (!snapshot) return;
|
|
173
|
+
restoreRegionScroll(snapshot);
|
|
174
|
+
if (scroll === "preserve") {
|
|
175
|
+
window.scrollTo(snapshot.window.x, snapshot.window.y);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
window.scrollTo(0, 0);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (options.viewTransition === false) {
|
|
182
|
+
commit();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
startViewTransition(commit);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const documentNavigate = (href: string, options: { replace?: boolean } = {}): void => {
|
|
189
|
+
if (options.replace) window.location.replace(href);
|
|
190
|
+
else window.location.assign(href);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const shouldEnhanceClick = (event: MouseEvent, anchor: HTMLAnchorElement): boolean => {
|
|
194
|
+
if (event.defaultPrevented || event.button !== 0) return false;
|
|
195
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
|
|
196
|
+
if (anchor.target && anchor.target !== "_self") return false;
|
|
197
|
+
if (anchor.hasAttribute("download")) return false;
|
|
198
|
+
|
|
199
|
+
const url = new URL(anchor.href);
|
|
200
|
+
return url.origin === window.location.origin;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const isSameDocumentHash = (url: URL): boolean => {
|
|
204
|
+
const current = new URL(window.location.href);
|
|
205
|
+
return url.hash.length > 0 && url.pathname === current.pathname && url.search === current.search;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const callUserClick = (handler: LinkProps["onClick"], event: MouseEvent, anchor: HTMLAnchorElement): void => {
|
|
209
|
+
if (!handler) return;
|
|
210
|
+
const typedEvent = event as MouseEvent & { currentTarget: HTMLAnchorElement; target: Element };
|
|
211
|
+
|
|
212
|
+
if (Array.isArray(handler)) {
|
|
213
|
+
handler[0].call(anchor, handler[1], typedEvent);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (typeof handler === "function") {
|
|
218
|
+
handler.call(anchor, typedEvent);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* SSR-safe anchor with opt-in progressive navigation.
|
|
224
|
+
*/
|
|
225
|
+
export function Link(props: LinkProps) {
|
|
226
|
+
const [local, anchorProps] = splitProps(props, ["href", "replace", "scroll", "onNavigate", "onClick"]);
|
|
227
|
+
|
|
228
|
+
const handleClick: JSX.EventHandler<HTMLAnchorElement, MouseEvent> = (event) => {
|
|
229
|
+
callUserClick(local.onClick, event, event.currentTarget);
|
|
230
|
+
if (!shouldEnhanceClick(event, event.currentTarget)) return;
|
|
231
|
+
|
|
232
|
+
const href = local.href;
|
|
233
|
+
const url = new URL(event.currentTarget.href);
|
|
234
|
+
|
|
235
|
+
// Preserve native target scrolling unless the application explicitly owns
|
|
236
|
+
// this hash navigation through onNavigate or a scroll option.
|
|
237
|
+
if (!local.onNavigate && local.scroll === undefined && isSameDocumentHash(url)) return;
|
|
238
|
+
|
|
239
|
+
const scroll = local.scroll ?? "top";
|
|
240
|
+
const replace = Boolean(local.replace);
|
|
241
|
+
const scrollSnapshot = captureScroll();
|
|
242
|
+
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
|
|
245
|
+
if (!local.onNavigate) {
|
|
246
|
+
navigate(url.href, { replace, scroll, scrollSnapshot });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let navigationOutcome: "none" | "history" | "document" = "none";
|
|
251
|
+
const runNavigation = async () => {
|
|
252
|
+
try {
|
|
253
|
+
await local.onNavigate!({
|
|
254
|
+
event,
|
|
255
|
+
href,
|
|
256
|
+
url,
|
|
257
|
+
replace,
|
|
258
|
+
scroll,
|
|
259
|
+
push: (nextHref = url.href, options = {}) => {
|
|
260
|
+
navigate(nextHref, {
|
|
261
|
+
...options,
|
|
262
|
+
replace: false,
|
|
263
|
+
scroll: options.scroll ?? scroll,
|
|
264
|
+
scrollSnapshot: options.scrollSnapshot ?? scrollSnapshot,
|
|
265
|
+
viewTransition: false,
|
|
266
|
+
});
|
|
267
|
+
navigationOutcome = "history";
|
|
268
|
+
},
|
|
269
|
+
replaceWith: (nextHref = url.href, options = {}) => {
|
|
270
|
+
navigate(nextHref, {
|
|
271
|
+
...options,
|
|
272
|
+
replace: true,
|
|
273
|
+
scroll: options.scroll ?? scroll,
|
|
274
|
+
scrollSnapshot: options.scrollSnapshot ?? scrollSnapshot,
|
|
275
|
+
viewTransition: false,
|
|
276
|
+
});
|
|
277
|
+
navigationOutcome = "history";
|
|
278
|
+
},
|
|
279
|
+
fallback: (nextHref = url.href) => {
|
|
280
|
+
documentNavigate(resolveNavigationUrl(nextHref).href, { replace });
|
|
281
|
+
navigationOutcome = "document";
|
|
282
|
+
},
|
|
283
|
+
scrollSnapshot,
|
|
284
|
+
captureScroll,
|
|
285
|
+
restoreScroll,
|
|
286
|
+
});
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error("[@k2b/ssr/nav] onNavigate failed; falling back to document navigation.", error);
|
|
289
|
+
if (navigationOutcome === "document") return;
|
|
290
|
+
const historyCommitted = navigationOutcome === "history";
|
|
291
|
+
const fallbackHref = historyCommitted ? window.location.href : url.href;
|
|
292
|
+
documentNavigate(fallbackHref, { replace: historyCommitted || replace });
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
startViewTransition(runNavigation);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return createDynamic(
|
|
300
|
+
() => "a",
|
|
301
|
+
mergeProps(anchorProps, {
|
|
302
|
+
get href() {
|
|
303
|
+
return local.href;
|
|
304
|
+
},
|
|
305
|
+
onClick: handleClick,
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
}
|
package/src/transform.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Babel transform for SSR - wraps island/client components in custom elements
|
|
3
|
+
* with serialized props. Handles both SSR (server) and DOM (client) modes.
|
|
4
|
+
*/
|
|
5
|
+
import { transformAsync, types as t } from "@babel/core";
|
|
6
|
+
// @ts-ignore - no types are available for this package
|
|
7
|
+
import solidPreset from "babel-preset-solid";
|
|
8
|
+
// @ts-ignore - no types are available for this package
|
|
9
|
+
import tsPreset from "@babel/preset-typescript";
|
|
10
|
+
import { dirname } from "path";
|
|
11
|
+
import { islandIdFromFile } from "./island-id";
|
|
12
|
+
import { resolveIslandImport } from "./island-resolve";
|
|
13
|
+
|
|
14
|
+
export { hash } from "./island-id";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Helpers
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
// JSX AST helpers
|
|
21
|
+
const jsx = (tag: string, attrs: any[], children: any[] = []) =>
|
|
22
|
+
t.jsxElement(
|
|
23
|
+
t.jsxOpeningElement(t.jsxIdentifier(tag), attrs, children.length === 0),
|
|
24
|
+
children.length ? t.jsxClosingElement(t.jsxIdentifier(tag)) : null,
|
|
25
|
+
children,
|
|
26
|
+
children.length === 0,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const attr = (name: string, value: any) =>
|
|
30
|
+
t.jsxAttribute(
|
|
31
|
+
t.jsxIdentifier(name),
|
|
32
|
+
typeof value === "string" ? t.stringLiteral(value) : t.jsxExpressionContainer(value),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Babel Plugin - Wraps island/client components
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
type ComponentType = "island" | "client";
|
|
40
|
+
|
|
41
|
+
const getComponentType = (path: string): ComponentType | null =>
|
|
42
|
+
path.includes(".island") ? "island" : path.includes(".client") ? "client" : null;
|
|
43
|
+
|
|
44
|
+
const componentWrapperPlugin = (filename: string, rootDir: string, dev: boolean) => {
|
|
45
|
+
const parentType = getComponentType(filename);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
visitor: {
|
|
49
|
+
Program(programPath: any) {
|
|
50
|
+
const componentImports = new Map<string, { path: string; type: ComponentType; id: string }>();
|
|
51
|
+
|
|
52
|
+
// Inject seroval serialize helper at the top
|
|
53
|
+
programPath.node.body.unshift(
|
|
54
|
+
t.importDeclaration(
|
|
55
|
+
[t.importSpecifier(t.identifier("serialize"), t.identifier("serialize"))],
|
|
56
|
+
t.stringLiteral("seroval"),
|
|
57
|
+
),
|
|
58
|
+
t.variableDeclaration("const", [
|
|
59
|
+
t.variableDeclarator(t.identifier("__seroval_serialize"), t.identifier("serialize")),
|
|
60
|
+
]),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
programPath.traverse({
|
|
64
|
+
ImportDeclaration(path: any) {
|
|
65
|
+
const source: string = path.node.source.value;
|
|
66
|
+
const type = getComponentType(source);
|
|
67
|
+
if (!type) return;
|
|
68
|
+
|
|
69
|
+
if (parentType) {
|
|
70
|
+
console.warn(
|
|
71
|
+
`[ssr] Warning: ${parentType} imports ${type} (${source}) - nested islands/clients are not supported`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const spec = path.node.specifiers.find((s: any) => s.type === "ImportDefaultSpecifier");
|
|
76
|
+
if (!spec) return;
|
|
77
|
+
|
|
78
|
+
const resolvedPath = resolveIslandImport(source, dirname(filename), filename);
|
|
79
|
+
const componentPath = resolvedPath;
|
|
80
|
+
const id = islandIdFromFile(resolvedPath, rootDir);
|
|
81
|
+
|
|
82
|
+
componentImports.set(spec.local.name, { path: componentPath, type, id });
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
JSXElement(path: any) {
|
|
86
|
+
const name = path.node.openingElement.name.name;
|
|
87
|
+
const component = componentImports.get(name);
|
|
88
|
+
if (!component) return;
|
|
89
|
+
|
|
90
|
+
const wrapperTag = component.type === "island" ? "solid-island" : "solid-client";
|
|
91
|
+
|
|
92
|
+
const props = t.objectExpression(
|
|
93
|
+
path.node.openingElement.attributes
|
|
94
|
+
.filter((a: any) => a.type === "JSXAttribute")
|
|
95
|
+
.map((a: any) =>
|
|
96
|
+
t.objectProperty(
|
|
97
|
+
t.identifier(a.name.name),
|
|
98
|
+
a.value?.type === "JSXExpressionContainer" ? a.value.expression : a.value || t.booleanLiteral(true),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// For islands: wrap the component, for client: empty wrapper (no SSR)
|
|
104
|
+
const children = component.type === "island" ? [path.node] : [];
|
|
105
|
+
|
|
106
|
+
// Extract filename from path (e.g., "Counter.island.tsx")
|
|
107
|
+
const file = component.path.split("/").pop() || "";
|
|
108
|
+
|
|
109
|
+
const attrs = [
|
|
110
|
+
attr("data-id", component.id),
|
|
111
|
+
attr("data-props", t.callExpression(t.identifier("__seroval_serialize"), [props])),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
// Add file attribute in dev mode
|
|
115
|
+
if (dev) {
|
|
116
|
+
attrs.push(attr("data-file", file));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const wrapper = jsx(wrapperTag, attrs, children);
|
|
120
|
+
|
|
121
|
+
path.replaceWith(wrapper);
|
|
122
|
+
path.skip();
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Client wrappers remove their original JSX usage. Refresh bindings so
|
|
127
|
+
// later presets can discard imports that became unused in this pass.
|
|
128
|
+
programPath.scope.crawl();
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ============================================================================
|
|
135
|
+
// Transform function
|
|
136
|
+
// ============================================================================
|
|
137
|
+
|
|
138
|
+
export const transform = async (
|
|
139
|
+
source: string,
|
|
140
|
+
filename: string,
|
|
141
|
+
mode: "ssr" | "dom",
|
|
142
|
+
dev: boolean = false,
|
|
143
|
+
rootDir: string = process.cwd(),
|
|
144
|
+
): Promise<string> => {
|
|
145
|
+
const result = await transformAsync(source, {
|
|
146
|
+
filename,
|
|
147
|
+
parserOpts: mode === "ssr" ? { plugins: ["jsx", "typescript"] } : undefined,
|
|
148
|
+
plugins: mode === "ssr" ? [() => componentWrapperPlugin(filename, rootDir, dev)] : [],
|
|
149
|
+
presets: [
|
|
150
|
+
[tsPreset, {}],
|
|
151
|
+
[solidPreset, { generate: mode, hydratable: false }],
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!result?.code) throw new Error(`Transform failed: ${filename}`);
|
|
156
|
+
return result.code;
|
|
157
|
+
};
|