@inglorious/ssx 0.2.0 → 0.2.2

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 ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ import { Command } from "commander"
7
+ import { Window } from "happy-dom"
8
+
9
+ import { patchRandom } from "../src/random.js"
10
+
11
+ const __filename = fileURLToPath(import.meta.url)
12
+ const __dirname = path.dirname(__filename)
13
+
14
+ // Read package.json for version
15
+ const packageJson = JSON.parse(
16
+ await readFile(path.join(__dirname, "../package.json"), "utf-8"),
17
+ )
18
+
19
+ const program = new Command()
20
+
21
+ program
22
+ .name("ssx")
23
+ .description("Static Site Xecution for @inglorious/web")
24
+ .version(packageJson.version)
25
+
26
+ program
27
+ .command("build")
28
+ .description("Build static site from pages directory")
29
+ .option("-r, --root <dir>", "source root directory", "src")
30
+ .option("-o, --out <dir>", "output directory", "dist")
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 build
58
+ const { build } = await import("../src/build.js")
59
+
60
+ await build({
61
+ rootDir: path.resolve(cwd, options.root),
62
+ outDir: path.resolve(cwd, options.out),
63
+ renderOptions: {
64
+ seed,
65
+ title: options.title,
66
+ meta: {},
67
+ styles: options.styles || [],
68
+ scripts: options.scripts || [],
69
+ },
70
+ })
71
+ } catch (error) {
72
+ console.error("Build failed:", error)
73
+ process.exit(1)
74
+ }
75
+ })
76
+
77
+ program.parse()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -22,29 +22,33 @@
22
22
  "framework"
23
23
  ],
24
24
  "type": "module",
25
+ "bin": {
26
+ "ssx": "./bin/ssx.js"
27
+ },
25
28
  "exports": {
26
- ".": {
27
- "types": "./types/index.d.ts",
28
- "import": "./src/store.js"
29
- },
30
- "./*": {
31
- "types": "./types/*.d.ts",
32
- "import": "./src/*"
33
- }
29
+ "./build": "./src/build.js",
30
+ "./router": "./src/router.js",
31
+ "./render": "./src/render.js",
32
+ "./html": "./src/html.js"
34
33
  },
35
34
  "files": [
36
- "src"
35
+ "bin",
36
+ "src",
37
+ "!src/__fixtures__",
38
+ "!src/__snapshots__"
37
39
  ],
38
40
  "publishConfig": {
39
41
  "access": "public"
40
42
  },
41
43
  "dependencies": {
44
+ "commander": "^14.0.2",
42
45
  "glob": "^13.0.0",
43
46
  "happy-dom": "^20.0.11",
44
- "@inglorious/web": "2.6.1"
47
+ "@inglorious/web": "3.0.0"
45
48
  },
46
49
  "devDependencies": {
47
50
  "prettier": "^3.6.2",
51
+ "rollup-plugin-minify-template-literals": "^1.1.7",
48
52
  "vite": "^7.1.3",
49
53
  "vitest": "^1.6.1",
50
54
  "@inglorious/eslint-config": "1.1.1"
@@ -56,6 +60,8 @@
56
60
  "format": "prettier --write '**/*.{js,jsx}'",
57
61
  "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
58
62
  "test:watch": "vitest",
59
- "test": "vitest run"
63
+ "test": "vitest run",
64
+ "dev": "node ./bin/ssx.js build -r ./src/__fixtures__",
65
+ "preview": "pnpm dlx serve -s dist"
60
66
  }
61
67
  }
package/src/build.js CHANGED
@@ -1,13 +1,125 @@
1
- // import { renderPage } from "./render.js"
2
- // import { getPages } from "./router.js"
3
-
4
- export async function build() {
5
- // const pages = await getPages()
6
- // for (const page of pages) {
7
- // const module = await import(page.filePath)
8
- // const { html, storeConfig, renderFn } = await renderPage(module, page)
9
- // TODO: implement this
10
- // Inject client script and write to dist/
11
- // await writePageToDisk(page.path, html, { storeConfig, renderFn })
12
- // }
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+
5
+ import { createStore } from "@inglorious/web"
6
+ import { build as viteBuild } from "vite"
7
+
8
+ import { getModuleName } from "./module.js"
9
+ import { renderPage } from "./render.js"
10
+ import { getPages } from "./router.js"
11
+ import { generateApp } from "./scripts/app.js"
12
+ import { generateLitLoader } from "./scripts/lit-loader.js"
13
+ import { generateMain } from "./scripts/main.js"
14
+ import { createViteConfig } from "./vite-config.js"
15
+
16
+ export async function build(options = {}) {
17
+ const { rootDir = "src", outDir = "dist", renderOptions = {} } = options
18
+
19
+ console.log("🔨 Starting build...\n")
20
+
21
+ // Clean output directory
22
+ await fs.rm(outDir, { recursive: true, force: true })
23
+ await fs.mkdir(outDir, { recursive: true })
24
+
25
+ // Get all pages to build
26
+ const pages = await getPages(path.join(rootDir, "pages"))
27
+ console.log(`📄 Found ${pages.length} pages to build\n`)
28
+
29
+ // Render all pages
30
+ const renderedPages = await generatePages(pages, options)
31
+
32
+ // Write all pages to disk
33
+ console.log("\n💾 Writing files...\n")
34
+
35
+ // Generate store config once for all pages
36
+ const store = await generateStore(pages, options)
37
+
38
+ // Generate lit-loader.js
39
+ const litLoader = generateLitLoader(renderOptions)
40
+ await fs.writeFile(path.join(outDir, "lit-loader.js"), litLoader, "utf-8")
41
+
42
+ const app = generateApp(store, renderedPages)
43
+ await fs.writeFile(path.join(outDir, "app.js"), app, "utf-8")
44
+
45
+ const main = generateMain()
46
+ await fs.writeFile(path.join(outDir, "main.js"), main, "utf-8")
47
+
48
+ for (const { page, html } of renderedPages) {
49
+ const filePath = await writePageToDisk(page.path, html, outDir)
50
+ console.log(` ✓ ${filePath}`)
51
+ }
52
+
53
+ // Bundle with Vite
54
+ console.log("\n📦 Bundling with Vite...\n")
55
+ const viteConfig = createViteConfig({ rootDir, outDir })
56
+ await viteBuild(viteConfig)
57
+
58
+ // Remove bundled files
59
+ console.log("\n🧹 Cleaning up...\n")
60
+ await fs.rm(path.join(outDir, "lit-loader.js"))
61
+ await fs.rm(path.join(outDir, "app.js"))
62
+
63
+ console.log("\n✨ Build complete!\n")
64
+
65
+ return { pages: renderedPages.length, outDir }
66
+ }
67
+
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
+ async function generatePages(pages, options = {}) {
86
+ const { renderOptions } = options
87
+
88
+ const renderedPages = []
89
+
90
+ for (const page of pages) {
91
+ console.log(` Rendering ${page.path}...`)
92
+
93
+ const store = await generateStore([page], options)
94
+ const module = await import(pathToFileURL(page.filePath))
95
+ const html = await renderPage(store, module, {
96
+ ...renderOptions,
97
+ wrap: true,
98
+ })
99
+ renderedPages.push({ page, module, html })
100
+ }
101
+
102
+ return renderedPages
103
+ }
104
+
105
+ /**
106
+ * Write a page to disk with proper directory structure.
107
+ */
108
+ async function writePageToDisk(pagePath, html, outDir = "dist") {
109
+ // Convert URL path to file path
110
+ // / -> dist/index.html
111
+ // /about -> dist/about/index.html
112
+ // /blog/post-1 -> dist/blog/post-1/index.html
113
+
114
+ // Remove leading slash
115
+ const cleanPath = pagePath.replace(/^\//, "")
116
+ const filePath = path.join(outDir, cleanPath, "index.html")
117
+
118
+ // Ensure directory exists
119
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
120
+
121
+ // Write file
122
+ await fs.writeFile(filePath, html, "utf-8")
123
+
124
+ return filePath
13
125
  }
@@ -0,0 +1,11 @@
1
+ import path from "node:path"
2
+
3
+ import { it } from "vitest"
4
+
5
+ import { build } from "./build"
6
+
7
+ const FIXTURES_DIR = path.join(__dirname, "__fixtures__")
8
+
9
+ it.skip("should build full static pages", async () => {
10
+ await build({ rootDir: FIXTURES_DIR })
11
+ })
package/src/html.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import { mount } from "@inglorious/web"
2
- import { Window } from "happy-dom"
3
2
 
4
3
  export function toHTML(store, renderFn, options = {}) {
5
- const window = new Window()
4
+ const window = globalThis.window
6
5
  const document = window.document
7
- document.body.innerHTML = '<div id="root"></div>'
8
6
 
7
+ document.body.innerHTML = '<div id="root"></div>'
9
8
  const root = document.getElementById("root")
9
+
10
10
  mount(store, renderFn, root)
11
+ store.update()
12
+
13
+ const html = options.stripLitMarkers
14
+ ? stripLitMarkers(root.innerHTML)
15
+ : root.innerHTML
11
16
 
12
- const html = stripLitMarkers(root.innerHTML)
13
17
  window.close()
14
18
 
15
19
  return options.wrap ? wrapHTML(html, options) : html
@@ -22,17 +26,22 @@ function stripLitMarkers(html) {
22
26
  }
23
27
 
24
28
  function wrapHTML(body, options) {
25
- const { title = "", metas = [], styles = [], scripts = [] } = options
29
+ const { title = "", meta = {}, styles = [], scripts = [] } = options
30
+
26
31
  return `<!DOCTYPE html>
27
32
  <html>
28
33
  <head>
29
34
  <meta charset="UTF-8">
30
35
  <title>${title}</title>
31
- ${metas.map((meta) => `<meta name="${meta.name}" content="${meta.content}">`).join("\n")}
36
+ ${Object.entries(meta)
37
+ .map(([name, content]) => `<meta name="${name}" content="${content}">`)
38
+ .join("\n")}
32
39
  ${styles.map((href) => `<link rel="stylesheet" href="${href}">`).join("\n")}
33
40
  </head>
34
41
  <body>
35
42
  <div id="root">${body}</div>
43
+
44
+ <script type="module" src="/main.js"></script>
36
45
  ${scripts.map((src) => `<script type="module" src="${src}"></script>`).join("\n")}
37
46
  </body>
38
47
  </html>`
package/src/html.test.js CHANGED
@@ -3,13 +3,15 @@ import { describe, expect, it } from "vitest"
3
3
 
4
4
  import { toHTML } from "./html.js"
5
5
 
6
+ const DEFAULT_OPTIONS = { stripLitMarkers: true }
7
+
6
8
  describe("toHTML", () => {
7
9
  describe("basic rendering", () => {
8
10
  it("should render simple HTML without wrapping", () => {
9
11
  const store = createStore()
10
12
  const renderFn = () => html`<h1>Hello World</h1>`
11
13
 
12
- const result = toHTML(store, renderFn)
14
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
13
15
 
14
16
  expect(result).toMatchSnapshot()
15
17
  })
@@ -18,7 +20,7 @@ describe("toHTML", () => {
18
20
  const store = createStore()
19
21
  const renderFn = () => html``
20
22
 
21
- const result = toHTML(store, renderFn)
23
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
22
24
 
23
25
  expect(result).toMatchSnapshot()
24
26
  })
@@ -31,7 +33,7 @@ describe("toHTML", () => {
31
33
  <p>Content</p>
32
34
  </div>`
33
35
 
34
- const result = toHTML(store, renderFn)
36
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
35
37
 
36
38
  expect(result).toMatchSnapshot()
37
39
  })
@@ -41,7 +43,7 @@ describe("toHTML", () => {
41
43
  const renderFn = () =>
42
44
  html`<div style="color: red; font-size: 16px;">Styled</div>`
43
45
 
44
- const result = toHTML(store, renderFn)
46
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
45
47
 
46
48
  expect(result).toMatchSnapshot()
47
49
  })
@@ -62,7 +64,7 @@ describe("toHTML", () => {
62
64
 
63
65
  const renderFn = (api) => html`<div>${api.render("greeting")}</div>`
64
66
 
65
- const result = toHTML(store, renderFn)
67
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
66
68
 
67
69
  expect(result).toMatchSnapshot()
68
70
  })
@@ -86,7 +88,7 @@ describe("toHTML", () => {
86
88
  ${api.render("item1")} ${api.render("item2")} ${api.render("item3")}
87
89
  </ul>`
88
90
 
89
- const result = toHTML(store, renderFn)
91
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
90
92
 
91
93
  expect(result).toMatchSnapshot()
92
94
  })
@@ -108,7 +110,7 @@ describe("toHTML", () => {
108
110
 
109
111
  const renderFn = (api) => html`<div>${api.render("content")}</div>`
110
112
 
111
- const result = toHTML(store, renderFn)
113
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
112
114
 
113
115
  expect(result).toMatchSnapshot()
114
116
  })
@@ -119,7 +121,11 @@ describe("toHTML", () => {
119
121
  const store = createStore()
120
122
  const renderFn = () => html`<h1>Page Title</h1>`
121
123
 
122
- const result = toHTML(store, renderFn, { wrap: true, title: "My Page" })
124
+ const result = toHTML(store, renderFn, {
125
+ ...DEFAULT_OPTIONS,
126
+ wrap: true,
127
+ title: "My Page",
128
+ })
123
129
 
124
130
  expect(result).toMatchSnapshot()
125
131
  })
@@ -129,12 +135,13 @@ describe("toHTML", () => {
129
135
  const renderFn = () => html`<p>Content</p>`
130
136
 
131
137
  const result = toHTML(store, renderFn, {
138
+ ...DEFAULT_OPTIONS,
132
139
  wrap: true,
133
140
  title: "Test Page",
134
- metas: [
135
- { name: "description", content: "Test description" },
136
- { name: "viewport", content: "width=device-width, initial-scale=1" },
137
- ],
141
+ meta: {
142
+ description: "Test description",
143
+ viewport: "width=device-width, initial-scale=1",
144
+ },
138
145
  })
139
146
 
140
147
  expect(result).toMatchSnapshot()
@@ -145,6 +152,7 @@ describe("toHTML", () => {
145
152
  const renderFn = () => html`<p>Content</p>`
146
153
 
147
154
  const result = toHTML(store, renderFn, {
155
+ ...DEFAULT_OPTIONS,
148
156
  wrap: true,
149
157
  styles: ["/css/style.css", "/css/theme.css"],
150
158
  })
@@ -157,6 +165,7 @@ describe("toHTML", () => {
157
165
  const renderFn = () => html`<p>Content</p>`
158
166
 
159
167
  const result = toHTML(store, renderFn, {
168
+ ...DEFAULT_OPTIONS,
160
169
  wrap: true,
161
170
  scripts: ["/js/app.js", "/js/analytics.js"],
162
171
  })
@@ -169,9 +178,10 @@ describe("toHTML", () => {
169
178
  const renderFn = () => html`<main>Main content</main>`
170
179
 
171
180
  const result = toHTML(store, renderFn, {
181
+ ...DEFAULT_OPTIONS,
172
182
  wrap: true,
173
183
  title: "Complete Page",
174
- metas: [{ name: "author", content: "Test Author" }],
184
+ meta: { author: "Test Author" },
175
185
  styles: ["/style.css"],
176
186
  scripts: ["/app.js"],
177
187
  })
@@ -183,18 +193,19 @@ describe("toHTML", () => {
183
193
  const store = createStore()
184
194
  const renderFn = () => html`<p>Content</p>`
185
195
 
186
- const result = toHTML(store, renderFn, { wrap: true })
196
+ const result = toHTML(store, renderFn, { ...DEFAULT_OPTIONS, wrap: true })
187
197
 
188
198
  expect(result).toMatchSnapshot()
189
199
  })
190
200
 
191
- it("should handle empty arrays for metas, styles, and scripts", () => {
201
+ it("should handle empty arrays for meta, styles, and scripts", () => {
192
202
  const store = createStore()
193
203
  const renderFn = () => html`<p>Content</p>`
194
204
 
195
205
  const result = toHTML(store, renderFn, {
206
+ ...DEFAULT_OPTIONS,
196
207
  wrap: true,
197
- metas: [],
208
+ meta: {},
198
209
  styles: [],
199
210
  scripts: [],
200
211
  })
@@ -220,7 +231,7 @@ describe("toHTML", () => {
220
231
 
221
232
  const renderFn = (api) => html`<div>${api.render("myWrapper")}</div>`
222
233
 
223
- const result = toHTML(store, renderFn)
234
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
224
235
 
225
236
  expect(result).toMatchSnapshot()
226
237
  })
@@ -248,7 +259,7 @@ describe("toHTML", () => {
248
259
  <footer>© 2024</footer>
249
260
  </div>`
250
261
 
251
- const result = toHTML(store, renderFn)
262
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
252
263
 
253
264
  expect(result).toMatchSnapshot()
254
265
  })
@@ -268,12 +279,13 @@ describe("toHTML", () => {
268
279
  </div>`
269
280
 
270
281
  const result = toHTML(store, renderFn, {
282
+ ...DEFAULT_OPTIONS,
271
283
  wrap: true,
272
284
  title: "My Website",
273
- metas: [
274
- { name: "description", content: "Welcome to my site" },
275
- { name: "viewport", content: "width=device-width" },
276
- ],
285
+ meta: {
286
+ description: "Welcome to my website",
287
+ viewport: "width=device-width",
288
+ },
277
289
  styles: ["/style.css"],
278
290
  scripts: ["/script.js"],
279
291
  })
@@ -300,7 +312,7 @@ describe("toHTML", () => {
300
312
 
301
313
  const renderFn = (api) => html`<div>${api.render("myButton")}</div>`
302
314
 
303
- const result = toHTML(store, renderFn)
315
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
304
316
 
305
317
  expect(result).toMatchSnapshot()
306
318
  })
@@ -328,7 +340,7 @@ describe("toHTML", () => {
328
340
 
329
341
  const renderFn = (api) => html`<div>${api.render("counter1")}</div>`
330
342
 
331
- const result = toHTML(store, renderFn)
343
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
332
344
 
333
345
  expect(result).toMatchSnapshot()
334
346
  })
@@ -339,7 +351,7 @@ describe("toHTML", () => {
339
351
  const store = createStore()
340
352
  const renderFn = () => html`<p>&lt;script&gt; &amp; "quotes"</p>`
341
353
 
342
- const result = toHTML(store, renderFn)
354
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
343
355
 
344
356
  expect(result).toMatchSnapshot()
345
357
  })
@@ -357,7 +369,10 @@ describe("toHTML", () => {
357
369
  const store = createStore()
358
370
  const renderFn = () => html`<p>Inner</p>`
359
371
 
360
- const result = toHTML(store, renderFn, { wrap: false })
372
+ const result = toHTML(store, renderFn, {
373
+ ...DEFAULT_OPTIONS,
374
+ wrap: false,
375
+ })
361
376
 
362
377
  expect(result).toMatchSnapshot()
363
378
  })
@@ -366,7 +381,7 @@ describe("toHTML", () => {
366
381
  const store = createStore()
367
382
  const renderFn = () => html`<p>Test</p>`
368
383
 
369
- const result = toHTML(store, renderFn)
384
+ const result = toHTML(store, renderFn, DEFAULT_OPTIONS)
370
385
 
371
386
  expect(result).toBeDefined()
372
387
  expect(result).not.toBeNull()
package/src/module.js ADDED
@@ -0,0 +1,20 @@
1
+ const RESERVED = ["title", "meta", "scripts", "styles", "load"]
2
+
3
+ export function getModuleName(pageModule) {
4
+ const name = Object.keys(pageModule).find((key) => {
5
+ if (RESERVED.includes(key)) return false
6
+ const value = pageModule[key]
7
+ return (
8
+ value && typeof value === "object" && typeof value.render === "function"
9
+ )
10
+ })
11
+
12
+ if (!name) {
13
+ throw new Error(
14
+ "Page module must export an entity with a render() method. " +
15
+ `Found exports: ${Object.keys(pageModule).join(", ")}`,
16
+ )
17
+ }
18
+
19
+ return name
20
+ }
package/src/random.js ADDED
@@ -0,0 +1,30 @@
1
+ let mode = "normal"
2
+ let seed = 0
3
+
4
+ export function patchRandom(seed) {
5
+ const original = Math.random
6
+ const restore = setSeed(seed)
7
+
8
+ Math.random = random
9
+
10
+ return () => {
11
+ restore()
12
+ Math.random = original
13
+ }
14
+ }
15
+
16
+ function random() {
17
+ if (mode === "seeded") {
18
+ seed = (seed * 1664525 + 1013904223) % 4294967296
19
+ return seed / 4294967296
20
+ }
21
+ return Math.random()
22
+ }
23
+
24
+ function setSeed(newSeed) {
25
+ seed = newSeed
26
+ mode = "seeded"
27
+ return () => {
28
+ mode = "normal"
29
+ }
30
+ }
package/src/render.js CHANGED
@@ -1,17 +1,31 @@
1
- import { createStore } from "@inglorious/web"
1
+ import { toHTML } from "./html.js"
2
+ import { getModuleName } from "./module.js"
2
3
 
3
- import { toHTML } from "./html"
4
+ export async function renderPage(store, pageModule, options) {
5
+ const name = getModuleName(pageModule)
6
+ const api = store._api
7
+ const entity = api.getEntity(name)
4
8
 
5
- export async function renderPage(pageModule, context) {
6
- const data = (await pageModule.getData?.(context)) ?? {}
7
- const storeConfig = pageModule.getStore(data)
8
- const store = createStore(storeConfig)
9
+ if (pageModule.load) {
10
+ await pageModule.load(entity, store._api)
11
+ }
9
12
 
10
- const html = toHTML(pageModule.render, store)
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
11
23
 
12
- return {
13
- html,
14
- storeConfig,
15
- renderFn: pageModule.render,
16
- }
24
+ return toHTML(store, (api) => api.render(name, { allowType: true }), {
25
+ ...options,
26
+ title,
27
+ meta,
28
+ scripts,
29
+ styles,
30
+ })
17
31
  }
@@ -0,0 +1,67 @@
1
+ import path from "node:path"
2
+
3
+ import { createStore } from "@inglorious/web"
4
+ import { expect, it } from "vitest"
5
+
6
+ import { renderPage } from "./render"
7
+
8
+ const PAGES_DIR = path.join(__dirname, "__fixtures__", "pages")
9
+
10
+ const DEFAULT_OPTIONS = { stripLitMarkers: true }
11
+
12
+ it("should render a static page fragment", async () => {
13
+ const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
14
+
15
+ const store = createStore({
16
+ types: { about: module.about },
17
+ updateMode: "manual",
18
+ })
19
+
20
+ const html = await renderPage(store, module, DEFAULT_OPTIONS)
21
+
22
+ expect(html).toMatchSnapshot()
23
+ })
24
+
25
+ it("should render a whole static page", async () => {
26
+ const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
27
+
28
+ const store = createStore({
29
+ types: { about: module.about },
30
+ updateMode: "manual",
31
+ })
32
+
33
+ const html = await renderPage(store, module, {
34
+ ...DEFAULT_OPTIONS,
35
+ wrap: true,
36
+ })
37
+
38
+ expect(html).toMatchSnapshot()
39
+ })
40
+
41
+ it("should render a page with entity", async () => {
42
+ const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
43
+
44
+ const store = createStore({
45
+ types: { about: module.about },
46
+ entities: { about: { type: "about", name: "Us" } },
47
+ updateMode: "manual",
48
+ })
49
+
50
+ const html = await renderPage(store, module, DEFAULT_OPTIONS)
51
+
52
+ expect(html).toMatchSnapshot()
53
+ })
54
+
55
+ it("should render a page with pre-fetched data", async () => {
56
+ const module = await import(path.resolve(path.join(PAGES_DIR, "posts.js")))
57
+
58
+ const store = createStore({
59
+ types: { posts: module.posts },
60
+ entities: { posts: { type: "posts", name: "Antony", posts: [] } },
61
+ updateMode: "manual",
62
+ })
63
+
64
+ const html = await renderPage(store, module, DEFAULT_OPTIONS)
65
+
66
+ expect(html).toMatchSnapshot()
67
+ })
package/src/router.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path"
2
+ import { pathToFileURL } from "node:url"
2
3
 
3
4
  import { glob } from "glob"
4
5
 
@@ -20,7 +21,7 @@ export async function getPages(pagesDir = "pages") {
20
21
  if (isDynamic(route.pattern)) {
21
22
  // Dynamic route - call getStaticPaths if it exists
22
23
  try {
23
- const module = await import(path.resolve(route.filePath))
24
+ const module = await import(pathToFileURL(path.resolve(route.filePath)))
24
25
 
25
26
  if (typeof module.getStaticPaths === "function") {
26
27
  const paths = await module.getStaticPaths()
@@ -35,6 +36,7 @@ export async function getPages(pagesDir = "pages") {
35
36
 
36
37
  pages.push({
37
38
  path: urlPath,
39
+ modulePath: route.modulePath,
38
40
  filePath: route.filePath,
39
41
  params,
40
42
  })
@@ -52,6 +54,7 @@ export async function getPages(pagesDir = "pages") {
52
54
  // Static route - add directly
53
55
  pages.push({
54
56
  path: route.pattern === "" ? "/" : route.pattern,
57
+ modulePath: route.modulePath,
55
58
  filePath: route.filePath,
56
59
  params: {},
57
60
  })
@@ -99,7 +102,8 @@ export async function getRoutes(pagesDir = "pages") {
99
102
  // Find all .js and .ts files in pages directory
100
103
  const files = await glob("**/*.{js,ts}", {
101
104
  cwd: pagesDir,
102
- ignore: ["**/_*.{js,ts}", "**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
105
+ ignore: ["**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
106
+ posix: true,
103
107
  })
104
108
 
105
109
  const routes = files.map((file) => {
@@ -109,6 +113,7 @@ export async function getRoutes(pagesDir = "pages") {
109
113
 
110
114
  return {
111
115
  pattern,
116
+ modulePath: file,
112
117
  filePath,
113
118
  regex,
114
119
  params,
@@ -138,8 +143,8 @@ function filePathToPattern(file) {
138
143
  .replace(/\.(js|ts)$/, "") // Remove extension
139
144
  .replace(/\/index$/, "") // index becomes root of directory
140
145
  .replace(/^index$/, "") // Handle root index
141
- .replace(/\[\.\.\.(\w+)\]/g, "*") // [...path] becomes *
142
- .replace(/\[(\w+)\]/g, ":$1") // [id] becomes :id
146
+ .replace(/__(\w+)/g, "*") // __path becomes *
147
+ .replace(/_(\w+)/g, ":$1") // _id becomes :id
143
148
 
144
149
  // Normalize to start with /
145
150
  return "/" + pattern.replace(/^\//, "")
@@ -38,7 +38,7 @@ describe("router", () => {
38
38
  // Root usually comes after specific paths but before catch-all if it was a catch-all root,
39
39
  // but here / is static.
40
40
  // Let's just check that we found them.
41
- expect(routes).toHaveLength(5)
41
+ expect(routes).toHaveLength(6)
42
42
  })
43
43
  })
44
44
 
@@ -90,21 +90,7 @@ describe("router", () => {
90
90
  const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
91
91
  const pages = await getPages(FIXTURES_DIR)
92
92
 
93
- // Static routes
94
- expect(pages).toContainEqual(
95
- expect.objectContaining({ path: "/", params: {} }),
96
- )
97
- expect(pages).toContainEqual(
98
- expect.objectContaining({ path: "/about", params: {} }),
99
- )
100
-
101
- // Dynamic routes with getStaticPaths
102
- expect(pages).toContainEqual(
103
- expect.objectContaining({ path: "/posts/1", params: { id: "1" } }),
104
- )
105
- expect(pages).toContainEqual(
106
- expect.objectContaining({ path: "/posts/2", params: { id: "2" } }),
107
- )
93
+ expect(pages).toMatchSnapshot()
108
94
 
109
95
  // Dynamic route without getStaticPaths should be skipped (and warn)
110
96
  const blogPage = pages.find((p) => p.path.includes("/blog/"))
@@ -0,0 +1,70 @@
1
+ import { getModuleName } from "../module.js"
2
+
3
+ /**
4
+ * Generate the code that goes inside the <!-- SSX --> marker.
5
+ * This creates the types and entities objects for the client-side store.
6
+ */
7
+ export function generateApp(store, renderedPages) {
8
+ // 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
+ }
21
+
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")
29
+
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
+ }
45
+
46
+ const entities = {
47
+ router: {
48
+ type: 'router',
49
+ routes: {
50
+ ${routes}
51
+ }
52
+ },
53
+ ${JSON.stringify(store.getState(), null, 2).slice(1, -1)}
54
+ }
55
+
56
+ const middlewares = []
57
+ if (import.meta.env.DEV) {
58
+ middlewares.push(createDevtools().middleware)
59
+ }
60
+
61
+ const store = createStore({ types, entities, middlewares })
62
+
63
+ const root = document.getElementById("root")
64
+ root.innerHTML = ""
65
+
66
+ mount(store, (api) => {
67
+ const { route } = api.getEntity("router")
68
+ return api.render(route, { allowType: true })
69
+ }, root)`
70
+ }
@@ -0,0 +1,23 @@
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
+ }
@@ -0,0 +1,5 @@
1
+ export function generateMain() {
2
+ return `import "./lit-loader.js"
3
+ await import("./app.js")
4
+ `
5
+ }
@@ -0,0 +1,38 @@
1
+ import path from "node:path"
2
+
3
+ import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
4
+
5
+ /**
6
+ * Generate Vite config for building the client bundle
7
+ */
8
+ export function createViteConfig(options = {}) {
9
+ const { rootDir = "src", outDir = "dist" } = options
10
+
11
+ return {
12
+ root: process.cwd(),
13
+ plugins: [minifyTemplateLiterals()],
14
+ build: {
15
+ outDir,
16
+ emptyOutDir: false, // Don't delete HTML files we already generated
17
+ rollupOptions: {
18
+ input: {
19
+ main: path.resolve(outDir, "main.js"),
20
+ },
21
+ output: {
22
+ entryFileNames: "[name].js",
23
+ chunkFileNames: "[name].[hash].js",
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
+ },
30
+ },
31
+ },
32
+ resolve: {
33
+ alias: {
34
+ "@": path.resolve(process.cwd(), rootDir),
35
+ },
36
+ },
37
+ }
38
+ }
@@ -1,7 +0,0 @@
1
- import { html } from "@inglorious/web"
2
-
3
- export const about = {
4
- render() {
5
- return html`<h1>About</h1>`
6
- },
7
- }
@@ -1,7 +0,0 @@
1
- import { html } from "@inglorious/web"
2
-
3
- export const api = {
4
- render() {
5
- return html`<h1>API</h1>`
6
- },
7
- }
@@ -1,7 +0,0 @@
1
- import { html } from "@inglorious/web"
2
-
3
- export const blog = {
4
- render() {
5
- return html`<h1>Blog</h1>`
6
- },
7
- }
@@ -1,7 +0,0 @@
1
- import { html } from "@inglorious/web"
2
-
3
- export const index = {
4
- render() {
5
- return html`<h1>Index</h1>`
6
- },
7
- }
@@ -1,11 +0,0 @@
1
- import { html } from "@inglorious/web"
2
-
3
- export const posts = {
4
- render() {
5
- return html`<h1>Posts</h1>`
6
- },
7
- }
8
-
9
- export async function getStaticPaths() {
10
- return [{ path: "/posts/1", params: { id: "1" } }, "/posts/2"]
11
- }
@@ -1,193 +0,0 @@
1
- // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
-
3
- exports[`toHTML > API rendering within components > should support api.render() method in component render function 1`] = `"<div><div><span>Test Item</span></div></div>"`;
4
-
5
- exports[`toHTML > HTML wrapping > should default to empty title when not provided 1`] = `
6
- "<!DOCTYPE html>
7
- <html>
8
- <head>
9
- <meta charset="UTF-8">
10
- <title></title>
11
-
12
-
13
- </head>
14
- <body>
15
- <div id="root"><p>Content</p></div>
16
-
17
- </body>
18
- </html>"
19
- `;
20
-
21
- exports[`toHTML > HTML wrapping > should handle empty arrays for metas, styles, and scripts 1`] = `
22
- "<!DOCTYPE html>
23
- <html>
24
- <head>
25
- <meta charset="UTF-8">
26
- <title></title>
27
-
28
-
29
- </head>
30
- <body>
31
- <div id="root"><p>Content</p></div>
32
-
33
- </body>
34
- </html>"
35
- `;
36
-
37
- exports[`toHTML > HTML wrapping > should include all options in wrapped HTML 1`] = `
38
- "<!DOCTYPE html>
39
- <html>
40
- <head>
41
- <meta charset="UTF-8">
42
- <title>Complete Page</title>
43
- <meta name="author" content="Test Author">
44
- <link rel="stylesheet" href="/style.css">
45
- </head>
46
- <body>
47
- <div id="root"><main>Main content</main></div>
48
- <script type="module" src="/app.js"></script>
49
- </body>
50
- </html>"
51
- `;
52
-
53
- exports[`toHTML > HTML wrapping > should include meta tags in wrapped HTML 1`] = `
54
- "<!DOCTYPE html>
55
- <html>
56
- <head>
57
- <meta charset="UTF-8">
58
- <title>Test Page</title>
59
- <meta name="description" content="Test description">
60
- <meta name="viewport" content="width=device-width, initial-scale=1">
61
-
62
- </head>
63
- <body>
64
- <div id="root"><p>Content</p></div>
65
-
66
- </body>
67
- </html>"
68
- `;
69
-
70
- exports[`toHTML > HTML wrapping > should include scripts in wrapped HTML 1`] = `
71
- "<!DOCTYPE html>
72
- <html>
73
- <head>
74
- <meta charset="UTF-8">
75
- <title></title>
76
-
77
-
78
- </head>
79
- <body>
80
- <div id="root"><p>Content</p></div>
81
- <script type="module" src="/js/app.js"></script>
82
- <script type="module" src="/js/analytics.js"></script>
83
- </body>
84
- </html>"
85
- `;
86
-
87
- exports[`toHTML > HTML wrapping > should include stylesheets in wrapped HTML 1`] = `
88
- "<!DOCTYPE html>
89
- <html>
90
- <head>
91
- <meta charset="UTF-8">
92
- <title></title>
93
-
94
- <link rel="stylesheet" href="/css/style.css">
95
- <link rel="stylesheet" href="/css/theme.css">
96
- </head>
97
- <body>
98
- <div id="root"><p>Content</p></div>
99
-
100
- </body>
101
- </html>"
102
- `;
103
-
104
- exports[`toHTML > HTML wrapping > should wrap HTML with basic DOCTYPE and structure 1`] = `
105
- "<!DOCTYPE html>
106
- <html>
107
- <head>
108
- <meta charset="UTF-8">
109
- <title>My Page</title>
110
-
111
-
112
- </head>
113
- <body>
114
- <div id="root"><h1>Page Title</h1></div>
115
-
116
- </body>
117
- </html>"
118
- `;
119
-
120
- exports[`toHTML > basic rendering > should render empty content 1`] = `""`;
121
-
122
- exports[`toHTML > basic rendering > should render nested elements 1`] = `
123
- "<div class="container">
124
- <h1>Title</h1>
125
- <p>Content</p>
126
- </div>"
127
- `;
128
-
129
- exports[`toHTML > basic rendering > should render simple HTML without wrapping 1`] = `"<h1>Hello World</h1>"`;
130
-
131
- exports[`toHTML > basic rendering > should render with inline styles 1`] = `"<div style="color: red; font-size: 16px;">Styled</div>"`;
132
-
133
- exports[`toHTML > complex scenarios > should render a complete page structure with message list 1`] = `
134
- "<div class="app">
135
- <header><h1>Messages</h1></header>
136
- <main><div class="message"><p>First message</p></div> <div class="message"><p>Second message</p></div></main>
137
- <footer>© 2024</footer>
138
- </div>"
139
- `;
140
-
141
- exports[`toHTML > complex scenarios > should render wrapped complex page with all assets 1`] = `
142
- "<!DOCTYPE html>
143
- <html>
144
- <head>
145
- <meta charset="UTF-8">
146
- <title>My Website</title>
147
- <meta name="description" content="Welcome to my site">
148
- <meta name="viewport" content="width=device-width">
149
- <link rel="stylesheet" href="/style.css">
150
- </head>
151
- <body>
152
- <div id="root"><div>
153
- <header><h1>My Website</h1></header>
154
- <p>Welcome!</p>
155
- </div></div>
156
- <script type="module" src="/script.js"></script>
157
- </body>
158
- </html>"
159
- `;
160
-
161
- exports[`toHTML > edge cases > should handle special characters in content 1`] = `"<p>&lt;script&gt; &amp; "quotes"</p>"`;
162
-
163
- exports[`toHTML > edge cases > should not include wrap by default 1`] = `"<p>Content</p>"`;
164
-
165
- exports[`toHTML > edge cases > should return only inner HTML when wrap is false 1`] = `"<p>Inner</p>"`;
166
-
167
- exports[`toHTML > event handling > should render event handlers in templates 1`] = `
168
- "<div><div>
169
- Click me
170
- </div></div>"
171
- `;
172
-
173
- exports[`toHTML > event handling > should render multiple event handlers 1`] = `
174
- "<div><div>
175
- <button>
176
- +
177
- </button>
178
- <span>5</span>
179
- <button>
180
- -
181
- </button>
182
- </div></div>"
183
- `;
184
-
185
- exports[`toHTML > rendering with state > should evaluate conditional rendering based on state 1`] = `"<div><p>Visible content</p></div>"`;
186
-
187
- exports[`toHTML > rendering with state > should render entities from store 1`] = `"<div><span>Hello from store</span></div>"`;
188
-
189
- exports[`toHTML > rendering with state > should render multiple entities 1`] = `
190
- "<ul>
191
- <li>First</li> <li>Second</li> <li>Third</li>
192
- </ul>"
193
- `;