@ijo-elaja/rev.js 0.4.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/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "useTabs": true,
3
+ "tabWidth": 4,
4
+ "semi": true,
5
+ "singleQuote": false
6
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Elia Perry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # Rev.js
2
+
3
+ A highly opionionated, lightweight, frontend<sup>1</sup> only solution.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Table of Contents](#table-of-contents)
8
+ - [Features](#features)
9
+ - [Current](#current)
10
+ - [Planned/Unplanned](#plannedunplanned)
11
+ - [Won't Do](#wont-do)
12
+ - [Usage](#usage)
13
+ - [Contributing](#contributing)
14
+ - [License](#license)
15
+
16
+ <sup>1</sup> As of v0.3.0 you *can* write "backend" code by extending the `Elysia` object through the config.
17
+
18
+ ## Features
19
+
20
+ ### Current
21
+
22
+ - Filesystem Based Router
23
+ - Slugs (dynamic data based routing)
24
+ - Components
25
+ - Inline JavaScript
26
+
27
+ ### Planned/Unplanned
28
+
29
+ - Template
30
+ - Static Site Building
31
+ - Component Props (think react but less bad)
32
+ - Documentation(?)
33
+
34
+ ### Won't Do
35
+
36
+ I will not *specifically* support non-Bun JavaScript runtimes. Bun has support for Windows, Mac, and Linux, and is faster than node/npm, yarn, or pnpm.
37
+
38
+ If you don't want Bun, you don't want this package.
39
+
40
+ ## Usage
41
+
42
+ First, you can install the package like so:
43
+
44
+ ```bash
45
+ bun install @ijo-elaja/rev.js
46
+ ```
47
+
48
+ Next, make sure you have this directory structure:
49
+
50
+ ```directory
51
+ | index.ts
52
+ | pages/
53
+ | _page.html
54
+ | _layout.html
55
+ | 404.html
56
+ | public/
57
+ | (public files such
58
+ | as a favicon.ico)
59
+ ```
60
+
61
+ You can also have a components directory at the root level:
62
+
63
+ ```directory
64
+ | index.ts
65
+ | pages/
66
+ | _page.html
67
+ | _layout.html
68
+ | 404.html
69
+ | components/
70
+ | SomeComponent.html
71
+ | public/
72
+ | (public files such
73
+ | as a favicon.ico)
74
+ ```
75
+
76
+ In your `index.ts` file, all you need is:
77
+
78
+ ```ts
79
+ import Rev from "@ijo-elaja/rev.js";
80
+
81
+ new Rev({
82
+ port: 8080,
83
+
84
+ // debug info isn't too important most of the time
85
+ // but it can be useful if you did something stupid
86
+ showDebug: false,
87
+
88
+ // im working to fix this, but no promises
89
+ // for now, it needs to be an "absolute" path
90
+ rootDir: __dirname,
91
+
92
+ // this is 100% optional, but can be useful
93
+ // when you want more functionality than just
94
+ // what rev.js provides by default
95
+ elysia: (app) => app
96
+ });
97
+ ```
98
+
99
+ In your `pages/_layout.html` file, is your (you guessed it) main layout. Any `pages/**/_page.html` will go in the `Outlet` built-in component.
100
+
101
+ Here's the absolute *bare minimum* `pages/_layout.html`:
102
+
103
+ ```ts
104
+ {{ %Outlet% }}
105
+ ```
106
+
107
+ Of course, this might not look very nice, but it will work.
108
+
109
+ However, if you do want the basics, here's a more "complete" example:
110
+
111
+ ```html
112
+ <!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <!-- this is a component (very useful for sharing heads between pages) -->
116
+ {{ %Head% }}
117
+ </head>
118
+ <body>
119
+ <header>
120
+ <h1>Header</h1>
121
+ </header>
122
+ <!-- this is a built-in component -->
123
+ <main>{{ %Outlet% }}</main>
124
+ <footer>Footer</footer>
125
+ </body>
126
+ </html>
127
+ ```
128
+
129
+ The `Head` component is **not** built-in. But its not very hard to implement:
130
+
131
+ In `components/Head.html`:
132
+
133
+ ```html
134
+ <meta charset="UTF-8" />
135
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
136
+ <title>Title</title>
137
+ ```
138
+
139
+ ## Contributing
140
+
141
+ This repository is not currently accepting contributions.
142
+
143
+ ## License
144
+
145
+ [MIT](./LICENSE)
package/index.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import Elysia from "elysia";
2
+
3
+ declare const log: (...args: any[]) => void;
4
+ declare const debug: (...args: any[]) => void;
5
+ declare const warn: (...args: any[]) => void;
6
+ declare const error: (...args: any[]) => void;
7
+
8
+ declare interface RevConfig {
9
+ port?: number;
10
+ showDebug?: boolean;
11
+ rootDir: string;
12
+ elysia?: (app: Elysia) => Elysia;
13
+ }
14
+
15
+ declare class Rev {
16
+ constructor(config: RevConfig);
17
+ }
package/index.ts ADDED
@@ -0,0 +1,372 @@
1
+ //#region logging utils
2
+ import { Logger, Style } from "@ijo-elaja/log4js";
3
+ import { appendFile } from "node:fs/promises";
4
+ const DEFAULT_LOGGER = new Logger("main");
5
+
6
+ const append_to_file = async (path: string, data: string | Uint8Array) => {
7
+ await appendFile(path, data);
8
+ };
9
+ const format = (...args: any[]) => args.map((arg) => `${arg}`).join(" ");
10
+ const datestr = () =>
11
+ `${new Date()}`
12
+ .split(" ")
13
+ .filter((_, i) => i < 5)
14
+ .join(" ");
15
+ const datestrc = () =>
16
+ `${Style.FOREGROUND_DARK_AQUA}${datestr()}${Style.RESET}`;
17
+ const _logf = (path: string, ...args: any[]) => {
18
+ append_to_file(path, format("[main/INFO]", datestr(), ...[...args, "\n"]));
19
+ DEFAULT_LOGGER.log(format(datestrc(), ...args));
20
+ };
21
+ const _debugf = (path: string, ...args: any[]) => {
22
+ append_to_file(path, format("[main/DEBUG]", datestr(), ...[...args, "\n"]));
23
+ DEFAULT_LOGGER.debug(format(datestrc(), ...args));
24
+ };
25
+ const _warnf = (path: string, ...args: any[]) => {
26
+ append_to_file(path, format("[main/WARN]", datestr(), ...[...args, "\n"]));
27
+ DEFAULT_LOGGER.warn(format(datestrc(), ...args));
28
+ };
29
+ const _errorf = (path: string, ...args: any[]) => {
30
+ append_to_file(path, format("[main/ERROR]", datestr(), ...[...args, "\n"]));
31
+ DEFAULT_LOGGER.error(format(datestrc(), ...args));
32
+ };
33
+ export const log = (...args: any[]) => _logf("revjs.log", ...args);
34
+ export const debug = (...args: any[]) => _debugf("revjs.log", ...args);
35
+ export const warn = (...args: any[]) => _warnf("revjs.log", ...args);
36
+ export const error = (...args: any[]) => _errorf("revjs.log", ...args);
37
+
38
+ //#endregion
39
+
40
+ //#region custom ssr
41
+
42
+ import prettier from "prettier";
43
+ import path from "node:path";
44
+
45
+ const FILENAMES = {
46
+ LAYOUT: "/_layout.html",
47
+ PAGE: "/_page.html",
48
+ SLUG: "/.slug",
49
+ NOT_FOUND: "/404.html",
50
+ };
51
+
52
+ let PAGES_DIR = "/pages";
53
+ let COMPONENTS_DIR = "/components/";
54
+
55
+ const localFetch = async (path: string): Promise<string> =>
56
+ await Bun.file(path).text();
57
+
58
+ const localExists = async (path: string): Promise<boolean> =>
59
+ await Bun.file(path).exists();
60
+
61
+ const urlToBasePath = (url: URL): string => {
62
+ if (url.pathname == "/") return PAGES_DIR;
63
+ switch (url.searchParams.get("type")) {
64
+ default:
65
+ return PAGES_DIR + url.pathname;
66
+ }
67
+ };
68
+
69
+ const getComponent = async (name: string): Promise<string> => {
70
+ const component = await localFetch(COMPONENTS_DIR + name + ".html");
71
+ return component;
72
+ };
73
+
74
+ const removeFirst = function (str: string): string {
75
+ return str.slice(1);
76
+ };
77
+
78
+ const removeLast = function (str: string): string {
79
+ return str.slice(0, -1);
80
+ };
81
+
82
+ const removeFirstN = function (str: string, n: number): string {
83
+ return str.slice(n);
84
+ };
85
+
86
+ const removeLastN = function (str: string, n: number): string {
87
+ return str.slice(0, -n);
88
+ };
89
+
90
+ const removeFirstNIf = function (
91
+ str: string,
92
+ n: number,
93
+ predicate: () => boolean,
94
+ ): string {
95
+ if (predicate()) return str.slice(n);
96
+ else return str.toString();
97
+ };
98
+
99
+ const removeLastNIf = function (
100
+ str: string,
101
+ n: number,
102
+ predicate: () => boolean,
103
+ ): string {
104
+ if (predicate()) return str.slice(0, -n);
105
+ return str.toString();
106
+ };
107
+
108
+ enum EvalstMode {
109
+ LAYOUT,
110
+ PAGE,
111
+ SCRIPT,
112
+ }
113
+
114
+ interface EvalstElement {
115
+ match: string;
116
+ mode: EvalstMode;
117
+ content: string;
118
+ }
119
+
120
+ interface EvalsElement {
121
+ selector: string;
122
+ evaluation: string;
123
+ content: string;
124
+ isJs: boolean;
125
+ }
126
+
127
+ const evaluate = async (
128
+ str: string,
129
+ initialState?: Record<string, any>,
130
+ outletContent?: string,
131
+ ): Promise<string> => {
132
+ let processedPageContent = str;
133
+ const evalst: EvalstElement[] = [];
134
+ const regex = /{{ ?(.*?)? ?}}/gims;
135
+ let m;
136
+ let __EVAL_STATE__ = initialState || {};
137
+
138
+ while ((m = regex.exec(str)) !== null) {
139
+ if (m.index === regex.lastIndex) {
140
+ regex.lastIndex++;
141
+ }
142
+
143
+ const match = m[1];
144
+ let toEval = match.trim();
145
+ if (toEval.startsWith("%") && toEval.endsWith("%")) {
146
+ toEval = removeLast(removeFirst(toEval.trim())).trim();
147
+ if (toEval == "Outlet" && outletContent) {
148
+ evalst.push({
149
+ match: m[0],
150
+ mode: EvalstMode.LAYOUT,
151
+ content: outletContent,
152
+ });
153
+ } else if (toEval == "Outlet" && !outletContent) {
154
+ // str is an error!
155
+ // we place some default 404 content here
156
+ error(`Missing outlet content for ${toEval}`);
157
+ evalst.push({
158
+ match: m[0],
159
+ mode: EvalstMode.LAYOUT,
160
+ content: "404 Not Found (Missing Outlet Content)",
161
+ });
162
+ } else {
163
+ try {
164
+ evalst.push({
165
+ match: m[0],
166
+ mode: EvalstMode.LAYOUT,
167
+ content: await evaluate(await getComponent(toEval)),
168
+ });
169
+ } catch (e) {
170
+ error(`Failed to load component ${toEval}`);
171
+ evalst.push({
172
+ match: m[0],
173
+ mode: EvalstMode.LAYOUT,
174
+ content:
175
+ '<i style="color: red !important">404 Not Found (Missing Component)</i>',
176
+ });
177
+ }
178
+ }
179
+ } else if (toEval.startsWith("<") && toEval.endsWith(">")) {
180
+ toEval = removeLast(removeFirst(toEval.trim()))
181
+ .trim()
182
+ .replaceAll("~", "__EVAL_STATE__");
183
+ evalst.push({
184
+ match: m[0],
185
+ mode: EvalstMode.PAGE,
186
+ content: toEval,
187
+ });
188
+ } else if (toEval.startsWith("/") && toEval.endsWith("/")) {
189
+ // script mode!
190
+ toEval = removeLast(removeFirst(toEval)).replaceAll(
191
+ "~",
192
+ "__EVAL_STATE__",
193
+ );
194
+
195
+ const code = `let __EVAL_STATE__=${JSON.stringify(
196
+ __EVAL_STATE__,
197
+ )};${toEval}`;
198
+ const data = await Bun.$`bun -e '${code}'`.json();
199
+ // copy result into __EVAL_STATE__
200
+ Object.assign(__EVAL_STATE__, data);
201
+ evalst.push({
202
+ match: m[0],
203
+ mode: EvalstMode.SCRIPT,
204
+ content: "",
205
+ });
206
+ } else {
207
+ evalst.push({
208
+ match: m[0],
209
+ mode: EvalstMode.PAGE,
210
+ content: toEval,
211
+ });
212
+ }
213
+ }
214
+
215
+ const evals: EvalsElement[] = [];
216
+
217
+ evalst.forEach(async (element) => {
218
+ if (element.mode == EvalstMode.PAGE) {
219
+ evals.push({
220
+ selector: element.match,
221
+ evaluation: eval(element.content),
222
+ content: element.content,
223
+ isJs: true,
224
+ });
225
+ } else {
226
+ evals.push({
227
+ selector: element.match,
228
+ evaluation: element.content,
229
+ content: element.content,
230
+ isJs: false,
231
+ });
232
+ }
233
+ });
234
+
235
+ evals.forEach((element) => {
236
+ processedPageContent = processedPageContent.replace(
237
+ element.selector,
238
+ element.evaluation,
239
+ );
240
+ });
241
+
242
+ return processedPageContent;
243
+ };
244
+
245
+ const loadLayout = async (basePath: string): Promise<string> => {
246
+ const layout = (await localExists(basePath + FILENAMES.LAYOUT))
247
+ ? await localFetch(basePath + FILENAMES.LAYOUT)
248
+ : await localFetch(PAGES_DIR + FILENAMES.LAYOUT);
249
+
250
+ let pageContent: string;
251
+ let initialState: Record<string, any> = {};
252
+
253
+ if (await localExists(basePath + FILENAMES.PAGE)) {
254
+ pageContent = await localFetch(basePath + FILENAMES.PAGE);
255
+ } else if (await localExists(path.join(basePath, "../") + FILENAMES.SLUG)) {
256
+ const slug = await localFetch(
257
+ path.join(basePath, "../", FILENAMES.SLUG),
258
+ );
259
+ let slugName = removeLast(removeFirst(slug));
260
+ initialState[slugName] = path.basename(basePath);
261
+ pageContent = await localFetch(
262
+ path.join(basePath, "../", slug, FILENAMES.PAGE),
263
+ );
264
+ } else {
265
+ pageContent = await localFetch(PAGES_DIR + FILENAMES.NOT_FOUND);
266
+ }
267
+
268
+ const processedPageContent = await evaluate(pageContent, initialState);
269
+ const processedLayout = await evaluate(
270
+ layout,
271
+ initialState,
272
+ processedPageContent,
273
+ );
274
+
275
+ return processedLayout;
276
+ };
277
+
278
+ const loadPage = async (request: Request): Promise<string> => {
279
+ const url = new URL(request.url);
280
+ log(`Loading ${url.pathname}`);
281
+
282
+ const basePath = urlToBasePath(url);
283
+ let page = await loadLayout(basePath);
284
+
285
+ // prettify for devtools
286
+ page = await prettier.format(page, { parser: "html" });
287
+
288
+ return page;
289
+ };
290
+
291
+ //#endregion
292
+
293
+ //#region server
294
+
295
+ import Elysia from "elysia";
296
+
297
+ interface RevConfig {
298
+ port?: number;
299
+ showDebug?: boolean;
300
+ rootDir: string;
301
+ elysia?: (app: Elysia) => Elysia;
302
+ }
303
+
304
+ class Rev {
305
+ constructor(
306
+ config: RevConfig = {
307
+ port: 3000,
308
+ showDebug: true,
309
+ rootDir: "./",
310
+ elysia: (app) => app,
311
+ },
312
+ ) {
313
+ PAGES_DIR = config.rootDir + PAGES_DIR;
314
+ COMPONENTS_DIR = config.rootDir + COMPONENTS_DIR;
315
+
316
+ log(`Starting web app at http://localhost:${config.port}`);
317
+ if (config.showDebug)
318
+ debug(
319
+ `PAGES_DIR = ${PAGES_DIR}\nCOMPONENTS_DIR = ${COMPONENTS_DIR}`,
320
+ );
321
+ let app = new Elysia()
322
+ .get("*", async ({ request }) => {
323
+ try {
324
+ // custom ssr anyone?
325
+ return new Response(await loadPage(request), {
326
+ status: 200,
327
+ headers: {
328
+ "Content-Type": "text/html",
329
+ },
330
+ });
331
+ } catch (err) {
332
+ error(
333
+ `Error on GET ${new URL(request.url).pathname}: ${err}`,
334
+ );
335
+ if (config.showDebug) debug();
336
+ return "Internal Server Error";
337
+ }
338
+ })
339
+ .get("/public/*", async ({ request }) =>
340
+ Bun.file(config.rootDir + new URL(request.url).pathname),
341
+ )
342
+ .get("/favicon.ico", async () => {
343
+ try {
344
+ return Bun.file(config.rootDir + "/public/favicon.ico");
345
+ } catch (err) {
346
+ if (config.showDebug)
347
+ debug(
348
+ "You might want a favicon.ico (/public/favicon.ico)",
349
+ );
350
+ return "not found";
351
+ }
352
+ })
353
+ .get("/robots.txt", async () => {
354
+ try {
355
+ return Bun.file(config.rootDir + "/public/robots.txt");
356
+ } catch (err) {
357
+ if (config.showDebug)
358
+ debug(
359
+ "You might want a robots.txt (/public/robots.txt)",
360
+ );
361
+ return "not found";
362
+ }
363
+ });
364
+
365
+ (config.elysia ? config.elysia(app) : app).listen(config.port || 3000);
366
+ }
367
+ }
368
+
369
+ export default Rev;
370
+ export type { RevConfig };
371
+
372
+ //#endregion
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@ijo-elaja/rev.js",
3
+ "module": "index.ts",
4
+ "version": "0.4.0",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "scripts": {
10
+ "live": "bun run test/live/live.test.ts",
11
+ "livedev": "bun run --watch test/live/live.test.ts"
12
+ },
13
+ "devDependencies": {
14
+ "@types/bun": "latest"
15
+ },
16
+ "peerDependencies": {
17
+ "typescript": "^5.0.0"
18
+ },
19
+ "dependencies": {
20
+ "@ijo-elaja/log4js": "^1.2.0",
21
+ "elysia": "^1.2.10",
22
+ "prettier": "^3.4.2"
23
+ }
24
+ }
@@ -0,0 +1,3 @@
1
+ <meta charset="UTF-8" />
2
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3
+ <title>Title</title>
@@ -0,0 +1,9 @@
1
+ // actually just a basic rev.js app
2
+
3
+ import Rev from "../..";
4
+
5
+ new Rev({
6
+ port: 8080,
7
+ showDebug: true,
8
+ rootDir: __dirname,
9
+ });
@@ -0,0 +1 @@
1
+ <i style="color: red !important">404 Not Found</i>
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <!-- this is a component (very useful for sharing heads between pages) -->
5
+ {{ %Head% }}
6
+ </head>
7
+ <body>
8
+ <header>
9
+ <section>
10
+ <h1><a href="/">test site</a></h1>
11
+ <small class="muted">subheadings are a thing</small>
12
+ </section>
13
+ </header>
14
+ <!-- this is a built-in component -->
15
+ <main>{{ %Outlet% }}</main>
16
+ </body>
17
+ </html>
@@ -0,0 +1,3 @@
1
+ content would go here
2
+ <br />
3
+ <a href="/whatever/wow!">oh boy, i wonder where this goes?</a>
@@ -0,0 +1 @@
1
+ [slug]
@@ -0,0 +1,16 @@
1
+ rev.js supports slugs!
2
+
3
+ <br />
4
+ <br />
5
+
6
+ <b>{{ < decodeURIComponent(~.slug) > }}</b>
7
+
8
+ <br />
9
+ <br />
10
+
11
+ <a href="./{{ < ~.slug + ' ' + ~.slug > }}">click me (trust)</a>
12
+
13
+ <br />
14
+ <br />
15
+
16
+ only one per folder in the fs for now
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Enable latest features
4
+ "lib": ["ESNext", "DOM"],
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+
22
+ // Some stricter flags (disabled by default)
23
+ "noUnusedLocals": false,
24
+ "noUnusedParameters": false,
25
+ "noPropertyAccessFromIndexSignature": false
26
+ }
27
+ }