mintiljs 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/README.md +192 -0
- package/auth/index.ts +1 -0
- package/i18n/index.ts +1 -0
- package/index.ts +1 -0
- package/package.json +68 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/jwt.ts +101 -0
- package/src/auth/middleware.ts +150 -0
- package/src/auth/session.ts +46 -0
- package/src/auth/store.ts +39 -0
- package/src/auth/types.ts +55 -0
- package/src/cli/cli.ts +142 -0
- package/src/cli/generate.ts +142 -0
- package/src/core/client.ts +157 -0
- package/src/core/config.ts +16 -0
- package/src/core/css.ts +72 -0
- package/src/core/islands.ts +90 -0
- package/src/core/logger.ts +37 -0
- package/src/core/routes.ts +247 -0
- package/src/core/runtime.ts +131 -0
- package/src/core/watcher.ts +43 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/loader.ts +125 -0
- package/src/i18n/translate.ts +89 -0
- package/src/i18n/types.ts +10 -0
- package/src/index.ts +10 -0
- package/src/render/island.tsx +78 -0
- package/src/render/ssr.tsx +198 -0
- package/src/render/stream.tsx +144 -0
- package/src/router/api.ts +111 -0
- package/src/router/index.ts +9 -0
- package/src/router/middleware.ts +16 -0
- package/src/router/pages.ts +97 -0
- package/src/router/shared.ts +3 -0
- package/src/types/api.ts +106 -0
- package/src/types/config.ts +35 -0
- package/src/types/index.ts +4 -0
- package/src/types/middleware.ts +21 -0
- package/src/types/page.ts +51 -0
- package/src/types/plugin.ts +29 -0
- package/src/utils/fs.ts +46 -0
- package/src/utils/logger.ts +21 -0
- package/src/utils/network.ts +14 -0
- package/templates/default/api/hello.ts +9 -0
- package/templates/default/components/card.tsx +10 -0
- package/templates/default/components/layouts/base.tsx +26 -0
- package/templates/default/lib/greeting.ts +3 -0
- package/templates/default/mintil.config.ts +10 -0
- package/templates/default/package.json +17 -0
- package/templates/default/pages/(*).tsx +11 -0
- package/templates/default/pages/about.tsx +13 -0
- package/templates/default/pages/blog/(:slug).tsx +12 -0
- package/templates/default/pages/counter.tsx +36 -0
- package/templates/default/pages/index.tsx +14 -0
- package/templates/default/public/readme.txt +1 -0
- package/templates/default/styles.css +1 -0
- package/templates/default/tsconfig.json +21 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import type { Hono } from "hono";
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import { renderPage } from "../render/ssr.tsx";
|
|
6
|
+
import { tryStreamPage } from "../render/stream.tsx";
|
|
7
|
+
import { resetIslands, getUsedIslands, getIslandDef } from "../render/island.tsx";
|
|
8
|
+
import {
|
|
9
|
+
collectApiMiddlewares,
|
|
10
|
+
collectApiRoutes,
|
|
11
|
+
collectPageLayouts,
|
|
12
|
+
collectPageRoutes,
|
|
13
|
+
loadRootMiddleware,
|
|
14
|
+
} from "../router/index.ts";
|
|
15
|
+
import type { ApiMiddleware, ApiRoute, PageLayout, PageRoute } from "../router/index.ts";
|
|
16
|
+
import { pagePropsFromContext } from "../router/pages.ts";
|
|
17
|
+
import type { MintilMiddleware } from "../types/middleware.ts";
|
|
18
|
+
import type { ResolvedConfig } from "../types/config.ts";
|
|
19
|
+
import type { PageComponent } from "../types/page.ts";
|
|
20
|
+
import { buildClientBundle, getFrameworkBundle, getFrameworkRoute, getSourceMap, getClientCacheKey } from "./client.ts";
|
|
21
|
+
import { loadAndRegisterIslands, buildIslandBundle } from "./islands.ts";
|
|
22
|
+
import { loadMessages, i18nDirectoryExists } from "../i18n/loader.ts";
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
export async function registerRootMiddleware(
|
|
26
|
+
app: Hono,
|
|
27
|
+
root: string,
|
|
28
|
+
nonce?: string,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const mw: MintilMiddleware | null = await loadRootMiddleware(root, nonce);
|
|
31
|
+
if (mw) (app as any).use(mw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function collectRoutes(root: string, nonce?: string) {
|
|
35
|
+
const [apiRoutes, apiMiddlewares, pageRoutes, pageLayouts] = await Promise.all([
|
|
36
|
+
collectApiRoutes(root, nonce),
|
|
37
|
+
collectApiMiddlewares(root, nonce),
|
|
38
|
+
collectPageRoutes(root, nonce),
|
|
39
|
+
collectPageLayouts(root, nonce),
|
|
40
|
+
]);
|
|
41
|
+
return { apiRoutes, apiMiddlewares, pageRoutes, pageLayouts };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function registerApiRoutes(
|
|
45
|
+
app: Hono,
|
|
46
|
+
apiRoutes: ApiRoute[],
|
|
47
|
+
apiMiddlewares: ApiMiddleware[],
|
|
48
|
+
) {
|
|
49
|
+
for (const api of apiRoutes) {
|
|
50
|
+
const activeMiddlewares = apiMiddlewares.filter((m) => isRoutePrefix(api.route, m.route));
|
|
51
|
+
const middlewareFns = activeMiddlewares.map((m) => m.middleware);
|
|
52
|
+
|
|
53
|
+
if (activeMiddlewares.length > 0) {
|
|
54
|
+
const mwList = activeMiddlewares.map((m) => `${m.route} (${path.basename(m.file)})`).join(", ");
|
|
55
|
+
console.log(` [middleware] ${api.route} <- ${mwList}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const { method, handler } of api.handlers) {
|
|
59
|
+
// Hono's app.on accepts any HTTP method and a list of handlers.
|
|
60
|
+
(app as any).on(method, api.route, ...middlewareFns, handler);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function registerI18nRoute(
|
|
66
|
+
app: Hono,
|
|
67
|
+
root: string,
|
|
68
|
+
) {
|
|
69
|
+
const exists = await i18nDirectoryExists(root);
|
|
70
|
+
if (!exists) return;
|
|
71
|
+
app.get("/_mintil/i18n/:locale", async (c: Context) => {
|
|
72
|
+
const locale = c.req.param("locale") || "";
|
|
73
|
+
const messages = await loadMessages(root, locale);
|
|
74
|
+
return c.json(messages);
|
|
75
|
+
});
|
|
76
|
+
console.log(" [i18n] /_mintil/i18n/:locale");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function registerPageRoutes(
|
|
80
|
+
app: Hono,
|
|
81
|
+
pageRoutes: PageRoute[],
|
|
82
|
+
pageLayouts: PageLayout[],
|
|
83
|
+
resolved: ResolvedConfig,
|
|
84
|
+
nonce?: string,
|
|
85
|
+
) {
|
|
86
|
+
// Build the shared framework bundle once (React + ReactDOM) and register its route.
|
|
87
|
+
const frameworkJs = await getFrameworkBundle();
|
|
88
|
+
const frameworkUrl = frameworkJs ? await getFrameworkRoute() : undefined;
|
|
89
|
+
if (frameworkUrl) {
|
|
90
|
+
app.get(frameworkUrl, (c: Context) => {
|
|
91
|
+
return c.text(frameworkJs!, 200, { "Content-Type": "application/javascript" });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Load and register island components from filesystem
|
|
96
|
+
await loadAndRegisterIslands(resolved.root);
|
|
97
|
+
|
|
98
|
+
// Register a single dynamic endpoint for island bundles
|
|
99
|
+
// Builds and caches on first request per island
|
|
100
|
+
const islandBundleCache = new Map<string, string>();
|
|
101
|
+
app.get("/_mintil/islands/:name", async (c: Context) => {
|
|
102
|
+
const raw = c.req.param("name") || "";
|
|
103
|
+
const name = raw.replace(/\.js$/i, "");
|
|
104
|
+
if (islandBundleCache.has(name)) {
|
|
105
|
+
return c.text(islandBundleCache.get(name)!, 200, { "Content-Type": "application/javascript" });
|
|
106
|
+
}
|
|
107
|
+
const def = getIslandDef(name);
|
|
108
|
+
if (!def) {
|
|
109
|
+
return c.text(`console.error("[mintil] island '${name}' not found")`, 404, {
|
|
110
|
+
"Content-Type": "application/javascript",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const js = await buildIslandBundle(name, def.file, { mode: resolved.mode });
|
|
114
|
+
if (!js) {
|
|
115
|
+
return c.text(`console.error("[mintil] failed to build island '${name}'")`, 500, {
|
|
116
|
+
"Content-Type": "application/javascript",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
islandBundleCache.set(name, js);
|
|
120
|
+
return c.text(js, 200, { "Content-Type": "application/javascript" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
for (const page of pageRoutes) {
|
|
124
|
+
const layout = pickLayout(page.route, pageLayouts);
|
|
125
|
+
|
|
126
|
+
let clientBundle: string | null = null;
|
|
127
|
+
let clientBundleName: string | null = null;
|
|
128
|
+
if (page.useClient && frameworkJs) {
|
|
129
|
+
const js = await buildClientBundle(page.file, page.route, { mode: resolved.mode });
|
|
130
|
+
if (js) {
|
|
131
|
+
const bundleName = routeToBundleName(page.route);
|
|
132
|
+
clientBundleName = bundleName;
|
|
133
|
+
clientBundle = `/_mintil/client/${bundleName}.js`;
|
|
134
|
+
app.get(clientBundle, (c: Context) => {
|
|
135
|
+
return c.text(js, 200, { "Content-Type": "application/javascript" });
|
|
136
|
+
});
|
|
137
|
+
// Serve source map in development mode
|
|
138
|
+
if (resolved.mode === "development") {
|
|
139
|
+
app.get(`/_mintil/client/${bundleName}.js.map`, (c: Context) => {
|
|
140
|
+
const map = getSourceMap(getClientCacheKey(page.route));
|
|
141
|
+
if (!map) return c.notFound();
|
|
142
|
+
return c.text(map, 200, { "Content-Type": "application/json" });
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
console.log(` [client] ${page.route} -> ${clientBundle}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
app.get(page.route, async (c: Context) => {
|
|
150
|
+
resetIslands();
|
|
151
|
+
|
|
152
|
+
const props = pagePropsFromContext(c);
|
|
153
|
+
|
|
154
|
+
let extraProps: Record<string, any> | undefined;
|
|
155
|
+
if (page.getServerSideProps) {
|
|
156
|
+
try {
|
|
157
|
+
const result = await page.getServerSideProps({
|
|
158
|
+
params: props.params,
|
|
159
|
+
searchParams: props.searchParams,
|
|
160
|
+
req: c.req.raw,
|
|
161
|
+
});
|
|
162
|
+
if (result && typeof result.props === "object") {
|
|
163
|
+
extraProps = result.props;
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error(`[mintil] getServerSideProps error for ${page.route}:`, err);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const pageOpts = {
|
|
171
|
+
root: resolved.root,
|
|
172
|
+
route: page.route,
|
|
173
|
+
params: props.params,
|
|
174
|
+
searchParams: props.searchParams,
|
|
175
|
+
mode: resolved.mode,
|
|
176
|
+
nonce,
|
|
177
|
+
layout,
|
|
178
|
+
clientBundle: clientBundle ?? undefined,
|
|
179
|
+
frameworkUrl,
|
|
180
|
+
extraProps,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Quick sync pass to detect used islands
|
|
184
|
+
resetIslands();
|
|
185
|
+
const React = await import("react");
|
|
186
|
+
const { renderToString } = await import("react-dom/server");
|
|
187
|
+
const quickElement = buildQuickElement(page.component, pageOpts, layout);
|
|
188
|
+
try { renderToString(quickElement); } catch {}
|
|
189
|
+
|
|
190
|
+
const usedIslands = getUsedIslands();
|
|
191
|
+
const needsIslands = usedIslands.length > 0;
|
|
192
|
+
|
|
193
|
+
if (!clientBundle && !needsIslands) {
|
|
194
|
+
// No client bundle and no islands — try streaming
|
|
195
|
+
const streamResponse = await tryStreamPage(page.component, pageOpts);
|
|
196
|
+
if (streamResponse) return streamResponse;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Sync render with island scripts if needed
|
|
200
|
+
const islandScripts: string[] = needsIslands
|
|
201
|
+
? usedIslands.map((name) => `<script type="module" src="/_mintil/islands/${name}.js"></script>`)
|
|
202
|
+
: [];
|
|
203
|
+
|
|
204
|
+
const html = await renderPage(page.component, {
|
|
205
|
+
...pageOpts,
|
|
206
|
+
islandScripts: islandScripts.length > 0 ? islandScripts : undefined,
|
|
207
|
+
});
|
|
208
|
+
return c.html(html);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function routeToBundleName(route: string): string {
|
|
214
|
+
return (
|
|
215
|
+
route
|
|
216
|
+
.replace(/^\/+/g, "")
|
|
217
|
+
.replace(/\/+/g, "_")
|
|
218
|
+
.replace(/[^a-zA-Z0-9_\-]/g, "_") || "index"
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isRoutePrefix(route: string, prefix: string): boolean {
|
|
223
|
+
if (prefix === "/") return true;
|
|
224
|
+
return route === prefix || route.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function pickLayout(
|
|
228
|
+
pageRoute: string,
|
|
229
|
+
layouts: PageLayout[],
|
|
230
|
+
): PageLayout["component"] | undefined {
|
|
231
|
+
for (const layout of layouts) {
|
|
232
|
+
if (isRoutePrefix(pageRoute, layout.scope)) {
|
|
233
|
+
return layout.component;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildQuickElement(
|
|
240
|
+
Page: PageComponent,
|
|
241
|
+
opts: { params: Record<string, string>; searchParams: URLSearchParams; extraProps?: Record<string, any> },
|
|
242
|
+
layout: PageLayout["component"] | undefined,
|
|
243
|
+
) {
|
|
244
|
+
const pageProps = { params: opts.params, searchParams: opts.searchParams, ...opts.extraProps };
|
|
245
|
+
const pageEl = React.createElement("div", { id: "__mintil-root" }, React.createElement(Page, pageProps));
|
|
246
|
+
return layout ? React.createElement(layout, { children: pageEl }) : pageEl;
|
|
247
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import type { Hono as HonoType } from "hono";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import { logMiddleware } from "./logger.ts";
|
|
6
|
+
import { resolveConfig } from "./config.ts";
|
|
7
|
+
import { getLocalIp } from "../utils/network.ts";
|
|
8
|
+
import { watchRoot, closeWatchers } from "./watcher.ts";
|
|
9
|
+
import { registerRootMiddleware, collectRoutes, registerApiRoutes, registerPageRoutes, registerI18nRoute } from "./routes.ts";
|
|
10
|
+
import { processCss } from "./css.ts";
|
|
11
|
+
import type { MintilConfig } from "../types/config.ts";
|
|
12
|
+
|
|
13
|
+
let started = false;
|
|
14
|
+
|
|
15
|
+
export async function createMintilApp(
|
|
16
|
+
config?: MintilConfig,
|
|
17
|
+
nonce?: string,
|
|
18
|
+
serverRef?: { current: ReturnType<typeof Bun.serve> | null },
|
|
19
|
+
): Promise<HonoType> {
|
|
20
|
+
const resolved = resolveConfig(
|
|
21
|
+
path.resolve(config?.root ?? process.cwd()),
|
|
22
|
+
config,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
|
|
27
|
+
app.use(logMiddleware(serverRef));
|
|
28
|
+
|
|
29
|
+
// User-supplied root middleware must be registered before routes.
|
|
30
|
+
await registerRootMiddleware(app, resolved.root, nonce);
|
|
31
|
+
|
|
32
|
+
app.get("/styles.css", async (c) => {
|
|
33
|
+
const css = await processCss(resolved.root, resolved.mode);
|
|
34
|
+
return c.text(css, 200, { "Content-Type": "text/css" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.get("/assets/*", async (c) => {
|
|
38
|
+
const subpath = c.req.path.replace(/^\/assets\//, "");
|
|
39
|
+
const filePath = path.join(resolved.root, "public", subpath);
|
|
40
|
+
if (!filePath.startsWith(path.join(resolved.root, "public"))) {
|
|
41
|
+
return c.notFound();
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const file = Bun.file(filePath);
|
|
45
|
+
if (!(await file.exists())) return c.notFound();
|
|
46
|
+
return new Response(file);
|
|
47
|
+
} catch {
|
|
48
|
+
return c.notFound();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Auto-register i18n API if i18n/messages directory exists
|
|
53
|
+
await registerI18nRoute(app, resolved.root);
|
|
54
|
+
|
|
55
|
+
const { apiRoutes, apiMiddlewares, pageRoutes, pageLayouts } = await collectRoutes(
|
|
56
|
+
resolved.root,
|
|
57
|
+
nonce,
|
|
58
|
+
);
|
|
59
|
+
registerApiRoutes(app, apiRoutes, apiMiddlewares);
|
|
60
|
+
await registerPageRoutes(app, pageRoutes, pageLayouts, resolved, nonce);
|
|
61
|
+
|
|
62
|
+
// Apply plugins
|
|
63
|
+
for (const plugin of resolved.plugins) {
|
|
64
|
+
try {
|
|
65
|
+
await plugin.setup(app, resolved);
|
|
66
|
+
console.log(` [plugin] ${plugin.name} registered`);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
console.error(`[mintil] plugin "${plugin.name}" setup error:`, err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return app;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function start(config?: MintilConfig): Promise<ReturnType<typeof Bun.serve>> {
|
|
76
|
+
if (started) {
|
|
77
|
+
throw new Error("Mintil runtime has already been started.");
|
|
78
|
+
}
|
|
79
|
+
started = true;
|
|
80
|
+
|
|
81
|
+
const resolved = resolveConfig(
|
|
82
|
+
path.resolve(config?.root ?? process.cwd()),
|
|
83
|
+
config,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
let nonce = String(Date.now());
|
|
87
|
+
const serverRef: { current: ReturnType<typeof Bun.serve> | null } = { current: null };
|
|
88
|
+
let app = await createMintilApp(config, nonce, serverRef);
|
|
89
|
+
|
|
90
|
+
const hostname = resolved.host ? "0.0.0.0" : "127.0.0.1";
|
|
91
|
+
const displayHost = resolved.host ? "0.0.0.0" : "localhost";
|
|
92
|
+
const localIp = resolved.host ? getLocalIp() : null;
|
|
93
|
+
|
|
94
|
+
console.log(
|
|
95
|
+
`Mintil runtime starting in ${resolved.mode} mode at http://${displayHost}:${resolved.port}`,
|
|
96
|
+
);
|
|
97
|
+
if (localIp) {
|
|
98
|
+
console.log(`Network: http://${localIp}:${resolved.port}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const server = Bun.serve({
|
|
102
|
+
hostname,
|
|
103
|
+
port: resolved.port,
|
|
104
|
+
fetch: app.fetch,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
serverRef.current = server;
|
|
108
|
+
|
|
109
|
+
if (resolved.mode === "development") {
|
|
110
|
+
watchRoot(resolved.root, async () => {
|
|
111
|
+
nonce = String(Date.now());
|
|
112
|
+
try {
|
|
113
|
+
app = await createMintilApp(config, nonce, serverRef);
|
|
114
|
+
server.reload({ fetch: app.fetch });
|
|
115
|
+
console.log("[mintil] project reloaded");
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error("[mintil] reload failed", err);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
process.on("exit", () => {
|
|
123
|
+
closeWatchers();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return server;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resetStarted() {
|
|
130
|
+
started = false;
|
|
131
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const activeWatchers: ReturnType<typeof watch>[] = [];
|
|
4
|
+
|
|
5
|
+
export function closeWatchers() {
|
|
6
|
+
for (const watcher of activeWatchers) {
|
|
7
|
+
try {
|
|
8
|
+
watcher.close();
|
|
9
|
+
} catch {
|
|
10
|
+
// ignore
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
activeWatchers.length = 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function watchRoot(root: string, onChange: () => void) {
|
|
17
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
+
|
|
19
|
+
const watcher = watch(
|
|
20
|
+
root,
|
|
21
|
+
{ recursive: true },
|
|
22
|
+
(_event, filename) => {
|
|
23
|
+
if (!filename) return;
|
|
24
|
+
if (
|
|
25
|
+
filename.includes("node_modules") ||
|
|
26
|
+
filename.includes(".git") ||
|
|
27
|
+
filename.includes("bun.lock")
|
|
28
|
+
) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (timeout) clearTimeout(timeout);
|
|
32
|
+
timeout = setTimeout(() => {
|
|
33
|
+
onChange();
|
|
34
|
+
}, 150);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
activeWatchers.push(watcher);
|
|
39
|
+
|
|
40
|
+
watcher.on("error", (err) => {
|
|
41
|
+
console.error("[mintil] file watcher error", err);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { i18nMessage, i18nOptions } from "./types.ts";
|
|
2
|
+
import { loadMessages, clearCache } from "./loader.ts";
|
|
3
|
+
|
|
4
|
+
const I18N_API_PATH = "/_mintil/i18n";
|
|
5
|
+
|
|
6
|
+
function isBrowser(): boolean {
|
|
7
|
+
return typeof globalThis !== "undefined" && typeof (globalThis as any).window !== "undefined" && typeof (Bun as any) === "undefined";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getRoot(options?: i18nOptions): string {
|
|
11
|
+
return options?.root ?? (typeof process !== "undefined" && process.cwd ? process.cwd() : "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load messages for the given locale.
|
|
16
|
+
*
|
|
17
|
+
* On the server (Bun), reads from `i18n/messages/[locale].json` or
|
|
18
|
+
* `i18n/messages/[locale]/` (numbered split files).
|
|
19
|
+
*
|
|
20
|
+
* In the browser, fetches from the auto-registered `/_mintil/i18n/:locale` endpoint.
|
|
21
|
+
*
|
|
22
|
+
* @param locale - Locale string (e.g. `"en"`, `"pt-BR"`).
|
|
23
|
+
* @param options - Optional root directory override.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { getMessages } from "mintiljs/i18n";
|
|
28
|
+
*
|
|
29
|
+
* const msgs = await getMessages("en");
|
|
30
|
+
* // msgs = [{ key: "greeting", value: "Hello" }, ...]
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export async function getMessages(
|
|
34
|
+
locale: string,
|
|
35
|
+
options?: i18nOptions,
|
|
36
|
+
): Promise<i18nMessage[]> {
|
|
37
|
+
if (isBrowser()) {
|
|
38
|
+
return fetchMessagesFromServer(locale);
|
|
39
|
+
}
|
|
40
|
+
const root = getRoot(options);
|
|
41
|
+
return loadMessages(root, locale);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchMessagesFromServer(locale: string): Promise<i18nMessage[]> {
|
|
45
|
+
try {
|
|
46
|
+
const base = (globalThis as any).location?.origin ?? "";
|
|
47
|
+
const res = await fetch(`${base}${I18N_API_PATH}/${locale}`);
|
|
48
|
+
if (!res.ok) return [];
|
|
49
|
+
return res.json() as Promise<i18nMessage[]>;
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type { i18nMessage, i18nOptions, i18nPlaceholders } from "./types.ts";
|
|
56
|
+
export { loadMessages, clearCache } from "./loader.ts";
|
|
57
|
+
export { format, toObject, t, createTranslate } from "./translate.ts";
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import type { i18nMessage } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const cache = new Map<string, i18nMessage[]>();
|
|
6
|
+
|
|
7
|
+
function flattenObject(
|
|
8
|
+
obj: Record<string, any>,
|
|
9
|
+
prefix = "",
|
|
10
|
+
): i18nMessage[] {
|
|
11
|
+
const result: i18nMessage[] = [];
|
|
12
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
13
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
14
|
+
if (typeof value === "string") {
|
|
15
|
+
result.push({ key: fullKey, value });
|
|
16
|
+
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
17
|
+
result.push(...flattenObject(value, fullKey));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function loadSingleFile(
|
|
24
|
+
filePath: string,
|
|
25
|
+
): Promise<i18nMessage[] | null> {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
28
|
+
const obj = JSON.parse(raw);
|
|
29
|
+
return flattenObject(obj);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadDirectory(
|
|
36
|
+
dirPath: string,
|
|
37
|
+
): Promise<i18nMessage[]> {
|
|
38
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
39
|
+
const jsonFiles = entries
|
|
40
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
41
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
42
|
+
|
|
43
|
+
const all: i18nMessage[] = [];
|
|
44
|
+
for (const file of jsonFiles) {
|
|
45
|
+
const filePath = path.join(dirPath, file.name);
|
|
46
|
+
const messages = await loadSingleFile(filePath);
|
|
47
|
+
if (messages) all.push(...messages);
|
|
48
|
+
}
|
|
49
|
+
return all;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loadMessages(
|
|
53
|
+
root: string,
|
|
54
|
+
locale: string,
|
|
55
|
+
): Promise<i18nMessage[]> {
|
|
56
|
+
const cacheKey = `${root}:${locale}`;
|
|
57
|
+
const cached = cache.get(cacheKey);
|
|
58
|
+
if (cached) return cached;
|
|
59
|
+
|
|
60
|
+
const i18nDir = path.join(root, "i18n", "messages");
|
|
61
|
+
|
|
62
|
+
// Try single file: i18n/messages/[locale].json
|
|
63
|
+
const singlePath = path.join(i18nDir, `${locale}.json`);
|
|
64
|
+
const fromFile = await loadSingleFile(singlePath);
|
|
65
|
+
if (fromFile) {
|
|
66
|
+
cache.set(cacheKey, fromFile);
|
|
67
|
+
return fromFile;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Try directory: i18n/messages/[locale]/
|
|
71
|
+
const dirPath = path.join(i18nDir, locale);
|
|
72
|
+
try {
|
|
73
|
+
const stat = await fs.stat(dirPath);
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
const fromDir = await loadDirectory(dirPath);
|
|
76
|
+
cache.set(cacheKey, fromDir);
|
|
77
|
+
return fromDir;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// directory does not exist
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function getMessagesFileList(
|
|
87
|
+
root: string,
|
|
88
|
+
): Promise<string[]> {
|
|
89
|
+
const i18nDir = path.join(root, "i18n", "messages");
|
|
90
|
+
try {
|
|
91
|
+
const entries = await fs.readdir(i18nDir, { withFileTypes: true });
|
|
92
|
+
const locales: string[] = [];
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
95
|
+
locales.push(entry.name.replace(/\.json$/, ""));
|
|
96
|
+
} else if (entry.isDirectory()) {
|
|
97
|
+
locales.push(entry.name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return locales;
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function i18nDirectoryExists(root: string): Promise<boolean> {
|
|
107
|
+
try {
|
|
108
|
+
const stat = await fs.stat(path.join(root, "i18n"));
|
|
109
|
+
return stat.isDirectory();
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function clearCache(root?: string, locale?: string) {
|
|
116
|
+
if (root && locale) {
|
|
117
|
+
cache.delete(`${root}:${locale}`);
|
|
118
|
+
} else if (root) {
|
|
119
|
+
for (const key of cache.keys()) {
|
|
120
|
+
if (key.startsWith(`${root}:`)) cache.delete(key);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
cache.clear();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { i18nMessage, i18nPlaceholders } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const PLACEHOLDER_RE = /\{(\w+)\}/g;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Replace `{key}` placeholders in a template string with values from `placeholders`.
|
|
7
|
+
*
|
|
8
|
+
* @param template - String containing `{key}` placeholders.
|
|
9
|
+
* @param placeholders - Object mapping keys to replacement values.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* format("Hello, {name}!", { name: "John" })
|
|
14
|
+
* // => "Hello, John!"
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function format(
|
|
18
|
+
template: string,
|
|
19
|
+
placeholders: i18nPlaceholders,
|
|
20
|
+
): string {
|
|
21
|
+
return template.replace(PLACEHOLDER_RE, (_, key: string) => {
|
|
22
|
+
const val = placeholders[key];
|
|
23
|
+
return val !== undefined ? String(val) : `{${key}}`;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert a flat `i18nMessage[]` array to a `Record<string, string>`.
|
|
29
|
+
*
|
|
30
|
+
* Useful for fast lookups and passing messages to client-side components.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* const map = toObject(msgs);
|
|
35
|
+
* map.greeting // "Hello"
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function toObject(messages: i18nMessage[]): Record<string, string> {
|
|
39
|
+
const obj: Record<string, string> = {};
|
|
40
|
+
for (const m of messages) obj[m.key] = m.value;
|
|
41
|
+
return obj;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Translate a message by key, optionally applying placeholders.
|
|
46
|
+
*
|
|
47
|
+
* Returns the raw message value if no placeholders given,
|
|
48
|
+
* or the formatted string with placeholders substituted.
|
|
49
|
+
*
|
|
50
|
+
* @param messages - Array of messages from `getMessages`.
|
|
51
|
+
* @param key - The message key to look up.
|
|
52
|
+
* @param placeholders - Optional key-value pairs for `{key}` substitution.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* const msgs = await getMessages("en");
|
|
57
|
+
*
|
|
58
|
+
* t(msgs, "greeting") // "Hello"
|
|
59
|
+
* t(msgs, "welcome", { name: "John" }) // "Welcome, John!"
|
|
60
|
+
* t(msgs, "missing") // "missing" (key not found)
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function t(
|
|
64
|
+
messages: i18nMessage[],
|
|
65
|
+
key: string,
|
|
66
|
+
placeholders?: i18nPlaceholders,
|
|
67
|
+
): string {
|
|
68
|
+
for (const m of messages) {
|
|
69
|
+
if (m.key === key) {
|
|
70
|
+
return placeholders ? format(m.value, placeholders) : m.value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Wraps a messages object (Record) with a `t` function for convenient use in components.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* const msg = createTranslate(msgs);
|
|
82
|
+
* msg("greeting") // "Hello"
|
|
83
|
+
* msg("welcome", { name: "John" }) // "Welcome, John!"
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function createTranslate(messages: i18nMessage[]) {
|
|
87
|
+
return (key: string, placeholders?: i18nPlaceholders) =>
|
|
88
|
+
t(messages, key, placeholders);
|
|
89
|
+
}
|