alabjs 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/dist/adapters/cloudflare.d.ts +31 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +30 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/deno.d.ts +22 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +21 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/web.d.ts +47 -0
- package/dist/adapters/web.d.ts.map +1 -0
- package/dist/adapters/web.js +212 -0
- package/dist/adapters/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/hooks.d.ts +119 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +220 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/hooks.test.d.ts +2 -0
- package/dist/client/hooks.test.d.ts.map +1 -0
- package/dist/client/hooks.test.js +45 -0
- package/dist/client/hooks.test.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/offline.d.ts +52 -0
- package/dist/client/offline.d.ts.map +1 -0
- package/dist/client/offline.js +90 -0
- package/dist/client/offline.js.map +1 -0
- package/dist/client/provider.d.ts +12 -0
- package/dist/client/provider.d.ts.map +1 -0
- package/dist/client/provider.js +10 -0
- package/dist/client/provider.js.map +1 -0
- package/dist/commands/build.d.ts +18 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +173 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +8 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +447 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/info.d.ts +6 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/ssg.d.ts +8 -0
- package/dist/commands/ssg.d.ts.map +1 -0
- package/dist/commands/ssg.js +124 -0
- package/dist/commands/ssg.js.map +1 -0
- package/dist/commands/start.d.ts +7 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +26 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/test.d.ts +24 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +87 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +38 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +46 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/Font.d.ts +57 -0
- package/dist/components/Font.d.ts.map +1 -0
- package/dist/components/Font.js +33 -0
- package/dist/components/Font.js.map +1 -0
- package/dist/components/Image.d.ts +74 -0
- package/dist/components/Image.d.ts.map +1 -0
- package/dist/components/Image.js +85 -0
- package/dist/components/Image.js.map +1 -0
- package/dist/components/Link.d.ts +23 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +48 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/Script.d.ts +37 -0
- package/dist/components/Script.d.ts.map +1 -0
- package/dist/components/Script.js +70 -0
- package/dist/components/Script.js.map +1 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/i18n/i18n.test.d.ts +2 -0
- package/dist/i18n/i18n.test.d.ts.map +1 -0
- package/dist/i18n/i18n.test.js +132 -0
- package/dist/i18n/i18n.test.js.map +1 -0
- package/dist/i18n/index.d.ts +135 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +189 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/router/code-router.d.ts +204 -0
- package/dist/router/code-router.d.ts.map +1 -0
- package/dist/router/code-router.js +258 -0
- package/dist/router/code-router.js.map +1 -0
- package/dist/router/code-router.test.d.ts +2 -0
- package/dist/router/code-router.test.d.ts.map +1 -0
- package/dist/router/code-router.test.js +128 -0
- package/dist/router/code-router.test.js.map +1 -0
- package/dist/router/index.d.ts +4 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +2 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/manifest.d.ts +12 -0
- package/dist/router/manifest.d.ts.map +1 -0
- package/dist/router/manifest.js +2 -0
- package/dist/router/manifest.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +407 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/cache.d.ts +99 -0
- package/dist/server/cache.d.ts.map +1 -0
- package/dist/server/cache.js +161 -0
- package/dist/server/cache.js.map +1 -0
- package/dist/server/cache.test.d.ts +2 -0
- package/dist/server/cache.test.d.ts.map +1 -0
- package/dist/server/cache.test.js +150 -0
- package/dist/server/cache.test.js.map +1 -0
- package/dist/server/csrf.d.ts +28 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/csrf.js +66 -0
- package/dist/server/csrf.js.map +1 -0
- package/dist/server/csrf.test.d.ts +2 -0
- package/dist/server/csrf.test.d.ts.map +1 -0
- package/dist/server/csrf.test.js +154 -0
- package/dist/server/csrf.test.js.map +1 -0
- package/dist/server/image.d.ts +18 -0
- package/dist/server/image.d.ts.map +1 -0
- package/dist/server/image.js +97 -0
- package/dist/server/image.js.map +1 -0
- package/dist/server/index.d.ts +57 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +58 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware.d.ts +53 -0
- package/dist/server/middleware.d.ts.map +1 -0
- package/dist/server/middleware.js +80 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/middleware.test.d.ts +2 -0
- package/dist/server/middleware.test.d.ts.map +1 -0
- package/dist/server/middleware.test.js +125 -0
- package/dist/server/middleware.test.js.map +1 -0
- package/dist/server/revalidate.d.ts +49 -0
- package/dist/server/revalidate.d.ts.map +1 -0
- package/dist/server/revalidate.js +62 -0
- package/dist/server/revalidate.js.map +1 -0
- package/dist/server/revalidate.test.d.ts +2 -0
- package/dist/server/revalidate.test.d.ts.map +1 -0
- package/dist/server/revalidate.test.js +93 -0
- package/dist/server/revalidate.test.js.map +1 -0
- package/dist/server/server-fn.test.d.ts +2 -0
- package/dist/server/server-fn.test.d.ts.map +1 -0
- package/dist/server/server-fn.test.js +105 -0
- package/dist/server/server-fn.test.js.map +1 -0
- package/dist/server/sitemap.d.ts +9 -0
- package/dist/server/sitemap.d.ts.map +1 -0
- package/dist/server/sitemap.js +26 -0
- package/dist/server/sitemap.js.map +1 -0
- package/dist/server/sitemap.test.d.ts +2 -0
- package/dist/server/sitemap.test.d.ts.map +1 -0
- package/dist/server/sitemap.test.js +61 -0
- package/dist/server/sitemap.test.js.map +1 -0
- package/dist/server/sse.d.ts +59 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +91 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/sse.test.d.ts +2 -0
- package/dist/server/sse.test.d.ts.map +1 -0
- package/dist/server/sse.test.js +68 -0
- package/dist/server/sse.test.js.map +1 -0
- package/dist/signals/index.d.ts +101 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +149 -0
- package/dist/signals/index.js.map +1 -0
- package/dist/signals/signals.test.d.ts +2 -0
- package/dist/signals/signals.test.d.ts.map +1 -0
- package/dist/signals/signals.test.js +146 -0
- package/dist/signals/signals.test.js.map +1 -0
- package/dist/ssr/html.d.ts +27 -0
- package/dist/ssr/html.d.ts.map +1 -0
- package/dist/ssr/html.js +107 -0
- package/dist/ssr/html.js.map +1 -0
- package/dist/ssr/html.test.d.ts +2 -0
- package/dist/ssr/html.test.d.ts.map +1 -0
- package/dist/ssr/html.test.js +178 -0
- package/dist/ssr/html.test.js.map +1 -0
- package/dist/ssr/render.d.ts +46 -0
- package/dist/ssr/render.d.ts.map +1 -0
- package/dist/ssr/render.js +87 -0
- package/dist/ssr/render.js.map +1 -0
- package/dist/ssr/router-dev.d.ts +60 -0
- package/dist/ssr/router-dev.d.ts.map +1 -0
- package/dist/ssr/router-dev.js +205 -0
- package/dist/ssr/router-dev.js.map +1 -0
- package/dist/ssr/router-dev.test.d.ts +2 -0
- package/dist/ssr/router-dev.test.d.ts.map +1 -0
- package/dist/ssr/router-dev.test.js +189 -0
- package/dist/ssr/router-dev.test.js.map +1 -0
- package/dist/test/index.d.ts +93 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +146 -0
- package/dist/test/index.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/napi.d.ts +15 -0
- package/dist/types/napi.d.ts.map +1 -0
- package/dist/types/napi.js +2 -0
- package/dist/types/napi.js.map +1 -0
- package/package.json +107 -0
- package/src/adapters/cloudflare.ts +30 -0
- package/src/adapters/deno.ts +21 -0
- package/src/adapters/web.ts +259 -0
- package/src/cli.ts +68 -0
- package/src/client/hooks.test.ts +54 -0
- package/src/client/hooks.ts +329 -0
- package/src/client/index.ts +5 -0
- package/src/client/offline-sw.ts +191 -0
- package/src/client/offline.ts +114 -0
- package/src/client/provider.tsx +14 -0
- package/src/commands/build.ts +201 -0
- package/src/commands/dev.ts +509 -0
- package/src/commands/info.ts +111 -0
- package/src/commands/ssg.ts +177 -0
- package/src/commands/start.ts +32 -0
- package/src/commands/test.ts +102 -0
- package/src/components/ErrorBoundary.tsx +73 -0
- package/src/components/Font.tsx +100 -0
- package/src/components/Image.tsx +141 -0
- package/src/components/Link.tsx +64 -0
- package/src/components/Script.tsx +97 -0
- package/src/components/index.ts +9 -0
- package/src/i18n/i18n.test.tsx +169 -0
- package/src/i18n/index.tsx +256 -0
- package/src/index.ts +10 -0
- package/src/router/code-router.test.ts +146 -0
- package/src/router/code-router.tsx +459 -0
- package/src/router/index.ts +18 -0
- package/src/router/manifest.ts +13 -0
- package/src/server/app.ts +466 -0
- package/src/server/cache.test.ts +192 -0
- package/src/server/cache.ts +195 -0
- package/src/server/csrf.test.ts +199 -0
- package/src/server/csrf.ts +80 -0
- package/src/server/image.ts +112 -0
- package/src/server/index.ts +144 -0
- package/src/server/middleware.test.ts +151 -0
- package/src/server/middleware.ts +95 -0
- package/src/server/revalidate.test.ts +106 -0
- package/src/server/revalidate.ts +75 -0
- package/src/server/server-fn.test.ts +127 -0
- package/src/server/sitemap.test.ts +68 -0
- package/src/server/sitemap.ts +30 -0
- package/src/server/sse.test.ts +81 -0
- package/src/server/sse.ts +110 -0
- package/src/signals/index.ts +177 -0
- package/src/signals/signals.test.ts +164 -0
- package/src/ssr/html.test.ts +200 -0
- package/src/ssr/html.ts +140 -0
- package/src/ssr/render.ts +144 -0
- package/src/ssr/router-dev.test.ts +230 -0
- package/src/ssr/router-dev.ts +229 -0
- package/src/test/index.ts +206 -0
- package/src/types/compiler.d.ts +25 -0
- package/src/types/index.ts +147 -0
- package/src/types/napi.ts +20 -0
- package/src/types/plugins.d.ts +3 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +32 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `alab ssg` — Static Site Generation
|
|
3
|
+
*
|
|
4
|
+
* Pre-renders all static routes (no dynamic `[param]` segments) to HTML files
|
|
5
|
+
* at build time. Output lands in `dist/` and can be served from any static
|
|
6
|
+
* host: Netlify, GitHub Pages, Cloudflare Pages, S3, etc.
|
|
7
|
+
*
|
|
8
|
+
* Dynamic routes (`/users/[id]`) are skipped — they require a running server.
|
|
9
|
+
* To pre-render dynamic pages, export `export const staticPaths = [...]` from
|
|
10
|
+
* the page module (planned for a future release).
|
|
11
|
+
*/
|
|
12
|
+
import { createServer } from "vite";
|
|
13
|
+
import { resolve, join } from "node:path";
|
|
14
|
+
import { mkdir, writeFile, cp } from "node:fs/promises";
|
|
15
|
+
import { scanDevRoutes } from "../ssr/router-dev.js";
|
|
16
|
+
import { htmlShellBefore, htmlShellAfter } from "../ssr/html.js";
|
|
17
|
+
import type { PageMetadata } from "../types/index.js";
|
|
18
|
+
|
|
19
|
+
interface SsgOptions {
|
|
20
|
+
cwd: string;
|
|
21
|
+
/** Output directory. Defaults to `dist`. */
|
|
22
|
+
outDir?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function ssg({ cwd, outDir = "dist" }: SsgOptions) {
|
|
26
|
+
const appDir = resolve(cwd, "app");
|
|
27
|
+
const publicDir = resolve(cwd, "public");
|
|
28
|
+
const outputDir = resolve(cwd, outDir);
|
|
29
|
+
|
|
30
|
+
console.log(` alab generating static site → ${outDir}/\n`);
|
|
31
|
+
|
|
32
|
+
// Spin up a Vite SSR server (no HTTP listener — port 0, ephemeral).
|
|
33
|
+
const vite = await createServer({
|
|
34
|
+
root: cwd,
|
|
35
|
+
appType: "custom",
|
|
36
|
+
server: { port: 0 },
|
|
37
|
+
plugins: [(await import("alabjs-vite-plugin")).alabPlugin()],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const allRoutes = scanDevRoutes(appDir);
|
|
41
|
+
|
|
42
|
+
await mkdir(outputDir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
// Load React renderer once.
|
|
45
|
+
const { renderToString: reactRenderToString } = (await vite.ssrLoadModule(
|
|
46
|
+
"react-dom/server",
|
|
47
|
+
)) as { renderToString: (el: unknown) => string };
|
|
48
|
+
const { createElement } = (await vite.ssrLoadModule("react")) as {
|
|
49
|
+
createElement: (type: unknown, props: unknown) => unknown;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let written = 0;
|
|
53
|
+
let skipped = 0;
|
|
54
|
+
|
|
55
|
+
for (const route of allRoutes) {
|
|
56
|
+
const mod = (await vite.ssrLoadModule(route.file)) as {
|
|
57
|
+
default?: unknown;
|
|
58
|
+
metadata?: PageMetadata;
|
|
59
|
+
generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
|
|
60
|
+
generateStaticParams?: () => Promise<Array<Record<string, string>>>;
|
|
61
|
+
ssr?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const Page = mod.default;
|
|
65
|
+
if (typeof Page !== "function") {
|
|
66
|
+
console.warn(` alab [ssg] skip ${route.file} — no default export`);
|
|
67
|
+
skipped++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Derive the URL path from the file path.
|
|
72
|
+
const urlPath =
|
|
73
|
+
route.file
|
|
74
|
+
.replace(appDir, "")
|
|
75
|
+
.replace(/\/page\.(tsx|ts)$/, "") || "/";
|
|
76
|
+
|
|
77
|
+
const routeFile = route.file.replace(cwd, "").replace(/^\//, "");
|
|
78
|
+
|
|
79
|
+
// ── Dynamic routes: require generateStaticParams ─────────────────────────
|
|
80
|
+
if (route.paramNames.length > 0) {
|
|
81
|
+
if (typeof mod.generateStaticParams !== "function") {
|
|
82
|
+
console.warn(
|
|
83
|
+
` alab [ssg] skip ${urlPath} — dynamic route missing generateStaticParams()`,
|
|
84
|
+
);
|
|
85
|
+
skipped++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let paramSets: Array<Record<string, string>>;
|
|
90
|
+
try {
|
|
91
|
+
paramSets = await mod.generateStaticParams();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn(` alab [ssg] skip ${urlPath} — generateStaticParams threw: ${String(err)}`);
|
|
94
|
+
skipped++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const params of paramSets) {
|
|
99
|
+
// Replace [param] segments with actual values.
|
|
100
|
+
const resolvedPath = urlPath.replace(/\[([^\]]+)\]/g, (_, name) => params[name] ?? name);
|
|
101
|
+
const segments = resolvedPath === "/" ? [] : resolvedPath.split("/").filter(Boolean);
|
|
102
|
+
const pageOutputDir = join(outputDir, ...segments);
|
|
103
|
+
await mkdir(pageOutputDir, { recursive: true });
|
|
104
|
+
const outputFile = join(pageOutputDir, "index.html");
|
|
105
|
+
|
|
106
|
+
const metadata: PageMetadata =
|
|
107
|
+
typeof mod.generateMetadata === "function"
|
|
108
|
+
? await mod.generateMetadata(params)
|
|
109
|
+
: (mod.metadata ?? {});
|
|
110
|
+
|
|
111
|
+
const ssrContent = reactRenderToString(
|
|
112
|
+
createElement(Page, { params, searchParams: {} }),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const shellBefore = htmlShellBefore({
|
|
116
|
+
metadata,
|
|
117
|
+
paramsJson: JSON.stringify(params),
|
|
118
|
+
searchParamsJson: "{}",
|
|
119
|
+
routeFile,
|
|
120
|
+
ssr: true,
|
|
121
|
+
});
|
|
122
|
+
const shellAfter = htmlShellAfter({});
|
|
123
|
+
|
|
124
|
+
await writeFile(outputFile, `${shellBefore}${ssrContent}${shellAfter}`, "utf8");
|
|
125
|
+
console.log(
|
|
126
|
+
` alab [ssg] ${resolvedPath.padEnd(30)} → ${outputFile.replace(cwd + "/", "")}`,
|
|
127
|
+
);
|
|
128
|
+
written++;
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Static routes ─────────────────────────────────────────────────────────
|
|
134
|
+
const metadata: PageMetadata =
|
|
135
|
+
typeof mod.generateMetadata === "function"
|
|
136
|
+
? await mod.generateMetadata({})
|
|
137
|
+
: (mod.metadata ?? {});
|
|
138
|
+
|
|
139
|
+
const ssrContent = reactRenderToString(
|
|
140
|
+
createElement(Page, { params: {}, searchParams: {} }),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const segments = urlPath === "/" ? [] : urlPath.split("/").filter(Boolean);
|
|
144
|
+
const pageOutputDir = join(outputDir, ...segments);
|
|
145
|
+
await mkdir(pageOutputDir, { recursive: true });
|
|
146
|
+
const outputFile = join(pageOutputDir, "index.html");
|
|
147
|
+
|
|
148
|
+
const shellBefore = htmlShellBefore({
|
|
149
|
+
metadata,
|
|
150
|
+
paramsJson: "{}",
|
|
151
|
+
searchParamsJson: "{}",
|
|
152
|
+
routeFile,
|
|
153
|
+
ssr: true,
|
|
154
|
+
});
|
|
155
|
+
const shellAfter = htmlShellAfter({});
|
|
156
|
+
|
|
157
|
+
await writeFile(outputFile, `${shellBefore}${ssrContent}${shellAfter}`, "utf8");
|
|
158
|
+
console.log(` alab [ssg] ${urlPath.padEnd(30)} → ${outputFile.replace(cwd + "/", "")}`);
|
|
159
|
+
written++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Copy public/ assets into the output directory.
|
|
163
|
+
try {
|
|
164
|
+
await cp(publicDir, join(outputDir, "public"), { recursive: true });
|
|
165
|
+
console.log(`\n alab [ssg] copied public/ assets`);
|
|
166
|
+
} catch {
|
|
167
|
+
// public/ may not exist — not an error.
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await vite.close();
|
|
171
|
+
|
|
172
|
+
console.log(
|
|
173
|
+
`\n alab ${written} page${written === 1 ? "" : "s"} written` +
|
|
174
|
+
(skipped > 0 ? `, ${skipped} dynamic route${skipped === 1 ? "" : "s"} skipped` : "") +
|
|
175
|
+
`\n`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { createApp } from "../server/app.js";
|
|
3
|
+
import type { RouteManifest } from "../router/manifest.js";
|
|
4
|
+
|
|
5
|
+
interface StartOptions {
|
|
6
|
+
cwd: string;
|
|
7
|
+
port?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function start({ cwd, port = 3000 }: StartOptions) {
|
|
11
|
+
const manifestPath = resolve(cwd, ".alabjs/route-manifest.json");
|
|
12
|
+
|
|
13
|
+
let manifest: RouteManifest;
|
|
14
|
+
try {
|
|
15
|
+
const { readFileSync } = await import("node:fs");
|
|
16
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as RouteManifest;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
19
|
+
if (code === "ENOENT") {
|
|
20
|
+
console.error(" alab no build found. Run `alab build` first.");
|
|
21
|
+
} else {
|
|
22
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
23
|
+
console.error(` alab failed to load route manifest: ${msg}`);
|
|
24
|
+
console.error(" alab try running `alab build` again.");
|
|
25
|
+
}
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const distDir = resolve(cwd, ".alabjs/dist");
|
|
30
|
+
const app = createApp(manifest, distDir);
|
|
31
|
+
app.listen(port);
|
|
32
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `alab test` — zero-config test runner.
|
|
3
|
+
*
|
|
4
|
+
* Runs Vitest with the correct environment pre-configured for Alab apps:
|
|
5
|
+
* - SSR modules tested in `node` environment (React server rendering)
|
|
6
|
+
* - Client modules tested in `jsdom` environment (React DOM)
|
|
7
|
+
* - Alab's Vite plugin wired in so `.server.ts` boundary rules apply
|
|
8
|
+
* - `alab/test` utilities (`mockServerFn`, `renderPage`) available globally
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* alab test — run all tests once
|
|
12
|
+
* alab test --watch — watch mode
|
|
13
|
+
* alab test --ui — Vitest UI
|
|
14
|
+
* alab test src/foo.test.ts — run specific file(s)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
interface TestOptions {
|
|
22
|
+
cwd: string;
|
|
23
|
+
watch?: boolean;
|
|
24
|
+
ui?: boolean;
|
|
25
|
+
files?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate a `vitest.config.ts` in the project root if one doesn't exist.
|
|
30
|
+
* This is written to a temp path and passed via `--config` so it doesn't
|
|
31
|
+
* overwrite an existing user config.
|
|
32
|
+
*/
|
|
33
|
+
function generateVitestConfig(cwd: string): string {
|
|
34
|
+
const configPath = resolve(cwd, ".alabjs/vitest.config.ts");
|
|
35
|
+
const config = `// Auto-generated by \`alab test\` — do not edit manually.
|
|
36
|
+
// Customise by creating your own vitest.config.ts in the project root.
|
|
37
|
+
import { defineConfig } from "vitest/config";
|
|
38
|
+
import { alabPlugin } from "alabjs-vite-plugin";
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
plugins: [alabPlugin()],
|
|
42
|
+
test: {
|
|
43
|
+
globals: true,
|
|
44
|
+
environment: "node",
|
|
45
|
+
environmentMatchGlobs: [
|
|
46
|
+
// Client-side component tests run in jsdom
|
|
47
|
+
["**/*.client.test.{ts,tsx}", "jsdom"],
|
|
48
|
+
["**/*.browser.test.{ts,tsx}", "jsdom"],
|
|
49
|
+
],
|
|
50
|
+
setupFiles: [],
|
|
51
|
+
include: ["**/*.{test,spec}.{ts,tsx}"],
|
|
52
|
+
exclude: ["node_modules", ".alabjs"],
|
|
53
|
+
coverage: {
|
|
54
|
+
provider: "v8",
|
|
55
|
+
reporter: ["text", "html"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
`;
|
|
60
|
+
writeFileSync(configPath, config, "utf8");
|
|
61
|
+
return configPath;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function test({ cwd, watch = false, ui = false, files = [] }: TestOptions) {
|
|
65
|
+
// Ensure .alabjs/ exists
|
|
66
|
+
const dotAlab = resolve(cwd, ".alabjs");
|
|
67
|
+
if (!existsSync(dotAlab)) {
|
|
68
|
+
const { mkdirSync } = await import("node:fs");
|
|
69
|
+
mkdirSync(dotAlab, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Use the project's local vitest if available, otherwise fall back to npx.
|
|
73
|
+
const vitestBin = resolve(cwd, "node_modules/.bin/vitest");
|
|
74
|
+
const bin = existsSync(vitestBin) ? vitestBin : "npx";
|
|
75
|
+
const binArgs = existsSync(vitestBin) ? [] : ["vitest"];
|
|
76
|
+
|
|
77
|
+
// If the user already has a vitest config, respect it.
|
|
78
|
+
const userConfig = ["vitest.config.ts", "vitest.config.js"].find((f) =>
|
|
79
|
+
existsSync(resolve(cwd, f)),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const configPath = userConfig ? resolve(cwd, userConfig) : generateVitestConfig(cwd);
|
|
83
|
+
|
|
84
|
+
const args: string[] = [
|
|
85
|
+
...binArgs,
|
|
86
|
+
...(ui ? ["--ui"] : []),
|
|
87
|
+
...(watch ? [] : ["run"]),
|
|
88
|
+
"--config", configPath,
|
|
89
|
+
...files,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
console.log(` alab running tests${watch ? " (watch)" : ""}...\n`);
|
|
93
|
+
|
|
94
|
+
await new Promise<void>((ok, fail) => {
|
|
95
|
+
const child = spawn(bin, args, { cwd, stdio: "inherit", shell: true });
|
|
96
|
+
child.on("close", (code) => {
|
|
97
|
+
if (code === 0 || watch) ok();
|
|
98
|
+
else fail(new Error(`[alabjs] Tests failed (vitest exited ${code})`));
|
|
99
|
+
});
|
|
100
|
+
child.on("error", fail);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Component, type ReactNode, type ErrorInfo } from "react";
|
|
2
|
+
|
|
3
|
+
interface ErrorBoundaryProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Custom fallback UI. Receives the error and a reset callback. */
|
|
6
|
+
fallback?: (props: { error: Error; reset: () => void }) => ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ErrorBoundaryState {
|
|
10
|
+
error: Error | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* React class error boundary that wraps the Alab page root.
|
|
15
|
+
*
|
|
16
|
+
* Catches unhandled errors thrown during rendering, inside event handlers called
|
|
17
|
+
* during the commit phase, and inside lifecycle methods. Renders the nearest
|
|
18
|
+
* `error.tsx` fallback (or a minimal built-in fallback) instead of crashing.
|
|
19
|
+
*
|
|
20
|
+
* Usage (automatic — wired by the `/@alabjs/client` virtual module):
|
|
21
|
+
* The virtual client module wraps `<Page>` in this boundary automatically.
|
|
22
|
+
* You do not need to add it manually unless building a custom entry point.
|
|
23
|
+
*
|
|
24
|
+
* Manual usage:
|
|
25
|
+
* import { ErrorBoundary } from "alabjs/components";
|
|
26
|
+
* <ErrorBoundary fallback={({ error, reset }) => <p onClick={reset}>{error.message}</p>}>
|
|
27
|
+
* <MyComponent />
|
|
28
|
+
* </ErrorBoundary>
|
|
29
|
+
*/
|
|
30
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
31
|
+
constructor(props: ErrorBoundaryProps) {
|
|
32
|
+
super(props);
|
|
33
|
+
this.state = { error: null };
|
|
34
|
+
this.reset = this.reset.bind(this);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
38
|
+
return { error };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
|
42
|
+
console.error("[alabjs] Unhandled render error:", error, info.componentStack);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
reset(): void {
|
|
46
|
+
this.setState({ error: null });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override render(): ReactNode {
|
|
50
|
+
const { error } = this.state;
|
|
51
|
+
if (!error) return this.props.children;
|
|
52
|
+
|
|
53
|
+
if (this.props.fallback) {
|
|
54
|
+
return this.props.fallback({ error, reset: this.reset });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Built-in minimal fallback
|
|
58
|
+
return (
|
|
59
|
+
<div style={{ padding: "2rem", fontFamily: "monospace" }}>
|
|
60
|
+
<h2 style={{ color: "#e11d48" }}>Something went wrong</h2>
|
|
61
|
+
<pre style={{ background: "#fef2f2", padding: "1rem", borderRadius: "0.5rem", overflow: "auto" }}>
|
|
62
|
+
{error.message}
|
|
63
|
+
</pre>
|
|
64
|
+
<button
|
|
65
|
+
onClick={this.reset}
|
|
66
|
+
style={{ marginTop: "1rem", padding: "0.5rem 1rem", cursor: "pointer" }}
|
|
67
|
+
>
|
|
68
|
+
Try again
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Font — self-hosted web font loader.
|
|
3
|
+
*
|
|
4
|
+
* In development this renders preconnect + Google Fonts stylesheet links so
|
|
5
|
+
* fonts load immediately with `font-display: swap`.
|
|
6
|
+
*
|
|
7
|
+
* In production (`alab build`) the build step downloads the font files to
|
|
8
|
+
* `public/_fonts/` and generates self-hosted `@font-face` CSS, so no
|
|
9
|
+
* third-party network request is made at runtime.
|
|
10
|
+
*
|
|
11
|
+
* Usage (inside a layout or page `<head>`-equivalent):
|
|
12
|
+
* ```tsx
|
|
13
|
+
* import { Font } from "alabjs/components";
|
|
14
|
+
*
|
|
15
|
+
* // Single family
|
|
16
|
+
* <Font family="Inter" weights={[400, 500, 700]} />
|
|
17
|
+
*
|
|
18
|
+
* // Multiple families
|
|
19
|
+
* <Font family="Inter" weights={[400, 700]} />
|
|
20
|
+
* <Font family="Fira Code" weights={[400]} subsets={["latin"]} />
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface FontProps {
|
|
25
|
+
/**
|
|
26
|
+
* Google Fonts family name, exactly as it appears on fonts.google.com.
|
|
27
|
+
* e.g. `"Inter"`, `"Roboto"`, `"Fira Code"`
|
|
28
|
+
*/
|
|
29
|
+
family: string;
|
|
30
|
+
/**
|
|
31
|
+
* Font weights to load. Defaults to `[400]`.
|
|
32
|
+
* Numeric weights (100–900). Pass `[400, 700]` for regular + bold.
|
|
33
|
+
*/
|
|
34
|
+
weights?: number[];
|
|
35
|
+
/**
|
|
36
|
+
* Unicode subsets to include. Defaults to `["latin"]`.
|
|
37
|
+
* Adding extra subsets (e.g. `"latin-ext"`, `"cyrillic"`) increases file size.
|
|
38
|
+
*/
|
|
39
|
+
subsets?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* CSS `font-display` value. Defaults to `"swap"` which prevents invisible
|
|
42
|
+
* text during font load (FOIT → FOUT is a better user experience).
|
|
43
|
+
*/
|
|
44
|
+
display?: "auto" | "block" | "swap" | "fallback" | "optional";
|
|
45
|
+
/**
|
|
46
|
+
* Whether to load italic variants in addition to the requested weights.
|
|
47
|
+
* Defaults to `false`.
|
|
48
|
+
*/
|
|
49
|
+
italic?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the Google Fonts v2 URL for the requested family + weights.
|
|
54
|
+
* Format: fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap
|
|
55
|
+
*/
|
|
56
|
+
function buildGoogleFontsUrl(props: FontProps): string {
|
|
57
|
+
const {
|
|
58
|
+
family,
|
|
59
|
+
weights = [400],
|
|
60
|
+
subsets = ["latin"],
|
|
61
|
+
display = "swap",
|
|
62
|
+
italic = false,
|
|
63
|
+
} = props;
|
|
64
|
+
|
|
65
|
+
const encodedFamily = family.replace(/ /g, "+");
|
|
66
|
+
|
|
67
|
+
// Google Fonts v2 axis syntax: "ital,wght@0,400;0,700;1,400;1,700"
|
|
68
|
+
const axes: string[] = [];
|
|
69
|
+
const sortedWeights = [...weights].sort((a, b) => a - b);
|
|
70
|
+
|
|
71
|
+
if (italic) {
|
|
72
|
+
for (const w of sortedWeights) axes.push(`0,${w}`);
|
|
73
|
+
for (const w of sortedWeights) axes.push(`1,${w}`);
|
|
74
|
+
const axisTag = `ital,wght@${axes.join(";")}`;
|
|
75
|
+
return `https://fonts.googleapis.com/css2?family=${encodedFamily}:${axisTag}&display=${display}&subset=${subsets.join(",")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const wghtValues = sortedWeights.join(";");
|
|
79
|
+
return `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@${wghtValues}&display=${display}&subset=${subsets.join(",")}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Renders the `<link>` tags needed to load a Google Font.
|
|
84
|
+
*
|
|
85
|
+
* In production (`alab build --self-host-fonts`), the alab Vite plugin
|
|
86
|
+
* replaces this with self-hosted CSS so no Google request is made.
|
|
87
|
+
*/
|
|
88
|
+
export function Font(props: FontProps) {
|
|
89
|
+
const href = buildGoogleFontsUrl(props);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
{/* Speed up the Google Fonts connection. */}
|
|
94
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
95
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
96
|
+
{/* The actual font stylesheet — renders server-side, no layout shift. */}
|
|
97
|
+
<link rel="stylesheet" href={href} />
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ImageProps {
|
|
4
|
+
/** Source path — relative to `/public` or an absolute URL. */
|
|
5
|
+
src: string;
|
|
6
|
+
alt: string;
|
|
7
|
+
/** Intrinsic width in pixels. Used to generate the srcset breakpoints. */
|
|
8
|
+
width: number;
|
|
9
|
+
/** Intrinsic height in pixels. Used to preserve layout before load. */
|
|
10
|
+
height: number;
|
|
11
|
+
/**
|
|
12
|
+
* The `sizes` attribute passed to the `<img>` element.
|
|
13
|
+
* @example "(max-width: 768px) 100vw, 50vw"
|
|
14
|
+
*/
|
|
15
|
+
sizes?: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Mark the image as the LCP element. Sets `loading="eager"` and
|
|
18
|
+
* `fetchpriority="high"` — omit the blur-up placeholder to avoid CLS.
|
|
19
|
+
*/
|
|
20
|
+
priority?: boolean | undefined;
|
|
21
|
+
/** Additional class names. */
|
|
22
|
+
className?: string | undefined;
|
|
23
|
+
/** Quality 1–100. Defaults to 80. */
|
|
24
|
+
quality?: number | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Base64-encoded tiny placeholder generated by `generateBlurPlaceholder()`.
|
|
27
|
+
* When provided, the image fades in over the blurred placeholder for an
|
|
28
|
+
* instant-load feel without layout shift.
|
|
29
|
+
*/
|
|
30
|
+
blurDataURL?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Standard responsive breakpoints (matches Tailwind + common viewport widths).
|
|
34
|
+
const BREAKPOINTS = [320, 640, 750, 828, 1080, 1200, 1920];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rust-powered optimised image component.
|
|
38
|
+
*
|
|
39
|
+
* - Generates a `srcset` of WebP variants via `/_alabjs/image` (Rust napi).
|
|
40
|
+
* - Blur-up placeholder support: pass `blurDataURL` from `generateBlurPlaceholder()`.
|
|
41
|
+
* - `loading="lazy"` by default; use `priority` for LCP images.
|
|
42
|
+
* - Always sets `width` + `height` to eliminate CLS (Cumulative Layout Shift).
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* import { Image, generateBlurPlaceholder } from "alabjs/components";
|
|
47
|
+
*
|
|
48
|
+
* // In a server function — runs on the server at build/request time:
|
|
49
|
+
* const blur = await generateBlurPlaceholder("/hero.jpg");
|
|
50
|
+
*
|
|
51
|
+
* // In the page component:
|
|
52
|
+
* <Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority blurDataURL={blur} />
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function Image({
|
|
56
|
+
src,
|
|
57
|
+
alt,
|
|
58
|
+
width,
|
|
59
|
+
height,
|
|
60
|
+
sizes,
|
|
61
|
+
priority = false,
|
|
62
|
+
className,
|
|
63
|
+
quality = 80,
|
|
64
|
+
blurDataURL,
|
|
65
|
+
}: ImageProps) {
|
|
66
|
+
const q = Math.max(1, Math.min(100, quality));
|
|
67
|
+
const encodedSrc = encodeURIComponent(src);
|
|
68
|
+
|
|
69
|
+
// Build srcset: breakpoints ≤ intrinsic width + the intrinsic width itself.
|
|
70
|
+
const widths = Array.from(
|
|
71
|
+
new Set([...BREAKPOINTS.filter((w) => w < width), width]),
|
|
72
|
+
).sort((a, b) => a - b);
|
|
73
|
+
|
|
74
|
+
const srcset = widths
|
|
75
|
+
.map((w) => `/_alabjs/image?src=${encodedSrc}&w=${w}&q=${q}&fmt=webp ${w}w`)
|
|
76
|
+
.join(", ");
|
|
77
|
+
|
|
78
|
+
const defaultSrc = `/_alabjs/image?src=${encodedSrc}&w=${width}&q=${q}&fmt=webp`;
|
|
79
|
+
|
|
80
|
+
const style: Record<string, string> = {
|
|
81
|
+
maxWidth: "100%",
|
|
82
|
+
height: "auto",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Blur-up: show the tiny placeholder as a CSS background while the real image loads.
|
|
86
|
+
if (blurDataURL && !priority) {
|
|
87
|
+
style["backgroundImage"] = `url(${blurDataURL})`;
|
|
88
|
+
style["backgroundSize"] = "cover";
|
|
89
|
+
style["backgroundPosition"] = "center";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return createElement("img", {
|
|
93
|
+
src: defaultSrc,
|
|
94
|
+
srcSet: srcset,
|
|
95
|
+
sizes: sizes ?? `${width}px`,
|
|
96
|
+
alt,
|
|
97
|
+
width,
|
|
98
|
+
height,
|
|
99
|
+
loading: priority ? "eager" : "lazy",
|
|
100
|
+
fetchPriority: priority ? "high" : undefined,
|
|
101
|
+
decoding: "async",
|
|
102
|
+
className,
|
|
103
|
+
style,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate a Base64 blur-up placeholder for an image in `public/`.
|
|
109
|
+
*
|
|
110
|
+
* Calls the Rust napi binding to resize the image to 8px wide and encode it
|
|
111
|
+
* as a tiny WebP, then Base64-encodes it into a data URL ready for `blurDataURL`.
|
|
112
|
+
*
|
|
113
|
+
* Run this in a server function — it reads from disk and must not run in the browser.
|
|
114
|
+
*
|
|
115
|
+
* @param src - Path relative to `public/` (e.g. `"/hero.jpg"`)
|
|
116
|
+
* @param publicDir - Absolute path to the `public/` directory
|
|
117
|
+
*/
|
|
118
|
+
export async function generateBlurPlaceholder(
|
|
119
|
+
src: string,
|
|
120
|
+
publicDir: string,
|
|
121
|
+
): Promise<string> {
|
|
122
|
+
const { readFile } = await import("node:fs/promises");
|
|
123
|
+
const { resolve } = await import("node:path");
|
|
124
|
+
|
|
125
|
+
const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
|
|
126
|
+
const filePath = resolve(publicDir, safeSrc);
|
|
127
|
+
|
|
128
|
+
const input = await readFile(filePath);
|
|
129
|
+
|
|
130
|
+
let napi: { optimizeImage: (b: Buffer, q: number | null, w: number | null, h: null, fmt: string) => Promise<Buffer> };
|
|
131
|
+
try {
|
|
132
|
+
napi = (await import("@alabjs/compiler")) as typeof napi;
|
|
133
|
+
} catch {
|
|
134
|
+
// napi not built — return empty string (image still loads, just no blur effect)
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const tiny = await napi.optimizeImage(input, 40, 8, null, "webp");
|
|
139
|
+
const b64 = Buffer.from(tiny).toString("base64");
|
|
140
|
+
return `data:image/webp;base64,${b64}`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, MouseEvent } from "react";
|
|
2
|
+
|
|
3
|
+
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
4
|
+
href: string;
|
|
5
|
+
/** Prefetch the target page on hover (default: true). */
|
|
6
|
+
prefetch?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
__alabjs_navigate?: (href: string) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Client-side navigation link for AlabJS.
|
|
17
|
+
*
|
|
18
|
+
* Intercepts same-origin clicks and swaps the page content without a full
|
|
19
|
+
* browser reload. Falls back to a standard `<a>` navigation when JavaScript
|
|
20
|
+
* is unavailable or when the user holds a modifier key (Cmd/Ctrl/Shift/Alt).
|
|
21
|
+
*
|
|
22
|
+
* On hover (with `prefetch`, default true), the target page is fetched in the
|
|
23
|
+
* background so the browser caches it before the user clicks.
|
|
24
|
+
*/
|
|
25
|
+
export function Link({ href, children, prefetch = true, onClick, ...rest }: LinkProps) {
|
|
26
|
+
const isSameOrigin = (url: string): boolean => {
|
|
27
|
+
if (url.startsWith("/")) return true;
|
|
28
|
+
try {
|
|
29
|
+
return new URL(url).origin === window.location.origin;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
|
|
36
|
+
// Let the browser handle modifier-key clicks (open in new tab etc.)
|
|
37
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
38
|
+
if (!isSameOrigin(href)) return;
|
|
39
|
+
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
onClick?.(e);
|
|
42
|
+
|
|
43
|
+
if (typeof window.__alabjs_navigate === "function") {
|
|
44
|
+
await window.__alabjs_navigate(href);
|
|
45
|
+
} else {
|
|
46
|
+
window.location.href = href;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleMouseEnter = prefetch
|
|
51
|
+
? () => {
|
|
52
|
+
if (typeof window.__alabjs_navigate === "function") {
|
|
53
|
+
// Fire-and-forget; browser caches the response automatically.
|
|
54
|
+
fetch(href, { priority: "low" } as RequestInit).catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<a href={href} onClick={handleClick} onMouseEnter={handleMouseEnter} {...rest}>
|
|
61
|
+
{children}
|
|
62
|
+
</a>
|
|
63
|
+
);
|
|
64
|
+
}
|