@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "1.1.0",
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",
@@ -1,11 +1,124 @@
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 } 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
- 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
+ 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
  })
@@ -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 pagesToBuild = allPages
72
+ let pagesToChange = allPages
61
73
  let pagesToSkip = []
62
74
 
63
75
  if (manifest) {
64
76
  const result = await determineRebuildPages(allPages, manifest, entitiesHash)
65
- pagesToBuild = result.pagesToBuild
77
+ pagesToChange = result.pagesToBuild
66
78
  pagesToSkip = result.pagesToSkip
67
79
 
68
80
  if (pagesToSkip.length) {
69
81
  console.log(
70
- `⚡ Incremental build: ${pagesToBuild.length} to change, ${pagesToSkip.length} to skip\n`,
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 generatedPages = await generatePages(store, pagesToBuild, mergedOptions)
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 = [...generatedPages, ...skippedPages]
99
+ const allGeneratedPages = [...changedPages, ...skippedPages]
88
100
 
89
- if (generatedPages.length) {
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 generatedPages) {
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
- // 7a. Generate sitemap if enabled
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
- // 7b. Generate RSS feed if enabled
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
- // 8. Bundle with Vite
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
- // 9. Cleanup
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
- generated: generatedPages.length,
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
@@ -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,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
+ })
@@ -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 })