@inglorious/ssx 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -4
- package/package.json +4 -1
- package/src/build/build.test.js +124 -4
- package/src/build/index.js +53 -22
- package/src/build/manifest.js +23 -15
- package/src/build/manifest.test.js +153 -0
- package/src/build/metadata.js +1 -1
- package/src/build/pages.js +16 -2
- package/src/build/pages.test.js +83 -0
- package/src/build/public.js +15 -0
- package/src/build/public.test.js +59 -0
- package/src/build/rss.js +30 -11
- package/src/build/rss.test.js +104 -0
- package/src/build/sitemap.js +15 -6
- package/src/build/sitemap.test.js +84 -0
- package/src/dev/index.js +18 -27
- package/src/dev/vite-config.js +20 -0
- package/src/dev/vite-config.test.js +46 -0
- package/src/render/html.js +27 -1
- package/src/render/index.js +11 -1
- package/src/render/layout.js +15 -0
- package/src/render/layout.test.js +58 -0
- package/src/render/render.test.js +87 -84
- package/src/router/index.js +118 -56
- package/src/router/router.test.js +33 -2
- package/src/scripts/app.js +6 -2
- package/src/scripts/app.test.js +46 -44
- package/src/store/index.js +44 -0
- package/src/store/store.test.js +74 -0
- package/src/{module.js → utils/module.js} +8 -0
- package/src/utils/module.test.js +64 -0
- package/src/utils/page-options.js +17 -0
- package/src/utils/page-options.test.js +57 -0
- package/src/module.test.js +0 -45
- package/src/page-options.js +0 -8
- package/src/store.js +0 -29
- package/src/store.test.js +0 -40
- /package/src/{config.js → utils/config.js} +0 -0
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ SSX takes your entity-based web apps and generates optimized static HTML with fu
|
|
|
30
30
|
- **Hot reload dev server** - See changes instantly
|
|
31
31
|
- **Lazy-loaded routes** - Code splitting automatically
|
|
32
32
|
- **lit-html hydration** - Interactive UI without the bloat
|
|
33
|
-
- **TypeScript
|
|
33
|
+
- **TypeScript Ready** - Write your pages and entities in TypeScript.
|
|
34
34
|
|
|
35
35
|
### 🚀 Production Ready
|
|
36
36
|
|
|
@@ -59,6 +59,32 @@ npm run dev
|
|
|
59
59
|
|
|
60
60
|
Or manually: -->
|
|
61
61
|
|
|
62
|
+
### Create Your First Site (TypeScript)
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// src/pages/index.ts
|
|
66
|
+
import { html } from "@inglorious/web"
|
|
67
|
+
|
|
68
|
+
// You can import API for type safety, though it's optional
|
|
69
|
+
// import type { API } from "@inglorious/web"
|
|
70
|
+
|
|
71
|
+
export const index = {
|
|
72
|
+
render(/* entity: any, api: API */) {
|
|
73
|
+
return html`
|
|
74
|
+
<div>
|
|
75
|
+
<h1>Welcome to SSX!</h1>
|
|
76
|
+
<p>This page was pre-rendered at build time.</p>
|
|
77
|
+
<nav>
|
|
78
|
+
<a href="/about">About</a>
|
|
79
|
+
</nav>
|
|
80
|
+
</div>
|
|
81
|
+
`
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Create Your First Site (JavaScript)
|
|
87
|
+
|
|
62
88
|
```javascript
|
|
63
89
|
// src/pages/index.js
|
|
64
90
|
import { html } from "@inglorious/web"
|
|
@@ -119,7 +145,7 @@ Deploy `dist/` to:
|
|
|
119
145
|
|
|
120
146
|
## Features
|
|
121
147
|
|
|
122
|
-
###
|
|
148
|
+
### 🗺️ Sitemap & RSS Generation
|
|
123
149
|
|
|
124
150
|
SSX automatically generates `sitemap.xml` and `rss.xml` based on your pages. Configure them in `src/site.config.js`:
|
|
125
151
|
|
|
@@ -159,7 +185,7 @@ export default {
|
|
|
159
185
|
|
|
160
186
|
Pages with a `published` date in metadata are included in RSS feeds.
|
|
161
187
|
|
|
162
|
-
###
|
|
188
|
+
### 📁 File-Based Routing
|
|
163
189
|
|
|
164
190
|
Your file structure defines your routes:
|
|
165
191
|
|
|
@@ -609,7 +635,7 @@ Check out these example projects:
|
|
|
609
635
|
|
|
610
636
|
## Roadmap
|
|
611
637
|
|
|
612
|
-
- [
|
|
638
|
+
- [x] TypeScript support
|
|
613
639
|
- [ ] Image optimization
|
|
614
640
|
- [ ] API routes (serverless functions)
|
|
615
641
|
- [ ] MDX support
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ssx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Server-Side-X. Xecution? Xperience? Who knows.",
|
|
5
5
|
"author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"bin": {
|
|
26
26
|
"ssx": "./bin/ssx.js"
|
|
27
27
|
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./site.config": "./types/site.config.d.ts"
|
|
30
|
+
},
|
|
28
31
|
"files": [
|
|
29
32
|
"bin",
|
|
30
33
|
"src",
|
package/src/build/build.test.js
CHANGED
|
@@ -1,11 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
1
2
|
import path from "node:path"
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { build as viteBuild, createServer } from "vite"
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
4
6
|
|
|
7
|
+
import { getPages } from "../router/index.js"
|
|
8
|
+
import { generateApp } from "../scripts/app.js"
|
|
9
|
+
import { generateStore } from "../store/index.js"
|
|
10
|
+
import { loadConfig } from "../utils/config.js"
|
|
5
11
|
import { build } from "."
|
|
12
|
+
import {
|
|
13
|
+
createManifest,
|
|
14
|
+
determineRebuildPages,
|
|
15
|
+
hashEntities,
|
|
16
|
+
loadManifest,
|
|
17
|
+
saveManifest,
|
|
18
|
+
} from "./manifest.js"
|
|
19
|
+
import { generatePages } from "./pages.js"
|
|
20
|
+
import { copyPublicDir } from "./public.js"
|
|
21
|
+
import { generateRSS } from "./rss.js"
|
|
22
|
+
import { generateSitemap } from "./sitemap.js"
|
|
23
|
+
import { createViteConfig } from "./vite-config.js"
|
|
6
24
|
|
|
7
|
-
|
|
25
|
+
vi.mock("node:fs/promises")
|
|
26
|
+
vi.mock("vite")
|
|
27
|
+
vi.mock("../router/index.js")
|
|
28
|
+
vi.mock("../scripts/app.js")
|
|
29
|
+
vi.mock("../store/index.js")
|
|
30
|
+
vi.mock("../utils/config.js")
|
|
31
|
+
vi.mock("./manifest.js")
|
|
32
|
+
vi.mock("./pages.js")
|
|
33
|
+
vi.mock("./public.js")
|
|
34
|
+
vi.mock("./rss.js")
|
|
35
|
+
vi.mock("./sitemap.js")
|
|
36
|
+
vi.mock("./vite-config.js")
|
|
8
37
|
|
|
9
|
-
|
|
10
|
-
|
|
38
|
+
describe("build", () => {
|
|
39
|
+
// Mock console to keep output clean
|
|
40
|
+
vi.spyOn(console, "log").mockImplementation(() => {})
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
createServer.mockResolvedValue({
|
|
44
|
+
ssrLoadModule: vi.fn(),
|
|
45
|
+
close: vi.fn(),
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.clearAllMocks()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("should run a full build sequence", async () => {
|
|
54
|
+
// Setup mocks
|
|
55
|
+
loadConfig.mockResolvedValue({})
|
|
56
|
+
loadManifest.mockResolvedValue(null) // First build
|
|
57
|
+
getPages.mockResolvedValue([{ path: "/" }])
|
|
58
|
+
hashEntities.mockResolvedValue("hash")
|
|
59
|
+
generateStore.mockResolvedValue({})
|
|
60
|
+
generatePages
|
|
61
|
+
.mockResolvedValueOnce([{ path: "/", html: "<html></html>" }])
|
|
62
|
+
.mockResolvedValueOnce([])
|
|
63
|
+
generateApp.mockReturnValue("console.log('app')")
|
|
64
|
+
createViteConfig.mockReturnValue({})
|
|
65
|
+
createManifest.mockResolvedValue({})
|
|
66
|
+
|
|
67
|
+
const result = await build({ rootDir: "src", outDir: "dist" })
|
|
68
|
+
|
|
69
|
+
// Verify sequence
|
|
70
|
+
expect(fs.rm).toHaveBeenCalledWith("dist", { recursive: true, force: true })
|
|
71
|
+
expect(fs.mkdir).toHaveBeenCalledWith("dist", { recursive: true })
|
|
72
|
+
expect(copyPublicDir).toHaveBeenCalled()
|
|
73
|
+
expect(getPages).toHaveBeenCalled()
|
|
74
|
+
expect(generateStore).toHaveBeenCalled()
|
|
75
|
+
expect(generatePages).toHaveBeenCalledTimes(2) // Changed + Skipped (empty)
|
|
76
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
77
|
+
path.normalize("dist/index.html"),
|
|
78
|
+
"<html></html>",
|
|
79
|
+
"utf-8",
|
|
80
|
+
)
|
|
81
|
+
expect(generateApp).toHaveBeenCalled()
|
|
82
|
+
expect(viteBuild).toHaveBeenCalled()
|
|
83
|
+
expect(saveManifest).toHaveBeenCalled()
|
|
84
|
+
|
|
85
|
+
expect(result).toEqual({ changed: 1, skipped: 0 })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("should handle incremental builds", async () => {
|
|
89
|
+
const manifest = { entities: "hash" }
|
|
90
|
+
loadManifest.mockResolvedValue(manifest)
|
|
91
|
+
hashEntities.mockResolvedValue("hash")
|
|
92
|
+
|
|
93
|
+
const allPages = [{ path: "/changed" }, { path: "/skipped" }]
|
|
94
|
+
getPages.mockResolvedValue(allPages)
|
|
95
|
+
|
|
96
|
+
determineRebuildPages.mockResolvedValue({
|
|
97
|
+
pagesToBuild: [{ path: "/changed" }],
|
|
98
|
+
pagesToSkip: [{ path: "/skipped" }],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
generatePages
|
|
102
|
+
.mockResolvedValueOnce([{ path: "/changed", html: "<html></html>" }]) // Changed
|
|
103
|
+
.mockResolvedValueOnce([{ path: "/skipped" }]) // Skipped
|
|
104
|
+
|
|
105
|
+
const result = await build({ incremental: true })
|
|
106
|
+
|
|
107
|
+
expect(determineRebuildPages).toHaveBeenCalled()
|
|
108
|
+
expect(generatePages).toHaveBeenCalledTimes(2)
|
|
109
|
+
// Should only write changed pages
|
|
110
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
111
|
+
expect.stringContaining("changed"),
|
|
112
|
+
expect.any(String),
|
|
113
|
+
expect.any(String),
|
|
114
|
+
)
|
|
115
|
+
expect(result).toEqual({ changed: 1, skipped: 1 })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("should generate sitemap and rss if configured", async () => {
|
|
119
|
+
loadConfig.mockResolvedValue({})
|
|
120
|
+
getPages.mockResolvedValue([])
|
|
121
|
+
generatePages.mockResolvedValue([])
|
|
122
|
+
|
|
123
|
+
await build({
|
|
124
|
+
sitemap: { hostname: "https://example.com" },
|
|
125
|
+
rss: { link: "https://example.com" },
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(generateSitemap).toHaveBeenCalled()
|
|
129
|
+
expect(generateRSS).toHaveBeenCalled()
|
|
130
|
+
})
|
|
11
131
|
})
|
package/src/build/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs/promises"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
-
import { build as viteBuild } from "vite"
|
|
4
|
+
import { build as viteBuild, createServer } from "vite"
|
|
5
5
|
|
|
6
|
-
import { loadConfig } from "../config.js"
|
|
7
6
|
import { getPages } from "../router/index.js"
|
|
8
7
|
import { generateApp } from "../scripts/app.js"
|
|
9
|
-
import { generateStore } from "../store.js"
|
|
8
|
+
import { generateStore } from "../store/index.js"
|
|
9
|
+
import { loadConfig } from "../utils/config.js"
|
|
10
10
|
import {
|
|
11
11
|
createManifest,
|
|
12
12
|
determineRebuildPages,
|
|
@@ -20,6 +20,18 @@ import { generateRSS } from "./rss.js"
|
|
|
20
20
|
import { generateSitemap } from "./sitemap.js"
|
|
21
21
|
import { createViteConfig } from "./vite-config.js"
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Orchestrates the full static site build process.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} options - Build options.
|
|
27
|
+
* @param {string} [options.rootDir="src"] - Source directory.
|
|
28
|
+
* @param {string} [options.outDir="dist"] - Output directory.
|
|
29
|
+
* @param {boolean} [options.incremental=true] - Whether to use incremental builds.
|
|
30
|
+
* @param {boolean} [options.clean=false] - Whether to clean the output directory before building.
|
|
31
|
+
* @param {Object} [options.sitemap] - Sitemap configuration.
|
|
32
|
+
* @param {Object} [options.rss] - RSS configuration.
|
|
33
|
+
* @returns {Promise<{changed: number, skipped: number}>} Build statistics.
|
|
34
|
+
*/
|
|
23
35
|
export async function build(options = {}) {
|
|
24
36
|
const config = await loadConfig(options)
|
|
25
37
|
|
|
@@ -35,6 +47,18 @@ export async function build(options = {}) {
|
|
|
35
47
|
|
|
36
48
|
console.log("🔨 Starting build...\n")
|
|
37
49
|
|
|
50
|
+
// Create a temporary Vite server to load modules (supports TS)
|
|
51
|
+
const vite = await createServer({
|
|
52
|
+
...createViteConfig(mergedOptions),
|
|
53
|
+
server: { middlewareMode: true, hmr: false },
|
|
54
|
+
appType: "custom",
|
|
55
|
+
})
|
|
56
|
+
const loader = (p) => vite.ssrLoadModule(p)
|
|
57
|
+
|
|
58
|
+
// 0. Get all pages to build (Fail fast if source is broken)
|
|
59
|
+
const allPages = await getPages(path.join(rootDir, "pages"), loader)
|
|
60
|
+
console.log(`📄 Found ${allPages.length} pages\n`)
|
|
61
|
+
|
|
38
62
|
// Load previous build manifest
|
|
39
63
|
const manifest = incremental && !clean ? await loadManifest(outDir) : null
|
|
40
64
|
|
|
@@ -51,32 +75,33 @@ export async function build(options = {}) {
|
|
|
51
75
|
// 2. Copy public assets before generating pages (could be useful if need to read `public/data.json`)
|
|
52
76
|
await copyPublicDir(options)
|
|
53
77
|
|
|
54
|
-
// 3. Get all pages to build
|
|
55
|
-
const allPages = await getPages(path.join(rootDir, "pages"))
|
|
56
|
-
console.log(`📄 Found ${allPages.length}\n`)
|
|
57
|
-
|
|
58
78
|
// Determine which pages need rebuilding
|
|
59
79
|
const entitiesHash = await hashEntities(rootDir)
|
|
60
|
-
let
|
|
80
|
+
let pagesToChange = allPages
|
|
61
81
|
let pagesToSkip = []
|
|
62
82
|
|
|
63
83
|
if (manifest) {
|
|
64
84
|
const result = await determineRebuildPages(allPages, manifest, entitiesHash)
|
|
65
|
-
|
|
85
|
+
pagesToChange = result.pagesToBuild
|
|
66
86
|
pagesToSkip = result.pagesToSkip
|
|
67
87
|
|
|
68
88
|
if (pagesToSkip.length) {
|
|
69
89
|
console.log(
|
|
70
|
-
`⚡ Incremental build: ${
|
|
90
|
+
`⚡ Incremental build: ${pagesToChange.length} to change, ${pagesToSkip.length} to skip\n`,
|
|
71
91
|
)
|
|
72
92
|
}
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
// 4. Generate store with all types and initial entities
|
|
76
|
-
const store = await generateStore(allPages, mergedOptions)
|
|
96
|
+
const store = await generateStore(allPages, mergedOptions, loader)
|
|
77
97
|
|
|
78
98
|
// 5. Render only pages that changed
|
|
79
|
-
const
|
|
99
|
+
const changedPages = await generatePages(
|
|
100
|
+
store,
|
|
101
|
+
pagesToChange,
|
|
102
|
+
mergedOptions,
|
|
103
|
+
loader,
|
|
104
|
+
)
|
|
80
105
|
// For skipped pages, load their metadata from disk if needed for sitemap/RSS
|
|
81
106
|
const skippedPages = await generatePages(store, pagesToSkip, {
|
|
82
107
|
...mergedOptions,
|
|
@@ -84,47 +109,48 @@ export async function build(options = {}) {
|
|
|
84
109
|
})
|
|
85
110
|
|
|
86
111
|
// Combine rendered and skipped pages for sitemap/RSS
|
|
87
|
-
const allGeneratedPages = [...
|
|
112
|
+
const allGeneratedPages = [...changedPages, ...skippedPages]
|
|
88
113
|
|
|
89
|
-
if (
|
|
114
|
+
if (changedPages.length) {
|
|
90
115
|
// 6. Generate client-side JavaScript
|
|
91
116
|
console.log("\n💾 Writing files...\n")
|
|
92
117
|
|
|
93
118
|
// 7. Write HTML pages
|
|
94
|
-
for (const page of
|
|
119
|
+
for (const page of changedPages) {
|
|
95
120
|
const filePath = await writePageToDisk(page.path, page.html, outDir)
|
|
96
121
|
console.log(` ✓ ${filePath}`)
|
|
97
122
|
}
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
// Always regenerate client-side JavaScript (it's cheap and ensures consistency)
|
|
125
|
+
// 8. Always regenerate client-side JavaScript (it's cheap and ensures consistency)
|
|
101
126
|
console.log("\n📝 Generating client scripts...\n")
|
|
102
127
|
|
|
103
128
|
const app = generateApp(store, allPages)
|
|
104
129
|
await fs.writeFile(path.join(outDir, "main.js"), app, "utf-8")
|
|
105
130
|
console.log(` ✓ main.js\n`)
|
|
106
131
|
|
|
107
|
-
//
|
|
132
|
+
// 9. Generate sitemap if enabled
|
|
108
133
|
if (sitemap?.hostname) {
|
|
109
134
|
console.log("\n🗺️ Generating sitemap.xml...\n")
|
|
110
135
|
await generateSitemap(allGeneratedPages, { outDir, ...sitemap })
|
|
111
136
|
}
|
|
112
137
|
|
|
113
|
-
//
|
|
138
|
+
// 10. Generate RSS feed if enabled
|
|
114
139
|
if (rss?.link) {
|
|
115
140
|
console.log("\n📡 Generating RSS feed...\n")
|
|
116
141
|
await generateRSS(allGeneratedPages, { outDir, ...rss })
|
|
117
142
|
}
|
|
118
143
|
|
|
119
|
-
//
|
|
144
|
+
// 11. Bundle with Vite
|
|
120
145
|
console.log("\n📦 Bundling with Vite...\n")
|
|
121
146
|
const viteConfig = createViteConfig(mergedOptions)
|
|
122
147
|
await viteBuild(viteConfig)
|
|
123
148
|
|
|
124
|
-
|
|
149
|
+
await vite.close()
|
|
150
|
+
// 12. Cleanup
|
|
125
151
|
// console.log("\n🧹 Cleaning up...\n")
|
|
126
152
|
|
|
127
|
-
// Save manifest for next build
|
|
153
|
+
// 13. Save manifest for next build
|
|
128
154
|
if (incremental) {
|
|
129
155
|
const newManifest = await createManifest(allGeneratedPages, entitiesHash)
|
|
130
156
|
await saveManifest(outDir, newManifest)
|
|
@@ -133,13 +159,18 @@ export async function build(options = {}) {
|
|
|
133
159
|
console.log("\n✨ Build complete!\n")
|
|
134
160
|
|
|
135
161
|
return {
|
|
136
|
-
|
|
162
|
+
changed: changedPages.length,
|
|
137
163
|
skipped: skippedPages.length,
|
|
138
164
|
}
|
|
139
165
|
}
|
|
140
166
|
|
|
141
167
|
/**
|
|
142
168
|
* Write a page to disk with proper directory structure.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} pagePath - The URL path of the page.
|
|
171
|
+
* @param {string} html - The rendered HTML content.
|
|
172
|
+
* @param {string} [outDir="dist"] - The output directory.
|
|
173
|
+
* @returns {Promise<string>} The absolute path of the written file.
|
|
143
174
|
*/
|
|
144
175
|
async function writePageToDisk(pagePath, html, outDir = "dist") {
|
|
145
176
|
// Convert URL path to file path
|
package/src/build/manifest.js
CHANGED
|
@@ -6,8 +6,9 @@ const MANIFEST_FILE = ".ssx-manifest.json"
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Loads the build manifest from the previous build.
|
|
9
|
-
*
|
|
10
|
-
* @
|
|
9
|
+
*
|
|
10
|
+
* @param {string} outDir - Output directory.
|
|
11
|
+
* @returns {Promise<Object>} The manifest object.
|
|
11
12
|
*/
|
|
12
13
|
export async function loadManifest(outDir) {
|
|
13
14
|
const manifestPath = path.join(outDir, MANIFEST_FILE)
|
|
@@ -23,8 +24,10 @@ export async function loadManifest(outDir) {
|
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Saves the build manifest for the next build.
|
|
26
|
-
*
|
|
27
|
-
* @param {
|
|
27
|
+
*
|
|
28
|
+
* @param {string} outDir - Output directory.
|
|
29
|
+
* @param {Object} manifest - The manifest to save.
|
|
30
|
+
* @returns {Promise<void>}
|
|
28
31
|
*/
|
|
29
32
|
export async function saveManifest(outDir, manifest) {
|
|
30
33
|
const manifestPath = path.join(outDir, MANIFEST_FILE)
|
|
@@ -34,8 +37,9 @@ export async function saveManifest(outDir, manifest) {
|
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* Computes a hash for a file's contents.
|
|
37
|
-
*
|
|
38
|
-
* @
|
|
40
|
+
*
|
|
41
|
+
* @param {string} filePath - Path to the file.
|
|
42
|
+
* @returns {Promise<string|null>} Hash of the file or null if not found.
|
|
39
43
|
*/
|
|
40
44
|
export async function hashFile(filePath) {
|
|
41
45
|
try {
|
|
@@ -48,8 +52,9 @@ export async function hashFile(filePath) {
|
|
|
48
52
|
|
|
49
53
|
/**
|
|
50
54
|
* Computes a hash for the entities file.
|
|
51
|
-
*
|
|
52
|
-
* @
|
|
55
|
+
*
|
|
56
|
+
* @param {string} rootDir - Source root directory.
|
|
57
|
+
* @returns {Promise<string|null>} Hash of entities.js.
|
|
53
58
|
*/
|
|
54
59
|
export async function hashEntities(rootDir) {
|
|
55
60
|
const entitiesPath = path.join(rootDir, "entities.js")
|
|
@@ -58,10 +63,12 @@ export async function hashEntities(rootDir) {
|
|
|
58
63
|
|
|
59
64
|
/**
|
|
60
65
|
* Determines which pages need to be rebuilt.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* @param {
|
|
64
|
-
* @
|
|
66
|
+
* Compares current file hashes against the manifest.
|
|
67
|
+
*
|
|
68
|
+
* @param {Array<Object>} pages - All pages to potentially build.
|
|
69
|
+
* @param {Object} manifest - Previous build manifest.
|
|
70
|
+
* @param {string} entitiesHash - Current entities hash.
|
|
71
|
+
* @returns {Promise<{pagesToBuild: Array<Object>, pagesToSkip: Array<Object>}>} Object with pagesToBuild and pagesSkipped.
|
|
65
72
|
*/
|
|
66
73
|
export async function determineRebuildPages(pages, manifest, entitiesHash) {
|
|
67
74
|
// If entities changed, rebuild all pages
|
|
@@ -89,9 +96,10 @@ export async function determineRebuildPages(pages, manifest, entitiesHash) {
|
|
|
89
96
|
|
|
90
97
|
/**
|
|
91
98
|
* Creates a new manifest from build results.
|
|
92
|
-
*
|
|
93
|
-
* @param {
|
|
94
|
-
* @
|
|
99
|
+
*
|
|
100
|
+
* @param {Array<Object>} renderedPages - All rendered pages.
|
|
101
|
+
* @param {string} entitiesHash - Hash of entities file.
|
|
102
|
+
* @returns {Promise<Object>} New manifest.
|
|
95
103
|
*/
|
|
96
104
|
export async function createManifest(renderedPages, entitiesHash) {
|
|
97
105
|
const pages = {}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createManifest,
|
|
7
|
+
determineRebuildPages,
|
|
8
|
+
hashEntities,
|
|
9
|
+
hashFile,
|
|
10
|
+
loadManifest,
|
|
11
|
+
saveManifest,
|
|
12
|
+
} from "./manifest"
|
|
13
|
+
|
|
14
|
+
vi.mock("node:fs/promises")
|
|
15
|
+
|
|
16
|
+
describe("manifest", () => {
|
|
17
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.clearAllMocks()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("loadManifest", () => {
|
|
24
|
+
it("should load and parse manifest if exists", async () => {
|
|
25
|
+
const mockManifest = { pages: {}, entities: "abc" }
|
|
26
|
+
fs.readFile.mockResolvedValue(JSON.stringify(mockManifest))
|
|
27
|
+
|
|
28
|
+
const result = await loadManifest("dist")
|
|
29
|
+
expect(result).toEqual(mockManifest)
|
|
30
|
+
expect(fs.readFile).toHaveBeenCalledWith(
|
|
31
|
+
expect.stringContaining(".ssx-manifest.json"),
|
|
32
|
+
"utf-8",
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("should return default manifest if file missing", async () => {
|
|
37
|
+
fs.readFile.mockRejectedValue(new Error("ENOENT"))
|
|
38
|
+
|
|
39
|
+
const result = await loadManifest("dist")
|
|
40
|
+
expect(result).toEqual({ pages: {}, entities: null, buildTime: null })
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("saveManifest", () => {
|
|
45
|
+
it("should write manifest to file", async () => {
|
|
46
|
+
const manifest = { pages: {} }
|
|
47
|
+
await saveManifest("dist", manifest)
|
|
48
|
+
|
|
49
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
50
|
+
expect.stringContaining(".ssx-manifest.json"),
|
|
51
|
+
JSON.stringify(manifest, null, 2),
|
|
52
|
+
"utf-8",
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("hashFile", () => {
|
|
58
|
+
it("should return md5 hash of file content", async () => {
|
|
59
|
+
fs.readFile.mockResolvedValue("content")
|
|
60
|
+
// md5("content") = 9a0364b9e99bb480dd25e1f0284c8555
|
|
61
|
+
const hash = await hashFile("file.txt")
|
|
62
|
+
expect(hash).toBe("9a0364b9e99bb480dd25e1f0284c8555")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("should return null if file read fails", async () => {
|
|
66
|
+
fs.readFile.mockRejectedValue(new Error("ENOENT"))
|
|
67
|
+
const hash = await hashFile("file.txt")
|
|
68
|
+
expect(hash).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("hashEntities", () => {
|
|
73
|
+
it("should hash entities.js in root dir", async () => {
|
|
74
|
+
fs.readFile.mockResolvedValue("entities")
|
|
75
|
+
await hashEntities("src")
|
|
76
|
+
expect(fs.readFile).toHaveBeenCalledWith(
|
|
77
|
+
expect.stringContaining("entities.js"),
|
|
78
|
+
"utf-8",
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe("determineRebuildPages", () => {
|
|
84
|
+
it("should rebuild all if entities hash changed", async () => {
|
|
85
|
+
const pages = [{ path: "/" }]
|
|
86
|
+
const manifest = { entities: "old" }
|
|
87
|
+
const result = await determineRebuildPages(pages, manifest, "new")
|
|
88
|
+
|
|
89
|
+
expect(result.pagesToBuild).toEqual(pages)
|
|
90
|
+
expect(result.pagesToSkip).toEqual([])
|
|
91
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
92
|
+
expect.stringContaining("Entities changed"),
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("should split pages based on hash changes", async () => {
|
|
97
|
+
const pages = [
|
|
98
|
+
{ path: "/changed", filePath: "changed.js" },
|
|
99
|
+
{ path: "/same", filePath: "same.js" },
|
|
100
|
+
]
|
|
101
|
+
const manifest = {
|
|
102
|
+
entities: "hash",
|
|
103
|
+
pages: {
|
|
104
|
+
"/changed": { hash: "old-hash" },
|
|
105
|
+
"/same": { hash: "9a0364b9e99bb480dd25e1f0284c8555" }, // md5("content")
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Mock hashFile behavior via fs.readFile
|
|
110
|
+
fs.readFile.mockImplementation(async (path) => {
|
|
111
|
+
if (path === "changed.js") return "new content"
|
|
112
|
+
if (path === "same.js") return "content"
|
|
113
|
+
return ""
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const result = await determineRebuildPages(pages, manifest, "hash")
|
|
117
|
+
|
|
118
|
+
expect(result.pagesToBuild).toHaveLength(1)
|
|
119
|
+
expect(result.pagesToBuild[0].path).toBe("/changed")
|
|
120
|
+
expect(result.pagesToSkip).toHaveLength(1)
|
|
121
|
+
expect(result.pagesToSkip[0].path).toBe("/same")
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe("createManifest", () => {
|
|
126
|
+
it("should create a new manifest with page hashes", async () => {
|
|
127
|
+
const renderedPages = [
|
|
128
|
+
{ path: "/", filePath: "index.js" },
|
|
129
|
+
{ path: "/about", filePath: "about.js" },
|
|
130
|
+
]
|
|
131
|
+
const entitiesHash = "entities-hash"
|
|
132
|
+
|
|
133
|
+
fs.readFile.mockImplementation(async (path) => {
|
|
134
|
+
if (path === "index.js") return "index content"
|
|
135
|
+
if (path === "about.js") return "about content"
|
|
136
|
+
return ""
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const manifest = await createManifest(renderedPages, entitiesHash)
|
|
140
|
+
|
|
141
|
+
expect(manifest.entities).toBe(entitiesHash)
|
|
142
|
+
expect(manifest.buildTime).toBeDefined()
|
|
143
|
+
expect(manifest.pages["/"]).toEqual({
|
|
144
|
+
hash: "176b689259e8d68ef0aa869fd3b3be45",
|
|
145
|
+
filePath: "index.js",
|
|
146
|
+
})
|
|
147
|
+
expect(manifest.pages["/about"]).toEqual({
|
|
148
|
+
hash: "f43ab6cf4975e90e757c05cc3c619a85",
|
|
149
|
+
filePath: "about.js",
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
})
|
package/src/build/metadata.js
CHANGED
package/src/build/pages.js
CHANGED
|
@@ -4,8 +4,22 @@ import { pathToFileURL } from "node:url"
|
|
|
4
4
|
import { renderPage } from "../render/index.js"
|
|
5
5
|
import { extractPageMetadata } from "./metadata.js"
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Generates HTML and metadata for a list of pages.
|
|
9
|
+
* It loads the page module, executes the `load` function (if defined),
|
|
10
|
+
* renders the HTML, and extracts metadata.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} store - The application store.
|
|
13
|
+
* @param {Array<Object>} pages - List of pages to generate.
|
|
14
|
+
* @param {Object} [options] - Generation options.
|
|
15
|
+
* @param {boolean} [options.shouldGenerateHtml=true] - Whether to generate HTML.
|
|
16
|
+
* @param {boolean} [options.shouldGenerateMetadata=true] - Whether to generate metadata.
|
|
17
|
+
* @param {Function} [loader] - Optional loader function.
|
|
18
|
+
* @returns {Promise<Array<Object>>} The processed pages with `html` and `metadata` properties added.
|
|
19
|
+
*/
|
|
20
|
+
export async function generatePages(store, pages, options = {}, loader) {
|
|
8
21
|
const { shouldGenerateHtml = true, shouldGenerateMetadata = true } = options
|
|
22
|
+
const load = loader || ((p) => import(pathToFileURL(path.resolve(p))))
|
|
9
23
|
|
|
10
24
|
const api = store._api
|
|
11
25
|
|
|
@@ -14,7 +28,7 @@ export async function generatePages(store, pages, options = {}) {
|
|
|
14
28
|
` Generating ${shouldGenerateHtml ? "HTML" : ""}${shouldGenerateHtml && shouldGenerateMetadata ? " and " : ""}${shouldGenerateMetadata ? "metadata" : ""} for ${page.path}...`,
|
|
15
29
|
)
|
|
16
30
|
|
|
17
|
-
const module = await
|
|
31
|
+
const module = await load(page.filePath)
|
|
18
32
|
page.module = module
|
|
19
33
|
|
|
20
34
|
const entity = api.getEntity(page.moduleName)
|