@inglorious/ssx 0.1.4 → 0.2.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/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.1.4",
3
+ "version": "0.2.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",
@@ -22,28 +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",
45
+ "glob": "^13.0.0",
42
46
  "happy-dom": "^20.0.11",
43
47
  "@inglorious/web": "2.6.1"
44
48
  },
45
49
  "devDependencies": {
46
50
  "prettier": "^3.6.2",
51
+ "rollup-plugin-minify-template-literals": "^1.1.7",
47
52
  "vite": "^7.1.3",
48
53
  "vitest": "^1.6.1",
49
54
  "@inglorious/eslint-config": "1.1.1"
@@ -55,6 +60,8 @@
55
60
  "format": "prettier --write '**/*.{js,jsx}'",
56
61
  "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
57
62
  "test:watch": "vitest",
58
- "test": "vitest run"
63
+ "test": "vitest run",
64
+ "dev": "node ./bin/ssx.js build -r ./src/__fixtures__",
65
+ "serve": "pnpm dlx serve dist"
59
66
  }
60
67
  }
package/src/build.js ADDED
@@ -0,0 +1,125 @@
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
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 = ["meta", "getStaticPaths", "getData"]
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 ADDED
@@ -0,0 +1,31 @@
1
+ import { toHTML } from "./html.js"
2
+ import { getModuleName } from "./module.js"
3
+
4
+ export async function renderPage(store, pageModule, options) {
5
+ const name = getModuleName(pageModule)
6
+ const api = store._api
7
+ const entity = api.getEntity(name)
8
+
9
+ if (pageModule.load) {
10
+ await pageModule.load(entity, store._api)
11
+ }
12
+
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
23
+
24
+ return toHTML(store, (api) => api.render(name, { allowType: true }), {
25
+ ...options,
26
+ title,
27
+ meta,
28
+ scripts,
29
+ styles,
30
+ })
31
+ }