@inglorious/ssx 1.0.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.
Files changed (47) hide show
  1. package/README.md +140 -61
  2. package/bin/ssx.js +17 -21
  3. package/package.json +3 -5
  4. package/src/build/build.test.js +124 -0
  5. package/src/build/index.js +178 -0
  6. package/src/build/manifest.js +120 -0
  7. package/src/build/manifest.test.js +153 -0
  8. package/src/build/metadata.js +53 -0
  9. package/src/build/pages.js +52 -0
  10. package/src/build/pages.test.js +83 -0
  11. package/src/build/public.js +49 -0
  12. package/src/build/public.test.js +59 -0
  13. package/src/build/rss.js +121 -0
  14. package/src/build/rss.test.js +104 -0
  15. package/src/build/sitemap.js +66 -0
  16. package/src/build/sitemap.test.js +84 -0
  17. package/src/build/vite-config.js +51 -0
  18. package/src/dev/index.js +98 -0
  19. package/src/dev/vite-config.js +60 -0
  20. package/src/dev/vite-config.test.js +46 -0
  21. package/src/{html.js → render/html.js} +33 -23
  22. package/src/render/index.js +53 -0
  23. package/src/render/layout.js +52 -0
  24. package/src/render/layout.test.js +58 -0
  25. package/src/render/render.test.js +114 -0
  26. package/src/router/index.js +293 -0
  27. package/src/{router.test.js → router/router.test.js} +35 -4
  28. package/src/scripts/app.js +15 -5
  29. package/src/scripts/app.test.js +49 -30
  30. package/src/{store.js → store/index.js} +11 -1
  31. package/src/store/store.test.js +56 -0
  32. package/src/utils/config.js +16 -0
  33. package/src/{module.js → utils/module.js} +8 -3
  34. package/src/utils/module.test.js +64 -0
  35. package/src/utils/page-options.js +17 -0
  36. package/src/utils/page-options.test.js +57 -0
  37. package/src/build.js +0 -96
  38. package/src/build.test.js +0 -11
  39. package/src/dev.js +0 -111
  40. package/src/module.test.js +0 -45
  41. package/src/random.js +0 -30
  42. package/src/render.js +0 -48
  43. package/src/render.test.js +0 -72
  44. package/src/router.js +0 -231
  45. package/src/store.test.js +0 -40
  46. package/src/vite-config.js +0 -40
  47. /package/src/{html.test.js → render/html.test.js} +0 -0
@@ -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
+ })
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ /**
5
+ * Generates an RSS feed for the site.
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.
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function generateRSS(pages = [], options = {}) {
21
+ const {
22
+ outDir = "dist",
23
+ title = "RSS Feed",
24
+ description = "Latest Posts",
25
+ link = "",
26
+ feedPath = "/feed.xml",
27
+ language = "en",
28
+ copyright = "",
29
+ maxItems = 50,
30
+ filter = () => true,
31
+ } = options
32
+
33
+ if (!link) {
34
+ console.warn("⚠️ No link provided for RSS feed, skipping...")
35
+ return
36
+ }
37
+
38
+ const items = pages
39
+ .filter(filter)
40
+ .map(({ metadata }) => metadata)
41
+ .sort(byNewest)
42
+ .slice(0, maxItems)
43
+
44
+ const rssItems = items.map(createRenderItem(link))
45
+
46
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
47
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
48
+ <channel>
49
+ <title>${escapeXml(title)}</title>
50
+ <link>${escapeXml(link)}</link>
51
+ <description>${escapeXml(description)}</description>
52
+ <language>${language}</language>
53
+ ${copyright ? `<copyright>${escapeXml(copyright)}</copyright>` : ""}
54
+ <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
55
+ <atom:link href="${escapeXml(link + feedPath)}" rel="self" type="application/rss+xml" />
56
+ ${rssItems.join("\n")}
57
+ </channel>
58
+ </rss>`
59
+
60
+ const feedFilePath = path.join(outDir, feedPath.replace(/^\//, ""))
61
+ await fs.mkdir(path.dirname(feedFilePath), { recursive: true })
62
+ await fs.writeFile(feedFilePath, xml, "utf-8")
63
+
64
+ console.log(` ✓ ${feedPath} (${items.length} items)\n`)
65
+ }
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
+ */
73
+ function createRenderItem(link) {
74
+ return (metadata) => {
75
+ const pubDate =
76
+ metadata.pubDate || metadata.date
77
+ ? new Date(metadata.pubDate || metadata.date).toUTCString()
78
+ : new Date().toUTCString()
79
+
80
+ const guid = metadata.guid || `${link}${metadata.path}`
81
+
82
+ return ` <item>
83
+ <title>${escapeXml(metadata.title)}</title>
84
+ <link>${escapeXml(link + metadata.path)}</link>
85
+ <guid>${escapeXml(guid)}</guid>
86
+ <pubDate>${pubDate}</pubDate>
87
+ ${metadata.description ? `<description>${escapeXml(metadata.description)}</description>` : ""}
88
+ ${metadata.author ? `<author>${escapeXml(metadata.author)}</author>` : ""}
89
+ ${metadata.category ? `<category>${escapeXml(metadata.category)}</category>` : ""}
90
+ </item>`
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Escape special XML characters.
96
+ *
97
+ * @param {string} str - The string to escape.
98
+ * @returns {string} The escaped string.
99
+ */
100
+ function escapeXml(str) {
101
+ if (typeof str !== "string") return str
102
+ return str
103
+ .replace(/&/g, "&amp;")
104
+ .replace(/</g, "&lt;")
105
+ .replace(/>/g, "&gt;")
106
+ .replace(/"/g, "&quot;")
107
+ .replace(/'/g, "&apos;")
108
+ }
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
+ */
117
+ function byNewest(a, b) {
118
+ const dateA = new Date(a.pubDate || a.date || 0)
119
+ const dateB = new Date(b.pubDate || b.date || 0)
120
+ return dateB - dateA
121
+ }
@@ -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
+ })
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ /**
5
+ * Generates a sitemap.xml file for the built site.
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
+ * @returns {Promise<void>}
13
+ */
14
+ export async function generateSitemap(pages = [], options = {}) {
15
+ const { outDir = "dist", hostname = "", filter = () => true } = options
16
+
17
+ if (!hostname) {
18
+ console.warn("⚠️ No hostname provided for sitemap, skipping...")
19
+ return
20
+ }
21
+
22
+ const items = pages.filter(filter).map(({ metadata }) => metadata)
23
+
24
+ // Build XML
25
+ const urls = items.map(renderItem)
26
+
27
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
28
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
29
+ ${urls.join("\n")}
30
+ </urlset>`
31
+
32
+ const sitemapPath = path.join(outDir, "sitemap.xml")
33
+ await fs.writeFile(sitemapPath, xml, "utf-8")
34
+
35
+ console.log(` ✓ sitemap.xml (${items.length} pages)\n`)
36
+ }
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
+ */
44
+ function renderItem(metadata) {
45
+ return ` <url>
46
+ <loc>${escapeXml(metadata.loc)}</loc>
47
+ <lastmod>${metadata.lastmod}</lastmod>
48
+ <changefreq>${metadata.changefreq}</changefreq>
49
+ <priority>${metadata.priority}</priority>
50
+ </url>`
51
+ }
52
+
53
+ /**
54
+ * Escape special XML characters
55
+ *
56
+ * @param {string} str - The string to escape.
57
+ * @returns {string} The escaped string.
58
+ */
59
+ function escapeXml(str) {
60
+ return str
61
+ .replace(/&/g, "&amp;")
62
+ .replace(/</g, "&lt;")
63
+ .replace(/>/g, "&gt;")
64
+ .replace(/"/g, "&quot;")
65
+ .replace(/'/g, "&apos;")
66
+ }
@@ -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
+ })
@@ -0,0 +1,51 @@
1
+ import path from "node:path"
2
+
3
+ import { mergeConfig } from "vite"
4
+
5
+ // import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
6
+
7
+ /**
8
+ * Generate Vite config for building the client bundle
9
+ */
10
+ export function createViteConfig(options = {}) {
11
+ const {
12
+ rootDir = "src",
13
+ outDir = "dist",
14
+ publicDir = "public",
15
+ vite = {},
16
+ } = options
17
+
18
+ return mergeConfig(
19
+ {
20
+ root: rootDir,
21
+ publicDir: path.resolve(process.cwd(), rootDir, publicDir),
22
+ // plugins: [minifyTemplateLiterals()], // TODO: minification breaks hydration. The footprint difference is minimal after all
23
+ build: {
24
+ outDir,
25
+ emptyOutDir: false, // Don't delete HTML files we already generated
26
+ rollupOptions: {
27
+ input: {
28
+ main: path.resolve(outDir, "main.js"),
29
+ },
30
+ output: {
31
+ entryFileNames: "[name].js",
32
+ chunkFileNames: "[name].[hash].js",
33
+ assetFileNames: "[name].[ext]",
34
+
35
+ manualChunks(id) {
36
+ if (id.includes("node_modules")) {
37
+ return "lib"
38
+ }
39
+ },
40
+ },
41
+ },
42
+ },
43
+ resolve: {
44
+ alias: {
45
+ "@": path.resolve(process.cwd(), rootDir),
46
+ },
47
+ },
48
+ },
49
+ vite,
50
+ )
51
+ }
@@ -0,0 +1,98 @@
1
+ import path from "node:path"
2
+
3
+ import connect from "connect"
4
+ import { createServer } from "vite"
5
+
6
+ import { renderPage } from "../render/index.js"
7
+ import { getPages, matchRoute } 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"
11
+ import { createViteConfig, virtualFiles } from "./vite-config.js"
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
+ */
20
+ export async function dev(options = {}) {
21
+ const config = await loadConfig(options)
22
+
23
+ const mergedOptions = { ...config, ...options }
24
+ const { rootDir = "src" } = mergedOptions
25
+
26
+ console.log("🚀 Starting dev server...\n")
27
+
28
+ // Get all pages once at startup
29
+ const pages = await getPages(path.join(rootDir, "pages"))
30
+ console.log(`📄 Found ${pages.length} pages\n`)
31
+
32
+ // Generate store config once for all pages
33
+ const store = await generateStore(pages, mergedOptions)
34
+
35
+ // Create Vite dev server
36
+ const viteConfig = createViteConfig(mergedOptions)
37
+ const viteServer = await createServer(viteConfig)
38
+
39
+ // Use Vite's middleware first (handles HMR, static files, etc.)
40
+ const connectServer = connect()
41
+
42
+ connectServer.use(viteServer.middlewares)
43
+
44
+ // Add SSR middleware
45
+ connectServer.use(async (req, res, next) => {
46
+ const [url] = req.url.split("?")
47
+
48
+ try {
49
+ // Skip special routes, static files, AND public assets
50
+ if (
51
+ url.startsWith("/@") ||
52
+ url.includes(".") || // Vite handles static files
53
+ url === "/favicon.ico"
54
+ ) {
55
+ return next() // Let Vite serve it
56
+ }
57
+
58
+ // Find matching page
59
+ const page = pages.find((p) => matchRoute(p.path, url))
60
+ if (!page) return next()
61
+
62
+ const module = await viteServer.ssrLoadModule(page.filePath)
63
+ page.module = module
64
+
65
+ const entity = store._api.getEntity(page.moduleName)
66
+ if (module.load) {
67
+ await module.load(entity, page)
68
+ }
69
+ const html = await renderPage(store, page, entity, {
70
+ ...mergedOptions,
71
+ wrap: true,
72
+ isDev: true,
73
+ })
74
+
75
+ const app = generateApp(store, pages)
76
+ virtualFiles.set("/main.js", app)
77
+
78
+ res.setHeader("Content-Type", "text/html")
79
+ res.end(html)
80
+ } catch (error) {
81
+ viteServer.ssrFixStacktrace(error)
82
+ next(error) // Let Vite handle the error overlay
83
+ }
84
+ })
85
+
86
+ const { port = 3000 } = viteConfig.server ?? {}
87
+ const server = connectServer.listen(port)
88
+
89
+ console.log(`\n✨ Dev server running at http://localhost:${port}\n`)
90
+ console.log("Press Ctrl+C to stop\n")
91
+
92
+ return {
93
+ close: () => {
94
+ server.close()
95
+ viteServer.close()
96
+ },
97
+ }
98
+ }
@@ -0,0 +1,60 @@
1
+ import path from "node:path"
2
+
3
+ import { mergeConfig } from "vite"
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
+ */
15
+ export function createViteConfig(options = {}) {
16
+ const { rootDir = "src", publicDir = "public", vite = {} } = options
17
+ const { port = 3000 } = vite.dev ?? {}
18
+
19
+ return mergeConfig(
20
+ {
21
+ root: process.cwd(),
22
+ publicDir: path.resolve(process.cwd(), rootDir, publicDir),
23
+ server: { port, middlewareMode: true },
24
+ appType: "custom",
25
+ plugins: [virtualPlugin()],
26
+ resolve: {
27
+ alias: {
28
+ "@": path.resolve(process.cwd(), rootDir),
29
+ },
30
+ },
31
+ },
32
+ vite,
33
+ )
34
+ }
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
+ */
41
+ export const virtualFiles = new Map()
42
+
43
+ /**
44
+ * A Vite plugin to serve virtual files from the `virtualFiles` map.
45
+ *
46
+ * @returns {Object} The Vite plugin object.
47
+ */
48
+ function virtualPlugin() {
49
+ return {
50
+ name: "ssx-virtual-files",
51
+
52
+ resolveId(id) {
53
+ if (virtualFiles.has(id)) return id
54
+ },
55
+
56
+ load(id) {
57
+ if (virtualFiles.has(id)) return virtualFiles.get(id)
58
+ },
59
+ }
60
+ }
@@ -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
+ })