@inglorious/ssx 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -4
- package/package.json +4 -1
- package/src/build/build.test.js +124 -4
- package/src/build/index.js +53 -22
- package/src/build/manifest.js +23 -15
- package/src/build/manifest.test.js +153 -0
- package/src/build/metadata.js +1 -1
- package/src/build/pages.js +16 -2
- package/src/build/pages.test.js +83 -0
- package/src/build/public.js +15 -0
- package/src/build/public.test.js +59 -0
- package/src/build/rss.js +30 -11
- package/src/build/rss.test.js +104 -0
- package/src/build/sitemap.js +15 -6
- package/src/build/sitemap.test.js +84 -0
- package/src/dev/index.js +18 -27
- package/src/dev/vite-config.js +20 -0
- package/src/dev/vite-config.test.js +46 -0
- package/src/render/html.js +27 -1
- package/src/render/index.js +11 -1
- package/src/render/layout.js +15 -0
- package/src/render/layout.test.js +58 -0
- package/src/render/render.test.js +87 -84
- package/src/router/index.js +118 -56
- package/src/router/router.test.js +33 -2
- package/src/scripts/app.js +6 -2
- package/src/scripts/app.test.js +46 -44
- package/src/store/index.js +44 -0
- package/src/store/store.test.js +74 -0
- package/src/{module.js → utils/module.js} +8 -0
- 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/module.test.js +0 -45
- package/src/page-options.js +0 -8
- package/src/store.js +0 -29
- package/src/store.test.js +0 -40
- /package/src/{config.js → utils/config.js} +0 -0
|
@@ -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
|
+
})
|
package/src/build/public.js
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
* @param {
|
|
8
|
-
* @param {
|
|
9
|
-
* @param {string} options.
|
|
10
|
-
* @param {string} options.
|
|
11
|
-
* @param {string} options.
|
|
12
|
-
* @param {string} options.
|
|
13
|
-
* @param {string} options.
|
|
14
|
-
* @param {string} options.
|
|
15
|
-
* @param {
|
|
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, "'")
|
|
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&A <Test>")
|
|
103
|
+
})
|
|
104
|
+
})
|
package/src/build/sitemap.js
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
* @param {Object}
|
|
8
|
-
* @param {
|
|
9
|
-
* @param {string} options.
|
|
10
|
-
* @param {
|
|
11
|
-
* @param {
|
|
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&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
|
|
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
|
-
}
|
package/src/dev/vite-config.js
CHANGED
|
@@ -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
|
+
})
|
package/src/render/html.js
CHANGED
|
@@ -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
|
|
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)
|