@inglorious/ssx 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +140 -61
  2. package/bin/ssx.js +17 -21
  3. package/package.json +3 -5
  4. package/src/build/build.test.js +124 -0
  5. package/src/build/index.js +178 -0
  6. package/src/build/manifest.js +120 -0
  7. package/src/build/manifest.test.js +153 -0
  8. package/src/build/metadata.js +53 -0
  9. package/src/build/pages.js +52 -0
  10. package/src/build/pages.test.js +83 -0
  11. package/src/build/public.js +49 -0
  12. package/src/build/public.test.js +59 -0
  13. package/src/build/rss.js +121 -0
  14. package/src/build/rss.test.js +104 -0
  15. package/src/build/sitemap.js +66 -0
  16. package/src/build/sitemap.test.js +84 -0
  17. package/src/build/vite-config.js +51 -0
  18. package/src/dev/index.js +98 -0
  19. package/src/dev/vite-config.js +60 -0
  20. package/src/dev/vite-config.test.js +46 -0
  21. package/src/{html.js → render/html.js} +33 -23
  22. package/src/render/index.js +53 -0
  23. package/src/render/layout.js +52 -0
  24. package/src/render/layout.test.js +58 -0
  25. package/src/render/render.test.js +114 -0
  26. package/src/router/index.js +293 -0
  27. package/src/{router.test.js → router/router.test.js} +35 -4
  28. package/src/scripts/app.js +15 -5
  29. package/src/scripts/app.test.js +49 -30
  30. package/src/{store.js → store/index.js} +11 -1
  31. package/src/store/store.test.js +56 -0
  32. package/src/utils/config.js +16 -0
  33. package/src/{module.js → utils/module.js} +8 -3
  34. package/src/utils/module.test.js +64 -0
  35. package/src/utils/page-options.js +17 -0
  36. package/src/utils/page-options.test.js +57 -0
  37. package/src/build.js +0 -96
  38. package/src/build.test.js +0 -11
  39. package/src/dev.js +0 -111
  40. package/src/module.test.js +0 -45
  41. package/src/random.js +0 -30
  42. package/src/render.js +0 -48
  43. package/src/render.test.js +0 -72
  44. package/src/router.js +0 -231
  45. package/src/store.test.js +0 -40
  46. package/src/vite-config.js +0 -40
  47. /package/src/{html.test.js → render/html.test.js} +0 -0
package/README.md CHANGED
@@ -77,7 +77,13 @@ export const index = {
77
77
  },
78
78
  }
79
79
 
80
- export const title = "Home"
80
+ export const metadata = {
81
+ title: "Home",
82
+ meta: {
83
+ description: "Welcome to our site",
84
+ "og:image": "/og-image.png",
85
+ },
86
+ }
81
87
  ```
82
88
 
83
89
  ### Development
@@ -113,7 +119,47 @@ Deploy `dist/` to:
113
119
 
114
120
  ## Features
115
121
 
116
- ### 📁 File-Based Routing
122
+ ### �️ Sitemap & RSS Generation
123
+
124
+ SSX automatically generates `sitemap.xml` and `rss.xml` based on your pages. Configure them in `src/site.config.js`:
125
+
126
+ ```javascript
127
+ export default {
128
+ // Basic metadata
129
+ title: "My Awesome Site",
130
+ meta: {
131
+ description: "A site built with SSX",
132
+ "og:type": "website",
133
+ "og:site_name": "My Site",
134
+ },
135
+
136
+ // Sitemap configuration
137
+ sitemap: {
138
+ hostname: "https://myblog.com",
139
+ filter: (page) => !["/admin", "/draft-*", "/test"].includes(page.pattern),
140
+ defaults: {
141
+ changefreq: "weekly",
142
+ priority: 0.5,
143
+ },
144
+ },
145
+
146
+ // RSS configuration
147
+ rss: {
148
+ title: "My Blog",
149
+ description: "Latest posts from my blog",
150
+ link: "https://myblog.com",
151
+ feedPath: "/feed.xml",
152
+ language: "en",
153
+ copyright: "© 2026 My Blog",
154
+ maxItems: 10,
155
+ filter: (page) => page.path.startsWith("/posts/"),
156
+ },
157
+ }
158
+ ```
159
+
160
+ Pages with a `published` date in metadata are included in RSS feeds.
161
+
162
+ ### �📁 File-Based Routing
117
163
 
118
164
  Your file structure defines your routes:
119
165
 
@@ -196,7 +242,7 @@ export const title = "Blog"
196
242
 
197
243
  The `load` function runs on the server during build. Data is serialized into the HTML and available immediately on the client.
198
244
 
199
- ### 🎨 Dynamic Routes with `getStaticPaths`
245
+ ### 🎨 Dynamic Routes with `staticPaths`
200
246
 
201
247
  Generate multiple pages from data:
202
248
 
@@ -224,22 +270,27 @@ export async function load(entity, page) {
224
270
  }
225
271
 
226
272
  // Tell SSX which pages to generate
227
- export async function getStaticPaths() {
273
+ export async function staticPaths() {
228
274
  const response = await fetch(`https://api.example.com/posts`)
229
275
  const posts = await response.json()
230
276
 
231
277
  return posts.map((post) => ({
232
- params: { id: post.id },
233
- path: `/posts/${post.id}`,
278
+ params: { slug: post.slug },
279
+ path: `/posts/${post.slug}`,
234
280
  }))
235
281
  }
236
282
 
237
- export const title = (entity) => entity.post.title ?? "Post"
283
+ export const metadata = (entity) => ({
284
+ title: entity.post.title ?? "Post",
285
+ meta: {
286
+ description: entity.post.excerpt,
287
+ },
288
+ })
238
289
  ```
239
290
 
240
291
  ### 📄 Page Metadata
241
292
 
242
- Export metadata for HTML `<head>`:
293
+ Export metadata for HTML `<head>`. The `metadata` export can be a plain object or a function:
243
294
 
244
295
  ```javascript
245
296
  export const index = {
@@ -249,22 +300,22 @@ export const index = {
249
300
  }
250
301
 
251
302
  // Static metadata
252
- export const title = "My Site"
253
- export const meta = {
254
- description: "An awesome static site",
255
- "og:image": "/og-image.png",
303
+ export const metadata = {
304
+ title: "My Site",
305
+ meta: {
306
+ description: "An awesome static site",
307
+ "og:image": "/og-image.png",
308
+ },
256
309
  }
257
310
 
258
311
  // Or dynamic metadata (uses entity data)
259
- export const title = (entity) => `${entity.user.name}'s Profile`
260
- export const meta = (entity) => ({
261
- description: entity.user.bio,
262
- "og:image": entity.user.avatar,
312
+ export const metadata = (entity) => ({
313
+ title: `${entity.user.name}'s Profile`,
314
+ meta: {
315
+ description: entity.user.bio,
316
+ "og:image": entity.user.avatar,
317
+ },
263
318
  })
264
-
265
- // Include CSS/JS files
266
- export const styles = ["./home.css", "./theme.css"]
267
- export const scripts = ["./analytics.js"]
268
319
  ```
269
320
 
270
321
  ### 🔥 Client-Side Hydration
@@ -326,11 +377,11 @@ Builds your static site:
326
377
  ssx build [options]
327
378
 
328
379
  Options:
329
- -r, --root <dir> Source root directory (default: "src")
330
- -o, --out <dir> Output directory (default: "dist")
331
- -t, --title <title> Default page title (default: "My Site")
332
- --styles <styles...> Global CSS files
333
- --scripts <scripts...> Global JS files
380
+ -c, --config <file> Config file (default: "site.config.js")
381
+ -r, --root <dir> Source root directory (default: "src")
382
+ -o, --out <dir> Output directory (default: "dist")
383
+ -i, --incremental Enable incremental builds (default: true)
384
+ -f, --force Force clean build, ignore cache
334
385
  ```
335
386
 
336
387
  ### `ssx dev`
@@ -341,8 +392,9 @@ Starts development server with hot reload:
341
392
  ssx dev [options]
342
393
 
343
394
  Options:
344
- -r, --root <dir> Source root directory (default: "src")
345
- -p, --port <port> Dev server port (default: 3000)
395
+ -c, --config <file> Config file (default: "site.config.js")
396
+ -r, --root <dir> Source root directory (default: "src")
397
+ -p, --port <port> Dev server port (default: 3000)
346
398
  ```
347
399
 
348
400
  ### Package.json Scripts
@@ -352,7 +404,7 @@ Options:
352
404
  "scripts": {
353
405
  "dev": "ssx dev",
354
406
  "build": "ssx build",
355
- "preview": "ssx build && npx serve dist"
407
+ "preview": "pnpm dlx serve dist"
356
408
  }
357
409
  }
358
410
  ```
@@ -374,7 +426,7 @@ my-site/
374
426
  │ └── types/ # Custom entity types (optional)
375
427
  ├── dist/ # Build output
376
428
  ├── package.json
377
- └── vite.config.js # Optional Vite config
429
+ └── site.config.js # Site configuration
378
430
  ```
379
431
 
380
432
  ---
@@ -404,23 +456,52 @@ SSX is perfect if you:
404
456
 
405
457
  ## Advanced Usage
406
458
 
407
- ### Custom Vite Config
459
+ ### Site Configuration
408
460
 
409
- Extend the default Vite configuration:
461
+ Customize SSX behavior in `src/site.config.js`:
410
462
 
411
463
  ```javascript
412
- // vite.config.js
413
- import { defineConfig } from "vite"
414
-
415
- export default defineConfig({
416
- // Your custom config
417
- plugins: [],
418
- resolve: {
419
- alias: {
420
- "@": "/src",
464
+ export default {
465
+ // Basic metadata
466
+ lang: "en",
467
+ charset: "UTF-8",
468
+ title: "My Awesome Site",
469
+ meta: {
470
+ description: "A site built with SSX",
471
+ "og:type": "website",
472
+ },
473
+
474
+ // Global assets
475
+ styles: ["./styles/reset.css", "./styles/theme.css"],
476
+ scripts: ["./scripts/analytics.js"],
477
+
478
+ // Build options
479
+ basePath: "/",
480
+ rootDir: "src",
481
+ outDir: "dist",
482
+ publicDir: "public",
483
+ favicon: "/favicon.ico",
484
+
485
+ // Router config
486
+ router: {
487
+ trailingSlash: false,
488
+ scrollBehavior: "smooth",
489
+ },
490
+
491
+ // Vite config passthrough
492
+ vite: {
493
+ server: {
494
+ port: 3000,
495
+ open: true,
421
496
  },
422
497
  },
423
- })
498
+
499
+ // Build hooks
500
+ hooks: {
501
+ beforeBuild: async (config) => console.log("Starting build..."),
502
+ afterBuild: async (result) => console.log(`Built ${result.pages} pages`),
503
+ },
504
+ }
424
505
  ```
425
506
 
426
507
  ### Environment Variables
@@ -452,7 +533,9 @@ export const notFound = {
452
533
  },
453
534
  }
454
535
 
455
- export const title = "404"
536
+ export const metadata = {
537
+ title: "404",
538
+ }
456
539
  ```
457
540
 
458
541
  Register it in your router:
@@ -469,13 +552,17 @@ setRoutes({
469
552
 
470
553
  ### Incremental Builds
471
554
 
472
- Currently, SSX rebuilds all pages. For large sites, consider:
555
+ SSX enables incremental builds by default. Only changed pages are rebuilt, dramatically speeding up your build process:
473
556
 
474
- 1. **Split into multiple deployments** - Blog vs. docs vs. marketing
475
- 2. **Use ISR-like patterns** - Rebuild changed pages via CI/CD
476
- 3. **Cache build artifacts** - Speed up unchanged pages
557
+ ```bash
558
+ ssx build
559
+ # Only changed pages are rebuilt
477
560
 
478
- Incremental builds are planned for future releases.
561
+ ssx build --force
562
+ # Force a clean rebuild of all pages
563
+ ```
564
+
565
+ Incremental builds respect your page dependencies and invalidate cache when dependencies change.
479
566
 
480
567
  ---
481
568
 
@@ -487,14 +574,11 @@ Incremental builds are planned for future releases.
487
574
  import { build } from "@inglorious/ssx/build"
488
575
 
489
576
  await build({
490
- rootDir: "src", // Source directory
491
- outDir: "dist", // Output directory
492
- renderOptions: {
493
- title: "My Site", // Default page title
494
- meta: {}, // Default meta tags
495
- styles: [], // Global CSS files
496
- scripts: [], // Global JS files
497
- },
577
+ rootDir: "src",
578
+ outDir: "dist",
579
+ configFile: "site.config.js",
580
+ incremental: true,
581
+ clean: false,
498
582
  })
499
583
  ```
500
584
 
@@ -506,9 +590,7 @@ import { dev } from "@inglorious/ssx/dev"
506
590
  await dev({
507
591
  rootDir: "src",
508
592
  port: 3000,
509
- renderOptions: {
510
- // ... same as build
511
- },
593
+ configFile: "site.config.js",
512
594
  })
513
595
  ```
514
596
 
@@ -529,10 +611,7 @@ Check out these example projects:
529
611
 
530
612
  - [ ] TypeScript support
531
613
  - [ ] Image optimization
532
- - [ ] Incremental builds
533
614
  - [ ] API routes (serverless functions)
534
- - [ ] RSS feed generation
535
- - [ ] Sitemap generation
536
615
  - [ ] MDX support
537
616
  - [ ] i18n helpers
538
617
 
package/bin/ssx.js CHANGED
@@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url"
5
5
 
6
6
  import { Command } from "commander"
7
7
 
8
- import { build } from "../src/build.js"
9
- import { dev } from "../src/dev.js"
8
+ import { build } from "../src/build/index.js"
9
+ import { dev } from "../src/dev/index.js"
10
10
 
11
11
  const __filename = fileURLToPath(import.meta.url)
12
12
  const __dirname = path.dirname(__filename)
@@ -26,24 +26,17 @@ program
26
26
  program
27
27
  .command("dev")
28
28
  .description("Start development server with hot reload")
29
+ .option("-c, --config <file>", "config file", "site.config.js")
29
30
  .option("-r, --root <dir>", "source root directory", "src")
30
31
  .option("-p, --port <port>", "dev server port", 3000)
31
- .option("-t, --title <title>", "default page title", "My Site")
32
- .option("--styles <styles...>", "CSS files to include")
33
- .option("--scripts <scripts...>", "JS files to include")
34
32
  .action(async (options) => {
35
33
  const cwd = process.cwd()
36
34
 
37
35
  try {
38
36
  await dev({
37
+ ...options,
39
38
  rootDir: path.resolve(cwd, options.root),
40
39
  port: Number(options.port),
41
- renderOptions: {
42
- title: options.title,
43
- meta: {},
44
- styles: options.styles || [],
45
- scripts: options.scripts || [],
46
- },
47
40
  })
48
41
  } catch (error) {
49
42
  console.error("Dev server failed:", error)
@@ -53,26 +46,29 @@ program
53
46
 
54
47
  program
55
48
  .command("build")
56
- .description("Build static site from pages directory")
49
+ .description("Build site from pages directory")
50
+ .option("-c, --config <file>", "config file", "site.config.js")
57
51
  .option("-r, --root <dir>", "source root directory", "src")
58
52
  .option("-o, --out <dir>", "output directory", "dist")
59
- .option("-t, --title <title>", "default page title", "My Site")
60
- .option("--styles <styles...>", "CSS files to include")
61
- .option("--scripts <scripts...>", "JS files to include")
53
+ .option("-i, --incremental", "enable incremental builds", true)
54
+ .option("-f, --force", "force clean build (ignore cache)", false)
62
55
  .action(async (options) => {
63
56
  const cwd = process.cwd()
64
57
 
65
58
  try {
66
59
  await build({
60
+ ...options,
67
61
  rootDir: path.resolve(cwd, options.root),
68
62
  outDir: path.resolve(cwd, options.out),
69
- renderOptions: {
70
- title: options.title,
71
- meta: {},
72
- styles: options.styles || [],
73
- scripts: options.scripts || [],
74
- },
63
+ incremental: options.incremental, // Enabled by default
64
+ clean: options.force,
75
65
  })
66
+
67
+ // if (result.skipped) {
68
+ // console.log(
69
+ // `\n⚡ Incremental build saved time by skipping ${result.skipped} unchanged pages`,
70
+ // )
71
+ // }
76
72
  } catch (error) {
77
73
  console.error("Build failed:", error)
78
74
  process.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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",
@@ -26,10 +26,7 @@
26
26
  "ssx": "./bin/ssx.js"
27
27
  },
28
28
  "exports": {
29
- "./build": "./src/build.js",
30
- "./router": "./src/router.js",
31
- "./render": "./src/render.js",
32
- "./html": "./src/html.js"
29
+ "./site.config": "./types/site.config.d.ts"
33
30
  },
34
31
  "files": [
35
32
  "bin",
@@ -44,6 +41,7 @@
44
41
  "@lit-labs/ssr": "^4.0.0",
45
42
  "commander": "^14.0.2",
46
43
  "connect": "^3.7.0",
44
+ "fast-xml-parser": "^5.3.3",
47
45
  "glob": "^13.0.0",
48
46
  "rollup-plugin-minify-template-literals": "^1.1.7",
49
47
  "vite": "^7.1.3",
@@ -0,0 +1,124 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+
4
+ import { build as viteBuild } from "vite"
5
+ import { afterEach, describe, expect, it, vi } from "vitest"
6
+
7
+ import { getPages } 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 { build } from "."
12
+ import {
13
+ createManifest,
14
+ determineRebuildPages,
15
+ hashEntities,
16
+ loadManifest,
17
+ saveManifest,
18
+ } from "./manifest.js"
19
+ import { generatePages } from "./pages.js"
20
+ import { copyPublicDir } from "./public.js"
21
+ import { generateRSS } from "./rss.js"
22
+ import { generateSitemap } from "./sitemap.js"
23
+ import { createViteConfig } from "./vite-config.js"
24
+
25
+ vi.mock("node:fs/promises")
26
+ vi.mock("vite")
27
+ vi.mock("../router/index.js")
28
+ vi.mock("../scripts/app.js")
29
+ vi.mock("../store/index.js")
30
+ vi.mock("../utils/config.js")
31
+ vi.mock("./manifest.js")
32
+ vi.mock("./pages.js")
33
+ vi.mock("./public.js")
34
+ vi.mock("./rss.js")
35
+ vi.mock("./sitemap.js")
36
+ vi.mock("./vite-config.js")
37
+
38
+ describe("build", () => {
39
+ // Mock console to keep output clean
40
+ vi.spyOn(console, "log").mockImplementation(() => {})
41
+
42
+ afterEach(() => {
43
+ vi.clearAllMocks()
44
+ })
45
+
46
+ it("should run a full build sequence", async () => {
47
+ // Setup mocks
48
+ loadConfig.mockResolvedValue({})
49
+ loadManifest.mockResolvedValue(null) // First build
50
+ getPages.mockResolvedValue([{ path: "/" }])
51
+ hashEntities.mockResolvedValue("hash")
52
+ generateStore.mockResolvedValue({})
53
+ generatePages
54
+ .mockResolvedValueOnce([{ path: "/", html: "<html></html>" }])
55
+ .mockResolvedValueOnce([])
56
+ generateApp.mockReturnValue("console.log('app')")
57
+ createViteConfig.mockReturnValue({})
58
+ createManifest.mockResolvedValue({})
59
+
60
+ const result = await build({ rootDir: "src", outDir: "dist" })
61
+
62
+ // Verify sequence
63
+ expect(fs.rm).toHaveBeenCalledWith("dist", { recursive: true, force: true })
64
+ expect(fs.mkdir).toHaveBeenCalledWith("dist", { recursive: true })
65
+ expect(copyPublicDir).toHaveBeenCalled()
66
+ expect(getPages).toHaveBeenCalled()
67
+ expect(generateStore).toHaveBeenCalled()
68
+ expect(generatePages).toHaveBeenCalledTimes(2) // Changed + Skipped (empty)
69
+ expect(fs.writeFile).toHaveBeenCalledWith(
70
+ path.normalize("dist/index.html"),
71
+ "<html></html>",
72
+ "utf-8",
73
+ )
74
+ expect(generateApp).toHaveBeenCalled()
75
+ expect(viteBuild).toHaveBeenCalled()
76
+ expect(saveManifest).toHaveBeenCalled()
77
+
78
+ expect(result).toEqual({ changed: 1, skipped: 0 })
79
+ })
80
+
81
+ it("should handle incremental builds", async () => {
82
+ const manifest = { entities: "hash" }
83
+ loadManifest.mockResolvedValue(manifest)
84
+ hashEntities.mockResolvedValue("hash")
85
+
86
+ const allPages = [{ path: "/changed" }, { path: "/skipped" }]
87
+ getPages.mockResolvedValue(allPages)
88
+
89
+ determineRebuildPages.mockResolvedValue({
90
+ pagesToBuild: [{ path: "/changed" }],
91
+ pagesToSkip: [{ path: "/skipped" }],
92
+ })
93
+
94
+ generatePages
95
+ .mockResolvedValueOnce([{ path: "/changed", html: "<html></html>" }]) // Changed
96
+ .mockResolvedValueOnce([{ path: "/skipped" }]) // Skipped
97
+
98
+ const result = await build({ incremental: true })
99
+
100
+ expect(determineRebuildPages).toHaveBeenCalled()
101
+ expect(generatePages).toHaveBeenCalledTimes(2)
102
+ // Should only write changed pages
103
+ expect(fs.writeFile).toHaveBeenCalledWith(
104
+ expect.stringContaining("changed"),
105
+ expect.any(String),
106
+ expect.any(String),
107
+ )
108
+ expect(result).toEqual({ changed: 1, skipped: 1 })
109
+ })
110
+
111
+ it("should generate sitemap and rss if configured", async () => {
112
+ loadConfig.mockResolvedValue({})
113
+ getPages.mockResolvedValue([])
114
+ generatePages.mockResolvedValue([])
115
+
116
+ await build({
117
+ sitemap: { hostname: "https://example.com" },
118
+ rss: { link: "https://example.com" },
119
+ })
120
+
121
+ expect(generateSitemap).toHaveBeenCalled()
122
+ expect(generateRSS).toHaveBeenCalled()
123
+ })
124
+ })