@inglorious/ssx 1.1.1 β 1.3.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 +39 -5
- package/package.json +4 -1
- package/src/build/build.test.js +9 -2
- package/src/build/index.js +18 -4
- package/src/build/pages.js +4 -2
- package/src/build/vite-config.js +7 -1
- package/src/dev/index.js +8 -7
- package/src/router/index.js +6 -4
- package/src/store/index.js +14 -9
- package/src/store/store.test.js +20 -2
package/README.md
CHANGED
|
@@ -30,7 +30,8 @@ SSX takes your entity-based web apps and generates optimized static HTML with fu
|
|
|
30
30
|
- **Hot reload dev server** - See changes instantly
|
|
31
31
|
- **Lazy-loaded routes** - Code splitting automatically
|
|
32
32
|
- **lit-html hydration** - Interactive UI without the bloat
|
|
33
|
-
- **TypeScript
|
|
33
|
+
- **TypeScript Ready** - Write your pages and entities in TypeScript.
|
|
34
|
+
- **Image Optimization** - Automatic compression for static assets.
|
|
34
35
|
|
|
35
36
|
### π Production Ready
|
|
36
37
|
|
|
@@ -59,6 +60,32 @@ npm run dev
|
|
|
59
60
|
|
|
60
61
|
Or manually: -->
|
|
61
62
|
|
|
63
|
+
### Create Your First Site (TypeScript)
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
// src/pages/index.ts
|
|
67
|
+
import { html } from "@inglorious/web"
|
|
68
|
+
|
|
69
|
+
// You can import API for type safety, though it's optional
|
|
70
|
+
// import type { API } from "@inglorious/web"
|
|
71
|
+
|
|
72
|
+
export const index = {
|
|
73
|
+
render(/* entity: any, api: API */) {
|
|
74
|
+
return html`
|
|
75
|
+
<div>
|
|
76
|
+
<h1>Welcome to SSX!</h1>
|
|
77
|
+
<p>This page was pre-rendered at build time.</p>
|
|
78
|
+
<nav>
|
|
79
|
+
<a href="/about">About</a>
|
|
80
|
+
</nav>
|
|
81
|
+
</div>
|
|
82
|
+
`
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Create Your First Site (JavaScript)
|
|
88
|
+
|
|
62
89
|
```javascript
|
|
63
90
|
// src/pages/index.js
|
|
64
91
|
import { html } from "@inglorious/web"
|
|
@@ -119,7 +146,7 @@ Deploy `dist/` to:
|
|
|
119
146
|
|
|
120
147
|
## Features
|
|
121
148
|
|
|
122
|
-
###
|
|
149
|
+
### πΊοΈ Sitemap & RSS Generation
|
|
123
150
|
|
|
124
151
|
SSX automatically generates `sitemap.xml` and `rss.xml` based on your pages. Configure them in `src/site.config.js`:
|
|
125
152
|
|
|
@@ -159,7 +186,7 @@ export default {
|
|
|
159
186
|
|
|
160
187
|
Pages with a `published` date in metadata are included in RSS feeds.
|
|
161
188
|
|
|
162
|
-
###
|
|
189
|
+
### π File-Based Routing
|
|
163
190
|
|
|
164
191
|
Your file structure defines your routes:
|
|
165
192
|
|
|
@@ -363,6 +390,13 @@ api.notify("navigate", {
|
|
|
363
390
|
|
|
364
391
|
Routes are lazy-loaded on demand, keeping initial bundle size small.
|
|
365
392
|
|
|
393
|
+
### πΌοΈ Image Optimization
|
|
394
|
+
|
|
395
|
+
SSX includes built-in image optimization using `vite-plugin-image-optimizer`.
|
|
396
|
+
|
|
397
|
+
- **Automatic compression** - PNG, JPEG, GIF, SVG, WebP, and AVIF are compressed at build time.
|
|
398
|
+
- **Lossless & Lossy** - Configurable settings via `vite` config in `site.config.js`.
|
|
399
|
+
|
|
366
400
|
---
|
|
367
401
|
|
|
368
402
|
## CLI
|
|
@@ -609,8 +643,8 @@ Check out these example projects:
|
|
|
609
643
|
|
|
610
644
|
## Roadmap
|
|
611
645
|
|
|
612
|
-
- [
|
|
613
|
-
- [
|
|
646
|
+
- [x] TypeScript support
|
|
647
|
+
- [x] Image optimization
|
|
614
648
|
- [ ] API routes (serverless functions)
|
|
615
649
|
- [ ] MDX support
|
|
616
650
|
- [ ] i18n helpers
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ssx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Server-Side-X. Xecution? Xperience? Who knows.",
|
|
5
5
|
"author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"prettier": "^3.6.2",
|
|
52
|
+
"sharp": "^0.34.5",
|
|
53
|
+
"svgo": "^4.0.0",
|
|
54
|
+
"vite-plugin-image-optimizer": "^2.0.3",
|
|
52
55
|
"vitest": "^1.6.1",
|
|
53
56
|
"@inglorious/eslint-config": "1.1.1"
|
|
54
57
|
},
|
package/src/build/build.test.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
-
import { build as viteBuild } from "vite"
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import { build as viteBuild, createServer } from "vite"
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
6
6
|
|
|
7
7
|
import { getPages } from "../router/index.js"
|
|
8
8
|
import { generateApp } from "../scripts/app.js"
|
|
@@ -39,6 +39,13 @@ describe("build", () => {
|
|
|
39
39
|
// Mock console to keep output clean
|
|
40
40
|
vi.spyOn(console, "log").mockImplementation(() => {})
|
|
41
41
|
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
createServer.mockResolvedValue({
|
|
44
|
+
ssrLoadModule: vi.fn(),
|
|
45
|
+
close: vi.fn(),
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
42
49
|
afterEach(() => {
|
|
43
50
|
vi.clearAllMocks()
|
|
44
51
|
})
|
package/src/build/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
|
|
4
|
-
import { build as viteBuild } from "vite"
|
|
4
|
+
import { build as viteBuild, createServer } from "vite"
|
|
5
5
|
|
|
6
6
|
import { getPages } from "../router/index.js"
|
|
7
7
|
import { generateApp } from "../scripts/app.js"
|
|
@@ -47,8 +47,16 @@ export async function build(options = {}) {
|
|
|
47
47
|
|
|
48
48
|
console.log("π¨ Starting build...\n")
|
|
49
49
|
|
|
50
|
+
// Create a temporary Vite server to load modules (supports TS)
|
|
51
|
+
const vite = await createServer({
|
|
52
|
+
...createViteConfig(mergedOptions),
|
|
53
|
+
server: { middlewareMode: true, hmr: false },
|
|
54
|
+
appType: "custom",
|
|
55
|
+
})
|
|
56
|
+
const loader = (p) => vite.ssrLoadModule(p)
|
|
57
|
+
|
|
50
58
|
// 0. Get all pages to build (Fail fast if source is broken)
|
|
51
|
-
const allPages = await getPages(path.join(rootDir, "pages"))
|
|
59
|
+
const allPages = await getPages(path.join(rootDir, "pages"), loader)
|
|
52
60
|
console.log(`π Found ${allPages.length} pages\n`)
|
|
53
61
|
|
|
54
62
|
// Load previous build manifest
|
|
@@ -85,10 +93,15 @@ export async function build(options = {}) {
|
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
// 4. Generate store with all types and initial entities
|
|
88
|
-
const store = await generateStore(allPages, mergedOptions)
|
|
96
|
+
const store = await generateStore(allPages, mergedOptions, loader)
|
|
89
97
|
|
|
90
98
|
// 5. Render only pages that changed
|
|
91
|
-
const changedPages = await generatePages(
|
|
99
|
+
const changedPages = await generatePages(
|
|
100
|
+
store,
|
|
101
|
+
pagesToChange,
|
|
102
|
+
mergedOptions,
|
|
103
|
+
loader,
|
|
104
|
+
)
|
|
92
105
|
// For skipped pages, load their metadata from disk if needed for sitemap/RSS
|
|
93
106
|
const skippedPages = await generatePages(store, pagesToSkip, {
|
|
94
107
|
...mergedOptions,
|
|
@@ -133,6 +146,7 @@ export async function build(options = {}) {
|
|
|
133
146
|
const viteConfig = createViteConfig(mergedOptions)
|
|
134
147
|
await viteBuild(viteConfig)
|
|
135
148
|
|
|
149
|
+
await vite.close()
|
|
136
150
|
// 12. Cleanup
|
|
137
151
|
// console.log("\nπ§Ή Cleaning up...\n")
|
|
138
152
|
|
package/src/build/pages.js
CHANGED
|
@@ -14,10 +14,12 @@ import { extractPageMetadata } from "./metadata.js"
|
|
|
14
14
|
* @param {Object} [options] - Generation options.
|
|
15
15
|
* @param {boolean} [options.shouldGenerateHtml=true] - Whether to generate HTML.
|
|
16
16
|
* @param {boolean} [options.shouldGenerateMetadata=true] - Whether to generate metadata.
|
|
17
|
+
* @param {Function} [loader] - Optional loader function.
|
|
17
18
|
* @returns {Promise<Array<Object>>} The processed pages with `html` and `metadata` properties added.
|
|
18
19
|
*/
|
|
19
|
-
export async function generatePages(store, pages, options = {}) {
|
|
20
|
+
export async function generatePages(store, pages, options = {}, loader) {
|
|
20
21
|
const { shouldGenerateHtml = true, shouldGenerateMetadata = true } = options
|
|
22
|
+
const load = loader || ((p) => import(pathToFileURL(path.resolve(p))))
|
|
21
23
|
|
|
22
24
|
const api = store._api
|
|
23
25
|
|
|
@@ -26,7 +28,7 @@ export async function generatePages(store, pages, options = {}) {
|
|
|
26
28
|
` Generating ${shouldGenerateHtml ? "HTML" : ""}${shouldGenerateHtml && shouldGenerateMetadata ? " and " : ""}${shouldGenerateMetadata ? "metadata" : ""} for ${page.path}...`,
|
|
27
29
|
)
|
|
28
30
|
|
|
29
|
-
const module = await
|
|
31
|
+
const module = await load(page.filePath)
|
|
30
32
|
page.module = module
|
|
31
33
|
|
|
32
34
|
const entity = api.getEntity(page.moduleName)
|
package/src/build/vite-config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
|
|
3
3
|
import { mergeConfig } from "vite"
|
|
4
|
+
import { ViteImageOptimizer } from "vite-plugin-image-optimizer"
|
|
4
5
|
|
|
5
6
|
// import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
|
|
6
7
|
|
|
@@ -19,7 +20,12 @@ export function createViteConfig(options = {}) {
|
|
|
19
20
|
{
|
|
20
21
|
root: rootDir,
|
|
21
22
|
publicDir: path.resolve(process.cwd(), rootDir, publicDir),
|
|
22
|
-
|
|
23
|
+
plugins: [
|
|
24
|
+
// minifyTemplateLiterals(), // TODO: minification breaks hydration. The footprint difference is minimal after all
|
|
25
|
+
ViteImageOptimizer({
|
|
26
|
+
// Options can be overridden by the user in site.config.js via the `vite` property
|
|
27
|
+
}),
|
|
28
|
+
],
|
|
23
29
|
build: {
|
|
24
30
|
outDir,
|
|
25
31
|
emptyOutDir: false, // Don't delete HTML files we already generated
|
package/src/dev/index.js
CHANGED
|
@@ -25,16 +25,17 @@ export async function dev(options = {}) {
|
|
|
25
25
|
|
|
26
26
|
console.log("π Starting dev server...\n")
|
|
27
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
|
+
|
|
28
33
|
// Get all pages once at startup
|
|
29
|
-
const pages = await getPages(path.join(rootDir, "pages"))
|
|
34
|
+
const pages = await getPages(path.join(rootDir, "pages"), loader)
|
|
30
35
|
console.log(`π Found ${pages.length} pages\n`)
|
|
31
36
|
|
|
32
37
|
// 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
|
+
const store = await generateStore(pages, mergedOptions, loader)
|
|
38
39
|
|
|
39
40
|
// Use Vite's middleware first (handles HMR, static files, etc.)
|
|
40
41
|
const connectServer = connect()
|
|
@@ -59,7 +60,7 @@ export async function dev(options = {}) {
|
|
|
59
60
|
const page = pages.find((p) => matchRoute(p.path, url))
|
|
60
61
|
if (!page) return next()
|
|
61
62
|
|
|
62
|
-
const module = await
|
|
63
|
+
const module = await loader(page.filePath)
|
|
63
64
|
page.module = module
|
|
64
65
|
|
|
65
66
|
const entity = store._api.getEntity(page.moduleName)
|
package/src/router/index.js
CHANGED
|
@@ -17,15 +17,17 @@ const SCORE_MULTIPLIER = 0.1
|
|
|
17
17
|
* to generate all possible paths.
|
|
18
18
|
*
|
|
19
19
|
* @param {string} pagesDir - The directory containing page files.
|
|
20
|
+
* @param {Function} [loader] - Optional loader function (e.g. vite.ssrLoadModule).
|
|
20
21
|
* @returns {Promise<Array<Object>>} A list of page objects with metadata.
|
|
21
22
|
*/
|
|
22
|
-
export async function getPages(pagesDir = "pages") {
|
|
23
|
+
export async function getPages(pagesDir = "pages", loader) {
|
|
23
24
|
const routes = await getRoutes(pagesDir)
|
|
24
25
|
const pages = []
|
|
26
|
+
const load = loader || ((p) => import(pathToFileURL(path.resolve(p))))
|
|
25
27
|
|
|
26
28
|
for (const route of routes) {
|
|
27
29
|
try {
|
|
28
|
-
const module = await
|
|
30
|
+
const module = await load(route.filePath)
|
|
29
31
|
const moduleName = getModuleName(module)
|
|
30
32
|
|
|
31
33
|
if (isDynamic(route.pattern)) {
|
|
@@ -122,7 +124,7 @@ export async function resolvePage(url, pagesDir = "pages") {
|
|
|
122
124
|
*/
|
|
123
125
|
export async function getRoutes(pagesDir = "pages") {
|
|
124
126
|
// Find all .js and .ts files in pages directory
|
|
125
|
-
const files = await glob("**/*.{js,ts}", {
|
|
127
|
+
const files = await glob("**/*.{js,ts,jsx,tsx}", {
|
|
126
128
|
cwd: pagesDir,
|
|
127
129
|
ignore: ["**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
|
|
128
130
|
posix: true,
|
|
@@ -190,7 +192,7 @@ export function matchRoute(pattern, url) {
|
|
|
190
192
|
function filePathToPattern(file) {
|
|
191
193
|
let pattern = file
|
|
192
194
|
.replace(/\\/g, "/")
|
|
193
|
-
.replace(/\.(js|ts)$/, "") // Remove extension
|
|
195
|
+
.replace(/\.(js|ts|jsx|tsx)$/, "") // Remove extension
|
|
194
196
|
.replace(/\/index$/, "") // index becomes root of directory
|
|
195
197
|
.replace(/^index$/, "") // Handle root index
|
|
196
198
|
.replace(/__(\w+)/g, "*") // __path becomes *
|
package/src/store/index.js
CHANGED
|
@@ -13,26 +13,31 @@ import { getModuleName } from "../utils/module.js"
|
|
|
13
13
|
* @param {Array<Object>} pages - List of page objects containing file paths.
|
|
14
14
|
* @param {Object} options - Configuration options.
|
|
15
15
|
* @param {string} [options.rootDir="src"] - Root directory to look for entities.js.
|
|
16
|
+
* @param {Function} [loader] - Optional loader function.
|
|
16
17
|
* @returns {Promise<Object>} The initialized store instance.
|
|
17
18
|
*/
|
|
18
|
-
export async function generateStore(pages = [], options = {}) {
|
|
19
|
+
export async function generateStore(pages = [], options = {}, loader) {
|
|
19
20
|
const { rootDir = "src" } = options
|
|
21
|
+
const load = loader || ((p) => import(pathToFileURL(p)))
|
|
20
22
|
|
|
21
23
|
const types = {}
|
|
22
24
|
for (const page of pages) {
|
|
23
|
-
const pageModule = await
|
|
25
|
+
const pageModule = await load(page.filePath)
|
|
24
26
|
const name = getModuleName(pageModule)
|
|
25
27
|
types[name] = pageModule[name]
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
let entities = {}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
const extensions = ["js", "ts"]
|
|
32
|
+
|
|
33
|
+
for (const ext of extensions) {
|
|
34
|
+
try {
|
|
35
|
+
const module = await load(path.join(rootDir, `entities.${ext}`))
|
|
36
|
+
entities = module.entities
|
|
37
|
+
break
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore and try next extension
|
|
40
|
+
}
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
return createStore({ types, entities, updateMode: "manual" })
|
package/src/store/store.test.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
|
|
3
|
-
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { describe, expect, it, vi } from "vitest"
|
|
4
4
|
|
|
5
5
|
import { generateStore } from "."
|
|
6
6
|
|
|
7
|
-
const ROOT_DIR = path.join(
|
|
7
|
+
const ROOT_DIR = path.join(import.meta.dirname, "..", "__fixtures__")
|
|
8
8
|
|
|
9
9
|
describe("generateStore", () => {
|
|
10
10
|
it("should generate the proper types and entities from a static page", async () => {
|
|
@@ -53,4 +53,22 @@ describe("generateStore", () => {
|
|
|
53
53
|
// Should initialize with empty entities (or at least not the ones from fixtures)
|
|
54
54
|
expect(store.getState()).not.toHaveProperty("about")
|
|
55
55
|
})
|
|
56
|
+
|
|
57
|
+
it("should attempt to load entities.js and entities.ts", async () => {
|
|
58
|
+
const loader = vi.fn(async (p) => {
|
|
59
|
+
// Mock a successful page load
|
|
60
|
+
if (p.endsWith("index.js")) {
|
|
61
|
+
return { index: { render: () => {} } }
|
|
62
|
+
}
|
|
63
|
+
// Mock entity files not being found
|
|
64
|
+
throw new Error("MODULE_NOT_FOUND")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const page = { filePath: path.join(ROOT_DIR, "pages", "index.js") }
|
|
68
|
+
await generateStore([page], { rootDir: "src" }, loader)
|
|
69
|
+
|
|
70
|
+
expect(loader).toHaveBeenCalledWith(page.filePath)
|
|
71
|
+
expect(loader).toHaveBeenCalledWith(path.join("src", "entities.js"))
|
|
72
|
+
expect(loader).toHaveBeenCalledWith(path.join("src", "entities.ts"))
|
|
73
|
+
})
|
|
56
74
|
})
|