@inglorious/ssx 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -30,7 +30,7 @@ Game engines solved state complexity years ago — Inglorious Store brings those
30
30
  - ✅ Entity-based state (manage multiple instances effortlessly)
31
31
  - ✅ No action creators, thunks, or slices
32
32
  - ✅ Predictable, testable, purely functional code
33
- - ✅ Built-in lifecycle events (`add`, `remove`, `morph`)
33
+ - ✅ Built-in lifecycle events (`add`, `remove`)
34
34
  - ✅ 10x faster immutability than Redux Toolkit (Mutative vs Immer)
35
35
 
36
36
  ---
@@ -363,7 +363,6 @@ Inglorious Store has a few built-in events that you can use:
363
363
 
364
364
  - `add`: adds a new entity to the state. Triggers a `create` lifecycle event.
365
365
  - `remove`: removes an entity from the state. Triggers a `destroy` lifecycle event.
366
- - `morph`: changes the behavior of a type (advanced, used by middlewares/rendering systems)
367
366
 
368
367
  The lifecycle events can be used to define event handlers similar to constructor and destructor methods in OOP:
369
368
 
@@ -720,12 +719,12 @@ Each handler receives three arguments:
720
719
  - `dispatch(action)` - optional, if you prefer Redux-style dispatching
721
720
  - `getTypes()` - type definitions (for middleware)
722
721
  - `getType(typeName)` - type definition (for overriding)
722
+ - `setType(typeName, type)` - change the behavior of a type
723
723
 
724
724
  ### Built-in Events
725
725
 
726
726
  - **`create(entity)`** - triggered when entity added via `add` event, visible only to that entity
727
727
  - **`destroy(entity)`** - triggered when entity removed via `remove` event, visible only to that entity
728
- - **`morph(typeName, newType)`** - used to change the behavior of a type on the fly
729
728
 
730
729
  ### Notify vs Dispatch
731
730
 
package/bin/ssx.js CHANGED
@@ -4,9 +4,9 @@ import path from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
5
 
6
6
  import { Command } from "commander"
7
- import { Window } from "happy-dom"
8
7
 
9
- import { patchRandom } from "../src/random.js"
8
+ import { build } from "../src/build.js"
9
+ import { dev } from "../src/dev.js"
10
10
 
11
11
  const __filename = fileURLToPath(import.meta.url)
12
12
  const __dirname = path.dirname(__filename)
@@ -28,40 +28,17 @@ program
28
28
  .description("Start development server with hot reload")
29
29
  .option("-r, --root <dir>", "source root directory", "src")
30
30
  .option("-p, --port <port>", "dev server port", 3000)
31
- .option("-s, --seed <seed>", "seed for random number generator", 42)
32
31
  .option("-t, --title <title>", "default page title", "My Site")
33
32
  .option("--styles <styles...>", "CSS files to include")
34
33
  .option("--scripts <scripts...>", "JS files to include")
35
34
  .action(async (options) => {
36
35
  const cwd = process.cwd()
37
- const seed = Number(options.seed)
38
36
 
39
37
  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
38
  await dev({
61
39
  rootDir: path.resolve(cwd, options.root),
62
40
  port: Number(options.port),
63
41
  renderOptions: {
64
- seed: Number(options.seed),
65
42
  title: options.title,
66
43
  meta: {},
67
44
  styles: options.styles || [],
@@ -79,40 +56,17 @@ program
79
56
  .description("Build static site from pages directory")
80
57
  .option("-r, --root <dir>", "source root directory", "src")
81
58
  .option("-o, --out <dir>", "output directory", "dist")
82
- .option("-s, --seed <seed>", "seed for random number generator", 42)
83
59
  .option("-t, --title <title>", "default page title", "My Site")
84
60
  .option("--styles <styles...>", "CSS files to include")
85
61
  .option("--scripts <scripts...>", "JS files to include")
86
62
  .action(async (options) => {
87
63
  const cwd = process.cwd()
88
- const seed = Number(options.seed)
89
64
 
90
65
  try {
91
- // 1️⃣ Install DOM *before anything else*
92
- const window = new Window()
93
-
94
- globalThis.window = window
95
- globalThis.document = window.document
96
- globalThis.HTMLElement = window.HTMLElement
97
- globalThis.Node = window.Node
98
- globalThis.Comment = window.Comment
99
-
100
- // Optional but sometimes needed
101
- globalThis.customElements = window.customElements
102
-
103
- // 3️⃣ Patch with the parsed seed
104
- const restore = patchRandom(seed)
105
- await import("@inglorious/web")
106
- restore()
107
-
108
- // 4️⃣ NOW import and run build
109
- const { build } = await import("../src/build.js")
110
-
111
66
  await build({
112
67
  rootDir: path.resolve(cwd, options.root),
113
68
  outDir: path.resolve(cwd, options.out),
114
69
  renderOptions: {
115
- seed,
116
70
  title: options.title,
117
71
  meta: {},
118
72
  styles: options.styles || [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -41,13 +41,13 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
+ "@lit-labs/ssr": "^4.0.0",
44
45
  "commander": "^14.0.2",
45
46
  "connect": "^3.7.0",
46
47
  "glob": "^13.0.0",
47
- "happy-dom": "^20.0.11",
48
48
  "rollup-plugin-minify-template-literals": "^1.1.7",
49
49
  "vite": "^7.1.3",
50
- "@inglorious/web": "3.0.1"
50
+ "@inglorious/web": "4.0.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "prettier": "^3.6.2",
package/src/build.js CHANGED
@@ -7,13 +7,11 @@ import { build as viteBuild } from "vite"
7
7
  import { renderPage } from "./render.js"
8
8
  import { getPages } from "./router.js"
9
9
  import { generateApp } from "./scripts/app.js"
10
- import { generateLitLoader } from "./scripts/lit-loader.js"
11
- import { generateMain } from "./scripts/main.js"
12
10
  import { generateStore } from "./store.js"
13
11
  import { createViteConfig } from "./vite-config.js"
14
12
 
15
13
  export async function build(options = {}) {
16
- const { rootDir = "src", outDir = "dist", renderOptions = {} } = options
14
+ const { rootDir = "src", outDir = "dist" } = options
17
15
 
18
16
  console.log("🔨 Starting build...\n")
19
17
 
@@ -25,29 +23,23 @@ export async function build(options = {}) {
25
23
  const pages = await getPages(path.join(rootDir, "pages"))
26
24
  console.log(`📄 Found ${pages.length} pages to build\n`)
27
25
 
26
+ // Generate store config once for all pages
27
+ const store = await generateStore(pages, options)
28
+
28
29
  // Render all pages
29
- const renderedPages = await generatePages(pages, options)
30
+ const htmls = await renderPages(store, pages, options)
30
31
 
31
32
  // Write all pages to disk
32
33
  console.log("\n💾 Writing files...\n")
33
34
 
34
- // Generate store config once for all pages
35
- const store = await generateStore(pages, options)
36
-
37
- // Generate lit-loader.js
38
- const litLoader = generateLitLoader(renderOptions)
39
- await fs.writeFile(path.join(outDir, "lit-loader.js"), litLoader, "utf-8")
40
-
41
35
  const app = generateApp(store, pages)
42
- await fs.writeFile(path.join(outDir, "app.js"), app, "utf-8")
36
+ await fs.writeFile(path.join(outDir, "main.js"), app, "utf-8")
43
37
 
44
- const main = generateMain()
45
- await fs.writeFile(path.join(outDir, "main.js"), main, "utf-8")
46
-
47
- for (const { page, html } of renderedPages) {
38
+ pages.forEach(async (page, index) => {
39
+ const html = htmls[index]
48
40
  const filePath = await writePageToDisk(page.path, html, outDir)
49
41
  console.log(` ✓ ${filePath}`)
50
- }
42
+ })
51
43
 
52
44
  // Bundle with Vite
53
45
  console.log("\n📦 Bundling with Vite...\n")
@@ -55,16 +47,14 @@ export async function build(options = {}) {
55
47
  await viteBuild(viteConfig)
56
48
 
57
49
  // Remove bundled files
58
- console.log("\n🧹 Cleaning up...\n")
59
- await fs.rm(path.join(outDir, "lit-loader.js"))
60
- await fs.rm(path.join(outDir, "app.js"))
50
+ // console.log("\n🧹 Cleaning up...\n")
61
51
 
62
52
  console.log("\n✨ Build complete!\n")
63
53
 
64
- return { pages: renderedPages.length, outDir }
54
+ return { pages: htmls.length, outDir }
65
55
  }
66
56
 
67
- async function generatePages(pages, options = {}) {
57
+ async function renderPages(store, pages, options = {}) {
68
58
  const { renderOptions } = options
69
59
 
70
60
  const renderedPages = []
@@ -72,13 +62,12 @@ async function generatePages(pages, options = {}) {
72
62
  for (const page of pages) {
73
63
  console.log(` Rendering ${page.path}...`)
74
64
 
75
- const store = await generateStore([page], options)
76
65
  const module = await import(pathToFileURL(page.filePath))
77
- const html = await renderPage(store, module, {
66
+ const html = await renderPage(store, page, module, {
78
67
  ...renderOptions,
79
68
  wrap: true,
80
69
  })
81
- renderedPages.push({ page, module, html })
70
+ renderedPages.push(html)
82
71
  }
83
72
 
84
73
  return renderedPages
package/src/dev.js CHANGED
@@ -6,8 +6,6 @@ import { createServer } from "vite"
6
6
  import { renderPage } from "./render.js"
7
7
  import { getPages } from "./router.js"
8
8
  import { generateApp } from "./scripts/app.js"
9
- import { generateLitLoader } from "./scripts/lit-loader.js"
10
- import { generateMain } from "./scripts/main.js"
11
9
  import { generateStore } from "./store.js"
12
10
 
13
11
  export async function dev(options = {}) {
@@ -22,15 +20,6 @@ export async function dev(options = {}) {
22
20
  // Generate store config once for all pages
23
21
  const store = await generateStore(pages, options)
24
22
 
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
23
  // Create Vite dev server
35
24
  const viteServer = await createServer({
36
25
  root: process.cwd(),
@@ -58,14 +47,15 @@ export async function dev(options = {}) {
58
47
  const page = pages.find((p) => matchRoute(p.path, url))
59
48
  if (!page) return next()
60
49
 
61
- const store = await generateStore([page], options)
62
50
  const module = await viteServer.ssrLoadModule(page.filePath)
63
- const html = await renderPage(store, module, {
51
+ const html = await renderPage(store, page, module, {
64
52
  ...renderOptions,
65
53
  wrap: true,
66
- dev: true,
67
54
  })
68
55
 
56
+ const app = generateApp(store, pages)
57
+ virtualFiles.set("/main.js", app)
58
+
69
59
  res.setHeader("Content-Type", "text/html")
70
60
  res.end(html)
71
61
  } catch (error) {
package/src/html.js CHANGED
@@ -1,22 +1,23 @@
1
- import { mount } from "@inglorious/web"
1
+ import { html } from "@inglorious/web"
2
+ import { render as ssrRender } from "@lit-labs/ssr"
3
+ import { collectResult } from "@lit-labs/ssr/lib/render-result.js"
2
4
 
3
- export function toHTML(store, renderFn, options = {}) {
4
- const window = globalThis.window
5
- const document = window.document
5
+ export async function toHTML(store, renderFn, options = {}) {
6
+ const api = { ...store._api }
7
+ api.render = createRender(api)
6
8
 
7
- document.body.innerHTML = '<div id="root"></div>'
8
- const root = document.getElementById("root")
9
+ // Generate the template
10
+ const template = renderFn(api)
9
11
 
10
- mount(store, renderFn, root)
11
- store.update()
12
+ // SSR render → HTML with hydration markers
13
+ const result = ssrRender(template)
14
+ const resultString = await collectResult(result)
12
15
 
13
- const html = options.stripLitMarkers
14
- ? stripLitMarkers(root.innerHTML)
15
- : root.innerHTML
16
+ const finalHTML = options.stripLitMarkers
17
+ ? stripLitMarkers(resultString)
18
+ : resultString
16
19
 
17
- window.close()
18
-
19
- return options.wrap ? wrapHTML(html, options) : html
20
+ return options.wrap ? wrapHTML(finalHTML, options) : finalHTML
20
21
  }
21
22
 
22
23
  function stripLitMarkers(html) {
@@ -46,3 +47,29 @@ function wrapHTML(body, options) {
46
47
  </body>
47
48
  </html>`
48
49
  }
50
+
51
+ function createRender(api) {
52
+ return function (id, options = {}) {
53
+ const entity = api.getEntity(id)
54
+
55
+ if (!entity) {
56
+ const { allowType } = options
57
+ if (!allowType) return ""
58
+
59
+ const type = api.getType(id)
60
+ if (!type?.render) {
61
+ console.warn(`No entity or type found: ${id}`)
62
+ return html`<div>Not found: ${id}</div>`
63
+ }
64
+ return type.render(api)
65
+ }
66
+
67
+ const type = api.getType(entity.type)
68
+ if (!type?.render) {
69
+ console.warn(`No render function for type: ${entity.type}`)
70
+ return html`<div>No renderer for ${entity.type}</div>`
71
+ }
72
+
73
+ return type.render(entity, api)
74
+ }
75
+ }
package/src/html.test.js CHANGED
@@ -5,27 +5,27 @@ import { toHTML } from "./html.js"
5
5
 
6
6
  const DEFAULT_OPTIONS = { stripLitMarkers: true }
7
7
 
8
- describe("toHTML", () => {
8
+ describe("await toHTML", () => {
9
9
  describe("basic rendering", () => {
10
- it("should render simple HTML without wrapping", () => {
10
+ it("should render simple HTML without wrapping", async () => {
11
11
  const store = createStore()
12
12
  const renderFn = () => html`<h1>Hello World</h1>`
13
13
 
14
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
14
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
15
15
 
16
16
  expect(result).toMatchSnapshot()
17
17
  })
18
18
 
19
- it("should render empty content", () => {
19
+ it("should render empty content", async () => {
20
20
  const store = createStore()
21
21
  const renderFn = () => html``
22
22
 
23
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
23
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
24
24
 
25
25
  expect(result).toMatchSnapshot()
26
26
  })
27
27
 
28
- it("should render nested elements", () => {
28
+ it("should render nested elements", async () => {
29
29
  const store = createStore()
30
30
  const renderFn = () =>
31
31
  html`<div class="container">
@@ -33,24 +33,24 @@ describe("toHTML", () => {
33
33
  <p>Content</p>
34
34
  </div>`
35
35
 
36
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
36
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
37
37
 
38
38
  expect(result).toMatchSnapshot()
39
39
  })
40
40
 
41
- it("should render with inline styles", () => {
41
+ it("should render with inline styles", async () => {
42
42
  const store = createStore()
43
43
  const renderFn = () =>
44
44
  html`<div style="color: red; font-size: 16px;">Styled</div>`
45
45
 
46
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
46
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
47
47
 
48
48
  expect(result).toMatchSnapshot()
49
49
  })
50
50
  })
51
51
 
52
52
  describe("rendering with state", () => {
53
- it("should render entities from store", () => {
53
+ it("should render entities from store", async () => {
54
54
  const store = createStore({
55
55
  types: {
56
56
  message: {
@@ -64,12 +64,12 @@ describe("toHTML", () => {
64
64
 
65
65
  const renderFn = (api) => html`<div>${api.render("greeting")}</div>`
66
66
 
67
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
67
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
68
68
 
69
69
  expect(result).toMatchSnapshot()
70
70
  })
71
71
 
72
- it("should render multiple entities", () => {
72
+ it("should render multiple entities", async () => {
73
73
  const store = createStore({
74
74
  types: {
75
75
  item: {
@@ -88,12 +88,12 @@ describe("toHTML", () => {
88
88
  ${api.render("item1")} ${api.render("item2")} ${api.render("item3")}
89
89
  </ul>`
90
90
 
91
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
91
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
92
92
 
93
93
  expect(result).toMatchSnapshot()
94
94
  })
95
95
 
96
- it("should evaluate conditional rendering based on state", () => {
96
+ it("should evaluate conditional rendering based on state", async () => {
97
97
  const store = createStore({
98
98
  types: {
99
99
  content: {
@@ -110,18 +110,18 @@ describe("toHTML", () => {
110
110
 
111
111
  const renderFn = (api) => html`<div>${api.render("content")}</div>`
112
112
 
113
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
113
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
114
114
 
115
115
  expect(result).toMatchSnapshot()
116
116
  })
117
117
  })
118
118
 
119
119
  describe("HTML wrapping", () => {
120
- it("should wrap HTML with basic DOCTYPE and structure", () => {
120
+ it("should wrap HTML with basic DOCTYPE and structure", async () => {
121
121
  const store = createStore()
122
122
  const renderFn = () => html`<h1>Page Title</h1>`
123
123
 
124
- const result = toHTML(store, renderFn, {
124
+ const result = await toHTML(store, renderFn, {
125
125
  ...DEFAULT_OPTIONS,
126
126
  wrap: true,
127
127
  title: "My Page",
@@ -130,11 +130,11 @@ describe("toHTML", () => {
130
130
  expect(result).toMatchSnapshot()
131
131
  })
132
132
 
133
- it("should include meta tags in wrapped HTML", () => {
133
+ it("should include meta tags in wrapped HTML", async () => {
134
134
  const store = createStore()
135
135
  const renderFn = () => html`<p>Content</p>`
136
136
 
137
- const result = toHTML(store, renderFn, {
137
+ const result = await toHTML(store, renderFn, {
138
138
  ...DEFAULT_OPTIONS,
139
139
  wrap: true,
140
140
  title: "Test Page",
@@ -147,11 +147,11 @@ describe("toHTML", () => {
147
147
  expect(result).toMatchSnapshot()
148
148
  })
149
149
 
150
- it("should include stylesheets in wrapped HTML", () => {
150
+ it("should include stylesheets in wrapped HTML", async () => {
151
151
  const store = createStore()
152
152
  const renderFn = () => html`<p>Content</p>`
153
153
 
154
- const result = toHTML(store, renderFn, {
154
+ const result = await toHTML(store, renderFn, {
155
155
  ...DEFAULT_OPTIONS,
156
156
  wrap: true,
157
157
  styles: ["/css/style.css", "/css/theme.css"],
@@ -160,11 +160,11 @@ describe("toHTML", () => {
160
160
  expect(result).toMatchSnapshot()
161
161
  })
162
162
 
163
- it("should include scripts in wrapped HTML", () => {
163
+ it("should include scripts in wrapped HTML", async () => {
164
164
  const store = createStore()
165
165
  const renderFn = () => html`<p>Content</p>`
166
166
 
167
- const result = toHTML(store, renderFn, {
167
+ const result = await toHTML(store, renderFn, {
168
168
  ...DEFAULT_OPTIONS,
169
169
  wrap: true,
170
170
  scripts: ["/js/app.js", "/js/analytics.js"],
@@ -173,11 +173,11 @@ describe("toHTML", () => {
173
173
  expect(result).toMatchSnapshot()
174
174
  })
175
175
 
176
- it("should include all options in wrapped HTML", () => {
176
+ it("should include all options in wrapped HTML", async () => {
177
177
  const store = createStore()
178
178
  const renderFn = () => html`<main>Main content</main>`
179
179
 
180
- const result = toHTML(store, renderFn, {
180
+ const result = await toHTML(store, renderFn, {
181
181
  ...DEFAULT_OPTIONS,
182
182
  wrap: true,
183
183
  title: "Complete Page",
@@ -189,20 +189,23 @@ describe("toHTML", () => {
189
189
  expect(result).toMatchSnapshot()
190
190
  })
191
191
 
192
- it("should default to empty title when not provided", () => {
192
+ it("should default to empty title when not provided", async () => {
193
193
  const store = createStore()
194
194
  const renderFn = () => html`<p>Content</p>`
195
195
 
196
- const result = toHTML(store, renderFn, { ...DEFAULT_OPTIONS, wrap: true })
196
+ const result = await toHTML(store, renderFn, {
197
+ ...DEFAULT_OPTIONS,
198
+ wrap: true,
199
+ })
197
200
 
198
201
  expect(result).toMatchSnapshot()
199
202
  })
200
203
 
201
- it("should handle empty arrays for meta, styles, and scripts", () => {
204
+ it("should handle empty arrays for meta, styles, and scripts", async () => {
202
205
  const store = createStore()
203
206
  const renderFn = () => html`<p>Content</p>`
204
207
 
205
- const result = toHTML(store, renderFn, {
208
+ const result = await toHTML(store, renderFn, {
206
209
  ...DEFAULT_OPTIONS,
207
210
  wrap: true,
208
211
  meta: {},
@@ -215,7 +218,7 @@ describe("toHTML", () => {
215
218
  })
216
219
 
217
220
  describe("API rendering within components", () => {
218
- it("should support api.render() method in component render function", () => {
221
+ it("should support api.render() method in component render function", async () => {
219
222
  const store = createStore({
220
223
  types: {
221
224
  wrapper: {
@@ -231,14 +234,14 @@ describe("toHTML", () => {
231
234
 
232
235
  const renderFn = (api) => html`<div>${api.render("myWrapper")}</div>`
233
236
 
234
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
237
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
235
238
 
236
239
  expect(result).toMatchSnapshot()
237
240
  })
238
241
  })
239
242
 
240
243
  describe("complex scenarios", () => {
241
- it("should render a complete page structure with message list", () => {
244
+ it("should render a complete page structure with message list", async () => {
242
245
  const store = createStore({
243
246
  types: {
244
247
  message: {
@@ -259,12 +262,12 @@ describe("toHTML", () => {
259
262
  <footer>© 2024</footer>
260
263
  </div>`
261
264
 
262
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
265
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
263
266
 
264
267
  expect(result).toMatchSnapshot()
265
268
  })
266
269
 
267
- it("should render wrapped complex page with all assets", () => {
270
+ it("should render wrapped complex page with all assets", async () => {
268
271
  const store = createStore({
269
272
  types: {
270
273
  header: { render: () => html`<header><h1>My Website</h1></header>` },
@@ -278,7 +281,7 @@ describe("toHTML", () => {
278
281
  <p>Welcome!</p>
279
282
  </div>`
280
283
 
281
- const result = toHTML(store, renderFn, {
284
+ const result = await toHTML(store, renderFn, {
282
285
  ...DEFAULT_OPTIONS,
283
286
  wrap: true,
284
287
  title: "My Website",
@@ -295,7 +298,7 @@ describe("toHTML", () => {
295
298
  })
296
299
 
297
300
  describe("event handling", () => {
298
- it("should render event handlers in templates", () => {
301
+ it("should render event handlers in templates", async () => {
299
302
  const store = createStore({
300
303
  types: {
301
304
  button: {
@@ -312,12 +315,12 @@ describe("toHTML", () => {
312
315
 
313
316
  const renderFn = (api) => html`<div>${api.render("myButton")}</div>`
314
317
 
315
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
318
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
316
319
 
317
320
  expect(result).toMatchSnapshot()
318
321
  })
319
322
 
320
- it("should render multiple event handlers", () => {
323
+ it("should render multiple event handlers", async () => {
321
324
  const store = createStore({
322
325
  types: {
323
326
  counter: {
@@ -340,36 +343,36 @@ describe("toHTML", () => {
340
343
 
341
344
  const renderFn = (api) => html`<div>${api.render("counter1")}</div>`
342
345
 
343
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
346
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
344
347
 
345
348
  expect(result).toMatchSnapshot()
346
349
  })
347
350
  })
348
351
 
349
352
  describe("edge cases", () => {
350
- it("should handle special characters in content", () => {
353
+ it("should handle special characters in content", async () => {
351
354
  const store = createStore()
352
355
  const renderFn = () => html`<p>&lt;script&gt; &amp; "quotes"</p>`
353
356
 
354
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
357
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
355
358
 
356
359
  expect(result).toMatchSnapshot()
357
360
  })
358
361
 
359
- it("should not include wrap by default", () => {
362
+ it("should not include wrap by default", async () => {
360
363
  const store = createStore()
361
364
  const renderFn = () => html`<p>Content</p>`
362
365
 
363
- const result = toHTML(store, renderFn, {})
366
+ const result = await toHTML(store, renderFn, {})
364
367
 
365
368
  expect(result).toMatchSnapshot()
366
369
  })
367
370
 
368
- it("should return only inner HTML when wrap is false", () => {
371
+ it("should return only inner HTML when wrap is false", async () => {
369
372
  const store = createStore()
370
373
  const renderFn = () => html`<p>Inner</p>`
371
374
 
372
- const result = toHTML(store, renderFn, {
375
+ const result = await toHTML(store, renderFn, {
373
376
  ...DEFAULT_OPTIONS,
374
377
  wrap: false,
375
378
  })
@@ -377,11 +380,11 @@ describe("toHTML", () => {
377
380
  expect(result).toMatchSnapshot()
378
381
  })
379
382
 
380
- it("should close DOM window properly", () => {
383
+ it("should close DOM window properly", async () => {
381
384
  const store = createStore()
382
385
  const renderFn = () => html`<p>Test</p>`
383
386
 
384
- const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
387
+ const result = await toHTML(store, renderFn, DEFAULT_OPTIONS)
385
388
 
386
389
  expect(result).toBeDefined()
387
390
  expect(result).not.toBeNull()
package/src/render.js CHANGED
@@ -1,31 +1,48 @@
1
1
  import { toHTML } from "./html.js"
2
2
  import { getModuleName } from "./module.js"
3
3
 
4
- export async function renderPage(store, pageModule, options) {
5
- const name = getModuleName(pageModule)
4
+ export async function renderPage(store, page, module, options = {}) {
5
+ const { title = "", meta = {}, scripts = [], styles = [] } = options
6
+
7
+ const name = getModuleName(module)
6
8
  const api = store._api
7
9
  const entity = api.getEntity(name)
8
10
 
9
- if (pageModule.load) {
10
- await pageModule.load(entity, store._api)
11
+ if (module.load) {
12
+ await module.load(entity, page, store._api)
13
+ }
14
+
15
+ const pageTitle = module.title
16
+ ? typeof module.title === "function"
17
+ ? module.title(entity, api)
18
+ : module.title
19
+ : title
20
+
21
+ const pageMeta = {
22
+ ...meta,
23
+ ...(typeof module.meta === "function"
24
+ ? module.meta(entity, api)
25
+ : (module.meta ?? {})),
11
26
  }
12
27
 
13
- const title =
14
- typeof pageModule.title === "function"
15
- ? pageModule.title(entity, api)
16
- : pageModule.title
17
- const meta =
18
- typeof options.meta === "function"
19
- ? options.meta(entity, api)
20
- : options.meta
21
- const scripts = pageModule.scripts
22
- const styles = pageModule.styles
28
+ const pageScripts = [
29
+ ...scripts,
30
+ ...(typeof module.scripts === "function"
31
+ ? module.scripts(entity, api)
32
+ : (module.scripts ?? [])),
33
+ ]
34
+ const pageStyles = [
35
+ ...styles,
36
+ ...(typeof module.styles === "function"
37
+ ? module.styles(entity, api)
38
+ : (module.styles ?? [])),
39
+ ]
23
40
 
24
41
  return toHTML(store, (api) => api.render(name, { allowType: true }), {
25
42
  ...options,
26
- title,
27
- meta,
28
- scripts,
29
- styles,
43
+ title: pageTitle,
44
+ meta: pageMeta,
45
+ scripts: pageScripts,
46
+ styles: pageStyles,
30
47
  })
31
48
  }
@@ -11,6 +11,7 @@ const PAGES_DIR = path.join(ROOT_DIR, "pages")
11
11
  const DEFAULT_OPTIONS = { stripLitMarkers: true }
12
12
 
13
13
  it("should render a static page fragment", async () => {
14
+ const page = { path: "/" }
14
15
  const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
15
16
 
16
17
  const store = createStore({
@@ -18,42 +19,45 @@ it("should render a static page fragment", async () => {
18
19
  updateMode: "manual",
19
20
  })
20
21
 
21
- const html = await renderPage(store, module, DEFAULT_OPTIONS)
22
+ const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
22
23
 
23
24
  expect(html).toMatchSnapshot()
24
25
  })
25
26
 
26
- it("should render a whole static page", async () => {
27
+ it("should render a page with entity", async () => {
28
+ const page = { path: "/about" }
27
29
  const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
28
30
 
29
31
  const store = createStore({
30
32
  types: { about: module.about },
33
+ entities: { about: { type: "about", name: "Us" } },
31
34
  updateMode: "manual",
32
35
  })
33
36
 
34
- const html = await renderPage(store, module, {
35
- ...DEFAULT_OPTIONS,
36
- wrap: true,
37
- })
37
+ const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
38
38
 
39
39
  expect(html).toMatchSnapshot()
40
40
  })
41
41
 
42
- it("should render a page with entity", async () => {
42
+ it("should render a page with metadata", async () => {
43
+ const page = { path: "/about" }
43
44
  const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
44
45
 
45
46
  const store = createStore({
46
47
  types: { about: module.about },
47
- entities: { about: { type: "about", name: "Us" } },
48
48
  updateMode: "manual",
49
49
  })
50
50
 
51
- const html = await renderPage(store, module, DEFAULT_OPTIONS)
51
+ const html = await renderPage(store, page, module, {
52
+ ...DEFAULT_OPTIONS,
53
+ wrap: true,
54
+ })
52
55
 
53
56
  expect(html).toMatchSnapshot()
54
57
  })
55
58
 
56
59
  it("should render a page with pre-fetched data", async () => {
60
+ const page = { path: "/posts" }
57
61
  const module = await import(path.resolve(path.join(PAGES_DIR, "posts.js")))
58
62
 
59
63
  const store = createStore({
@@ -62,7 +66,7 @@ it("should render a page with pre-fetched data", async () => {
62
66
  updateMode: "manual",
63
67
  })
64
68
 
65
- const html = await renderPage(store, module, DEFAULT_OPTIONS)
69
+ const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
66
70
 
67
71
  expect(html).toMatchSnapshot()
68
72
  })
package/src/router.js CHANGED
@@ -3,6 +3,8 @@ import { pathToFileURL } from "node:url"
3
3
 
4
4
  import { glob } from "glob"
5
5
 
6
+ import { getModuleName } from "./module.js"
7
+
6
8
  const NEXT_MATCH = 1
7
9
 
8
10
  const STATIC_SEGMENT_WEIGHT = 3
@@ -18,44 +20,43 @@ export async function getPages(pagesDir = "pages") {
18
20
  const pages = []
19
21
 
20
22
  for (const route of routes) {
23
+ const module = await import(pathToFileURL(path.resolve(route.filePath)))
24
+ const moduleName = getModuleName(module)
25
+
21
26
  if (isDynamic(route.pattern)) {
22
27
  // Dynamic route - call getStaticPaths if it exists
23
- try {
24
- const module = await import(pathToFileURL(path.resolve(route.filePath)))
25
-
26
- if (typeof module.getStaticPaths === "function") {
27
- const paths = await module.getStaticPaths()
28
-
29
- for (const pathOrObject of paths) {
30
- const urlPath =
31
- typeof pathOrObject === "string"
32
- ? pathOrObject
33
- : pathOrObject.path
34
-
35
- const params = extractParams(route, urlPath)
36
-
37
- pages.push({
38
- path: urlPath,
39
- modulePath: route.modulePath,
40
- filePath: route.filePath,
41
- params,
42
- })
43
- }
44
- } else {
45
- console.warn(
46
- `Dynamic route ${route.filePath} has no getStaticPaths export. ` +
47
- `It will be skipped during SSG.`,
48
- )
28
+ if (typeof module.getStaticPaths === "function") {
29
+ const paths = await module.getStaticPaths()
30
+
31
+ for (const pathOrObject of paths) {
32
+ const urlPath =
33
+ typeof pathOrObject === "string" ? pathOrObject : pathOrObject.path
34
+
35
+ const params = extractParams(route, urlPath)
36
+
37
+ pages.push({
38
+ pattern: route.pattern,
39
+ path: urlPath,
40
+ modulePath: route.modulePath,
41
+ filePath: route.filePath,
42
+ moduleName,
43
+ params,
44
+ })
49
45
  }
50
- } catch (error) {
51
- console.error(`Error loading ${route.filePath}:`, error)
46
+ } else {
47
+ console.warn(
48
+ `Dynamic route ${route.filePath} has no getStaticPaths export. ` +
49
+ `It will be skipped during SSG.`,
50
+ )
52
51
  }
53
52
  } else {
54
53
  // Static route - add directly
55
54
  pages.push({
56
- path: route.pattern === "" ? "/" : route.pattern,
55
+ pattern: route.pattern,
56
+ path: route.pattern || "/",
57
57
  modulePath: route.modulePath,
58
58
  filePath: route.filePath,
59
+ moduleName,
59
60
  params: {},
60
61
  })
61
62
  }
@@ -1,41 +1,58 @@
1
+ /* eslint-disable no-unused-vars */
2
+
1
3
  /**
2
4
  * Generate the code that goes inside the <!-- SSX --> marker.
3
5
  * This creates the types and entities objects for the client-side store.
4
6
  */
5
7
  export function generateApp(store, pages) {
6
8
  // Collect all unique page modules and their exports
7
- const routeEntries = pages.map(
8
- (page) =>
9
- ` "${page.path}": () => import("@/pages/${page.modulePath}")`,
10
- )
9
+ const routes = pages
10
+ .filter((page, index) => {
11
+ return pages.findIndex((p) => p.pattern === page.pattern) === index
12
+ })
13
+ .map(
14
+ (page) =>
15
+ ` "${page.pattern}": () => import("@/pages/${page.modulePath}")`,
16
+ )
11
17
 
12
18
  return `import { createDevtools, createStore, mount } from "@inglorious/web"
13
- import { router } from "@inglorious/web/router"
19
+ import { getRoute, router, setRoutes } from "@inglorious/web/router"
20
+
21
+ const pages = ${JSON.stringify(pages.map(({ filePath, ...page }) => page))}
22
+ const path = window.location.pathname + window.location.search + window.location.hash
23
+ const page = pages.find((page) => page.path === path)
14
24
 
15
25
  const types = { router }
16
26
 
17
27
  const entities = {
18
28
  router: {
19
29
  type: "router",
20
- routes: {
21
- ${routeEntries.join(",\n")}
22
- }
30
+ path: page.path,
31
+ route: page.moduleName,
23
32
  },
24
33
  ${JSON.stringify(store.getState(), null, 2).slice(1, -1)}
25
34
  }
26
35
 
27
36
  const middlewares = []
28
- // if (import.meta.env.DEV) {
37
+ if (import.meta.env.DEV) {
29
38
  middlewares.push(createDevtools().middleware)
30
- // }
39
+ }
40
+
41
+ setRoutes({
42
+ ${routes.join(",\n")}
43
+ })
44
+
45
+ const module = await getRoute(page.pattern)()
46
+ const type = module[page.moduleName]
47
+ types[page.moduleName] = type
31
48
 
32
49
  const store = createStore({ types, entities, middlewares })
33
50
 
34
51
  const root = document.getElementById("root")
35
- root.innerHTML = ""
36
52
 
37
53
  mount(store, (api) => {
38
54
  const { route } = api.getEntity("router")
39
55
  return api.render(route, { allowType: true })
40
- }, root)`
56
+ }, root)
57
+ `
41
58
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path"
2
2
 
3
- import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
3
+ // import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
4
4
 
5
5
  /**
6
6
  * Generate Vite config for building the client bundle
@@ -9,8 +9,8 @@ export function createViteConfig(options = {}) {
9
9
  const { rootDir = "src", outDir = "dist" } = options
10
10
 
11
11
  return {
12
- root: outDir,
13
- plugins: [minifyTemplateLiterals()],
12
+ root: rootDir,
13
+ // plugins: [minifyTemplateLiterals()], // TODO: minification breaks hydration. The footprint difference is minimal after all
14
14
  build: {
15
15
  outDir,
16
16
  emptyOutDir: false, // Don't delete HTML files we already generated
@@ -22,6 +22,12 @@ export function createViteConfig(options = {}) {
22
22
  entryFileNames: "[name].js",
23
23
  chunkFileNames: "[name].[hash].js",
24
24
  assetFileNames: "[name].[ext]",
25
+
26
+ manualChunks(id) {
27
+ if (id.includes("node_modules")) {
28
+ return "lib"
29
+ }
30
+ },
25
31
  },
26
32
  },
27
33
  },
@@ -1,23 +0,0 @@
1
- export function generateLitLoader(options = {}) {
2
- return `let seed = ${options.seed}
3
- let mode = "seeded"
4
-
5
- const originalRandom = Math.random
6
- Math.random = random
7
-
8
- await import("@inglorious/web")
9
-
10
- queueMicrotask(() => {
11
- Math.random = originalRandom
12
- mode = "normal"
13
- })
14
-
15
- function random() {
16
- if (mode === "seeded") {
17
- seed = (seed * 1664525 + 1013904223) % 4294967296
18
- return seed / 4294967296
19
- }
20
- return originalRandom()
21
- }
22
- `
23
- }
@@ -1,5 +0,0 @@
1
- export function generateMain() {
2
- return `import "/lit-loader.js"
3
- await import("/app.js")
4
- `
5
- }