@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 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 ready** - Full type support (coming soon)
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
- ### �️ Sitemap & RSS Generation
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
- ### οΏ½πŸ“ File-Based Routing
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
- - [ ] TypeScript support
613
- - [ ] Image optimization
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.1.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
  },
@@ -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
  })
@@ -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(store, pagesToChange, mergedOptions)
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
 
@@ -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 import(pathToFileURL(path.resolve(page.filePath)))
31
+ const module = await load(page.filePath)
30
32
  page.module = module
31
33
 
32
34
  const entity = api.getEntity(page.moduleName)
@@ -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
- // plugins: [minifyTemplateLiterals()], // TODO: minification breaks hydration. The footprint difference is minimal after all
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 viteServer.ssrLoadModule(page.filePath)
63
+ const module = await loader(page.filePath)
63
64
  page.module = module
64
65
 
65
66
  const entity = store._api.getEntity(page.moduleName)
@@ -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 import(pathToFileURL(path.resolve(route.filePath)))
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 *
@@ -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 import(pathToFileURL(page.filePath))
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
- try {
30
- const module = await import(
31
- pathToFileURL(path.join(rootDir, "entities.js"))
32
- )
33
- entities = module.entities
34
- } catch {
35
- entities = {}
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" })
@@ -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(__dirname, "..", "__fixtures__")
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
  })