@inglorious/ssx 0.2.3 → 0.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/bin/ssx.js CHANGED
@@ -23,6 +23,57 @@ program
23
23
  .description("Static Site Xecution for @inglorious/web")
24
24
  .version(packageJson.version)
25
25
 
26
+ program
27
+ .command("dev")
28
+ .description("Start development server with hot reload")
29
+ .option("-r, --root <dir>", "source root directory", "src")
30
+ .option("-p, --port <port>", "dev server port", 3000)
31
+ .option("-s, --seed <seed>", "seed for random number generator", 42)
32
+ .option("-t, --title <title>", "default page title", "My Site")
33
+ .option("--styles <styles...>", "CSS files to include")
34
+ .option("--scripts <scripts...>", "JS files to include")
35
+ .action(async (options) => {
36
+ const cwd = process.cwd()
37
+ const seed = Number(options.seed)
38
+
39
+ try {
40
+ // 1️⃣ Install DOM *before anything else*
41
+ const window = new Window()
42
+
43
+ globalThis.window = window
44
+ globalThis.document = window.document
45
+ globalThis.HTMLElement = window.HTMLElement
46
+ globalThis.Node = window.Node
47
+ globalThis.Comment = window.Comment
48
+
49
+ // Optional but sometimes needed
50
+ globalThis.customElements = window.customElements
51
+
52
+ // 3️⃣ Patch with the parsed seed
53
+ const restore = patchRandom(seed)
54
+ await import("@inglorious/web")
55
+ restore()
56
+
57
+ // 4️⃣ NOW import and run dev
58
+ const { dev } = await import("../src/dev.js")
59
+
60
+ await dev({
61
+ rootDir: path.resolve(cwd, options.root),
62
+ port: Number(options.port),
63
+ renderOptions: {
64
+ seed: Number(options.seed),
65
+ title: options.title,
66
+ meta: {},
67
+ styles: options.styles || [],
68
+ scripts: options.scripts || [],
69
+ },
70
+ })
71
+ } catch (error) {
72
+ console.error("Dev server failed:", error)
73
+ process.exit(1)
74
+ }
75
+ })
76
+
26
77
  program
27
78
  .command("build")
28
79
  .description("Build static site from pages directory")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "0.2.3",
3
+ "version": "0.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",
@@ -34,22 +34,23 @@
34
34
  "files": [
35
35
  "bin",
36
36
  "src",
37
- "!src/__fixtures__",
38
- "!src/__snapshots__"
37
+ "!src/**/__fixtures__",
38
+ "!src/**/__snapshots__"
39
39
  ],
40
40
  "publishConfig": {
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
44
  "commander": "^14.0.2",
45
+ "connect": "^3.7.0",
45
46
  "glob": "^13.0.0",
46
47
  "happy-dom": "^20.0.11",
48
+ "rollup-plugin-minify-template-literals": "^1.1.7",
49
+ "vite": "^7.1.3",
47
50
  "@inglorious/web": "3.0.1"
48
51
  },
49
52
  "devDependencies": {
50
53
  "prettier": "^3.6.2",
51
- "rollup-plugin-minify-template-literals": "^1.1.7",
52
- "vite": "^7.1.3",
53
54
  "vitest": "^1.6.1",
54
55
  "@inglorious/eslint-config": "1.1.1"
55
56
  },
@@ -61,7 +62,8 @@
61
62
  "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
62
63
  "test:watch": "vitest",
63
64
  "test": "vitest run",
64
- "dev": "node ./bin/ssx.js build -r ./src/__fixtures__",
65
- "preview": "pnpm dlx serve -s dist"
65
+ "dev": "node ./bin/ssx.js dev -r ./src/__fixtures__",
66
+ "build": "node ./bin/ssx.js build -r ./src/__fixtures__",
67
+ "preview": "pnpm dlx serve dist"
66
68
  }
67
69
  }
package/src/build.js CHANGED
@@ -2,15 +2,14 @@ import fs from "node:fs/promises"
2
2
  import path from "node:path"
3
3
  import { pathToFileURL } from "node:url"
4
4
 
5
- import { createStore } from "@inglorious/web"
6
5
  import { build as viteBuild } from "vite"
7
6
 
8
- import { getModuleName } from "./module.js"
9
7
  import { renderPage } from "./render.js"
10
8
  import { getPages } from "./router.js"
11
9
  import { generateApp } from "./scripts/app.js"
12
10
  import { generateLitLoader } from "./scripts/lit-loader.js"
13
11
  import { generateMain } from "./scripts/main.js"
12
+ import { generateStore } from "./store.js"
14
13
  import { createViteConfig } from "./vite-config.js"
15
14
 
16
15
  export async function build(options = {}) {
@@ -39,7 +38,7 @@ export async function build(options = {}) {
39
38
  const litLoader = generateLitLoader(renderOptions)
40
39
  await fs.writeFile(path.join(outDir, "lit-loader.js"), litLoader, "utf-8")
41
40
 
42
- const app = generateApp(store, renderedPages)
41
+ const app = generateApp(store, pages)
43
42
  await fs.writeFile(path.join(outDir, "app.js"), app, "utf-8")
44
43
 
45
44
  const main = generateMain()
@@ -65,23 +64,6 @@ export async function build(options = {}) {
65
64
  return { pages: renderedPages.length, outDir }
66
65
  }
67
66
 
68
- async function generateStore(pages = [], options = {}) {
69
- const { rootDir = "src" } = options
70
-
71
- const types = {}
72
- for (const page of pages) {
73
- const pageModule = await import(pathToFileURL(page.filePath))
74
- const name = getModuleName(pageModule)
75
- types[name] = pageModule[name]
76
- }
77
-
78
- const { entities } = await import(
79
- pathToFileURL(path.join(rootDir, "entities.js"))
80
- )
81
-
82
- return createStore({ types, entities, updateMode: "manual" })
83
- }
84
-
85
67
  async function generatePages(pages, options = {}) {
86
68
  const { renderOptions } = options
87
69
 
package/src/build.test.js CHANGED
@@ -4,8 +4,8 @@ import { it } from "vitest"
4
4
 
5
5
  import { build } from "./build"
6
6
 
7
- const FIXTURES_DIR = path.join(__dirname, "__fixtures__")
7
+ const ROOT_DIR = path.join(__dirname, "__fixtures__")
8
8
 
9
9
  it.skip("should build full static pages", async () => {
10
- await build({ rootDir: FIXTURES_DIR })
10
+ await build({ rootDir: ROOT_DIR })
11
11
  })
package/src/dev.js ADDED
@@ -0,0 +1,121 @@
1
+ import path from "node:path"
2
+
3
+ import connect from "connect"
4
+ import { createServer } from "vite"
5
+
6
+ import { renderPage } from "./render.js"
7
+ import { getPages } from "./router.js"
8
+ import { generateApp } from "./scripts/app.js"
9
+ import { generateLitLoader } from "./scripts/lit-loader.js"
10
+ import { generateMain } from "./scripts/main.js"
11
+ import { generateStore } from "./store.js"
12
+
13
+ export async function dev(options = {}) {
14
+ const { rootDir = "src", port = 3000, renderOptions = {} } = options
15
+
16
+ console.log("🚀 Starting dev server...\n")
17
+
18
+ // Get all pages once at startup
19
+ const pages = await getPages(path.join(rootDir, "pages"))
20
+ console.log(`📄 Found ${pages.length} pages\n`)
21
+
22
+ // Generate store config once for all pages
23
+ const store = await generateStore(pages, options)
24
+
25
+ const litLoader = generateLitLoader(renderOptions)
26
+ virtualFiles.set("/lit-loader.js", litLoader)
27
+
28
+ const app = generateApp(store, pages)
29
+ virtualFiles.set("/app.js", app)
30
+
31
+ const main = generateMain()
32
+ virtualFiles.set("/main.js", main)
33
+
34
+ // Create Vite dev server
35
+ const viteServer = await createServer({
36
+ root: process.cwd(),
37
+ server: { port, middlewareMode: true },
38
+ appType: "custom",
39
+ plugins: [virtualPlugin()],
40
+ resolve: {
41
+ alias: {
42
+ "@": path.resolve(process.cwd(), rootDir),
43
+ },
44
+ },
45
+ })
46
+
47
+ // Use Vite's middleware first (handles HMR, static files, etc.)
48
+ const connectServer = connect()
49
+
50
+ connectServer.use(viteServer.middlewares)
51
+
52
+ // Add SSR middleware
53
+ connectServer.use(async (req, res, next) => {
54
+ const [url] = req.url.split("?")
55
+
56
+ try {
57
+ // Find matching page
58
+ const page = pages.find((p) => matchRoute(p.path, url))
59
+ if (!page) return next()
60
+
61
+ const store = await generateStore([page], options)
62
+ const module = await viteServer.ssrLoadModule(page.filePath)
63
+ const html = await renderPage(store, module, {
64
+ ...renderOptions,
65
+ wrap: true,
66
+ dev: true,
67
+ })
68
+
69
+ res.setHeader("Content-Type", "text/html")
70
+ res.end(html)
71
+ } catch (error) {
72
+ viteServer.ssrFixStacktrace(error)
73
+ next(error) // Let Vite handle the error overlay
74
+ }
75
+ })
76
+
77
+ const server = connectServer.listen(port)
78
+
79
+ console.log(`\n✨ Dev server running at http://localhost:${port}\n`)
80
+ console.log("Press Ctrl+C to stop\n")
81
+
82
+ return {
83
+ close: () => {
84
+ server.close()
85
+ viteServer.close()
86
+ },
87
+ }
88
+ }
89
+
90
+ // Simple route matcher (could be moved to router.js)
91
+ function matchRoute(pattern, url) {
92
+ const patternParts = pattern.split("/").filter(Boolean)
93
+ const urlParts = url.split("/").filter(Boolean)
94
+
95
+ if (patternParts.length !== urlParts.length) {
96
+ return false
97
+ }
98
+
99
+ return patternParts.every((part, i) => {
100
+ if (part.startsWith(":") || part.startsWith("[")) {
101
+ return true
102
+ }
103
+ return part === urlParts[i]
104
+ })
105
+ }
106
+
107
+ const virtualFiles = new Map()
108
+
109
+ function virtualPlugin() {
110
+ return {
111
+ name: "ssx-virtual-files",
112
+
113
+ resolveId(id) {
114
+ if (virtualFiles.has(id)) return id
115
+ },
116
+
117
+ load(id) {
118
+ if (virtualFiles.has(id)) return virtualFiles.get(id)
119
+ },
120
+ }
121
+ }
package/src/html.js CHANGED
@@ -26,7 +26,7 @@ function stripLitMarkers(html) {
26
26
  }
27
27
 
28
28
  function wrapHTML(body, options) {
29
- const { title = "", meta = {}, styles = [], scripts = [] } = options
29
+ const { dev, title = "", meta = {}, styles = [], scripts = [] } = options
30
30
 
31
31
  return `<!DOCTYPE html>
32
32
  <html>
@@ -41,7 +41,7 @@ function wrapHTML(body, options) {
41
41
  <body>
42
42
  <div id="root">${body}</div>
43
43
 
44
- <script type="module" src="/main.js"></script>
44
+ ${dev ? `<script type="module" src="/@vite/client"></script>` : ``}<script type="module" src="/main.js"></script>
45
45
  ${scripts.map((src) => `<script type="module" src="${src}"></script>`).join("\n")}
46
46
  </body>
47
47
  </html>`
@@ -0,0 +1,45 @@
1
+ import { expect, it } from "vitest"
2
+
3
+ import { getModuleName } from "./module"
4
+
5
+ it("should get the name when it's the only export", () => {
6
+ const module = {
7
+ about: {
8
+ render: () => {},
9
+ },
10
+ }
11
+
12
+ expect(getModuleName(module)).toBe("about")
13
+ })
14
+
15
+ it("should get the name when there's other exports", () => {
16
+ const module = {
17
+ about: {
18
+ render: () => {},
19
+ },
20
+ title: "About",
21
+ meta: {
22
+ description: "About page",
23
+ },
24
+ scripts: ["/script.js"],
25
+ styles: ["/style.css"],
26
+ }
27
+
28
+ expect(getModuleName(module)).toBe("about")
29
+ })
30
+
31
+ it("should get the name when the exports are functions", () => {
32
+ const module = {
33
+ about: {
34
+ render: () => {},
35
+ },
36
+ title: () => "About",
37
+ meta: () => ({
38
+ description: "About page",
39
+ }),
40
+ scripts: ["/script.js"],
41
+ styles: ["/style.css"],
42
+ }
43
+
44
+ expect(getModuleName(module)).toBe("about")
45
+ })
@@ -5,7 +5,8 @@ import { expect, it } from "vitest"
5
5
 
6
6
  import { renderPage } from "./render"
7
7
 
8
- const PAGES_DIR = path.join(__dirname, "__fixtures__", "pages")
8
+ const ROOT_DIR = path.join(__dirname, "__fixtures__")
9
+ const PAGES_DIR = path.join(ROOT_DIR, "pages")
9
10
 
10
11
  const DEFAULT_OPTIONS = { stripLitMarkers: true }
11
12
 
@@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"
4
4
 
5
5
  import { getPages, getRoutes, resolvePage } from "./router.js"
6
6
 
7
- const FIXTURES_DIR = path.join(__dirname, "__fixtures__", "pages")
7
+ const ROOT_DIR = path.join(__dirname, "__fixtures__")
8
+ const PAGES_DIR = path.join(ROOT_DIR, "pages")
8
9
 
9
10
  describe("router", () => {
10
11
  afterEach(() => {
@@ -13,7 +14,7 @@ describe("router", () => {
13
14
 
14
15
  describe("getRoutes", () => {
15
16
  it("should discover and sort routes correctly", async () => {
16
- const routes = await getRoutes(FIXTURES_DIR)
17
+ const routes = await getRoutes(PAGES_DIR)
17
18
 
18
19
  // Expected order based on specificity:
19
20
  // 1. /posts/:id (static 'posts' + dynamic 'id') -> score ~4.2
@@ -44,28 +45,28 @@ describe("router", () => {
44
45
 
45
46
  describe("resolvePage", () => {
46
47
  it("should resolve root page", async () => {
47
- const page = await resolvePage("/", FIXTURES_DIR)
48
+ const page = await resolvePage("/", PAGES_DIR)
48
49
  expect(page).not.toBeNull()
49
50
  expect(page.filePath).toContain("index.js")
50
51
  expect(page.params).toEqual({})
51
52
  })
52
53
 
53
54
  it("should resolve static page", async () => {
54
- const page = await resolvePage("/about", FIXTURES_DIR)
55
+ const page = await resolvePage("/about", PAGES_DIR)
55
56
  expect(page).not.toBeNull()
56
57
  expect(page.filePath).toContain("about.js")
57
58
  expect(page.params).toEqual({})
58
59
  })
59
60
 
60
61
  it("should resolve dynamic page with params", async () => {
61
- const page = await resolvePage("/blog/hello-world", FIXTURES_DIR)
62
+ const page = await resolvePage("/blog/hello-world", PAGES_DIR)
62
63
  expect(page).not.toBeNull()
63
64
  expect(page.filePath).toContain("blog")
64
65
  expect(page.params).toEqual({ slug: "hello-world" })
65
66
  })
66
67
 
67
68
  it("should resolve catch-all page", async () => {
68
- const page = await resolvePage("/api/v1/users", FIXTURES_DIR)
69
+ const page = await resolvePage("/api/v1/users", PAGES_DIR)
69
70
  expect(page).not.toBeNull()
70
71
  expect(page.filePath).toContain("api")
71
72
  expect(page.params).toEqual({ path: "v1/users" })
@@ -80,7 +81,7 @@ describe("router", () => {
80
81
  // /about matches /about.
81
82
  // / matches /.
82
83
  // So /foo should return null.
83
- const page = await resolvePage("/foo", FIXTURES_DIR)
84
+ const page = await resolvePage("/foo", PAGES_DIR)
84
85
  expect(page).toBeNull()
85
86
  })
86
87
  })
@@ -88,7 +89,7 @@ describe("router", () => {
88
89
  describe("getPages", () => {
89
90
  it("should generate static paths for all pages", async () => {
90
91
  const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
91
- const pages = await getPages(FIXTURES_DIR)
92
+ const pages = await getPages(PAGES_DIR)
92
93
 
93
94
  expect(pages).toMatchSnapshot()
94
95
 
@@ -1,62 +1,33 @@
1
- import { getModuleName } from "../module.js"
2
-
3
1
  /**
4
2
  * Generate the code that goes inside the <!-- SSX --> marker.
5
3
  * This creates the types and entities objects for the client-side store.
6
4
  */
7
- export function generateApp(store, renderedPages) {
5
+ export function generateApp(store, pages) {
8
6
  // Collect all unique page modules and their exports
9
- const pageImports = new Map()
10
- const routeEntries = []
11
-
12
- for (const { page, module } of renderedPages) {
13
- const importPath = "@/pages/" + page.modulePath
14
-
15
- const exportName = getModuleName(module)
16
-
17
- pageImports.set(importPath, exportName)
18
-
19
- routeEntries.push(` '${page.path}': '${exportName}'`)
20
- }
7
+ const routeEntries = pages.map(
8
+ (page) =>
9
+ ` "${page.path}": () => import("@/pages/${page.modulePath}")`,
10
+ )
21
11
 
22
- // Generate import statements
23
- const imports = Array.from(pageImports.entries())
24
- .map(
25
- ([importPath, exportName]) =>
26
- `import { ${exportName} } from '${importPath}'`,
27
- )
28
- .join("\n")
12
+ return `import { createDevtools, createStore, mount } from "@inglorious/web"
13
+ import { router } from "@inglorious/web/router"
29
14
 
30
- // Generate routes object
31
- const routes = routeEntries.join(",\n")
32
-
33
- // Generate type registrations
34
- const typeEntries = Array.from(pageImports.values())
35
- .map((name) => ` ${name}`)
36
- .join(",\n")
37
-
38
- return `import { createDevtools, createStore, mount, router } from "@inglorious/web"
39
- ${imports}
40
-
41
- const types = {
42
- router,
43
- ${typeEntries}
44
- }
15
+ const types = { router }
45
16
 
46
17
  const entities = {
47
18
  router: {
48
- type: 'router',
19
+ type: "router",
49
20
  routes: {
50
- ${routes}
21
+ ${routeEntries.join(",\n")}
51
22
  }
52
23
  },
53
24
  ${JSON.stringify(store.getState(), null, 2).slice(1, -1)}
54
25
  }
55
26
 
56
27
  const middlewares = []
57
- if (import.meta.env.DEV) {
28
+ // if (import.meta.env.DEV) {
58
29
  middlewares.push(createDevtools().middleware)
59
- }
30
+ // }
60
31
 
61
32
  const store = createStore({ types, entities, middlewares })
62
33
 
@@ -0,0 +1,47 @@
1
+ import path from "node:path"
2
+
3
+ import { expect, it } from "vitest"
4
+
5
+ import { generateStore } from "../store"
6
+ import { generateApp } from "./app"
7
+
8
+ const ROOT_DIR = path.join(__dirname, "..", "__fixtures__")
9
+
10
+ it("should generate the app script for a static page", async () => {
11
+ const page = {
12
+ path: "/",
13
+ modulePath: "index.js",
14
+ filePath: path.join(ROOT_DIR, "pages", "index.js"),
15
+ }
16
+ const store = await generateStore([page], { rootDir: ROOT_DIR })
17
+
18
+ const app = generateApp(store, [page])
19
+
20
+ expect(app).toMatchSnapshot()
21
+ })
22
+
23
+ it("should generate the app script for a page with an entity", async () => {
24
+ const page = {
25
+ path: "/about",
26
+ modulePath: "about.js",
27
+ filePath: path.join(ROOT_DIR, "pages", "about.js"),
28
+ }
29
+ const store = await generateStore([page], { rootDir: ROOT_DIR })
30
+
31
+ const app = generateApp(store, [page])
32
+
33
+ expect(app).toMatchSnapshot()
34
+ })
35
+
36
+ it("should generate the app script for a page that has metadata", async () => {
37
+ const page = {
38
+ path: "/posts",
39
+ modulePath: "posts.js",
40
+ filePath: path.join(ROOT_DIR, "pages", "posts.js"),
41
+ }
42
+ const store = await generateStore([page], { rootDir: ROOT_DIR })
43
+
44
+ const app = generateApp(store, [page])
45
+
46
+ expect(app).toMatchSnapshot()
47
+ })
@@ -1,5 +1,5 @@
1
1
  export function generateMain() {
2
- return `import "./lit-loader.js"
3
- await import("./app.js")
2
+ return `import "/lit-loader.js"
3
+ await import("/app.js")
4
4
  `
5
5
  }
package/src/store.js ADDED
@@ -0,0 +1,23 @@
1
+ import path from "node:path"
2
+ import { pathToFileURL } from "node:url"
3
+
4
+ import { createStore } from "@inglorious/web"
5
+
6
+ import { getModuleName } from "./module.js"
7
+
8
+ export async function generateStore(pages = [], options = {}) {
9
+ const { rootDir = "src" } = options
10
+
11
+ const types = {}
12
+ for (const page of pages) {
13
+ const pageModule = await import(pathToFileURL(page.filePath))
14
+ const name = getModuleName(pageModule)
15
+ types[name] = pageModule[name]
16
+ }
17
+
18
+ const { entities } = await import(
19
+ pathToFileURL(path.join(rootDir, "entities.js"))
20
+ )
21
+
22
+ return createStore({ types, entities, updateMode: "manual" })
23
+ }
@@ -0,0 +1,40 @@
1
+ import path from "node:path"
2
+
3
+ import { expect, it } from "vitest"
4
+
5
+ import { generateStore } from "./store"
6
+
7
+ const ROOT_DIR = path.join(__dirname, "__fixtures__")
8
+
9
+ it("should generate the proper types and entities from a static page", async () => {
10
+ const page = {
11
+ filePath: path.join(ROOT_DIR, "pages", "index.js"),
12
+ }
13
+
14
+ const store = await generateStore([page], { rootDir: ROOT_DIR })
15
+
16
+ expect(store.getType("index").render).toBeDefined()
17
+ expect(store.getState()).toMatchSnapshot()
18
+ })
19
+
20
+ it("should generate the proper types and entities from a page with an entity", async () => {
21
+ const page = {
22
+ filePath: path.join(ROOT_DIR, "pages", "about.js"),
23
+ }
24
+
25
+ const store = await generateStore([page], { rootDir: ROOT_DIR })
26
+
27
+ expect(store.getType("about").render).toBeDefined()
28
+ expect(store.getState()).toMatchSnapshot()
29
+ })
30
+
31
+ it("should generate the proper types and entities from a page that has metadata", async () => {
32
+ const page = {
33
+ filePath: path.join(ROOT_DIR, "pages", "posts.js"),
34
+ }
35
+
36
+ const store = await generateStore([page], { rootDir: ROOT_DIR })
37
+
38
+ expect(store.getType("posts").render).toBeDefined()
39
+ expect(store.getState()).toMatchSnapshot()
40
+ })
@@ -9,7 +9,7 @@ export function createViteConfig(options = {}) {
9
9
  const { rootDir = "src", outDir = "dist" } = options
10
10
 
11
11
  return {
12
- root: process.cwd(),
12
+ root: outDir,
13
13
  plugins: [minifyTemplateLiterals()],
14
14
  build: {
15
15
  outDir,
@@ -22,10 +22,6 @@ export function createViteConfig(options = {}) {
22
22
  entryFileNames: "[name].js",
23
23
  chunkFileNames: "[name].[hash].js",
24
24
  assetFileNames: "[name].[ext]",
25
- manualChunks(id) {
26
- // if (id.includes("node_modules/@inglorious")) return "inglorious"
27
- if (id.includes("node_modules")) return "vendor"
28
- },
29
25
  },
30
26
  },
31
27
  },