@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.
- package/README.md +140 -61
- package/bin/ssx.js +17 -21
- package/package.json +3 -5
- package/src/build/build.test.js +124 -0
- package/src/build/index.js +178 -0
- package/src/build/manifest.js +120 -0
- package/src/build/manifest.test.js +153 -0
- package/src/build/metadata.js +53 -0
- package/src/build/pages.js +52 -0
- package/src/build/pages.test.js +83 -0
- package/src/build/public.js +49 -0
- package/src/build/public.test.js +59 -0
- package/src/build/rss.js +121 -0
- package/src/build/rss.test.js +104 -0
- package/src/build/sitemap.js +66 -0
- package/src/build/sitemap.test.js +84 -0
- package/src/build/vite-config.js +51 -0
- package/src/dev/index.js +98 -0
- package/src/dev/vite-config.js +60 -0
- package/src/dev/vite-config.test.js +46 -0
- package/src/{html.js → render/html.js} +33 -23
- package/src/render/index.js +53 -0
- package/src/render/layout.js +52 -0
- package/src/render/layout.test.js +58 -0
- package/src/render/render.test.js +114 -0
- package/src/router/index.js +293 -0
- package/src/{router.test.js → router/router.test.js} +35 -4
- package/src/scripts/app.js +15 -5
- package/src/scripts/app.test.js +49 -30
- package/src/{store.js → store/index.js} +11 -1
- package/src/store/store.test.js +56 -0
- package/src/utils/config.js +16 -0
- package/src/{module.js → utils/module.js} +8 -3
- package/src/utils/module.test.js +64 -0
- package/src/utils/page-options.js +17 -0
- package/src/utils/page-options.test.js +57 -0
- package/src/build.js +0 -96
- package/src/build.test.js +0 -11
- package/src/dev.js +0 -111
- package/src/module.test.js +0 -45
- package/src/random.js +0 -30
- package/src/render.js +0 -48
- package/src/render.test.js +0 -72
- package/src/router.js +0 -231
- package/src/store.test.js +0 -40
- package/src/vite-config.js +0 -40
- /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
|
+
})
|
package/src/build/rss.js
ADDED
|
@@ -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, "&")
|
|
104
|
+
.replace(/</g, "<")
|
|
105
|
+
.replace(/>/g, ">")
|
|
106
|
+
.replace(/"/g, """)
|
|
107
|
+
.replace(/'/g, "'")
|
|
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&A <Test>")
|
|
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, "&")
|
|
62
|
+
.replace(/</g, "<")
|
|
63
|
+
.replace(/>/g, ">")
|
|
64
|
+
.replace(/"/g, """)
|
|
65
|
+
.replace(/'/g, "'")
|
|
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&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
|
+
}
|
package/src/dev/index.js
ADDED
|
@@ -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
|
+
})
|