@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
package/src/render/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createGetPageOption } from "../page-options.js"
|
|
1
|
+
import { createGetPageOption } from "../utils/page-options.js"
|
|
2
2
|
import { toHTML } from "./html.js"
|
|
3
3
|
|
|
4
4
|
const DEFAULT_OPTIONS = {
|
|
@@ -11,6 +11,16 @@ const DEFAULT_OPTIONS = {
|
|
|
11
11
|
scripts: [],
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Renders a specific page using the store and page options.
|
|
16
|
+
* It resolves page-specific metadata (title, meta, etc.) before rendering.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} store - The application store.
|
|
19
|
+
* @param {Object} page - The page object (from router).
|
|
20
|
+
* @param {Object} entity - The entity associated with the page.
|
|
21
|
+
* @param {Object} [options] - Global site options/defaults.
|
|
22
|
+
* @returns {Promise<string>} The rendered HTML.
|
|
23
|
+
*/
|
|
14
24
|
export async function renderPage(store, page, entity, options = {}) {
|
|
15
25
|
const { moduleName, module } = page
|
|
16
26
|
|
package/src/render/layout.js
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default layout function for wrapping content in a full HTML document.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} body - The body content HTML.
|
|
5
|
+
* @param {Object} options - Layout options.
|
|
6
|
+
* @param {string} [options.lang="en"] - Language attribute.
|
|
7
|
+
* @param {string} [options.charset="UTF-8"] - Character set.
|
|
8
|
+
* @param {string} [options.title=""] - Page title.
|
|
9
|
+
* @param {Object} [options.meta={}] - Meta tags.
|
|
10
|
+
* @param {string[]} [options.styles=[]] - Stylesheets.
|
|
11
|
+
* @param {string} [options.head=""] - Additional head content.
|
|
12
|
+
* @param {string[]} [options.scripts=[]] - Scripts.
|
|
13
|
+
* @param {boolean} [options.isDev] - Whether in dev mode.
|
|
14
|
+
* @returns {string} The full HTML document.
|
|
15
|
+
*/
|
|
1
16
|
export function layout(body, options) {
|
|
2
17
|
const {
|
|
3
18
|
lang = "en",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { layout } from "./layout"
|
|
4
|
+
|
|
5
|
+
describe("layout", () => {
|
|
6
|
+
it("should render default layout structure", () => {
|
|
7
|
+
const html = layout("<h1>Hello</h1>", {})
|
|
8
|
+
expect(html).toContain("<!DOCTYPE html>")
|
|
9
|
+
expect(html).toContain('<html lang="en">')
|
|
10
|
+
expect(html).toContain('<meta charset="UTF-8" />')
|
|
11
|
+
expect(html).toContain('<div id="root"><h1>Hello</h1></div>')
|
|
12
|
+
expect(html).toContain('<script type="module" src="/main.js"></script>')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("should render with custom title and language", () => {
|
|
16
|
+
const html = layout("", { title: "My Page", lang: "fr" })
|
|
17
|
+
expect(html).toContain("<title>My Page</title>")
|
|
18
|
+
expect(html).toContain('<html lang="fr">')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("should render meta tags", () => {
|
|
22
|
+
const html = layout("", {
|
|
23
|
+
meta: { description: "Desc", viewport: "width=device-width" },
|
|
24
|
+
})
|
|
25
|
+
expect(html).toContain('<meta name="description" content="Desc">')
|
|
26
|
+
expect(html).toContain(
|
|
27
|
+
'<meta name="viewport" content="width=device-width">',
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("should render styles", () => {
|
|
32
|
+
const html = layout("", { styles: ["/style.css", "/theme.css"] })
|
|
33
|
+
expect(html).toContain('<link rel="stylesheet" href="/style.css">')
|
|
34
|
+
expect(html).toContain('<link rel="stylesheet" href="/theme.css">')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("should render scripts", () => {
|
|
38
|
+
const html = layout("", { scripts: ["/app.js"] })
|
|
39
|
+
expect(html).toContain('<script type="module" src="/app.js"></script>')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("should render additional head content", () => {
|
|
43
|
+
const html = layout("", { head: '<link rel="icon" href="/favicon.ico">' })
|
|
44
|
+
expect(html).toContain('<link rel="icon" href="/favicon.ico">')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("should include vite client in dev mode", () => {
|
|
48
|
+
const html = layout("", { isDev: true })
|
|
49
|
+
expect(html).toContain(
|
|
50
|
+
'<script type="module" src="/@vite/client"></script>',
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("should not include vite client in prod mode", () => {
|
|
55
|
+
const html = layout("", { isDev: false })
|
|
56
|
+
expect(html).not.toContain("/@vite/client")
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -1,111 +1,114 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
+
import { pathToFileURL } from "node:url"
|
|
2
3
|
|
|
3
4
|
import { createStore } from "@inglorious/web"
|
|
4
|
-
import { expect, it } from "vitest"
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
5
6
|
|
|
6
7
|
import { renderPage } from "."
|
|
7
8
|
|
|
8
|
-
const ROOT_DIR = path.join(
|
|
9
|
+
const ROOT_DIR = path.join(import.meta.dirname, "..", "__fixtures__")
|
|
9
10
|
const PAGES_DIR = path.join(ROOT_DIR, "pages")
|
|
10
11
|
|
|
11
12
|
const DEFAULT_OPTIONS = { stripLitMarkers: true }
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
describe("renderPage", () => {
|
|
15
|
+
it("should render a static page fragment", async () => {
|
|
16
|
+
const module = await import(pathToFileURL(path.join(PAGES_DIR, "index.js")))
|
|
17
|
+
const page = { path: "/", moduleName: "index", module }
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const html = await renderPage(store, page, undefined, DEFAULT_OPTIONS)
|
|
23
|
-
|
|
24
|
-
expect(html).toMatchSnapshot()
|
|
25
|
-
})
|
|
19
|
+
const store = createStore({
|
|
20
|
+
types: { index: module.index },
|
|
21
|
+
updateMode: "manual",
|
|
22
|
+
})
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
|
|
29
|
-
const page = { path: "/about", moduleName: "about", module }
|
|
30
|
-
const entity = { type: "about", name: "Us" }
|
|
24
|
+
const html = await renderPage(store, page, undefined, DEFAULT_OPTIONS)
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
types: { about: module.about },
|
|
34
|
-
entities: { about: entity },
|
|
35
|
-
updateMode: "manual",
|
|
26
|
+
expect(html).toMatchSnapshot()
|
|
36
27
|
})
|
|
37
28
|
|
|
38
|
-
|
|
29
|
+
it("should render a page with entity", async () => {
|
|
30
|
+
const module = await import(pathToFileURL(path.join(PAGES_DIR, "about.js")))
|
|
31
|
+
const page = { path: "/about", moduleName: "about", module }
|
|
32
|
+
const entity = { type: "about", name: "Us" }
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
}
|
|
34
|
+
const store = createStore({
|
|
35
|
+
types: { about: module.about },
|
|
36
|
+
entities: { about: entity },
|
|
37
|
+
updateMode: "manual",
|
|
38
|
+
})
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
|
|
45
|
-
const page = { path: "/about", moduleName: "about", module }
|
|
46
|
-
const entity = { type: "about", name: "Us" }
|
|
40
|
+
const html = await renderPage(store, page, entity, DEFAULT_OPTIONS)
|
|
47
41
|
|
|
48
|
-
|
|
49
|
-
types: { about: module.about },
|
|
50
|
-
entities: { about: entity },
|
|
51
|
-
updateMode: "manual",
|
|
42
|
+
expect(html).toMatchSnapshot()
|
|
52
43
|
})
|
|
53
44
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
45
|
+
it("should render a page with metadata", async () => {
|
|
46
|
+
const module = await import(pathToFileURL(path.join(PAGES_DIR, "about.js")))
|
|
47
|
+
const page = { path: "/about", moduleName: "about", module }
|
|
48
|
+
const entity = { type: "about", name: "Us" }
|
|
58
49
|
|
|
59
|
-
|
|
60
|
-
}
|
|
50
|
+
const store = createStore({
|
|
51
|
+
types: { about: module.about },
|
|
52
|
+
entities: { about: entity },
|
|
53
|
+
updateMode: "manual",
|
|
54
|
+
})
|
|
61
55
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
type: "blog",
|
|
67
|
-
name: "Antony",
|
|
68
|
-
posts: [
|
|
69
|
-
{ id: 1, title: "First Post" },
|
|
70
|
-
{ id: 2, title: "Second Post" },
|
|
71
|
-
{ id: 3, title: "Third Post" },
|
|
72
|
-
],
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const store = createStore({
|
|
76
|
-
types: { blog: module.blog },
|
|
77
|
-
entities: { blog: entity },
|
|
78
|
-
updateMode: "manual",
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
|
|
82
|
-
|
|
83
|
-
expect(html).toMatchSnapshot()
|
|
84
|
-
})
|
|
56
|
+
const html = await renderPage(store, page, module, {
|
|
57
|
+
...DEFAULT_OPTIONS,
|
|
58
|
+
wrap: true,
|
|
59
|
+
})
|
|
85
60
|
|
|
86
|
-
|
|
87
|
-
const module = await import(
|
|
88
|
-
path.resolve(path.join(PAGES_DIR, "posts/_slug.js"))
|
|
89
|
-
)
|
|
90
|
-
const page = { path: "/posts/1", moduleName: "post", module }
|
|
91
|
-
const entity = {
|
|
92
|
-
type: "blog",
|
|
93
|
-
name: "Antony",
|
|
94
|
-
post: {
|
|
95
|
-
id: 1,
|
|
96
|
-
title: "First Post",
|
|
97
|
-
date: "2026-01-04",
|
|
98
|
-
body: "Hello world!",
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const store = createStore({
|
|
103
|
-
types: { blog: module.post },
|
|
104
|
-
entities: { post: entity },
|
|
105
|
-
updateMode: "manual",
|
|
61
|
+
expect(html).toMatchSnapshot()
|
|
106
62
|
})
|
|
107
63
|
|
|
108
|
-
|
|
64
|
+
it("should render a page with pre-fetched data", async () => {
|
|
65
|
+
const module = await import(pathToFileURL(path.join(PAGES_DIR, "blog.js")))
|
|
66
|
+
const page = { path: "/blog", moduleName: "blog", module }
|
|
67
|
+
const entity = {
|
|
68
|
+
type: "blog",
|
|
69
|
+
name: "Antony",
|
|
70
|
+
posts: [
|
|
71
|
+
{ id: 1, title: "First Post" },
|
|
72
|
+
{ id: 2, title: "Second Post" },
|
|
73
|
+
{ id: 3, title: "Third Post" },
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const store = createStore({
|
|
78
|
+
types: { blog: module.blog },
|
|
79
|
+
entities: { blog: entity },
|
|
80
|
+
updateMode: "manual",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
|
|
84
|
+
|
|
85
|
+
expect(html).toMatchSnapshot()
|
|
86
|
+
})
|
|
109
87
|
|
|
110
|
-
|
|
88
|
+
it("should render a dynamic page", async () => {
|
|
89
|
+
const module = await import(
|
|
90
|
+
pathToFileURL(path.join(PAGES_DIR, "posts", "_slug.js"))
|
|
91
|
+
)
|
|
92
|
+
const page = { path: "/posts/1", moduleName: "post", module }
|
|
93
|
+
const entity = {
|
|
94
|
+
type: "blog",
|
|
95
|
+
name: "Antony",
|
|
96
|
+
post: {
|
|
97
|
+
id: 1,
|
|
98
|
+
title: "First Post",
|
|
99
|
+
date: "2026-01-04",
|
|
100
|
+
body: "Hello world!",
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const store = createStore({
|
|
105
|
+
types: { blog: module.post },
|
|
106
|
+
entities: { post: entity },
|
|
107
|
+
updateMode: "manual",
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
|
|
111
|
+
|
|
112
|
+
expect(html).toMatchSnapshot()
|
|
113
|
+
})
|
|
111
114
|
})
|
package/src/router/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"
|
|
|
3
3
|
|
|
4
4
|
import { glob } from "glob"
|
|
5
5
|
|
|
6
|
-
import { getModuleName } from "../module.js"
|
|
6
|
+
import { getModuleName } from "../utils/module.js"
|
|
7
7
|
|
|
8
8
|
const NEXT_MATCH = 1
|
|
9
9
|
|
|
@@ -12,55 +12,69 @@ const CATCH_ALL_ROUTE_WEIGHT = -10
|
|
|
12
12
|
const SCORE_MULTIPLIER = 0.1
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* Scans the pages directory and returns a list of all pages to be built.
|
|
16
|
+
* For dynamic routes, it calls the `staticPaths` export of the page module
|
|
17
|
+
* to generate all possible paths.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} pagesDir - The directory containing page files.
|
|
20
|
+
* @param {Function} [loader] - Optional loader function (e.g. vite.ssrLoadModule).
|
|
21
|
+
* @returns {Promise<Array<Object>>} A list of page objects with metadata.
|
|
16
22
|
*/
|
|
17
|
-
export async function getPages(pagesDir = "pages") {
|
|
23
|
+
export async function getPages(pagesDir = "pages", loader) {
|
|
18
24
|
const routes = await getRoutes(pagesDir)
|
|
19
25
|
const pages = []
|
|
26
|
+
const load = loader || ((p) => import(pathToFileURL(path.resolve(p))))
|
|
20
27
|
|
|
21
28
|
for (const route of routes) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
staticPaths
|
|
29
|
-
|
|
29
|
+
try {
|
|
30
|
+
const module = await load(route.filePath)
|
|
31
|
+
const moduleName = getModuleName(module)
|
|
32
|
+
|
|
33
|
+
if (isDynamic(route.pattern)) {
|
|
34
|
+
let { staticPaths = [] } = module
|
|
35
|
+
if (typeof staticPaths === "function") {
|
|
36
|
+
staticPaths = await staticPaths()
|
|
37
|
+
}
|
|
30
38
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
if (staticPaths.length) {
|
|
40
|
+
for (const pathOrObject of staticPaths) {
|
|
41
|
+
const path =
|
|
42
|
+
typeof pathOrObject === "string"
|
|
43
|
+
? pathOrObject
|
|
44
|
+
: pathOrObject.path
|
|
45
|
+
|
|
46
|
+
const params = extractParams(route, path)
|
|
47
|
+
|
|
48
|
+
pages.push({
|
|
49
|
+
pattern: route.pattern,
|
|
50
|
+
path,
|
|
51
|
+
params,
|
|
52
|
+
moduleName,
|
|
53
|
+
modulePath: route.modulePath,
|
|
54
|
+
filePath: route.filePath,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.warn(
|
|
59
|
+
`Dynamic route ${route.filePath} has no staticPaths export. ` +
|
|
60
|
+
`It will be skipped during SSG.`,
|
|
61
|
+
)
|
|
46
62
|
}
|
|
47
63
|
} else {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
// Static route - add directly
|
|
65
|
+
pages.push({
|
|
66
|
+
pattern: route.pattern,
|
|
67
|
+
path: route.pattern || "/",
|
|
68
|
+
params: {},
|
|
69
|
+
module,
|
|
70
|
+
moduleName,
|
|
71
|
+
modulePath: route.modulePath,
|
|
72
|
+
filePath: route.filePath,
|
|
73
|
+
})
|
|
52
74
|
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pattern: route.pattern,
|
|
57
|
-
path: route.pattern || "/",
|
|
58
|
-
params: {},
|
|
59
|
-
module,
|
|
60
|
-
moduleName,
|
|
61
|
-
modulePath: route.modulePath,
|
|
62
|
-
filePath: route.filePath,
|
|
63
|
-
})
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`\n❌ Failed to load page: ${route.filePath}`)
|
|
77
|
+
throw error
|
|
64
78
|
}
|
|
65
79
|
}
|
|
66
80
|
|
|
@@ -68,8 +82,12 @@ export async function getPages(pagesDir = "pages") {
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
85
|
+
* Resolves a URL to a specific page file and extracts route parameters.
|
|
86
|
+
* This is primarily used by the development server for on-demand rendering.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} url - The URL to resolve (e.g., "/posts/hello").
|
|
89
|
+
* @param {string} pagesDir - The directory containing page files.
|
|
90
|
+
* @returns {Promise<{filePath: string, params: Object}|null>} The resolved page info or null if not found.
|
|
73
91
|
*/
|
|
74
92
|
export async function resolvePage(url, pagesDir = "pages") {
|
|
75
93
|
const routes = await getRoutes(pagesDir)
|
|
@@ -98,12 +116,15 @@ export async function resolvePage(url, pagesDir = "pages") {
|
|
|
98
116
|
}
|
|
99
117
|
|
|
100
118
|
/**
|
|
101
|
-
* Discovers all
|
|
102
|
-
*
|
|
119
|
+
* Discovers all page files and converts them into route definitions.
|
|
120
|
+
* Routes are sorted by specificity so that more specific routes match first.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} pagesDir - The directory containing page files.
|
|
123
|
+
* @returns {Promise<Array<Object>>} A list of route objects.
|
|
103
124
|
*/
|
|
104
125
|
export async function getRoutes(pagesDir = "pages") {
|
|
105
126
|
// Find all .js and .ts files in pages directory
|
|
106
|
-
const files = await glob("**/*.{js,ts}", {
|
|
127
|
+
const files = await glob("**/*.{js,ts,jsx,tsx}", {
|
|
107
128
|
cwd: pagesDir,
|
|
108
129
|
ignore: ["**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
|
|
109
130
|
posix: true,
|
|
@@ -134,16 +155,44 @@ export async function getRoutes(pagesDir = "pages") {
|
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
158
|
+
* Simple route matcher.
|
|
159
|
+
* Checks if a URL matches a route pattern (handling dynamic segments).
|
|
160
|
+
*
|
|
161
|
+
* @param {string} pattern - The route pattern (e.g. "/posts/:id").
|
|
162
|
+
* @param {string} url - The actual URL (e.g. "/posts/123").
|
|
163
|
+
* @returns {boolean} True if it matches.
|
|
164
|
+
*/
|
|
165
|
+
export function matchRoute(pattern, url) {
|
|
166
|
+
const patternParts = pattern.split("/").filter(Boolean)
|
|
167
|
+
const urlParts = url.split("/").filter(Boolean)
|
|
168
|
+
|
|
169
|
+
if (patternParts.length !== urlParts.length) {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return patternParts.every((part, i) => {
|
|
174
|
+
if (part.startsWith(":") || part.startsWith("[")) {
|
|
175
|
+
return true
|
|
176
|
+
}
|
|
177
|
+
return part === urlParts[i]
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Converts a file path to a route pattern.
|
|
183
|
+
* Examples:
|
|
184
|
+
* - pages/index.js -> /
|
|
185
|
+
* - pages/about.js -> /about
|
|
186
|
+
* - pages/blog/_slug.js -> /blog/:slug
|
|
187
|
+
* - pages/api/__path.js -> /api/*
|
|
188
|
+
*
|
|
189
|
+
* @param {string} file - The relative file path.
|
|
190
|
+
* @returns {string} The route pattern.
|
|
142
191
|
*/
|
|
143
192
|
function filePathToPattern(file) {
|
|
144
193
|
let pattern = file
|
|
145
194
|
.replace(/\\/g, "/")
|
|
146
|
-
.replace(/\.(js|ts)$/, "") // Remove extension
|
|
195
|
+
.replace(/\.(js|ts|jsx|tsx)$/, "") // Remove extension
|
|
147
196
|
.replace(/\/index$/, "") // index becomes root of directory
|
|
148
197
|
.replace(/^index$/, "") // Handle root index
|
|
149
198
|
.replace(/__(\w+)/g, "*") // __path becomes *
|
|
@@ -154,7 +203,10 @@ function filePathToPattern(file) {
|
|
|
154
203
|
}
|
|
155
204
|
|
|
156
205
|
/**
|
|
157
|
-
*
|
|
206
|
+
* Converts a route pattern to a regex and extracts parameter names.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} pattern - The route pattern.
|
|
209
|
+
* @returns {{regex: RegExp, params: string[]}} The regex and parameter names.
|
|
158
210
|
*/
|
|
159
211
|
function patternToRegex(pattern) {
|
|
160
212
|
const params = []
|
|
@@ -181,8 +233,11 @@ function patternToRegex(pattern) {
|
|
|
181
233
|
}
|
|
182
234
|
|
|
183
235
|
/**
|
|
184
|
-
*
|
|
236
|
+
* Calculates route specificity for sorting.
|
|
185
237
|
* Higher score = more specific = should match first.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} pattern - The route pattern.
|
|
240
|
+
* @returns {number} The specificity score.
|
|
186
241
|
*/
|
|
187
242
|
function routeSpecificity(pattern) {
|
|
188
243
|
let score = 0
|
|
@@ -211,14 +266,21 @@ function routeSpecificity(pattern) {
|
|
|
211
266
|
}
|
|
212
267
|
|
|
213
268
|
/**
|
|
214
|
-
*
|
|
269
|
+
* Checks if a pattern is dynamic (contains params or wildcards).
|
|
270
|
+
*
|
|
271
|
+
* @param {string} pattern - The route pattern.
|
|
272
|
+
* @returns {boolean} True if dynamic.
|
|
215
273
|
*/
|
|
216
274
|
function isDynamic(pattern) {
|
|
217
275
|
return pattern.includes(":") || pattern.includes("*")
|
|
218
276
|
}
|
|
219
277
|
|
|
220
278
|
/**
|
|
221
|
-
*
|
|
279
|
+
* Extracts params from a URL based on a route.
|
|
280
|
+
*
|
|
281
|
+
* @param {Object} route - The route object.
|
|
282
|
+
* @param {string} url - The URL to match.
|
|
283
|
+
* @returns {Object} The extracted parameters.
|
|
222
284
|
*/
|
|
223
285
|
function extractParams(route, url) {
|
|
224
286
|
const match = route.regex.exec(url)
|
|
@@ -2,9 +2,9 @@ import path from "node:path"
|
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
4
|
|
|
5
|
-
import { getPages, getRoutes, resolvePage } from "./index.js"
|
|
5
|
+
import { getPages, getRoutes, matchRoute, resolvePage } from "./index.js"
|
|
6
6
|
|
|
7
|
-
const ROOT_DIR = path.join(
|
|
7
|
+
const ROOT_DIR = path.join(import.meta.dirname, "..", "__fixtures__")
|
|
8
8
|
const PAGES_DIR = path.join(ROOT_DIR, "pages")
|
|
9
9
|
|
|
10
10
|
describe("router", () => {
|
|
@@ -84,6 +84,12 @@ describe("router", () => {
|
|
|
84
84
|
const page = await resolvePage("/foo", PAGES_DIR)
|
|
85
85
|
expect(page).toBeNull()
|
|
86
86
|
})
|
|
87
|
+
|
|
88
|
+
it("should return null for dynamic route missing param", async () => {
|
|
89
|
+
// /posts/:slug requires a slug
|
|
90
|
+
const page = await resolvePage("/posts", PAGES_DIR)
|
|
91
|
+
expect(page).toBeNull()
|
|
92
|
+
})
|
|
87
93
|
})
|
|
88
94
|
|
|
89
95
|
describe("getPages", () => {
|
|
@@ -101,4 +107,29 @@ describe("router", () => {
|
|
|
101
107
|
expect(consoleSpy.mock.calls[1][0]).toContain("has no staticPaths")
|
|
102
108
|
})
|
|
103
109
|
})
|
|
110
|
+
|
|
111
|
+
describe("matchRoute", () => {
|
|
112
|
+
it("should match static routes", () => {
|
|
113
|
+
expect(matchRoute("/", "/")).toBe(true)
|
|
114
|
+
expect(matchRoute("/about", "/about")).toBe(true)
|
|
115
|
+
expect(matchRoute("/about", "/contact")).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("should match dynamic routes", () => {
|
|
119
|
+
expect(matchRoute("/posts/:id", "/posts/123")).toBe(true)
|
|
120
|
+
expect(matchRoute("/users/[id]", "/users/antony")).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("should not match if segment length differs", () => {
|
|
124
|
+
expect(matchRoute("/", "/about")).toBe(false)
|
|
125
|
+
expect(matchRoute("/posts/:id", "/posts/123/comments")).toBe(false)
|
|
126
|
+
expect(matchRoute("/posts/:id", "/posts")).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("should handle trailing slashes implicitly via split", () => {
|
|
130
|
+
// split('/').filter(Boolean) removes empty strings, so trailing slashes are ignored
|
|
131
|
+
expect(matchRoute("/about/", "/about")).toBe(true)
|
|
132
|
+
expect(matchRoute("/about", "/about/")).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
104
135
|
})
|
package/src/scripts/app.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* This
|
|
2
|
+
* Generates the client-side entry point script.
|
|
3
|
+
* This script hydrates the store with the initial state (entities) and sets up the router.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} store - The server-side store instance containing the initial state.
|
|
6
|
+
* @param {Array<Object>} pages - List of page objects to generate routes for.
|
|
7
|
+
* @returns {string} The generated JavaScript code for the client entry point.
|
|
4
8
|
*/
|
|
5
9
|
export function generateApp(store, pages) {
|
|
6
10
|
// Collect all unique page modules and their exports
|