@inglorious/ssx 1.1.0 → 1.1.1
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/package.json +4 -1
- package/src/build/build.test.js +117 -4
- package/src/build/index.js +37 -20
- 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 +12 -0
- 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 +10 -20
- 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 +113 -53
- 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.js → store/index.js} +11 -1
- package/src/store/store.test.js +56 -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.test.js +0 -40
- /package/src/{config.js → utils/config.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ssx",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
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,124 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
1
2
|
import path from "node:path"
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import { build as viteBuild } from "vite"
|
|
5
|
+
import { afterEach, 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
|
+
afterEach(() => {
|
|
43
|
+
vi.clearAllMocks()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("should run a full build sequence", async () => {
|
|
47
|
+
// Setup mocks
|
|
48
|
+
loadConfig.mockResolvedValue({})
|
|
49
|
+
loadManifest.mockResolvedValue(null) // First build
|
|
50
|
+
getPages.mockResolvedValue([{ path: "/" }])
|
|
51
|
+
hashEntities.mockResolvedValue("hash")
|
|
52
|
+
generateStore.mockResolvedValue({})
|
|
53
|
+
generatePages
|
|
54
|
+
.mockResolvedValueOnce([{ path: "/", html: "<html></html>" }])
|
|
55
|
+
.mockResolvedValueOnce([])
|
|
56
|
+
generateApp.mockReturnValue("console.log('app')")
|
|
57
|
+
createViteConfig.mockReturnValue({})
|
|
58
|
+
createManifest.mockResolvedValue({})
|
|
59
|
+
|
|
60
|
+
const result = await build({ rootDir: "src", outDir: "dist" })
|
|
61
|
+
|
|
62
|
+
// Verify sequence
|
|
63
|
+
expect(fs.rm).toHaveBeenCalledWith("dist", { recursive: true, force: true })
|
|
64
|
+
expect(fs.mkdir).toHaveBeenCalledWith("dist", { recursive: true })
|
|
65
|
+
expect(copyPublicDir).toHaveBeenCalled()
|
|
66
|
+
expect(getPages).toHaveBeenCalled()
|
|
67
|
+
expect(generateStore).toHaveBeenCalled()
|
|
68
|
+
expect(generatePages).toHaveBeenCalledTimes(2) // Changed + Skipped (empty)
|
|
69
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
70
|
+
path.normalize("dist/index.html"),
|
|
71
|
+
"<html></html>",
|
|
72
|
+
"utf-8",
|
|
73
|
+
)
|
|
74
|
+
expect(generateApp).toHaveBeenCalled()
|
|
75
|
+
expect(viteBuild).toHaveBeenCalled()
|
|
76
|
+
expect(saveManifest).toHaveBeenCalled()
|
|
77
|
+
|
|
78
|
+
expect(result).toEqual({ changed: 1, skipped: 0 })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("should handle incremental builds", async () => {
|
|
82
|
+
const manifest = { entities: "hash" }
|
|
83
|
+
loadManifest.mockResolvedValue(manifest)
|
|
84
|
+
hashEntities.mockResolvedValue("hash")
|
|
85
|
+
|
|
86
|
+
const allPages = [{ path: "/changed" }, { path: "/skipped" }]
|
|
87
|
+
getPages.mockResolvedValue(allPages)
|
|
88
|
+
|
|
89
|
+
determineRebuildPages.mockResolvedValue({
|
|
90
|
+
pagesToBuild: [{ path: "/changed" }],
|
|
91
|
+
pagesToSkip: [{ path: "/skipped" }],
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
generatePages
|
|
95
|
+
.mockResolvedValueOnce([{ path: "/changed", html: "<html></html>" }]) // Changed
|
|
96
|
+
.mockResolvedValueOnce([{ path: "/skipped" }]) // Skipped
|
|
97
|
+
|
|
98
|
+
const result = await build({ incremental: true })
|
|
99
|
+
|
|
100
|
+
expect(determineRebuildPages).toHaveBeenCalled()
|
|
101
|
+
expect(generatePages).toHaveBeenCalledTimes(2)
|
|
102
|
+
// Should only write changed pages
|
|
103
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
104
|
+
expect.stringContaining("changed"),
|
|
105
|
+
expect.any(String),
|
|
106
|
+
expect.any(String),
|
|
107
|
+
)
|
|
108
|
+
expect(result).toEqual({ changed: 1, skipped: 1 })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("should generate sitemap and rss if configured", async () => {
|
|
112
|
+
loadConfig.mockResolvedValue({})
|
|
113
|
+
getPages.mockResolvedValue([])
|
|
114
|
+
generatePages.mockResolvedValue([])
|
|
115
|
+
|
|
116
|
+
await build({
|
|
117
|
+
sitemap: { hostname: "https://example.com" },
|
|
118
|
+
rss: { link: "https://example.com" },
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(generateSitemap).toHaveBeenCalled()
|
|
122
|
+
expect(generateRSS).toHaveBeenCalled()
|
|
123
|
+
})
|
|
11
124
|
})
|
package/src/build/index.js
CHANGED
|
@@ -3,10 +3,10 @@ import path from "node:path"
|
|
|
3
3
|
|
|
4
4
|
import { build as viteBuild } 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,10 @@ export async function build(options = {}) {
|
|
|
35
47
|
|
|
36
48
|
console.log("🔨 Starting build...\n")
|
|
37
49
|
|
|
50
|
+
// 0. Get all pages to build (Fail fast if source is broken)
|
|
51
|
+
const allPages = await getPages(path.join(rootDir, "pages"))
|
|
52
|
+
console.log(`📄 Found ${allPages.length} pages\n`)
|
|
53
|
+
|
|
38
54
|
// Load previous build manifest
|
|
39
55
|
const manifest = incremental && !clean ? await loadManifest(outDir) : null
|
|
40
56
|
|
|
@@ -51,23 +67,19 @@ export async function build(options = {}) {
|
|
|
51
67
|
// 2. Copy public assets before generating pages (could be useful if need to read `public/data.json`)
|
|
52
68
|
await copyPublicDir(options)
|
|
53
69
|
|
|
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
70
|
// Determine which pages need rebuilding
|
|
59
71
|
const entitiesHash = await hashEntities(rootDir)
|
|
60
|
-
let
|
|
72
|
+
let pagesToChange = allPages
|
|
61
73
|
let pagesToSkip = []
|
|
62
74
|
|
|
63
75
|
if (manifest) {
|
|
64
76
|
const result = await determineRebuildPages(allPages, manifest, entitiesHash)
|
|
65
|
-
|
|
77
|
+
pagesToChange = result.pagesToBuild
|
|
66
78
|
pagesToSkip = result.pagesToSkip
|
|
67
79
|
|
|
68
80
|
if (pagesToSkip.length) {
|
|
69
81
|
console.log(
|
|
70
|
-
`⚡ Incremental build: ${
|
|
82
|
+
`⚡ Incremental build: ${pagesToChange.length} to change, ${pagesToSkip.length} to skip\n`,
|
|
71
83
|
)
|
|
72
84
|
}
|
|
73
85
|
}
|
|
@@ -76,7 +88,7 @@ export async function build(options = {}) {
|
|
|
76
88
|
const store = await generateStore(allPages, mergedOptions)
|
|
77
89
|
|
|
78
90
|
// 5. Render only pages that changed
|
|
79
|
-
const
|
|
91
|
+
const changedPages = await generatePages(store, pagesToChange, mergedOptions)
|
|
80
92
|
// For skipped pages, load their metadata from disk if needed for sitemap/RSS
|
|
81
93
|
const skippedPages = await generatePages(store, pagesToSkip, {
|
|
82
94
|
...mergedOptions,
|
|
@@ -84,47 +96,47 @@ export async function build(options = {}) {
|
|
|
84
96
|
})
|
|
85
97
|
|
|
86
98
|
// Combine rendered and skipped pages for sitemap/RSS
|
|
87
|
-
const allGeneratedPages = [...
|
|
99
|
+
const allGeneratedPages = [...changedPages, ...skippedPages]
|
|
88
100
|
|
|
89
|
-
if (
|
|
101
|
+
if (changedPages.length) {
|
|
90
102
|
// 6. Generate client-side JavaScript
|
|
91
103
|
console.log("\n💾 Writing files...\n")
|
|
92
104
|
|
|
93
105
|
// 7. Write HTML pages
|
|
94
|
-
for (const page of
|
|
106
|
+
for (const page of changedPages) {
|
|
95
107
|
const filePath = await writePageToDisk(page.path, page.html, outDir)
|
|
96
108
|
console.log(` ✓ ${filePath}`)
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
// Always regenerate client-side JavaScript (it's cheap and ensures consistency)
|
|
112
|
+
// 8. Always regenerate client-side JavaScript (it's cheap and ensures consistency)
|
|
101
113
|
console.log("\n📝 Generating client scripts...\n")
|
|
102
114
|
|
|
103
115
|
const app = generateApp(store, allPages)
|
|
104
116
|
await fs.writeFile(path.join(outDir, "main.js"), app, "utf-8")
|
|
105
117
|
console.log(` ✓ main.js\n`)
|
|
106
118
|
|
|
107
|
-
//
|
|
119
|
+
// 9. Generate sitemap if enabled
|
|
108
120
|
if (sitemap?.hostname) {
|
|
109
121
|
console.log("\n🗺️ Generating sitemap.xml...\n")
|
|
110
122
|
await generateSitemap(allGeneratedPages, { outDir, ...sitemap })
|
|
111
123
|
}
|
|
112
124
|
|
|
113
|
-
//
|
|
125
|
+
// 10. Generate RSS feed if enabled
|
|
114
126
|
if (rss?.link) {
|
|
115
127
|
console.log("\n📡 Generating RSS feed...\n")
|
|
116
128
|
await generateRSS(allGeneratedPages, { outDir, ...rss })
|
|
117
129
|
}
|
|
118
130
|
|
|
119
|
-
//
|
|
131
|
+
// 11. Bundle with Vite
|
|
120
132
|
console.log("\n📦 Bundling with Vite...\n")
|
|
121
133
|
const viteConfig = createViteConfig(mergedOptions)
|
|
122
134
|
await viteBuild(viteConfig)
|
|
123
135
|
|
|
124
|
-
//
|
|
136
|
+
// 12. Cleanup
|
|
125
137
|
// console.log("\n🧹 Cleaning up...\n")
|
|
126
138
|
|
|
127
|
-
// Save manifest for next build
|
|
139
|
+
// 13. Save manifest for next build
|
|
128
140
|
if (incremental) {
|
|
129
141
|
const newManifest = await createManifest(allGeneratedPages, entitiesHash)
|
|
130
142
|
await saveManifest(outDir, newManifest)
|
|
@@ -133,13 +145,18 @@ export async function build(options = {}) {
|
|
|
133
145
|
console.log("\n✨ Build complete!\n")
|
|
134
146
|
|
|
135
147
|
return {
|
|
136
|
-
|
|
148
|
+
changed: changedPages.length,
|
|
137
149
|
skipped: skippedPages.length,
|
|
138
150
|
}
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
/**
|
|
142
154
|
* Write a page to disk with proper directory structure.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} pagePath - The URL path of the page.
|
|
157
|
+
* @param {string} html - The rendered HTML content.
|
|
158
|
+
* @param {string} [outDir="dist"] - The output directory.
|
|
159
|
+
* @returns {Promise<string>} The absolute path of the written file.
|
|
143
160
|
*/
|
|
144
161
|
async function writePageToDisk(pagePath, html, outDir = "dist") {
|
|
145
162
|
// 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,6 +4,18 @@ import { pathToFileURL } from "node:url"
|
|
|
4
4
|
import { renderPage } from "../render/index.js"
|
|
5
5
|
import { extractPageMetadata } from "./metadata.js"
|
|
6
6
|
|
|
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
|
+
* @returns {Promise<Array<Object>>} The processed pages with `html` and `metadata` properties added.
|
|
18
|
+
*/
|
|
7
19
|
export async function generatePages(store, pages, options = {}) {
|
|
8
20
|
const { shouldGenerateHtml = true, shouldGenerateMetadata = true } = options
|
|
9
21
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
|
5
|
+
|
|
6
|
+
import { renderPage } from "../render/index.js"
|
|
7
|
+
import { extractPageMetadata } from "./metadata.js"
|
|
8
|
+
import { generatePages } from "./pages"
|
|
9
|
+
|
|
10
|
+
vi.mock("../render/index.js")
|
|
11
|
+
vi.mock("./metadata.js")
|
|
12
|
+
|
|
13
|
+
describe("generatePages", () => {
|
|
14
|
+
// Create a temporary page module for testing dynamic imports
|
|
15
|
+
const tempDir = path.join(import.meta.dirname, "__temp_pages__")
|
|
16
|
+
const pageFile = path.join(tempDir, "test-page.js")
|
|
17
|
+
|
|
18
|
+
// Mock console.log to keep test output clean
|
|
19
|
+
vi.spyOn(console, "log").mockImplementation(() => {})
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
await fs.mkdir(tempDir, { recursive: true })
|
|
23
|
+
// Create a dummy module that exports a load function
|
|
24
|
+
await fs.writeFile(
|
|
25
|
+
pageFile,
|
|
26
|
+
`
|
|
27
|
+
export const load = async (entity, page) => {
|
|
28
|
+
page.loaded = true
|
|
29
|
+
}
|
|
30
|
+
export const render = () => {}
|
|
31
|
+
`,
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await fs.rm(tempDir, { recursive: true, force: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("should generate HTML and metadata by default", async () => {
|
|
40
|
+
const store = { _api: { getEntity: vi.fn(() => ({})) } }
|
|
41
|
+
const pages = [{ path: "/p1", filePath: pageFile, moduleName: "p1" }]
|
|
42
|
+
|
|
43
|
+
renderPage.mockResolvedValue("<html></html>")
|
|
44
|
+
extractPageMetadata.mockReturnValue({ title: "Test" })
|
|
45
|
+
|
|
46
|
+
await generatePages(store, pages)
|
|
47
|
+
|
|
48
|
+
expect(pages[0].html).toBe("<html></html>")
|
|
49
|
+
expect(pages[0].metadata).toEqual({ title: "Test" })
|
|
50
|
+
expect(pages[0].loaded).toBe(true) // Verify load() was called
|
|
51
|
+
expect(renderPage).toHaveBeenCalled()
|
|
52
|
+
expect(extractPageMetadata).toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("should skip HTML generation when disabled", async () => {
|
|
56
|
+
const store = { _api: { getEntity: vi.fn(() => ({})) } }
|
|
57
|
+
const pages = [{ path: "/p2", filePath: pageFile, moduleName: "p2" }]
|
|
58
|
+
|
|
59
|
+
vi.clearAllMocks()
|
|
60
|
+
|
|
61
|
+
await generatePages(store, pages, { shouldGenerateHtml: false })
|
|
62
|
+
|
|
63
|
+
expect(pages[0].html).toBeUndefined()
|
|
64
|
+
expect(pages[0].metadata).toBeDefined()
|
|
65
|
+
expect(renderPage).not.toHaveBeenCalled()
|
|
66
|
+
expect(extractPageMetadata).toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("should skip metadata generation when disabled", async () => {
|
|
70
|
+
const store = { _api: { getEntity: vi.fn(() => ({})) } }
|
|
71
|
+
const pages = [{ path: "/p3", filePath: pageFile, moduleName: "p3" }]
|
|
72
|
+
|
|
73
|
+
vi.clearAllMocks()
|
|
74
|
+
renderPage.mockResolvedValue("<html></html>")
|
|
75
|
+
|
|
76
|
+
await generatePages(store, pages, { shouldGenerateMetadata: false })
|
|
77
|
+
|
|
78
|
+
expect(pages[0].html).toBeDefined()
|
|
79
|
+
expect(pages[0].metadata).toBeUndefined()
|
|
80
|
+
expect(renderPage).toHaveBeenCalled()
|
|
81
|
+
expect(extractPageMetadata).not.toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/build/public.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs/promises"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Copies the contents of the public directory to the output directory.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} options - Build options.
|
|
8
|
+
* @param {string} [options.outDir="dist"] - The output directory.
|
|
9
|
+
* @param {string} [options.publicDir="public"] - The public assets directory (relative to CWD).
|
|
10
|
+
* @returns {Promise<void>}
|
|
11
|
+
*/
|
|
4
12
|
export async function copyPublicDir(options = {}) {
|
|
5
13
|
const { outDir = "dist", publicDir = "public" } = options
|
|
6
14
|
|
|
@@ -17,6 +25,13 @@ export async function copyPublicDir(options = {}) {
|
|
|
17
25
|
}
|
|
18
26
|
}
|
|
19
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Recursively copies a directory.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} src - Source directory path.
|
|
32
|
+
* @param {string} dest - Destination directory path.
|
|
33
|
+
* @returns {Promise<void>}
|
|
34
|
+
*/
|
|
20
35
|
async function copyDir(src, dest) {
|
|
21
36
|
await fs.mkdir(dest, { recursive: true })
|
|
22
37
|
const entries = await fs.readdir(src, { withFileTypes: true })
|