@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 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 ready** - Full type support (coming soon)
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
- ### �️ Sitemap & RSS Generation
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
- ### �📁 File-Based Routing
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
- - [ ] TypeScript support
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.1.0",
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",
@@ -1,11 +1,131 @@
1
+ import fs from "node:fs/promises"
1
2
  import path from "node:path"
2
3
 
3
- import { it } from "vitest"
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
- const ROOT_DIR = path.join(__dirname, "__fixtures__")
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
- it.skip("should build full static pages", async () => {
10
- await build({ rootDir: ROOT_DIR })
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
  })
@@ -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 pagesToBuild = allPages
80
+ let pagesToChange = allPages
61
81
  let pagesToSkip = []
62
82
 
63
83
  if (manifest) {
64
84
  const result = await determineRebuildPages(allPages, manifest, entitiesHash)
65
- pagesToBuild = result.pagesToBuild
85
+ pagesToChange = result.pagesToBuild
66
86
  pagesToSkip = result.pagesToSkip
67
87
 
68
88
  if (pagesToSkip.length) {
69
89
  console.log(
70
- `⚡ Incremental build: ${pagesToBuild.length} to change, ${pagesToSkip.length} to skip\n`,
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 generatedPages = await generatePages(store, pagesToBuild, mergedOptions)
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 = [...generatedPages, ...skippedPages]
112
+ const allGeneratedPages = [...changedPages, ...skippedPages]
88
113
 
89
- if (generatedPages.length) {
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 generatedPages) {
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
- // 7a. Generate sitemap if enabled
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
- // 7b. Generate RSS feed if enabled
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
- // 8. Bundle with Vite
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
- // 9. Cleanup
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
- generated: generatedPages.length,
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
@@ -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
- * @param {string} outDir - Output directory
10
- * @returns {Promise<Object>} The manifest object
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
- * @param {string} outDir - Output directory
27
- * @param {Object} manifest - The manifest to save
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
- * @param {string} filePath - Path to the file
38
- * @returns {Promise<string>} Hash of the file
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
- * @param {string} rootDir - Source root directory
52
- * @returns {Promise<string>} Hash of entities.js
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
- * @param {Array} pages - All pages to potentially build
62
- * @param {Object} manifest - Previous build manifest
63
- * @param {string} entitiesHash - Current entities hash
64
- * @returns {Promise<Object>} Object with pagesToBuild and pagesSkipped
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
- * @param {Array} renderedPages - All rendered pages
93
- * @param {string} entitiesHash - Hash of entities file
94
- * @returns {Object} New manifest
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
+ })
@@ -1,4 +1,4 @@
1
- import { createGetPageOption } from "../page-options.js"
1
+ import { createGetPageOption } from "../utils/page-options.js"
2
2
 
3
3
  const DEFAULT_OPTIONS = {
4
4
  title: "",
@@ -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
- export async function generatePages(store, pages, options = {}) {
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 import(pathToFileURL(path.resolve(page.filePath)))
31
+ const module = await load(page.filePath)
18
32
  page.module = module
19
33
 
20
34
  const entity = api.getEntity(page.moduleName)