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.
Files changed (57) hide show
  1. package/README.md +192 -0
  2. package/auth/index.ts +1 -0
  3. package/i18n/index.ts +1 -0
  4. package/index.ts +1 -0
  5. package/package.json +68 -0
  6. package/src/auth/index.ts +5 -0
  7. package/src/auth/jwt.ts +101 -0
  8. package/src/auth/middleware.ts +150 -0
  9. package/src/auth/session.ts +46 -0
  10. package/src/auth/store.ts +39 -0
  11. package/src/auth/types.ts +55 -0
  12. package/src/cli/cli.ts +142 -0
  13. package/src/cli/generate.ts +142 -0
  14. package/src/core/client.ts +157 -0
  15. package/src/core/config.ts +16 -0
  16. package/src/core/css.ts +72 -0
  17. package/src/core/islands.ts +90 -0
  18. package/src/core/logger.ts +37 -0
  19. package/src/core/routes.ts +247 -0
  20. package/src/core/runtime.ts +131 -0
  21. package/src/core/watcher.ts +43 -0
  22. package/src/i18n/index.ts +57 -0
  23. package/src/i18n/loader.ts +125 -0
  24. package/src/i18n/translate.ts +89 -0
  25. package/src/i18n/types.ts +10 -0
  26. package/src/index.ts +10 -0
  27. package/src/render/island.tsx +78 -0
  28. package/src/render/ssr.tsx +198 -0
  29. package/src/render/stream.tsx +144 -0
  30. package/src/router/api.ts +111 -0
  31. package/src/router/index.ts +9 -0
  32. package/src/router/middleware.ts +16 -0
  33. package/src/router/pages.ts +97 -0
  34. package/src/router/shared.ts +3 -0
  35. package/src/types/api.ts +106 -0
  36. package/src/types/config.ts +35 -0
  37. package/src/types/index.ts +4 -0
  38. package/src/types/middleware.ts +21 -0
  39. package/src/types/page.ts +51 -0
  40. package/src/types/plugin.ts +29 -0
  41. package/src/utils/fs.ts +46 -0
  42. package/src/utils/logger.ts +21 -0
  43. package/src/utils/network.ts +14 -0
  44. package/templates/default/api/hello.ts +9 -0
  45. package/templates/default/components/card.tsx +10 -0
  46. package/templates/default/components/layouts/base.tsx +26 -0
  47. package/templates/default/lib/greeting.ts +3 -0
  48. package/templates/default/mintil.config.ts +10 -0
  49. package/templates/default/package.json +17 -0
  50. package/templates/default/pages/(*).tsx +11 -0
  51. package/templates/default/pages/about.tsx +13 -0
  52. package/templates/default/pages/blog/(:slug).tsx +12 -0
  53. package/templates/default/pages/counter.tsx +36 -0
  54. package/templates/default/pages/index.tsx +14 -0
  55. package/templates/default/public/readme.txt +1 -0
  56. package/templates/default/styles.css +1 -0
  57. 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
+ }
@@ -0,0 +1,10 @@
1
+ export interface i18nMessage {
2
+ key: string;
3
+ value: string;
4
+ }
5
+
6
+ export interface i18nOptions {
7
+ root?: string;
8
+ }
9
+
10
+ export type i18nPlaceholders = Record<string, string | number>;