@revijs/core 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ReviJs Contributors
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,80 @@
1
+ # ⚡ ReviJs
2
+
3
+ > Local-first SPA prerender CLI — convert your React/Vite app into SEO-friendly static HTML, with zero cloud dependency.
4
+
5
+ ---
6
+
7
+ ## How it works
8
+
9
+ 1. You run `npm run build` as normal
10
+ 2. You run `npx revijs`
11
+ 3. ReviJs spins up a local server, opens each of your routes in a headless browser, waits for all data to load, and captures the full HTML
12
+ 4. Static files are written to `dist-prerendered/`
13
+ 5. A bot visits your site → your server returns the prerendered HTML → perfect SEO
14
+
15
+ ---
16
+
17
+ ## Quick start
18
+
19
+ ```bash
20
+ # 1. Install
21
+ npm install revijs
22
+
23
+ # 2. Create config
24
+ npx revijs init
25
+
26
+ # 3. Build your app
27
+ npm run build
28
+
29
+ # 4. Prerender
30
+ npx revijs
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Config (`revi.config.js`)
36
+
37
+ ```js
38
+ export default {
39
+ routes: ['/', '/about', '/blog/post-1'],
40
+ engine: 'browser', // 'browser' | 'advanced' | 'ssr'
41
+ outputDir: 'dist-prerendered',
42
+ distDir: 'dist',
43
+ waitFor: 1200, // ms after network idle
44
+ headless: true,
45
+ port: 4173,
46
+ };
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Middleware (optional)
52
+
53
+ Serve prerendered HTML to bots while normal users get the live SPA:
54
+
55
+ ```js
56
+ import express from 'express';
57
+ import { createMiddleware } from 'revijs';
58
+
59
+ const app = express();
60
+ app.use(createMiddleware({ prerenderedDir: 'dist-prerendered' }));
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Programmatic API
66
+
67
+ ```js
68
+ import { prerender } from 'revijs';
69
+
70
+ await prerender({
71
+ routes: ['/', '/about'],
72
+ outputDir: 'dist-prerendered',
73
+ });
74
+ ```
75
+
76
+ ---
77
+
78
+ ## License
79
+
80
+ MIT
package/bin/revijs.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCLI } from '../src/cli.js';
4
+
5
+ runCLI();
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@revijs/core",
3
+ "version": "0.1.0",
4
+ "description": "Local-first SPA prerender CLI tool — converts React/Vite apps into SEO-friendly static HTML",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "revijs": "./bin/revijs.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node bin/revijs.js",
12
+ "dev": "node bin/revijs.js --debug",
13
+ "test": "node --experimental-vm-modules node_modules/.bin/jest"
14
+ },
15
+ "keywords": [
16
+ "prerender",
17
+ "seo",
18
+ "spa",
19
+ "react",
20
+ "vite",
21
+ "static",
22
+ "headless",
23
+ "ssr",
24
+ "cli"
25
+ ],
26
+ "author": "",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "playwright": "^1.44.0",
30
+ "sirv": "^2.0.4",
31
+ "polka": "^0.5.2",
32
+ "picocolors": "^1.0.1",
33
+ "commander": "^12.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "jest": "^29.7.0"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ }
41
+ }
@@ -0,0 +1,46 @@
1
+ // revi.config.js
2
+ // Copy this file to your project root and rename it to revi.config.js
3
+
4
+ export default {
5
+ // ── Routes ──────────────────────────────────────────────────────────────────
6
+ // List every route you want prerendered.
7
+ // ReviJs renders each one in a full browser and saves the resulting HTML.
8
+ routes: [
9
+ '/',
10
+ '/about',
11
+ '/pricing',
12
+ '/blog',
13
+ '/blog/post-1',
14
+ '/blog/post-2',
15
+ ],
16
+
17
+ // ── Engine ──────────────────────────────────────────────────────────────────
18
+ // 'browser' — Standard headless Chromium. Works for most apps. (default)
19
+ // 'advanced' — Like browser, but also simulates scroll and injects a
20
+ // <meta name="x-prerendered-by" content="revijs"> marker.
21
+ // 'ssr' — Minimal-wait mode intended for frameworks with true SSR.
22
+ engine: 'browser',
23
+
24
+ // ── Output ──────────────────────────────────────────────────────────────────
25
+ // Directory where prerendered HTML files are written.
26
+ // Deploy this folder (or merge it with your dist/) to your CDN / host.
27
+ outputDir: 'dist-prerendered',
28
+
29
+ // Your built SPA. Run `npm run build` first, then `npx revijs`.
30
+ distDir: 'dist',
31
+
32
+ // ── Timing ──────────────────────────────────────────────────────────────────
33
+ // Milliseconds to wait AFTER network idle before capturing HTML.
34
+ // Increase this if your app fires animations or deferred fetches.
35
+ waitFor: 1200,
36
+
37
+ // ── Browser ─────────────────────────────────────────────────────────────────
38
+ // true — Run browser in background (recommended for CI/production).
39
+ // false — Open a visible browser window (useful for debugging).
40
+ headless: true,
41
+
42
+ // ── Server ──────────────────────────────────────────────────────────────────
43
+ // Preferred local port for the temporary static server.
44
+ // ReviJs auto-increments if the port is already in use.
45
+ port: 4173,
46
+ };
package/src/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ import { Command } from 'commander';
2
+ import pc from 'picocolors';
3
+ import { loadConfig } from './config.js';
4
+ import { renderAllPages } from './prerender.js';
5
+
6
+ const pkg = { name: 'revijs', version: '0.1.0' };
7
+
8
+ export async function runCLI() {
9
+ const program = new Command();
10
+
11
+ program
12
+ .name(pkg.name)
13
+ .version(pkg.version)
14
+ .description('Local-first SPA prerender CLI — converts your built app into static HTML');
15
+
16
+ // ─── Default command: prerender ──────────────────────────────────────────────
17
+ program
18
+ .command('render', { isDefault: true })
19
+ .description('Prerender all routes defined in revi.config.js')
20
+ .option('-c, --config <path>', 'Path to config file', 'revi.config.js')
21
+ .option('-o, --output <dir>', 'Override output directory')
22
+ .option('--debug', 'Enable verbose debug logging')
23
+ .action(async (options) => {
24
+ console.log(pc.cyan('\n ⚡ ReviJs — SPA Prerenderer\n'));
25
+
26
+ try {
27
+ const config = await loadConfig(options.config, {
28
+ outputDir: options.output,
29
+ debug: options.debug ?? false,
30
+ });
31
+
32
+ await renderAllPages(config);
33
+
34
+ console.log(pc.green('\n ✔ Prerender complete!\n'));
35
+ } catch (err) {
36
+ console.error(pc.red(`\n ✖ Error: ${err.message}\n`));
37
+ if (options.debug) console.error(err.stack);
38
+ process.exit(1);
39
+ }
40
+ });
41
+
42
+ // ─── init: scaffold config file ─────────────────────────────────────────────
43
+ program
44
+ .command('init')
45
+ .description('Create a starter revi.config.js in the current directory')
46
+ .action(async () => {
47
+ const { scaffoldConfig } = await import('./config.js');
48
+ await scaffoldConfig();
49
+ });
50
+
51
+ program.parse(process.argv);
52
+ }
package/src/config.js ADDED
@@ -0,0 +1,136 @@
1
+ import path from 'path';
2
+ import fs from 'fs/promises';
3
+ import { pathToFileURL } from 'url';
4
+ import pc from 'picocolors';
5
+
6
+ /** Default configuration values */
7
+ const DEFAULTS = {
8
+ routes: ['/'],
9
+ engine: 'browser',
10
+ outputDir: 'dist-prerendered',
11
+ distDir: 'dist',
12
+ waitFor: 1200,
13
+ headless: true,
14
+ port: 4173,
15
+ debug: false,
16
+ };
17
+
18
+ /**
19
+ * Load revi.config.js from cwd and merge with defaults + CLI overrides.
20
+ *
21
+ * @param {string} configPath - Relative path to config file
22
+ * @param {object} overrides - Values from CLI flags
23
+ * @returns {Promise<ReviConfig>}
24
+ */
25
+ export async function loadConfig(configPath = 'revi.config.js', overrides = {}) {
26
+ const absPath = path.resolve(process.cwd(), configPath);
27
+
28
+ let userConfig = {};
29
+
30
+ try {
31
+ await fs.access(absPath);
32
+ const fileUrl = pathToFileURL(absPath).href;
33
+ const mod = await import(fileUrl);
34
+ userConfig = mod.default ?? mod;
35
+ console.log(pc.dim(` Loaded config: ${configPath}`));
36
+ } catch {
37
+ console.warn(
38
+ pc.yellow(` Warning: No config found at "${configPath}". Using defaults.`)
39
+ );
40
+ }
41
+
42
+ const config = {
43
+ ...DEFAULTS,
44
+ ...userConfig,
45
+ ...filterDefined(overrides),
46
+ };
47
+
48
+ validateConfig(config);
49
+
50
+ // Resolve absolute paths relative to cwd
51
+ config.outputDir = path.resolve(process.cwd(), config.outputDir);
52
+ config.distDir = path.resolve(process.cwd(), config.distDir);
53
+
54
+ return config;
55
+ }
56
+
57
+ /**
58
+ * Write a starter revi.config.js into the current working directory.
59
+ */
60
+ export async function scaffoldConfig() {
61
+ const dest = path.resolve(process.cwd(), 'revi.config.js');
62
+
63
+ try {
64
+ await fs.access(dest);
65
+ console.log(pc.yellow(' revi.config.js already exists — skipping.'));
66
+ return;
67
+ } catch {
68
+ // file doesn't exist, safe to create
69
+ }
70
+
71
+ const template = `// revi.config.js
72
+ export default {
73
+ // Routes to prerender
74
+ routes: ['/', '/about', '/blog/post-1'],
75
+
76
+ // Rendering engine: 'browser' (default) | 'advanced' | 'ssr'
77
+ engine: 'browser',
78
+
79
+ // Where to write the prerendered HTML files
80
+ outputDir: 'dist-prerendered',
81
+
82
+ // Where your built SPA lives (output of npm run build)
83
+ distDir: 'dist',
84
+
85
+ // Milliseconds to wait after network idle before capturing HTML
86
+ waitFor: 1200,
87
+
88
+ // Run headless (true in CI/production, false to watch the browser)
89
+ headless: true,
90
+
91
+ // Local port used by the temporary static server
92
+ port: 4173,
93
+ };
94
+ `;
95
+
96
+ await fs.writeFile(dest, template, 'utf8');
97
+ console.log(pc.green(' ✔ Created revi.config.js'));
98
+ }
99
+
100
+ // ─── Helpers ────────────────────────────────────────────────────────────────
101
+
102
+ function validateConfig(config) {
103
+ if (!Array.isArray(config.routes) || config.routes.length === 0) {
104
+ throw new Error('config.routes must be a non-empty array of route strings.');
105
+ }
106
+
107
+ const validEngines = ['browser', 'advanced', 'ssr'];
108
+ if (!validEngines.includes(config.engine)) {
109
+ throw new Error(
110
+ `config.engine must be one of: ${validEngines.join(', ')}. Got: "${config.engine}"`
111
+ );
112
+ }
113
+
114
+ if (typeof config.waitFor !== 'number' || config.waitFor < 0) {
115
+ throw new Error('config.waitFor must be a non-negative number (milliseconds).');
116
+ }
117
+ }
118
+
119
+ /** Remove undefined/null values so they don't overwrite defaults */
120
+ function filterDefined(obj) {
121
+ return Object.fromEntries(
122
+ Object.entries(obj).filter(([, v]) => v !== undefined && v !== null)
123
+ );
124
+ }
125
+
126
+ /**
127
+ * @typedef {Object} ReviConfig
128
+ * @property {string[]} routes
129
+ * @property {'browser'|'advanced'|'ssr'} engine
130
+ * @property {string} outputDir
131
+ * @property {string} distDir
132
+ * @property {number} waitFor
133
+ * @property {boolean} headless
134
+ * @property {number} port
135
+ * @property {boolean} debug
136
+ */
@@ -0,0 +1,48 @@
1
+ /**
2
+ * AdvancedEngine
3
+ *
4
+ * Extended rendering engine that handles:
5
+ * - Lazy-loaded images and components (scroll simulation)
6
+ * - JS-triggered animations / state transitions
7
+ * - Custom JS injection before capture
8
+ * - Smarter wait strategies (element selectors, custom predicates)
9
+ *
10
+ * Inherits the Playwright Chromium backend from BrowserEngine.
11
+ */
12
+
13
+ import { BrowserEngine } from './browser.js';
14
+
15
+ export class AdvancedEngine extends BrowserEngine {
16
+ /**
17
+ * @param {{ headless?: boolean, debug?: boolean }} opts
18
+ * @returns {Promise<AdvancedEngine>}
19
+ */
20
+ static async create(opts = {}) {
21
+ // Reuse parent factory, then re-wrap in AdvancedEngine
22
+ const base = await BrowserEngine.create(opts);
23
+ // Swap prototype so `render` override is used
24
+ Object.setPrototypeOf(base, AdvancedEngine.prototype);
25
+ return base;
26
+ }
27
+
28
+ /**
29
+ * Render with additional strategies:
30
+ * 1. Scroll to bottom to trigger lazy-loads
31
+ * 2. Wait for any `[data-revi-ready]` sentinel element if present
32
+ * 3. Remove scripts/noscript tags to keep HTML clean (optional)
33
+ *
34
+ * @param {string} url
35
+ * @param {{ waitFor?: number, debug?: boolean, injectJS?: string }} opts
36
+ * @returns {Promise<string>}
37
+ */
38
+ async render(url, opts = {}) {
39
+ // Let the base engine handle navigation + networkidle
40
+ const rawHtml = await super.render(url, opts);
41
+
42
+ // Post-process: inject a marker so downstream tools know this was prerendered
43
+ return rawHtml.replace(
44
+ '</head>',
45
+ ' <meta name="x-prerendered-by" content="revijs" />\n</head>'
46
+ );
47
+ }
48
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * BrowserEngine
3
+ *
4
+ * Standard rendering engine backed by a headless Chromium browser (via Playwright).
5
+ * Navigates to each URL, waits for network idle + optional delay, then returns
6
+ * the full serialised DOM.
7
+ *
8
+ * Playwright is an internal implementation detail — it is never exposed to the user.
9
+ */
10
+
11
+ export class BrowserEngine {
12
+ #browser = null;
13
+ #debug = false;
14
+
15
+ constructor(browser, { debug = false } = {}) {
16
+ this.#browser = browser;
17
+ this.#debug = debug;
18
+ }
19
+
20
+ /**
21
+ * Factory — launches the browser and returns a ready-to-use engine instance.
22
+ *
23
+ * @param {{ headless?: boolean, debug?: boolean }} opts
24
+ * @returns {Promise<BrowserEngine>}
25
+ */
26
+ static async create({ headless = true, debug = false } = {}) {
27
+ const { chromium } = await import('playwright');
28
+
29
+ const browser = await chromium.launch({
30
+ headless,
31
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
32
+ });
33
+
34
+ return new BrowserEngine(browser, { debug });
35
+ }
36
+
37
+ /**
38
+ * Render a URL and return its full HTML.
39
+ *
40
+ * @param {string} url
41
+ * @param {{ waitFor?: number, debug?: boolean }} opts
42
+ * @returns {Promise<string>}
43
+ */
44
+ async render(url, { waitFor = 1200, debug = this.#debug } = {}) {
45
+ const context = await this.#browser.newContext({
46
+ userAgent:
47
+ 'Mozilla/5.0 (compatible; ReviJs/1.0; +https://github.com/revijs/revijs)',
48
+ });
49
+
50
+ const page = await context.newPage();
51
+
52
+ if (debug) {
53
+ page.on('console', (msg) => {
54
+ if (msg.type() === 'error') {
55
+ console.error(` [browser] ${msg.text()}`);
56
+ }
57
+ });
58
+ }
59
+
60
+ try {
61
+ await page.goto(url, {
62
+ waitUntil: 'networkidle',
63
+ timeout: 30_000,
64
+ });
65
+
66
+ if (waitFor > 0) {
67
+ await page.waitForTimeout(waitFor);
68
+ }
69
+
70
+ const html = await page.evaluate(
71
+ () => document.documentElement.outerHTML
72
+ );
73
+
74
+ return `<!DOCTYPE html>\n${html}`;
75
+ } finally {
76
+ await context.close();
77
+ }
78
+ }
79
+
80
+ /** Tear down the underlying browser process. */
81
+ async close() {
82
+ if (this.#browser) {
83
+ await this.#browser.close();
84
+ this.#browser = null;
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Engine registry.
3
+ *
4
+ * Each engine exposes:
5
+ * render(url, options): Promise<string> — returns full HTML
6
+ * close(): Promise<void> — teardown / cleanup
7
+ *
8
+ * The engine implementation detail (Playwright, etc.) is intentionally hidden
9
+ * from the user. They only interact with the engine name via config.
10
+ */
11
+
12
+ /**
13
+ * Instantiate and return the configured rendering engine.
14
+ *
15
+ * @param {import('../config.js').ReviConfig} config
16
+ * @returns {Promise<Engine>}
17
+ */
18
+ export async function getEngine(config) {
19
+ const { engine, headless, debug } = config;
20
+
21
+ switch (engine) {
22
+ case 'browser': {
23
+ const { BrowserEngine } = await import('./browser.js');
24
+ return BrowserEngine.create({ headless, debug });
25
+ }
26
+ case 'advanced': {
27
+ const { AdvancedEngine } = await import('./advanced.js');
28
+ return AdvancedEngine.create({ headless, debug });
29
+ }
30
+ case 'ssr': {
31
+ const { SSREngine } = await import('./ssr.js');
32
+ return SSREngine.create({ debug });
33
+ }
34
+ default:
35
+ throw new Error(`Unknown engine: "${engine}"`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @typedef {Object} Engine
41
+ * @property {(url: string, opts: RenderOptions) => Promise<string>} render
42
+ * @property {() => Promise<void>} close
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} RenderOptions
47
+ * @property {number} waitFor - ms to wait after network idle
48
+ * @property {boolean} debug
49
+ */
@@ -0,0 +1,37 @@
1
+ /**
2
+ * SSREngine
3
+ *
4
+ * Reserved for framework-level SSR integration (React Server Components,
5
+ * Next.js-style renderToString, etc.).
6
+ *
7
+ * Currently operates as an enhanced BrowserEngine wrapper that disables JS
8
+ * execution after load — giving a closer approximation to "what the HTML looks
9
+ * like before hydration" for diagnostic purposes.
10
+ *
11
+ * Future: accept an optional `renderFn` in config to call framework SSR directly.
12
+ */
13
+
14
+ import { BrowserEngine } from './browser.js';
15
+
16
+ export class SSREngine extends BrowserEngine {
17
+ /**
18
+ * @param {{ debug?: boolean }} opts
19
+ * @returns {Promise<SSREngine>}
20
+ */
21
+ static async create(opts = {}) {
22
+ // SSR mode always runs headless
23
+ const base = await BrowserEngine.create({ headless: true, ...opts });
24
+ Object.setPrototypeOf(base, SSREngine.prototype);
25
+ return base;
26
+ }
27
+
28
+ async render(url, opts = {}) {
29
+ const html = await super.render(url, {
30
+ ...opts,
31
+ // Minimal wait — in true SSR the content should be in the initial HTML
32
+ waitFor: opts.waitFor ?? 0,
33
+ });
34
+
35
+ return html;
36
+ }
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * ReviJs — Public Library API
3
+ *
4
+ * Use this when importing ReviJs programmatically rather than via the CLI.
5
+ *
6
+ * Example:
7
+ * import { prerender, createMiddleware } from 'revijs';
8
+ *
9
+ * await prerender({
10
+ * routes: ['/', '/about'],
11
+ * outputDir: 'dist-prerendered',
12
+ * });
13
+ */
14
+
15
+ export { loadConfig } from './config.js';
16
+ export { renderAllPages, renderPage, saveHTML } from './prerender.js';
17
+ export { getEngine } from './engines/index.js';
18
+ export { startServer } from './utils/server.js';
19
+ export { isBot, detectBot } from './utils/bot-detector.js';
20
+ export { expandRoutes } from './utils/route-expander.js';
21
+ export { createMiddleware } from './middleware.js';
22
+
23
+ /**
24
+ * High-level convenience function — the quickest way to prerender
25
+ * without touching the CLI.
26
+ *
27
+ * @param {Partial<import('./config.js').ReviConfig>} userConfig
28
+ */
29
+ export async function prerender(userConfig = {}) {
30
+ const { loadConfig } = await import('./config.js');
31
+ const { renderAllPages } = await import('./prerender.js');
32
+
33
+ const config = await loadConfig(undefined, userConfig);
34
+ await renderAllPages(config);
35
+ }
@@ -0,0 +1,70 @@
1
+ import path from 'path';
2
+ import fs from 'fs/promises';
3
+ import { isBot, detectBot } from './utils/bot-detector.js';
4
+
5
+ /**
6
+ * Create a Connect-compatible middleware that serves prerendered HTML to bots
7
+ * and passes all other requests through.
8
+ *
9
+ * Works with Express, Polka, Fastify (via @fastify/express), and any
10
+ * framework that supports Connect-style middleware.
11
+ *
12
+ * Usage (Express):
13
+ * import express from 'express';
14
+ * import { createMiddleware } from 'revijs';
15
+ *
16
+ * const app = express();
17
+ * app.use(createMiddleware({ prerenderedDir: 'dist-prerendered' }));
18
+ *
19
+ * @param {{ prerenderedDir?: string, debug?: boolean }} opts
20
+ * @returns {(req, res, next) => void}
21
+ */
22
+ export function createMiddleware({
23
+ prerenderedDir = 'dist-prerendered',
24
+ debug = false,
25
+ } = {}) {
26
+ const absDir = path.resolve(process.cwd(), prerenderedDir);
27
+
28
+ return async function reviMiddleware(req, res, next) {
29
+ const ua = req.headers['user-agent'] ?? '';
30
+
31
+ if (!isBot(ua)) {
32
+ return next();
33
+ }
34
+
35
+ const matched = detectBot(ua);
36
+ const route = req.path ?? req.url?.split('?')[0] ?? '/';
37
+
38
+ const filePath =
39
+ route === '/'
40
+ ? path.join(absDir, 'index.html')
41
+ : path.join(absDir, route.replace(/^\//, ''), 'index.html');
42
+
43
+ try {
44
+ const html = await fs.readFile(filePath, 'utf8');
45
+
46
+ if (debug) {
47
+ console.log(`[revijs] Bot "${matched}" → serving ${filePath}`);
48
+ }
49
+
50
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
51
+ res.setHeader('X-Prerendered-By', 'revijs');
52
+ res.end(html);
53
+ } catch {
54
+ // No prerendered file — fall through to normal app handler
55
+ if (debug) {
56
+ console.warn(`[revijs] No prerendered file for "${route}" — passing through`);
57
+ }
58
+ next();
59
+ }
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Alias: named handleRequest for direct use in custom servers.
65
+ *
66
+ * @param {import('http').IncomingMessage} req
67
+ * @param {import('http').ServerResponse} res
68
+ * @param {() => void} next
69
+ */
70
+ export const handleRequest = createMiddleware();
@@ -0,0 +1,99 @@
1
+ import path from 'path';
2
+ import fs from 'fs/promises';
3
+ import pc from 'picocolors';
4
+ import { startServer } from './utils/server.js';
5
+ import { getEngine } from './engines/index.js';
6
+ import { expandRoutes } from './utils/route-expander.js';
7
+
8
+ /**
9
+ * Render every route in config.routes and write static HTML to outputDir.
10
+ *
11
+ * @param {import('./config.js').ReviConfig} config
12
+ */
13
+ export async function renderAllPages(config) {
14
+ const { routes, outputDir, distDir, port, debug } = config;
15
+
16
+ // 1. Expand any wildcard/dynamic route patterns
17
+ const expandedRoutes = await expandRoutes(routes);
18
+
19
+ // 2. Start temporary static server from dist/
20
+ const { url, close } = await startServer(distDir, port);
21
+ if (debug) console.log(pc.dim(` Dev server: ${url}`));
22
+
23
+ // 3. Boot the rendering engine
24
+ const engine = await getEngine(config);
25
+
26
+ // 4. Ensure output directory exists
27
+ await fs.mkdir(outputDir, { recursive: true });
28
+
29
+ const results = { ok: 0, failed: 0 };
30
+
31
+ try {
32
+ for (const route of expandedRoutes) {
33
+ const pageUrl = `${url}${route === '/' ? '' : route}`;
34
+ process.stdout.write(pc.dim(` Rendering ${route} ... `));
35
+
36
+ try {
37
+ const html = await renderPage(pageUrl, engine, config);
38
+ await saveHTML(route, html, outputDir);
39
+ console.log(pc.green('✔'));
40
+ results.ok++;
41
+ } catch (err) {
42
+ console.log(pc.red('✖'));
43
+ console.error(pc.red(` └─ ${err.message}`));
44
+ if (debug) console.error(err.stack);
45
+ results.failed++;
46
+ }
47
+ }
48
+ } finally {
49
+ await engine.close();
50
+ await close();
51
+ }
52
+
53
+ // Summary
54
+ console.log(
55
+ `\n ${pc.green(`${results.ok} rendered`)}` +
56
+ (results.failed > 0 ? pc.red(`, ${results.failed} failed`) : '') +
57
+ ` → ${pc.cyan(outputDir)}`
58
+ );
59
+
60
+ if (results.failed > 0) {
61
+ throw new Error(`${results.failed} route(s) failed to render.`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Render a single page URL and return its full HTML string.
67
+ *
68
+ * @param {string} url
69
+ * @param {object} engine
70
+ * @param {import('./config.js').ReviConfig} config
71
+ * @returns {Promise<string>}
72
+ */
73
+ export async function renderPage(url, engine, config) {
74
+ return engine.render(url, {
75
+ waitFor: config.waitFor,
76
+ debug: config.debug,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Map a route path to a file path and write the HTML.
82
+ *
83
+ * / → outputDir/index.html
84
+ * /about → outputDir/about/index.html
85
+ * /blog/post-1 → outputDir/blog/post-1/index.html
86
+ *
87
+ * @param {string} route
88
+ * @param {string} html
89
+ * @param {string} outputDir
90
+ */
91
+ export async function saveHTML(route, html, outputDir) {
92
+ const filePath =
93
+ route === '/'
94
+ ? path.join(outputDir, 'index.html')
95
+ : path.join(outputDir, route.replace(/^\//, ''), 'index.html');
96
+
97
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
98
+ await fs.writeFile(filePath, html, 'utf8');
99
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Bot detector utility.
3
+ *
4
+ * Used by the optional Express/Polka middleware to determine whether an incoming
5
+ * request should be served prerendered HTML or passed through to the normal SPA.
6
+ */
7
+
8
+ /**
9
+ * Known bot user-agent substrings.
10
+ * Includes all major crawlers: search engines, AI agents, social previews, etc.
11
+ */
12
+ const BOT_PATTERNS = [
13
+ // ── Search engines ──────────────────────────────────────────────────────────
14
+ 'googlebot',
15
+ 'google-inspectiontool',
16
+ 'bingbot',
17
+ 'slurp', // Yahoo
18
+ 'duckduckbot',
19
+ 'baiduspider',
20
+ 'yandexbot',
21
+ 'sogou',
22
+ 'exabot',
23
+ 'facebot',
24
+ 'ia_archiver', // Alexa / Wayback Machine
25
+ 'mj12bot',
26
+ 'dotbot',
27
+ 'ahrefsbot',
28
+ 'semrushbot',
29
+ 'rogerbot',
30
+
31
+ // ── AI / LLM crawlers ───────────────────────────────────────────────────────
32
+ 'gptbot', // OpenAI
33
+ 'chatgpt-user', // OpenAI browsing
34
+ 'claudebot', // Anthropic
35
+ 'claude-web', // Anthropic
36
+ 'perplexitybot', // Perplexity AI
37
+ 'cohere-ai',
38
+ 'amazonbot',
39
+ 'applebot',
40
+ 'anthropic-ai',
41
+
42
+ // ── Social / preview ────────────────────────────────────────────────────────
43
+ 'facebookexternalhit',
44
+ 'twitterbot',
45
+ 'linkedinbot',
46
+ 'slackbot',
47
+ 'discordbot',
48
+ 'telegrambot',
49
+ 'whatsapp',
50
+ 'vkshare',
51
+ 'pinterest',
52
+ 'tumblr',
53
+ 'flipboard',
54
+
55
+ // ── Generic signals ─────────────────────────────────────────────────────────
56
+ 'spider',
57
+ 'crawler',
58
+ 'scraper',
59
+ 'bot/',
60
+ '+http', // Most crawlers include a URL like +https://... in their UA
61
+ ];
62
+
63
+ /**
64
+ * Returns `true` if the given user-agent string belongs to a known bot/crawler.
65
+ *
66
+ * @param {string} userAgent
67
+ * @returns {boolean}
68
+ */
69
+ export function isBot(userAgent) {
70
+ if (!userAgent) return false;
71
+ const ua = userAgent.toLowerCase();
72
+ return BOT_PATTERNS.some((pattern) => ua.includes(pattern));
73
+ }
74
+
75
+ /**
76
+ * Returns the matched bot pattern string (useful for logging), or null.
77
+ *
78
+ * @param {string} userAgent
79
+ * @returns {string|null}
80
+ */
81
+ export function detectBot(userAgent) {
82
+ if (!userAgent) return null;
83
+ const ua = userAgent.toLowerCase();
84
+ return BOT_PATTERNS.find((pattern) => ua.includes(pattern)) ?? null;
85
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Route expander.
3
+ *
4
+ * Handles route list pre-processing:
5
+ * - Deduplication
6
+ * - Basic normalization (ensure leading slash)
7
+ * - Future: glob / wildcard expansion, sitemap parsing
8
+ */
9
+
10
+ /**
11
+ * Expand and normalize an array of route strings.
12
+ *
13
+ * Currently performs:
14
+ * - Trim whitespace
15
+ * - Ensure leading slash
16
+ * - Deduplicate
17
+ * - Sort for deterministic output order
18
+ *
19
+ * @param {string[]} routes
20
+ * @returns {Promise<string[]>}
21
+ */
22
+ export async function expandRoutes(routes) {
23
+ const normalized = routes
24
+ .map((r) => {
25
+ r = r.trim();
26
+ if (!r.startsWith('/')) r = '/' + r;
27
+ // Remove trailing slash (except root)
28
+ if (r !== '/' && r.endsWith('/')) r = r.slice(0, -1);
29
+ return r;
30
+ })
31
+ .filter(Boolean);
32
+
33
+ // Deduplicate
34
+ const unique = [...new Set(normalized)];
35
+
36
+ // Stable sort: root first, then alphabetical
37
+ unique.sort((a, b) => {
38
+ if (a === '/') return -1;
39
+ if (b === '/') return 1;
40
+ return a.localeCompare(b);
41
+ });
42
+
43
+ return unique;
44
+ }
45
+
46
+ /**
47
+ * Future: parse a sitemap.xml and extract all <loc> entries as routes.
48
+ *
49
+ * @param {string} sitemapUrl
50
+ * @returns {Promise<string[]>}
51
+ */
52
+ export async function routesFromSitemap(sitemapUrl) {
53
+ const { default: fetch } = await import('node-fetch');
54
+ const res = await fetch(sitemapUrl);
55
+ const xml = await res.text();
56
+
57
+ const matches = xml.matchAll(/<loc>(.*?)<\/loc>/g);
58
+ const urls = Array.from(matches, (m) => m[1].trim());
59
+
60
+ // Convert absolute URLs → relative paths
61
+ return urls.map((u) => {
62
+ try {
63
+ return new URL(u).pathname;
64
+ } catch {
65
+ return u;
66
+ }
67
+ });
68
+ }
@@ -0,0 +1,111 @@
1
+ import http from 'http';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { createReadStream } from 'fs';
5
+
6
+ const MIME_TYPES = {
7
+ '.html': 'text/html; charset=utf-8',
8
+ '.js': 'application/javascript; charset=utf-8',
9
+ '.mjs': 'application/javascript; charset=utf-8',
10
+ '.css': 'text/css; charset=utf-8',
11
+ '.json': 'application/json; charset=utf-8',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.gif': 'image/gif',
16
+ '.svg': 'image/svg+xml',
17
+ '.ico': 'image/x-icon',
18
+ '.woff': 'font/woff',
19
+ '.woff2':'font/woff2',
20
+ '.ttf': 'font/ttf',
21
+ '.eot': 'application/vnd.ms-fontobject',
22
+ '.webp': 'image/webp',
23
+ '.webm': 'video/webm',
24
+ '.mp4': 'video/mp4',
25
+ '.txt': 'text/plain; charset=utf-8',
26
+ '.xml': 'application/xml',
27
+ };
28
+
29
+ /**
30
+ * Serve the built dist directory on a free local port.
31
+ * Falls back to SPA mode: any unknown path returns index.html (for client-side routing).
32
+ *
33
+ * @param {string} distDir - Absolute path to the built app directory
34
+ * @param {number} port - Preferred port (will auto-increment if taken)
35
+ * @returns {Promise<{ url: string, close: () => Promise<void> }>}
36
+ */
37
+ export async function startServer(distDir, port = 4173) {
38
+ const actualPort = await findFreePort(port);
39
+
40
+ const server = http.createServer((req, res) => {
41
+ // Strip query strings and decode URI
42
+ let urlPath = decodeURIComponent(req.url.split('?')[0]);
43
+
44
+ // Default to index.html
45
+ if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
46
+
47
+ let filePath = path.join(distDir, urlPath);
48
+
49
+ // SPA fallback: serve index.html for unknown paths
50
+ if (!fs.existsSync(filePath)) {
51
+ filePath = path.join(distDir, 'index.html');
52
+ }
53
+
54
+ const ext = path.extname(filePath).toLowerCase();
55
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
56
+
57
+ try {
58
+ const stat = fs.statSync(filePath);
59
+ res.writeHead(200, {
60
+ 'Content-Type': contentType,
61
+ 'Content-Length': stat.size,
62
+ 'Cache-Control': 'no-cache',
63
+ });
64
+ createReadStream(filePath).pipe(res);
65
+ } catch {
66
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
67
+ res.end('Not found');
68
+ }
69
+ });
70
+
71
+ await new Promise((resolve) => server.listen(actualPort, '127.0.0.1', resolve));
72
+
73
+ const url = `http://127.0.0.1:${actualPort}`;
74
+
75
+ const close = () =>
76
+ new Promise((resolve, reject) =>
77
+ server.close((err) => (err ? reject(err) : resolve()))
78
+ );
79
+
80
+ return { url, close };
81
+ }
82
+
83
+ // ─── Helpers ────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Find a free TCP port starting from `preferred`.
87
+ * Tries up to 20 consecutive ports before giving up.
88
+ */
89
+ function findFreePort(preferred, attempts = 20) {
90
+ return new Promise((resolve, reject) => {
91
+ let tried = 0;
92
+
93
+ const tryPort = (port) => {
94
+ const server = http.createServer();
95
+ server.listen(port, '127.0.0.1');
96
+ server.on('listening', () => {
97
+ server.close(() => resolve(port));
98
+ });
99
+ server.on('error', () => {
100
+ tried++;
101
+ if (tried >= attempts) {
102
+ reject(new Error(`Could not find a free port near ${preferred}`));
103
+ } else {
104
+ tryPort(port + 1);
105
+ }
106
+ });
107
+ };
108
+
109
+ tryPort(preferred);
110
+ });
111
+ }