@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.
@@ -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 })
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ import { afterEach, describe, expect, it, vi } from "vitest"
5
+
6
+ import { copyPublicDir } from "./public"
7
+
8
+ vi.mock("node:fs/promises")
9
+
10
+ describe("copyPublicDir", () => {
11
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
12
+
13
+ afterEach(() => {
14
+ vi.clearAllMocks()
15
+ })
16
+
17
+ it("should copy public assets when directory exists", async () => {
18
+ // Mock access to succeed
19
+ fs.access.mockResolvedValue(undefined)
20
+
21
+ // Mock readdir sequence:
22
+ // 1. Root directory contains 'favicon.ico' (file) and 'assets' (dir)
23
+ // 2. 'assets' directory contains 'logo.png' (file)
24
+ fs.readdir
25
+ .mockResolvedValueOnce([
26
+ { name: "favicon.ico", isDirectory: () => false },
27
+ { name: "assets", isDirectory: () => true },
28
+ ])
29
+ .mockResolvedValueOnce([{ name: "logo.png", isDirectory: () => false }])
30
+
31
+ await copyPublicDir({ outDir: "dist", publicDir: "public" })
32
+
33
+ // Verify mkdir calls (root dest + subfolder)
34
+ expect(fs.mkdir).toHaveBeenCalledTimes(2)
35
+ expect(fs.mkdir).toHaveBeenCalledWith("dist", { recursive: true })
36
+ expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining("assets"), {
37
+ recursive: true,
38
+ })
39
+
40
+ // Verify copyFile calls
41
+ expect(fs.copyFile).toHaveBeenCalledTimes(2)
42
+ // We can't easily check exact paths without mocking process.cwd() or doing complex string matching,
43
+ // but we can check that it was called for the files we defined.
44
+ const copyCalls = fs.copyFile.mock.calls
45
+ const filesCopied = copyCalls.map((call) => path.basename(call[0]))
46
+ expect(filesCopied).toContain("favicon.ico")
47
+ expect(filesCopied).toContain("logo.png")
48
+ })
49
+
50
+ it("should do nothing if public directory does not exist", async () => {
51
+ fs.access.mockRejectedValue(new Error("ENOENT"))
52
+
53
+ await copyPublicDir()
54
+
55
+ expect(consoleSpy).not.toHaveBeenCalled()
56
+ expect(fs.readdir).not.toHaveBeenCalled()
57
+ expect(fs.copyFile).not.toHaveBeenCalled()
58
+ })
59
+ })
package/src/build/rss.js CHANGED
@@ -3,16 +3,18 @@ import path from "node:path"
3
3
 
4
4
  /**
5
5
  * Generates an RSS feed for the site.
6
- * @param {Object} options - RSS generation options
7
- * @param {string} options.outDir - Output directory (default: "dist")
8
- * @param {Array} options.items - Array of items to include in the feed
9
- * @param {string} options.title - Feed title
10
- * @param {string} options.description - Feed description
11
- * @param {string} options.link - Site URL
12
- * @param {string} options.feedPath - Path for the RSS file (default: "/feed.xml")
13
- * @param {string} options.language - Feed language (default: "en")
14
- * @param {string} options.copyright - Copyright notice
15
- * @param {number} options.maxItems - Maximum items to include (default: 50)
6
+ *
7
+ * @param {Array<Object>} pages - Array of page objects. Each page must have a `metadata` property.
8
+ * @param {Object} options - RSS generation options.
9
+ * @param {string} [options.outDir="dist"] - Output directory.
10
+ * @param {string} [options.title="RSS Feed"] - Feed title.
11
+ * @param {string} [options.description="Latest Posts"] - Feed description.
12
+ * @param {string} options.link - Site URL (required).
13
+ * @param {string} [options.feedPath="/feed.xml"] - Path for the RSS file.
14
+ * @param {string} [options.language="en"] - Feed language.
15
+ * @param {string} [options.copyright] - Copyright notice.
16
+ * @param {number} [options.maxItems=50] - Maximum items to include.
17
+ * @param {Function} [options.filter] - Function to filter pages.
16
18
  * @returns {Promise<void>}
17
19
  */
18
20
  export async function generateRSS(pages = [], options = {}) {
@@ -62,6 +64,12 @@ ${rssItems.join("\n")}
62
64
  console.log(` ✓ ${feedPath} (${items.length} items)\n`)
63
65
  }
64
66
 
67
+ /**
68
+ * Creates a function to render a single RSS item.
69
+ *
70
+ * @param {string} link - The base URL of the site.
71
+ * @returns {Function} A function that takes metadata and returns an XML string.
72
+ */
65
73
  function createRenderItem(link) {
66
74
  return (metadata) => {
67
75
  const pubDate =
@@ -82,8 +90,12 @@ function createRenderItem(link) {
82
90
  </item>`
83
91
  }
84
92
  }
93
+
85
94
  /**
86
- * Escape special XML characters
95
+ * Escape special XML characters.
96
+ *
97
+ * @param {string} str - The string to escape.
98
+ * @returns {string} The escaped string.
87
99
  */
88
100
  function escapeXml(str) {
89
101
  if (typeof str !== "string") return str
@@ -95,6 +107,13 @@ function escapeXml(str) {
95
107
  .replace(/'/g, "&apos;")
96
108
  }
97
109
 
110
+ /**
111
+ * Sorts items by date (newest first).
112
+ *
113
+ * @param {Object} a - First item.
114
+ * @param {Object} b - Second item.
115
+ * @returns {number} Sort order.
116
+ */
98
117
  function byNewest(a, b) {
99
118
  const dateA = new Date(a.pubDate || a.date || 0)
100
119
  const dateB = new Date(b.pubDate || b.date || 0)
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs/promises"
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+
5
+ import { generateRSS } from "./rss"
6
+
7
+ vi.mock("node:fs/promises")
8
+
9
+ describe("generateRSS", () => {
10
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
11
+ vi.spyOn(console, "log").mockImplementation(() => {})
12
+
13
+ afterEach(() => {
14
+ vi.clearAllMocks()
15
+ })
16
+
17
+ it("should generate valid RSS xml", async () => {
18
+ const pages = [
19
+ {
20
+ metadata: {
21
+ title: "Post 1",
22
+ path: "/post-1",
23
+ date: "2024-01-01",
24
+ description: "Desc 1",
25
+ },
26
+ },
27
+ ]
28
+
29
+ await generateRSS(pages, {
30
+ link: "https://example.com",
31
+ title: "My Blog",
32
+ })
33
+
34
+ expect(fs.writeFile).toHaveBeenCalledTimes(1)
35
+ const [filePath, content] = fs.writeFile.mock.calls[0]
36
+
37
+ expect(filePath).toContain("feed.xml")
38
+ expect(content).toContain('<?xml version="1.0" encoding="UTF-8"?>')
39
+ expect(content).toContain('<rss version="2.0"')
40
+ expect(content).toContain("<title>My Blog</title>")
41
+ expect(content).toContain("<link>https://example.com</link>")
42
+ expect(content).toContain("<item>")
43
+ expect(content).toContain("<title>Post 1</title>")
44
+ expect(content).toContain("<link>https://example.com/post-1</link>")
45
+ expect(content).toContain("<pubDate>Mon, 01 Jan 2024")
46
+ })
47
+
48
+ it("should warn and skip if link is missing", async () => {
49
+ await generateRSS([], {})
50
+ expect(consoleSpy).toHaveBeenCalledWith(
51
+ expect.stringContaining("No link provided"),
52
+ )
53
+ expect(fs.writeFile).not.toHaveBeenCalled()
54
+ })
55
+
56
+ it("should sort items by newest date", async () => {
57
+ const pages = [
58
+ { metadata: { title: "Old", date: "2023-01-01" } },
59
+ { metadata: { title: "New", date: "2024-01-01" } },
60
+ ]
61
+
62
+ await generateRSS(pages, { link: "https://site.com" })
63
+
64
+ const [, content] = fs.writeFile.mock.calls[0]
65
+ const oldIndex = content.indexOf("<title>Old</title>")
66
+ const newIndex = content.indexOf("<title>New</title>")
67
+
68
+ expect(newIndex).toBeLessThan(oldIndex)
69
+ })
70
+
71
+ it("should respect maxItems option", async () => {
72
+ const pages = Array.from({ length: 10 }, (_, i) => ({
73
+ metadata: { title: `Post ${i}`, date: `2024-01-${i + 1}` },
74
+ }))
75
+
76
+ await generateRSS(pages, { link: "https://site.com", maxItems: 5 })
77
+
78
+ const [, content] = fs.writeFile.mock.calls[0]
79
+ const matches = content.match(/<item>/g)
80
+ expect(matches).toHaveLength(5)
81
+ })
82
+
83
+ it("should filter pages", async () => {
84
+ const pages = [
85
+ { path: "/blog/1", metadata: { title: "Blog 1" } },
86
+ { path: "/about", metadata: { title: "About" } },
87
+ ]
88
+
89
+ const filter = (page) => page.path.startsWith("/blog")
90
+
91
+ await generateRSS(pages, { link: "https://site.com", filter })
92
+
93
+ const [, content] = fs.writeFile.mock.calls[0]
94
+ expect(content).toContain("Blog 1")
95
+ expect(content).not.toContain("About")
96
+ })
97
+
98
+ it("should escape special characters", async () => {
99
+ const pages = [{ metadata: { title: "Q&A <Test>" } }]
100
+ await generateRSS(pages, { link: "https://site.com" })
101
+ const [, content] = fs.writeFile.mock.calls[0]
102
+ expect(content).toContain("Q&amp;A &lt;Test&gt;")
103
+ })
104
+ })
@@ -3,12 +3,12 @@ import path from "node:path"
3
3
 
4
4
  /**
5
5
  * Generates a sitemap.xml file for the built site.
6
- * @param {Array} pages - Array of page objects with path, updatedAt, etc.
7
- * @param {Object} options - Sitemap generation options
8
- * @param {string} options.outDir - Output directory (default: "dist")
9
- * @param {string} options.hostname - Base URL of the site (e.g., "https://mysite.com")
10
- * @param {Array} options.exclude - Array of path patterns to exclude (e.g., ["/admin", "/draft-*"])
11
- * @param {Object} options.defaults - Default values for all pages
6
+ *
7
+ * @param {Array<Object>} pages - Array of page objects. Each page must have a `metadata` property.
8
+ * @param {Object} options - Sitemap generation options.
9
+ * @param {string} [options.outDir="dist"] - Output directory.
10
+ * @param {string} options.hostname - Base URL of the site (e.g., "https://mysite.com").
11
+ * @param {Function} [options.filter] - Function to filter pages before generation.
12
12
  * @returns {Promise<void>}
13
13
  */
14
14
  export async function generateSitemap(pages = [], options = {}) {
@@ -35,6 +35,12 @@ ${urls.join("\n")}
35
35
  console.log(` ✓ sitemap.xml (${items.length} pages)\n`)
36
36
  }
37
37
 
38
+ /**
39
+ * Renders a single URL entry for the sitemap.
40
+ *
41
+ * @param {Object} metadata - Page metadata (loc, lastmod, changefreq, priority).
42
+ * @returns {string} The XML string for the url element.
43
+ */
38
44
  function renderItem(metadata) {
39
45
  return ` <url>
40
46
  <loc>${escapeXml(metadata.loc)}</loc>
@@ -46,6 +52,9 @@ function renderItem(metadata) {
46
52
 
47
53
  /**
48
54
  * Escape special XML characters
55
+ *
56
+ * @param {string} str - The string to escape.
57
+ * @returns {string} The escaped string.
49
58
  */
50
59
  function escapeXml(str) {
51
60
  return str
@@ -0,0 +1,84 @@
1
+ import fs from "node:fs/promises"
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+
5
+ import { generateSitemap } from "./sitemap"
6
+
7
+ vi.mock("node:fs/promises")
8
+
9
+ describe("generateSitemap", () => {
10
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
11
+ vi.spyOn(console, "log").mockImplementation(() => {})
12
+
13
+ afterEach(() => {
14
+ vi.clearAllMocks()
15
+ })
16
+
17
+ it("should generate valid sitemap xml", async () => {
18
+ const pages = [
19
+ {
20
+ metadata: {
21
+ loc: "https://example.com/",
22
+ lastmod: "2024-01-01",
23
+ changefreq: "daily",
24
+ priority: 1.0,
25
+ },
26
+ },
27
+ {
28
+ metadata: {
29
+ loc: "https://example.com/about",
30
+ lastmod: "2024-01-02",
31
+ changefreq: "monthly",
32
+ priority: 0.8,
33
+ },
34
+ },
35
+ ]
36
+
37
+ await generateSitemap(pages, { hostname: "https://example.com" })
38
+
39
+ expect(fs.writeFile).toHaveBeenCalledTimes(1)
40
+ const [filePath, content] = fs.writeFile.mock.calls[0]
41
+
42
+ expect(filePath).toContain("sitemap.xml")
43
+ expect(content).toContain('<?xml version="1.0" encoding="UTF-8"?>')
44
+ expect(content).toContain(
45
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
46
+ )
47
+ expect(content).toContain("<loc>https://example.com/</loc>")
48
+ expect(content).toContain("<priority>1</priority>")
49
+ expect(content).toContain("<loc>https://example.com/about</loc>")
50
+ expect(content).toContain("<changefreq>monthly</changefreq>")
51
+ })
52
+
53
+ it("should warn and skip if hostname is missing", async () => {
54
+ await generateSitemap([], {})
55
+ expect(consoleSpy).toHaveBeenCalledWith(
56
+ expect.stringContaining("No hostname"),
57
+ )
58
+ expect(fs.writeFile).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it("should filter pages based on filter function", async () => {
62
+ const pages = [
63
+ { path: "/public", metadata: { loc: "https://site.com/public" } },
64
+ { path: "/admin", metadata: { loc: "https://site.com/admin" } },
65
+ ]
66
+
67
+ const filter = (page) => page.path !== "/admin"
68
+
69
+ await generateSitemap(pages, { hostname: "https://site.com", filter })
70
+
71
+ const [, content] = fs.writeFile.mock.calls[0]
72
+ expect(content).toContain("/public")
73
+ expect(content).not.toContain("/admin")
74
+ })
75
+
76
+ it("should escape special characters in URLs", async () => {
77
+ const pages = [{ metadata: { loc: "https://site.com/foo?bar=1&baz=2" } }]
78
+
79
+ await generateSitemap(pages, { hostname: "https://site.com" })
80
+
81
+ const [, content] = fs.writeFile.mock.calls[0]
82
+ expect(content).toContain("foo?bar=1&amp;baz=2")
83
+ })
84
+ })
package/src/dev/index.js CHANGED
@@ -3,13 +3,20 @@ import path from "node:path"
3
3
  import connect from "connect"
4
4
  import { createServer } from "vite"
5
5
 
6
- import { loadConfig } from "../config.js"
7
6
  import { renderPage } from "../render/index.js"
8
- import { getPages } from "../router/index.js"
7
+ import { getPages, matchRoute } from "../router/index.js"
9
8
  import { generateApp } from "../scripts/app.js"
10
- import { generateStore } from "../store.js"
9
+ import { generateStore } from "../store/index.js"
10
+ import { loadConfig } from "../utils/config.js"
11
11
  import { createViteConfig, virtualFiles } from "./vite-config.js"
12
12
 
13
+ /**
14
+ * Starts the development server.
15
+ * It sets up a Vite server with SSR middleware to render pages on demand.
16
+ *
17
+ * @param {Object} options - Configuration options.
18
+ * @returns {Promise<{close: Function}>} A promise that resolves to a server control object.
19
+ */
13
20
  export async function dev(options = {}) {
14
21
  const config = await loadConfig(options)
15
22
 
@@ -18,16 +25,17 @@ export async function dev(options = {}) {
18
25
 
19
26
  console.log("🚀 Starting dev server...\n")
20
27
 
28
+ // Create Vite dev server
29
+ const viteConfig = createViteConfig(mergedOptions)
30
+ const viteServer = await createServer(viteConfig)
31
+ const loader = (p) => viteServer.ssrLoadModule(p)
32
+
21
33
  // Get all pages once at startup
22
- const pages = await getPages(path.join(rootDir, "pages"))
34
+ const pages = await getPages(path.join(rootDir, "pages"), loader)
23
35
  console.log(`📄 Found ${pages.length} pages\n`)
24
36
 
25
37
  // Generate store config once for all pages
26
- const store = await generateStore(pages, mergedOptions)
27
-
28
- // Create Vite dev server
29
- const viteConfig = createViteConfig(mergedOptions)
30
- const viteServer = await createServer(viteConfig)
38
+ const store = await generateStore(pages, mergedOptions, loader)
31
39
 
32
40
  // Use Vite's middleware first (handles HMR, static files, etc.)
33
41
  const connectServer = connect()
@@ -52,7 +60,7 @@ export async function dev(options = {}) {
52
60
  const page = pages.find((p) => matchRoute(p.path, url))
53
61
  if (!page) return next()
54
62
 
55
- const module = await viteServer.ssrLoadModule(page.filePath)
63
+ const module = await loader(page.filePath)
56
64
  page.module = module
57
65
 
58
66
  const entity = store._api.getEntity(page.moduleName)
@@ -89,20 +97,3 @@ export async function dev(options = {}) {
89
97
  },
90
98
  }
91
99
  }
92
-
93
- // Simple route matcher (could be moved to router.js)
94
- function matchRoute(pattern, url) {
95
- const patternParts = pattern.split("/").filter(Boolean)
96
- const urlParts = url.split("/").filter(Boolean)
97
-
98
- if (patternParts.length !== urlParts.length) {
99
- return false
100
- }
101
-
102
- return patternParts.every((part, i) => {
103
- if (part.startsWith(":") || part.startsWith("[")) {
104
- return true
105
- }
106
- return part === urlParts[i]
107
- })
108
- }
@@ -2,6 +2,16 @@ import path from "node:path"
2
2
 
3
3
  import { mergeConfig } from "vite"
4
4
 
5
+ /**
6
+ * Creates a Vite configuration object for the SSX dev server.
7
+ * It sets up the root directory, public directory, aliases, and the virtual file plugin.
8
+ *
9
+ * @param {Object} options - SSX configuration options.
10
+ * @param {string} [options.rootDir="src"] - The source root directory.
11
+ * @param {string} [options.publicDir="public"] - The public assets directory (relative to rootDir).
12
+ * @param {Object} [options.vite={}] - Additional Vite configuration to merge.
13
+ * @returns {Object} The merged Vite configuration.
14
+ */
5
15
  export function createViteConfig(options = {}) {
6
16
  const { rootDir = "src", publicDir = "public", vite = {} } = options
7
17
  const { port = 3000 } = vite.dev ?? {}
@@ -23,8 +33,18 @@ export function createViteConfig(options = {}) {
23
33
  )
24
34
  }
25
35
 
36
+ /**
37
+ * A map to store virtual files generated during runtime (e.g., the client entry point).
38
+ * Keys are file paths (e.g., "/main.js") and values are the file content.
39
+ * @type {Map<string, string>}
40
+ */
26
41
  export const virtualFiles = new Map()
27
42
 
43
+ /**
44
+ * A Vite plugin to serve virtual files from the `virtualFiles` map.
45
+ *
46
+ * @returns {Object} The Vite plugin object.
47
+ */
28
48
  function virtualPlugin() {
29
49
  return {
30
50
  name: "ssx-virtual-files",
@@ -0,0 +1,46 @@
1
+ import path from "node:path"
2
+
3
+ import { afterEach, describe, expect, it } from "vitest"
4
+
5
+ import { createViteConfig, virtualFiles } from "./vite-config"
6
+
7
+ describe("createViteConfig", () => {
8
+ it("should create default config", () => {
9
+ const config = createViteConfig()
10
+ expect(config.root).toBe(process.cwd())
11
+ expect(config.server.port).toBe(3000)
12
+ expect(config.resolve.alias["@"]).toBe(path.resolve(process.cwd(), "src"))
13
+ })
14
+
15
+ it("should respect custom rootDir", () => {
16
+ const config = createViteConfig({ rootDir: "app" })
17
+ expect(config.resolve.alias["@"]).toBe(path.resolve(process.cwd(), "app"))
18
+ })
19
+
20
+ it("should merge custom vite config", () => {
21
+ const config = createViteConfig({
22
+ vite: {
23
+ server: { port: 4000 },
24
+ define: { __TEST__: true },
25
+ },
26
+ })
27
+ expect(config.server.port).toBe(4000)
28
+ expect(config.define.__TEST__).toBe(true)
29
+ })
30
+ })
31
+
32
+ describe("virtualPlugin", () => {
33
+ afterEach(() => {
34
+ virtualFiles.clear()
35
+ })
36
+
37
+ it("should resolve and load virtual files", () => {
38
+ const config = createViteConfig()
39
+ const plugin = config.plugins.find((p) => p.name === "ssx-virtual-files")
40
+
41
+ virtualFiles.set("/virtual.js", "console.log('virtual')")
42
+
43
+ expect(plugin.resolveId("/virtual.js")).toBe("/virtual.js")
44
+ expect(plugin.load("/virtual.js")).toBe("console.log('virtual')")
45
+ })
46
+ })
@@ -4,6 +4,18 @@ import { collectResult } from "@lit-labs/ssr/lib/render-result.js"
4
4
 
5
5
  import { layout as defaultLayout } from "./layout.js"
6
6
 
7
+ /**
8
+ * Renders a page or component to HTML using the store state.
9
+ * It handles SSR rendering of Lit templates and optional HTML wrapping.
10
+ *
11
+ * @param {Object} store - The application store instance.
12
+ * @param {Function} renderFn - A function that returns a Lit template. Receives `api` as argument.
13
+ * @param {Object} [options] - Rendering options.
14
+ * @param {boolean} [options.wrap=false] - Whether to wrap the output in a full HTML document.
15
+ * @param {Function} [options.layout] - Custom layout function.
16
+ * @param {boolean} [options.stripLitMarkers=false] - Whether to remove Lit hydration markers (for static output).
17
+ * @returns {Promise<string>} The generated HTML string.
18
+ */
7
19
  export async function toHTML(store, renderFn, options = {}) {
8
20
  const api = { ...store._api }
9
21
  api.render = createRender(api)
@@ -22,9 +34,16 @@ export async function toHTML(store, renderFn, options = {}) {
22
34
  if (!options.wrap) return finalHTML
23
35
 
24
36
  const layout = options.layout ?? defaultLayout
25
- return options.wrap ? layout(finalHTML, options) : finalHTML
37
+ return layout(finalHTML, options)
26
38
  }
27
39
 
40
+ /**
41
+ * Removes Lit hydration markers from the HTML string.
42
+ * Useful for generating clean static HTML that doesn't need client-side hydration.
43
+ *
44
+ * @param {string} html - The HTML string with markers.
45
+ * @returns {string} Cleaned HTML string.
46
+ */
28
47
  function stripLitMarkers(html) {
29
48
  return html
30
49
  .replace(/<!--\?[^>]*-->/g, "") // All lit-html markers
@@ -32,6 +51,13 @@ function stripLitMarkers(html) {
32
51
  }
33
52
 
34
53
  // TODO: this was copied from @inglorious/web, maybe expose it?
54
+ /**
55
+ * Creates a render function bound to the store API.
56
+ * This mimics the `api.render` behavior but for SSR context.
57
+ *
58
+ * @param {Object} api - The store API.
59
+ * @returns {Function} The render function.
60
+ */
35
61
  function createRender(api) {
36
62
  return function (id, options = {}) {
37
63
  const entity = api.getEntity(id)