@zyrab/domo-ssg 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 +21 -0
- package/README +92 -0
- package/package.json +35 -0
- package/scripts/init.js +17 -0
- package/src/config.js +37 -0
- package/src/event-utils.js +130 -0
- package/src/file-utils.js +50 -0
- package/src/index.js +43 -0
- package/src/route-handler.js +67 -0
- package/src/route-traversal.js +135 -0
- package/src/sitemap.js +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Zyrab
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
package/README
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @zyrab/domo-ssg
|
|
2
|
+
|
|
3
|
+
**Minimal static site generator (SSG) for Domo**
|
|
4
|
+
Write JavaScript templates using [@zyrab/domo](https://www.npmjs.com/package/@zyrab/domo), define routes and layouts, and generate static HTML files with optional event hydration.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## ✨ Features
|
|
9
|
+
|
|
10
|
+
- 🧱 Declarative page structure with `Domo` and `DomoSVG`
|
|
11
|
+
- 🗺️ Simple nested route tree (`routes.js`)
|
|
12
|
+
- 🎨 Custom layout wrapper (`layout.js`)
|
|
13
|
+
- ⚡ Virtual DOM-like output (no DOM dependency during build)
|
|
14
|
+
- 🧠 Hydration-ready: automatic inline scripts for events
|
|
15
|
+
- 🧹 Built-in cleanup and sitemap generation
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 📦 Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add -D @zyrab/domo-ssg
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Note: Use in a pnpm monorepo if you're working with other Domo packages like @zyrab/domo or @zyrab/domo-router.
|
|
26
|
+
|
|
27
|
+
## 🛠 Usage
|
|
28
|
+
|
|
29
|
+
### 1. Scaffold basic structure
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnpm exec domo-ssg-init
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This will create:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
domo.config.js;
|
|
39
|
+
routes.js;
|
|
40
|
+
layout.js;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Add a script to your root package.json
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "pnpm --filter @zyrab/domo-ssg exec node src/index.js"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 3. Run the generator
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pnpm build
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
# 🧩 Project Structure
|
|
60
|
+
|
|
61
|
+
| File | Purpose |
|
|
62
|
+
| ---------------- | ----------------------------------- |
|
|
63
|
+
| `routes.js` | Your route tree (nested supported) |
|
|
64
|
+
| `layout.js` | Optional layout wrapper |
|
|
65
|
+
| `domo.config.js` | Customize output dir, base URL, etc |
|
|
66
|
+
|
|
67
|
+
# 🧪 Example Route File
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
import { Domo } from "@zyrab/domo";
|
|
71
|
+
|
|
72
|
+
export const routes = {
|
|
73
|
+
"/": () => Domo("div").txt("Hello Home"),
|
|
74
|
+
"/about": () => Domo("div").txt("About Page"),
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
# ⚙️ Configuration (domo.config.js)
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
export default {
|
|
82
|
+
outDir: "./dist",
|
|
83
|
+
routesFile: "./routes.js",
|
|
84
|
+
layout: "./layout.js",
|
|
85
|
+
exclude: ["css", "js", "assets"],
|
|
86
|
+
baseUrl: "https://example.com",
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
# 📜 License
|
|
91
|
+
|
|
92
|
+
MIT — © Zyrab
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zyrab/domo-ssg",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Static Site Generator (SSG) for Domo-based projects.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"domo-ssg-init": "./scripts/init.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"domo",
|
|
12
|
+
"ssg",
|
|
13
|
+
"static site generator",
|
|
14
|
+
"build tool",
|
|
15
|
+
"frontend"
|
|
16
|
+
],
|
|
17
|
+
"author": "Zyrab",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@zyrab/domo": "^1.2.0",
|
|
21
|
+
"@zyrab/domo-router": "^0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src/"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "node src/index.js",
|
|
32
|
+
"dev": "node src/index.js --watch",
|
|
33
|
+
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true }); console.log('Cleaned dist folder');\""
|
|
34
|
+
}
|
|
35
|
+
}
|
package/scripts/init.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cpSync, existsSync } from "fs";
|
|
2
|
+
import { resolve, join } from "path";
|
|
3
|
+
|
|
4
|
+
const targetDir = process.cwd();
|
|
5
|
+
const templateDir = resolve(import.meta.dirname, "../templates");
|
|
6
|
+
|
|
7
|
+
const filesToCopy = ["routes.js", "layout.js", "domo.config.js"];
|
|
8
|
+
|
|
9
|
+
for (const file of filesToCopy) {
|
|
10
|
+
const dest = join(targetDir, file);
|
|
11
|
+
if (!existsSync(dest)) {
|
|
12
|
+
cpSync(join(templateDir, file), dest);
|
|
13
|
+
console.log(`✅ Created: ${file}`);
|
|
14
|
+
} else {
|
|
15
|
+
console.log(`⚠️ Skipped (already exists): ${file}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/config.js
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loads and processes the SSG configuration.
|
|
6
|
+
* @param {string} configFilePath - Absolute path to the user's config file.
|
|
7
|
+
* @returns {Promise<object>} The resolved configuration object.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadConfig(configFilePath) {
|
|
10
|
+
// Default configuration values
|
|
11
|
+
const defaultConfig = {
|
|
12
|
+
outDir: "./dist",
|
|
13
|
+
routesFile: "./routes.js", // Assuming a common name for routes
|
|
14
|
+
layout: "./layout.js", // Assuming a common name for layout
|
|
15
|
+
exclude: ["css", "js", "assets", "robots.txt", "admin"],
|
|
16
|
+
baseUrl: "http://localhost:3000", // Default base URL for sitemap
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let userConfig = {};
|
|
20
|
+
try {
|
|
21
|
+
const importedConfig = await import(configFilePath);
|
|
22
|
+
userConfig = importedConfig.default || importedConfig;
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.warn(`⚠️ No custom config file found at ${configFilePath}. Using default settings.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Merge user config with defaults
|
|
28
|
+
const mergedConfig = { ...defaultConfig, ...userConfig };
|
|
29
|
+
|
|
30
|
+
// Resolve paths relative to the current working directory of the build script
|
|
31
|
+
// This assumes the config file itself specifies paths relative to its own location
|
|
32
|
+
mergedConfig.outDir = path.resolve(process.cwd(), mergedConfig.outDir);
|
|
33
|
+
mergedConfig.routesFile = path.resolve(process.cwd(), mergedConfig.routesFile);
|
|
34
|
+
mergedConfig.layout = path.resolve(process.cwd(), mergedConfig.layout);
|
|
35
|
+
|
|
36
|
+
return mergedConfig;
|
|
37
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/event-utils.js
|
|
2
|
+
|
|
3
|
+
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
export function generateScriptContent(events) {
|
|
7
|
+
return events
|
|
8
|
+
.map(({ id, event, handlers }) => {
|
|
9
|
+
const varSet = new Set();
|
|
10
|
+
const logicLines = [];
|
|
11
|
+
const closureFunctions = [];
|
|
12
|
+
let matchCounter = 0;
|
|
13
|
+
|
|
14
|
+
for (const { type, selector, handler } of handlers) {
|
|
15
|
+
const fnSource = handler.toString();
|
|
16
|
+
const { name, body } = destructureFunction(fnSource);
|
|
17
|
+
const vars = extractExposedVariables(body);
|
|
18
|
+
vars.forEach((v) => varSet.add(v));
|
|
19
|
+
|
|
20
|
+
if (type === "closest") {
|
|
21
|
+
const matchVar = `match${++matchCounter}`;
|
|
22
|
+
logicLines.push(`const ${matchVar} = e.target.closest("${selector}");`);
|
|
23
|
+
|
|
24
|
+
if (name) {
|
|
25
|
+
logicLines.push(`if (${matchVar}) ${name}(e, ${matchVar});`);
|
|
26
|
+
closureFunctions.push(fnSource);
|
|
27
|
+
} else {
|
|
28
|
+
const adjustedBody = body.replace(/\btarget\b/g, matchVar);
|
|
29
|
+
logicLines.push(`if (${matchVar}) {\n${indent(adjustedBody, 2)}\n}`);
|
|
30
|
+
}
|
|
31
|
+
} else if (type === "match") {
|
|
32
|
+
const matchExpr = `e.target.matches("${selector}")`;
|
|
33
|
+
|
|
34
|
+
if (name) {
|
|
35
|
+
logicLines.push(`if (${matchExpr}) ${name}(e, e.target);`);
|
|
36
|
+
closureFunctions.push(fnSource);
|
|
37
|
+
} else {
|
|
38
|
+
const adjustedBody = body.replace(/\btarget\b/g, "e.target");
|
|
39
|
+
logicLines.push(`if (${matchExpr}) {\n${indent(adjustedBody, 2)}\n}`);
|
|
40
|
+
}
|
|
41
|
+
} else if (type === "direct") {
|
|
42
|
+
logicLines.push(body);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const varsStr = [...varSet].join("\n");
|
|
47
|
+
const handlerBody = `function(e) {\n${indent(logicLines.join("\n"), 1)}\n}`;
|
|
48
|
+
const closures = closureFunctions.length ? `\n\n${closureFunctions.join("\n\n")}` : "";
|
|
49
|
+
|
|
50
|
+
return `${
|
|
51
|
+
varsStr ? varsStr + "\n\n" : ""
|
|
52
|
+
}document.getElementById("${id}").addEventListener("${event}", ${handlerBody});${closures}`;
|
|
53
|
+
})
|
|
54
|
+
.join("\n\n\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function collectEvents(node, out = []) {
|
|
58
|
+
if (!node || typeof node !== "object") return out;
|
|
59
|
+
const el = node.element;
|
|
60
|
+
if (Array.isArray(el._events) && el._events.length > 0) {
|
|
61
|
+
out.push(...el._events);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (Array.isArray(el._child)) {
|
|
65
|
+
for (const child of el._child) {
|
|
66
|
+
collectEvents(child, out);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function writeJs(constent, outputDir, path) {
|
|
74
|
+
const events = collectEvents(constent);
|
|
75
|
+
if (events.length <= 0) return;
|
|
76
|
+
const jsDir = join(outputDir, "js");
|
|
77
|
+
if (!existsSync(jsDir)) mkdirSync(jsDir);
|
|
78
|
+
|
|
79
|
+
const fileName = path === "/" ? "index.js" : path.replace(/^\/|\/$/g, "").replace(/\//g, "-") + ".js";
|
|
80
|
+
const jsContent = generateScriptContent(events);
|
|
81
|
+
writeFileSync(join(jsDir, fileName), jsContent, "utf8");
|
|
82
|
+
|
|
83
|
+
return join("js", fileName);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractExposedVariables(source) {
|
|
87
|
+
const lines = source.split("\n");
|
|
88
|
+
const injected = [];
|
|
89
|
+
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
|
|
93
|
+
if (trimmed === "") continue;
|
|
94
|
+
|
|
95
|
+
if (trimmed.startsWith("// @ssg-let")) {
|
|
96
|
+
const decl = trimmed.replace("// @ssg-let", "").replace(/;$/, "").trim();
|
|
97
|
+
injected.push(`let ${decl};`);
|
|
98
|
+
} else if (trimmed.startsWith("// @ssg-const")) {
|
|
99
|
+
const decl = trimmed.replace("// @ssg-const", "").replace(/;$/, "").trim();
|
|
100
|
+
injected.push(`const ${decl};`);
|
|
101
|
+
} else if (!trimmed.startsWith("//")) {
|
|
102
|
+
break; // stop at first non-comment code line
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return injected;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function destructureFunction(fnSource) {
|
|
110
|
+
const funcMatch = fnSource.match(/^function\s*([a-zA-Z0-9_$]*)\s*\([^)]*\)\s*\{([\s\S]*)\}$/);
|
|
111
|
+
if (funcMatch) {
|
|
112
|
+
const [, name, body] = funcMatch;
|
|
113
|
+
return { name: name.trim(), body: body.trim() };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const arrowMatch = fnSource.match(/^\(?[a-zA-Z0-9_,\s]*\)?\s*=>\s*\{([\s\S]*)\}$/);
|
|
117
|
+
if (arrowMatch) {
|
|
118
|
+
return { name: "", body: arrowMatch[1].trim() };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { name: "", body: "" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function indent(str, level = 1) {
|
|
125
|
+
const pad = " ".repeat(level);
|
|
126
|
+
return str
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((line) => pad + line)
|
|
129
|
+
.join("\n");
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/file-utils.js
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ensures that the directory for a given file path exists.
|
|
7
|
+
* Creates directories recursively if they don't.
|
|
8
|
+
* @param {string} filePath - The full path to the file.
|
|
9
|
+
* @returns {void}
|
|
10
|
+
*/
|
|
11
|
+
export function ensureDir(filePath) {
|
|
12
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Writes HTML content to a file in the output directory.
|
|
17
|
+
* @param {string} outputDir - The base output directory.
|
|
18
|
+
* @param {string} routePath - The URL path of the route (e.g., "/about", "/").
|
|
19
|
+
* @param {string} html - The HTML content to write.
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
export function writeHTML(outputDir, routePath, html) {
|
|
23
|
+
// Adjust output path for root (/) and 404 (*) routes
|
|
24
|
+
const fileName = routePath === "/*" ? "404" : routePath === "/" ? "" : routePath;
|
|
25
|
+
const outPath = path.join(outputDir, fileName, "index.html");
|
|
26
|
+
ensureDir(outPath);
|
|
27
|
+
fs.writeFileSync(outPath, html, "utf8");
|
|
28
|
+
console.log(`Generated: ${path.relative(outputDir, outPath)}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cleans the output directory by removing all content except specified exclusions.
|
|
33
|
+
* @param {string} outputDir - The directory to clean.
|
|
34
|
+
* @param {string[]} exclude - An array of file/folder names to exclude from cleaning.
|
|
35
|
+
* @returns {void}
|
|
36
|
+
*/
|
|
37
|
+
export function cleanOutputDir(outputDir, exclude) {
|
|
38
|
+
if (fs.existsSync(outputDir)) {
|
|
39
|
+
const entries = fs.readdirSync(outputDir);
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (exclude.includes(entry)) continue;
|
|
42
|
+
const entryPath = path.join(outputDir, entry);
|
|
43
|
+
fs.rmSync(entryPath, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
console.log(`Cleaned output directory: ${outputDir}`);
|
|
46
|
+
} else {
|
|
47
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
48
|
+
console.log(`Created output directory: ${outputDir}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/index.js (formerly build.mjs)
|
|
2
|
+
import { pathToFileURL } from "url";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { cleanOutputDir } from "./file-utils.js";
|
|
6
|
+
import { generateSitemap } from "./sitemap.js";
|
|
7
|
+
import { buildRoutes } from "./route-traversal.js";
|
|
8
|
+
|
|
9
|
+
// __filename and __dirname equivalents for ES Modules
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
// Determine where the user's config file should be
|
|
13
|
+
// Assumes config is at the root of the project where the script is run
|
|
14
|
+
const userConfigPath = path.resolve(process.cwd(), "domo.config.js");
|
|
15
|
+
|
|
16
|
+
const config = await loadConfig(pathToFileURL(userConfigPath).href);
|
|
17
|
+
|
|
18
|
+
// Import layout and route tree using pathToFileURL and .href for dynamic imports
|
|
19
|
+
const { routes } = await import(pathToFileURL(config.routesFile).href);
|
|
20
|
+
const { renderLayout } = await import(pathToFileURL(config.layout).href);
|
|
21
|
+
|
|
22
|
+
console.log("🚀 Starting Domo SSG build...");
|
|
23
|
+
console.log(`Output directory: ${config.outDir}`);
|
|
24
|
+
console.log(`Routes file: ${config.routesFile}`);
|
|
25
|
+
console.log(`Layout file: ${config.layout}`);
|
|
26
|
+
|
|
27
|
+
// 1. Clean the output directory
|
|
28
|
+
cleanOutputDir(config.outDir, config.exclude);
|
|
29
|
+
|
|
30
|
+
// 2. Build all routes recursively
|
|
31
|
+
await buildRoutes(routes, "", {}, renderLayout, config.outDir);
|
|
32
|
+
|
|
33
|
+
// 3. Generate sitemap
|
|
34
|
+
generateSitemap(config.outDir, config.baseUrl, config.exclude);
|
|
35
|
+
|
|
36
|
+
console.log("✅ Domo SSG build complete!");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Execute the build process
|
|
40
|
+
main().catch((error) => {
|
|
41
|
+
console.error("❌ Domo SSG build failed:", error);
|
|
42
|
+
process.exit(1); // Exit with a non-zero code to indicate failure
|
|
43
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/route-handler.js
|
|
2
|
+
import Router from "@zyrab/domo-router";
|
|
3
|
+
import { writeHTML } from "./file-utils.js";
|
|
4
|
+
import { writeJs } from "./event-utils.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helper to join URL path segments correctly.
|
|
8
|
+
* @param {...string} segments - Path segments to join.
|
|
9
|
+
* @returns {string} The normalized joined path.
|
|
10
|
+
*/
|
|
11
|
+
export function joinPaths(...segments) {
|
|
12
|
+
let pathStr = segments
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.map((s) => String(s).replace(/(^\/+|\/+$)/g, ""))
|
|
15
|
+
.join("/");
|
|
16
|
+
|
|
17
|
+
return "/" + pathStr.replace(/\/+/g, "/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Handles the rendering and writing of a single route's HTML file.
|
|
22
|
+
* @param {object} routeConfig - Configuration for the current route.
|
|
23
|
+
* @param {string} routeConfig.path - The full URL path for this route (e.g., "/about", "/blog/post-1").
|
|
24
|
+
* @param {object} routeConfig.props - Properties to pass to the component.
|
|
25
|
+
* @param {Function} routeConfig.component - The component function that returns an HTMLElement or string.
|
|
26
|
+
* @param {string} [routeConfig.script] - Optional script to include in the layout.
|
|
27
|
+
* @param {object} [routeConfig.meta={}] - Optional metadata for the page (title, description).
|
|
28
|
+
* @param {Function} renderLayout - The layout rendering function from the user's config.
|
|
29
|
+
* @param {string} outputDir - The base output directory for generated files.
|
|
30
|
+
* @returns {Promise<void>}
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export async function handleRoute({ path, props, component, script, meta = {} }, renderLayout, outputDir) {
|
|
34
|
+
try {
|
|
35
|
+
// Set router info for server-side context
|
|
36
|
+
Router.setInfo(path, props);
|
|
37
|
+
|
|
38
|
+
// Calculate base depth for relative paths in layout if needed
|
|
39
|
+
const baseDepth = path === "/" ? 0 : path.split("/").filter(Boolean).length;
|
|
40
|
+
|
|
41
|
+
// Render the component content
|
|
42
|
+
const content = await component(props);
|
|
43
|
+
|
|
44
|
+
// --- Write JS file ---
|
|
45
|
+
const eScript = writeJs(content, outputDir, path);
|
|
46
|
+
let allScript = [];
|
|
47
|
+
if (Array.isArray(script) && script.length > 0) {
|
|
48
|
+
allScript.push(...script);
|
|
49
|
+
}
|
|
50
|
+
if (eScript && typeof eScript === "string" && eScript.trim() !== "") {
|
|
51
|
+
allScript.push(eScript);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Render the full HTML layout
|
|
55
|
+
const html = await renderLayout(content, {
|
|
56
|
+
title: meta.title || "",
|
|
57
|
+
description: meta.description || "",
|
|
58
|
+
script: allScript,
|
|
59
|
+
baseDepth,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Write the generated HTML to a file
|
|
63
|
+
writeHTML(outputDir, path, html);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.warn(`⚠️ Error rendering ${path}:\n${e.stack}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/route-traversal.js
|
|
2
|
+
import { handleRoute, joinPaths } from "./route-handler.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursively builds HTML files for all defined routes, including nested and dynamic routes.
|
|
6
|
+
* @param {object} routes - The route configuration object.
|
|
7
|
+
* @param {string} [parentPath=""] - The path accumulated from parent routes.
|
|
8
|
+
* @param {object} [props={}] - Accumulated properties from parent dynamic routes.
|
|
9
|
+
* @param {Function} renderLayout - The layout rendering function.
|
|
10
|
+
* @param {string} outputDir - The base output directory.
|
|
11
|
+
* @returns {Promise<void>}
|
|
12
|
+
*/
|
|
13
|
+
export async function buildRoutes(routes, parentPath = "", props = {}, renderLayout, outputDir) {
|
|
14
|
+
for (const routeKey in routes) {
|
|
15
|
+
const r = routes[routeKey];
|
|
16
|
+
// Skip '/' if it's a child of another path (handled by parent's segment)
|
|
17
|
+
if (parentPath !== "" && routeKey === "/") continue;
|
|
18
|
+
|
|
19
|
+
// Handle dynamic routes with getDinamicList
|
|
20
|
+
if (r.getDinamicList) {
|
|
21
|
+
try {
|
|
22
|
+
// Extract parameter name from dynamic segment (e.g., ":slug" -> "slug")
|
|
23
|
+
const paramKey = routeKey.split(":").filter(Boolean).pop();
|
|
24
|
+
// The last segment of the parent path is the slug for nested dynamic lists
|
|
25
|
+
const slugParam = parentPath.split("/").filter(Boolean).pop();
|
|
26
|
+
|
|
27
|
+
const list = await r.getDinamicList(slugParam);
|
|
28
|
+
|
|
29
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
30
|
+
console.warn(`⚠️ No items returned for dynamic route at ${joinPaths(parentPath, routeKey)}`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const item of list) {
|
|
35
|
+
if (!item[paramKey]) {
|
|
36
|
+
console.warn(
|
|
37
|
+
`⚠️ Missing required parameter '${paramKey}' in item for dynamic route at ${joinPaths(
|
|
38
|
+
parentPath,
|
|
39
|
+
routeKey
|
|
40
|
+
)}`
|
|
41
|
+
);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const segment = item[paramKey]; // Use the actual value from the item for the URL segment
|
|
46
|
+
const meta = { title: item.title, description: item.description, ...item.meta }; // Merge item's meta with route's meta
|
|
47
|
+
const routePath = joinPaths(parentPath, segment); // Full path for this specific dynamic item
|
|
48
|
+
const childProps = { ...props, [paramKey]: segment, itemData: item }; // Pass item data as prop
|
|
49
|
+
|
|
50
|
+
if (r.component) {
|
|
51
|
+
await handleRoute(
|
|
52
|
+
{
|
|
53
|
+
path: routePath,
|
|
54
|
+
props: childProps,
|
|
55
|
+
script: r.script,
|
|
56
|
+
component: r.component,
|
|
57
|
+
meta,
|
|
58
|
+
},
|
|
59
|
+
renderLayout,
|
|
60
|
+
outputDir
|
|
61
|
+
);
|
|
62
|
+
} else if (r.children?.["/"]?.component) {
|
|
63
|
+
// If dynamic route has children with a default component
|
|
64
|
+
await handleRoute(
|
|
65
|
+
{
|
|
66
|
+
path: routePath,
|
|
67
|
+
props: childProps,
|
|
68
|
+
script: r.children["/"].script,
|
|
69
|
+
component: r.children["/"].component,
|
|
70
|
+
meta: r.children["/"].meta || meta, // Children's meta takes precedence
|
|
71
|
+
},
|
|
72
|
+
renderLayout,
|
|
73
|
+
outputDir
|
|
74
|
+
);
|
|
75
|
+
// Recursively build children of this dynamic item if they exist
|
|
76
|
+
if (r.children) {
|
|
77
|
+
await buildRoutes(r.children, routePath, childProps, renderLayout, outputDir);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
console.warn(
|
|
81
|
+
`⚠️ Dynamic route at ${routePath} missing a 'component' or a default 'children["/"].component'.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.warn(`⚠️ Skipped dynamic route generation for ${joinPaths(parentPath, routeKey)}: ${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
continue; // Move to the next route key after handling dynamic list
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Handle static routes with a component
|
|
92
|
+
if (r.component) {
|
|
93
|
+
const routePath = joinPaths(parentPath, routeKey);
|
|
94
|
+
await handleRoute(
|
|
95
|
+
{
|
|
96
|
+
path: routePath,
|
|
97
|
+
props,
|
|
98
|
+
script: r.script,
|
|
99
|
+
component: r.component,
|
|
100
|
+
meta: r.meta,
|
|
101
|
+
},
|
|
102
|
+
renderLayout,
|
|
103
|
+
outputDir
|
|
104
|
+
);
|
|
105
|
+
// Continue to children if they exist for this static route
|
|
106
|
+
if (r.children) {
|
|
107
|
+
await buildRoutes(r.children, routePath, { ...props }, renderLayout, outputDir);
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Handle routes with only children (e.g., a folder route with no direct component, but an index component)
|
|
113
|
+
if (r.children) {
|
|
114
|
+
const routePath = joinPaths(parentPath, routeKey);
|
|
115
|
+
// Render the default child component ('/') for this path if it exists
|
|
116
|
+
if (r.children["/"]?.component) {
|
|
117
|
+
await handleRoute(
|
|
118
|
+
{
|
|
119
|
+
path: routePath,
|
|
120
|
+
props,
|
|
121
|
+
script: r.children["/"].script,
|
|
122
|
+
component: r.children["/"].component,
|
|
123
|
+
meta: r.children["/"].meta,
|
|
124
|
+
},
|
|
125
|
+
renderLayout,
|
|
126
|
+
outputDir
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
console.warn(`⚠️ Route at ${routePath} has children but no default component ('/')`);
|
|
130
|
+
}
|
|
131
|
+
// Recursively build the children
|
|
132
|
+
await buildRoutes(r.children, routePath, { ...props }, renderLayout, outputDir);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/sitemap.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/sitemap.js
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates an XML sitemap based on the generated HTML files in the output directory.
|
|
7
|
+
* @param {string} outputDir - The directory containing the generated HTML files.
|
|
8
|
+
* @param {string} baseUrl - The base URL of the website (e.g., "https://example.com").
|
|
9
|
+
* @param {string[]} exclude - Array of file/folder names to exclude from the sitemap.
|
|
10
|
+
* @returns {void}
|
|
11
|
+
*/
|
|
12
|
+
export function generateSitemap(outputDir, baseUrl, exclude) {
|
|
13
|
+
const urls = [];
|
|
14
|
+
|
|
15
|
+
function walk(dirPath) {
|
|
16
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
17
|
+
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (exclude.includes(entry.name)) continue;
|
|
20
|
+
|
|
21
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
const indexPath = path.join(fullPath, "index.html");
|
|
24
|
+
if (fs.existsSync(indexPath)) {
|
|
25
|
+
let relative = path.relative(outputDir, fullPath);
|
|
26
|
+
// Convert Windows backslashes to forward slashes for URLs
|
|
27
|
+
let urlPath = "/" + relative.replace(/\\/g, "/");
|
|
28
|
+
// Handle root path specifically for sitemap
|
|
29
|
+
if (urlPath === "//" || urlPath === "/.") {
|
|
30
|
+
urlPath = "/";
|
|
31
|
+
}
|
|
32
|
+
urls.push(`${baseUrl}${urlPath}`);
|
|
33
|
+
}
|
|
34
|
+
walk(fullPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
walk(outputDir);
|
|
40
|
+
|
|
41
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
42
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
43
|
+
${urls
|
|
44
|
+
.map(
|
|
45
|
+
(url) => ` <url>
|
|
46
|
+
<loc>${url}</loc>
|
|
47
|
+
</url>`
|
|
48
|
+
)
|
|
49
|
+
.join("\n")}
|
|
50
|
+
</urlset>`;
|
|
51
|
+
|
|
52
|
+
fs.writeFileSync(path.join(outputDir, "sitemap.xml"), xml, "utf8");
|
|
53
|
+
console.log(`Generated sitemap.xml at: ${path.join(outputDir, "sitemap.xml")}`);
|
|
54
|
+
}
|