jinrai 1.0.2 → 1.0.4

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/lib/index.js CHANGED
@@ -1,7 +1 @@
1
- // src/config/define.ts
2
- var defineConfig = (config) => {
3
- return config;
4
- };
5
- export {
6
- defineConfig
7
- };
1
+ var e=o=>o;export{e as defineConfig};
@@ -1,9 +1,16 @@
1
+ export interface IndexProps {
2
+ html: string;
3
+ head: string;
4
+ }
1
5
  export interface Config {
2
- url: string;
6
+ url?: string;
7
+ preview?: string;
3
8
  pages: string[];
4
- api: string;
5
- dev?: boolean;
6
- meta: string;
7
- outDir: string;
9
+ debug?: boolean;
10
+ test?: boolean;
11
+ dist?: string;
12
+ index?: (props: IndexProps) => string;
13
+ proxy?: Record<string, string>;
14
+ meta?: string;
8
15
  }
9
- export declare const getUserConfig: () => Promise<Config>;
16
+ export declare const getUserConfig: (configName: string) => Promise<Config>;
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "jinrai",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "A powerful library that analyzes your modern web application and automatically generates a perfectly rendered, static snapshot of its pages. Experience unparalleled loading speed and SEO clarity without the complexity of traditional SSR setups. Simply point Jinrai at your SPA and witness divine speed.",
5
5
  "main": "lib/index.ts",
6
6
  "scripts": {
7
- "test": "node ./lib/bin",
8
- "test2": "node --import tsx src/bin.ts",
7
+ "test": "vitest",
8
+ "dev": "nodemon --watch './src' --ext 'ts' --exec \"npm run build\"",
9
9
  "build": "npm run build-config && npm run build-index && npm run build:types",
10
10
  "build:types": "tsc",
11
- "build-index": "npx esbuild index.ts --bundle --platform=node --format=esm --outfile=lib/index.js --external:jiti --external:node:* --external:playwright",
12
- "build-config": "npx esbuild src/bin.ts --bundle --platform=node --format=esm --outfile=lib/bin.js --external:jiti --external:node:* --external:playwright --external:prettier"
11
+ "build-index": "npx esbuild index.ts --bundle --platform=node --format=esm --outfile=lib/index.js --external:jiti --external:node:* --external:playwright --minify",
12
+ "build-config": "npx esbuild src/bin.ts --bundle --platform=node --format=esm --outfile=lib/bin.js --external:jiti --external:node:* --external:playwright --external:prettier --external:vite --minify"
13
13
  },
14
14
  "keywords": [],
15
15
  "author": "",
@@ -18,9 +18,12 @@
18
18
  "devDependencies": {
19
19
  "@types/node": "^24.5.2",
20
20
  "@types/ora": "^3.1.0",
21
+ "@vitest/coverage-v8": "^4.0.8",
21
22
  "dotenv": "^17.2.2",
23
+ "nodemon": "^3.1.10",
22
24
  "tsx": "^4.20.6",
23
- "typescript": "^5.9.2"
25
+ "typescript": "^5.9.2",
26
+ "vitest": "^3.2.4"
24
27
  },
25
28
  "bin": {
26
29
  "jinrai": "lib/bin.js"
package/readme.md CHANGED
@@ -7,18 +7,33 @@ Simply point Jinrai at your SPA and witness divine speed.
7
7
 
8
8
  ---
9
9
 
10
- .ssr.config.ts
10
+ install
11
+
12
+ ```
13
+ npm i -D jinrai
14
+ ```
15
+
16
+ ---
17
+
18
+ .jinrai.config.ts
11
19
 
12
20
  ```ts
13
21
  import { defineConfig } from "jinrai"
14
22
 
15
23
  export default defineConfig({
16
- url: "<spa-url>",
17
- dev: true,
18
- pages: ["/", "/products", "/products/{phones}", "/product/{iphone17pro}", "/{iphone17pro_42}"],
19
- export: {
20
- outDir: "export",
21
- index: "index.html",
22
- },
24
+ pages: ["", "products/pro", "products/teams", "docs"],
23
25
  })
24
26
  ```
27
+
28
+ add to build
29
+
30
+ package.json
31
+
32
+ ```
33
+ "scripts": {
34
+ "dev": "vite",
35
+ "build": "tsc -b && vite build && jinrai",
36
+ "lint": "eslint .",
37
+ "preview": "vite preview"
38
+ },
39
+ ```
package/src/bin.ts CHANGED
@@ -1,45 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { writeFile } from "node:fs/promises"
4
- import { Config, getUserConfig } from "./config/userConfig"
4
+ import { Config, getUserConfig, IndexProps } from "./config/userConfig"
5
5
  import { getRoutesAndTemplates } from "./routes/getRoutes"
6
6
  import { getRawPageData } from "./templates"
7
7
  import path from "node:path"
8
8
  import { mkdir } from "node:fs/promises"
9
- import prettier from "prettier"
10
9
  import Task from "./ui/task"
11
10
  import { defaultIndexHtml } from "./config/defaultIndexHtml"
11
+ import { removeDevScripts } from "./routes/replaceDevScripts"
12
+ import { normalizeHtmlWhitespace } from "./content/normolizeContent"
13
+ import { vitePreview } from "./server/vitePreview"
14
+
15
+ const indexProps: IndexProps = {
16
+ html: "<!--app-html-->",
17
+ head: "<!--app-head-->",
18
+ }
12
19
 
13
20
  const task = new Task()
14
21
 
15
- task.do("Load config")
16
- const config: Config = await getUserConfig()
22
+ const configName = process.argv[2] ? process.argv[2] : "jinrai.config"
23
+ task.do("Init")
24
+ const config: Config = await getUserConfig(configName)
17
25
  task.success()
18
26
 
19
- const data = await getRawPageData(config.url, config.pages, config.dev)
27
+ const [serverUrl, close] = await vitePreview()
28
+
29
+ const data = await getRawPageData(serverUrl, config.pages, config.test, config.debug)
30
+
31
+ close()
32
+
33
+ const outputcashe = path.join(config.dist ?? "dist", ".cached")
20
34
 
21
35
  task.do("Format")
22
36
  const { routes, templates } = getRoutesAndTemplates(data.pages)
23
37
 
24
- task.next(`Export: ${config.outDir} (${templates.length})`)
25
- await mkdir(config.outDir, { recursive: true })
26
-
27
- await writeFile(path.join(config.outDir, "config.json"), JSON.stringify(routes, null, 2))
28
- await writeFile(path.join(config.outDir, "index.html"), data.indexHtml ?? defaultIndexHtml)
29
-
30
- for await (let [index, template] of templates.entries()) {
31
- try {
32
- template = await prettier.format(template, {
33
- parser: "html",
34
- printWidth: 500,
35
- tabWidth: 2,
36
- useTabs: false,
37
- semi: true,
38
- singleQuote: true,
39
- })
40
- } catch (error) {}
41
-
42
- await writeFile(path.join(config.outDir, `${index}.html`), template)
38
+ task.next(`Export: (${templates.length})`)
39
+ await mkdir(outputcashe, { recursive: true })
40
+
41
+ console.log("dev")
42
+
43
+ const exportConfig = { routes, proxy: config.proxy, meta: config.meta }
44
+
45
+ await writeFile(path.join(outputcashe, "config.json"), JSON.stringify(exportConfig, null, 2))
46
+ // await writeFile(
47
+ // path.join(outputcashe, "index.html"),
48
+ // config.index ? config.index(indexProps) : removeDevScripts(data.indexHtml ?? defaultIndexHtml),
49
+ // )
50
+
51
+ for await (const [template, name] of Object.entries(templates)) {
52
+ await writeFile(path.join(outputcashe, `${name}.html`), template)
53
+ }
54
+
55
+ if (config.test) {
56
+ task.next(`Tests`)
57
+ for await (const page of data.pages) {
58
+ if (!page.test) {
59
+ continue
60
+ }
61
+
62
+ await writeFile(path.join(outputcashe, `test_${page.id}.html`), normalizeHtmlWhitespace(page.test))
63
+ }
43
64
  }
44
65
 
45
66
  task.success()
@@ -1,11 +1,11 @@
1
1
  export const defaultIndexHtml = `
2
2
  <!doctype html>
3
- <html lang="eng">
3
+ <html lang="en">
4
4
  <head>
5
5
  <meta charset="UTF-8" />
6
6
  <link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
- <title>Jinrai app</title>
8
+ <title>App</title>
9
9
  <!--app-head-->
10
10
  </head>
11
11
  <body>
@@ -2,21 +2,29 @@ import { createJiti } from "jiti"
2
2
  import { pathToFileURL } from "url"
3
3
  import { resolve } from "path"
4
4
 
5
+ export interface IndexProps {
6
+ html: string
7
+ head: string
8
+ }
9
+
5
10
  export interface Config {
6
- url: string
11
+ url?: string
12
+ preview?: string
7
13
  pages: string[]
8
- api: string
9
- dev?: boolean
10
- meta: string
11
- outDir: string
14
+ debug?: boolean
15
+ test?: boolean
16
+ dist?: string
17
+ index?: (props: IndexProps) => string
18
+ proxy?: Record<string, string>
19
+ meta?: string
12
20
  }
13
21
 
14
- export const getUserConfig = async (): Promise<Config> => {
22
+ export const getUserConfig = async (configName: string): Promise<Config> => {
15
23
  const jiti = createJiti(import.meta.url, {
16
24
  debug: false,
17
25
  })
18
26
 
19
- const configPath = pathToFileURL(resolve(process.cwd(), "jinrai.config")).href
27
+ const configPath = pathToFileURL(resolve(process.cwd(), configName)).href
20
28
 
21
29
  const configModule = (await jiti.import(configPath)) as { default: Config }
22
30
 
@@ -0,0 +1,7 @@
1
+ export const normalizeHtmlWhitespace = (html: string): string => {
2
+ return html
3
+ .replace(/\r?\n|\r/g, " ")
4
+ .replace(/\s+/g, " ")
5
+ .replace(/>\s+</g, "><")
6
+ .trim()
7
+ }
@@ -0,0 +1,148 @@
1
+ import { normalizeHtmlWhitespace } from "../content/normolizeContent"
2
+ import { createHash } from "node:crypto"
3
+
4
+ interface ParserOptions {
5
+ templates?: boolean
6
+ normolize?: boolean
7
+ }
8
+
9
+ export type Element = ArrayElement | HtmlElement | ValueElement | CustomElement
10
+
11
+ interface ArrayElement {
12
+ type: "array"
13
+ key: string
14
+ data: Element[]
15
+ }
16
+
17
+ interface HtmlElement {
18
+ type: "html"
19
+ content: string
20
+ }
21
+
22
+ interface ValueElement {
23
+ type: "value"
24
+ key: string
25
+ }
26
+
27
+ interface CustomElement {
28
+ type: "custom"
29
+ name: string
30
+ props: string
31
+ }
32
+
33
+ export class Parser {
34
+ options?: ParserOptions
35
+
36
+ openVar = "{{"
37
+ createVar = "}}"
38
+ createArray = "</loopwrapper"
39
+ createCustom = "</custom"
40
+ deepUp = "<loopwrapper"
41
+ deepUp2 = "<custom"
42
+
43
+ templates: Record<string, string> = {}
44
+
45
+ constructor(options?: ParserOptions) {
46
+ this.options = options
47
+ }
48
+
49
+ parse(content: string) {
50
+ const tree: Element[] = []
51
+ this.handle(this.options?.normolize ? normalizeHtmlWhitespace(content) : content, tree)
52
+ return tree
53
+ }
54
+
55
+ handle(content: string, tree: Element[]) {
56
+ let match
57
+ let deep = 0
58
+ let lastIndex = 0
59
+
60
+ const tagPattern = new RegExp(
61
+ `(<loopwrapper(\\s+[^>]*)?>|</loopwrapper>|\{\{|\}\}|<custom(\\s+[^>]*)?>|</custom>)`,
62
+ "gi",
63
+ )
64
+
65
+ while ((match = tagPattern.exec(content)) !== null) {
66
+ const currentTag = match[0]
67
+ const value = content.substring(lastIndex, match.index)
68
+
69
+ if (currentTag.startsWith(this.createArray)) {
70
+ deep--
71
+ if (deep > 0) continue
72
+ this.createElement(tree, value)
73
+ } else if (currentTag.startsWith(this.deepUp)) {
74
+ deep++
75
+ if (deep > 1) continue
76
+ this.createElement(tree, value)
77
+ } else if (currentTag.startsWith(this.createCustom)) {
78
+ deep--
79
+ if (deep != 0) continue
80
+ this.createCustomElement(tree, value)
81
+ } else if (currentTag.startsWith(this.deepUp2)) {
82
+ deep++
83
+ if (deep > 1) continue
84
+ this.createElement(tree, value)
85
+ } else if (currentTag == this.createVar) {
86
+ if (deep != 0) continue
87
+ this.createElement(tree, value, true)
88
+ } else {
89
+ if (deep != 0) continue
90
+ this.createElement(tree, value)
91
+ }
92
+
93
+ lastIndex = match.index + currentTag.length
94
+ }
95
+
96
+ if (lastIndex < content.length) {
97
+ const value = content.substring(lastIndex)
98
+ this.createElement(tree, value)
99
+ }
100
+ }
101
+
102
+ createCustomElement(parent: Element[], value: string) {
103
+ const [name, ...props] = value.trimStart().split("|")
104
+ value = props.join("|")
105
+
106
+ parent.push({
107
+ type: "custom",
108
+ name,
109
+ props: value,
110
+ })
111
+ }
112
+
113
+ createElement(parent: Element[], value: string, isVarible?: boolean) {
114
+ if (isVarible)
115
+ return parent.push({
116
+ type: "value",
117
+ key: value,
118
+ })
119
+
120
+ if (value.trimStart().startsWith("ArrayDataKey=")) {
121
+ const [key, ...val] = value.trimStart().substring(13).split("|")
122
+ value = val.join("|")
123
+
124
+ const children: Element[] = []
125
+ this.handle(value, children)
126
+
127
+ return parent.push({
128
+ type: "array",
129
+ data: children,
130
+ key,
131
+ })
132
+ }
133
+
134
+ if (value)
135
+ parent.push({
136
+ type: "html",
137
+ content: this.options?.templates ? this.createTemplate(value) : value,
138
+ })
139
+ }
140
+
141
+ createTemplate(html: string): string {
142
+ if (!(html in this.templates)) {
143
+ this.templates[html] = createHash("md5").update(Object.keys(this.templates).length.toString()).digest("hex")
144
+ }
145
+
146
+ return this.templates[html]
147
+ }
148
+ }
@@ -1,21 +1,24 @@
1
1
  import { input, PageData } from "../templates"
2
- import { ParseItem, Parser } from "./parser"
2
+ import { Element, Parser } from "./Parser"
3
3
 
4
4
  interface Route {
5
+ id: number
5
6
  mask: string
6
7
  requests: input[]
7
- content: ParseItem[]
8
+ content: Element[]
8
9
  }
9
10
 
10
11
  export const getRoutesAndTemplates = (templates: PageData[]) => {
11
12
  const routes: Route[] = []
12
- const parser = new Parser()
13
+ const parser = new Parser({ normolize: true, templates: true })
13
14
 
14
- for (const template of templates) {
15
+ for (const [id, template] of templates.entries()) {
15
16
  const content = parser.parse(template.root)
17
+
16
18
  const mask = template.mask.replaceAll("/", "\\/").replace(/{(.*?)}/, ".+?")
17
19
 
18
20
  routes.push({
21
+ id,
19
22
  content,
20
23
  mask,
21
24
  requests: template.input,
@@ -0,0 +1,16 @@
1
+ export const removeDevScripts = (indexHtml: string) => {
2
+ const parts = [
3
+ '<script type="module">import { injectIntoGlobalHook } from "/@react-refresh";',
4
+ "injectIntoGlobalHook(window);",
5
+ "window.$RefreshReg$ = () => {};",
6
+ "window.$RefreshSig$ = () => (type) => type;</script>",
7
+ '<script type="module" src="/@vite/client"></script>',
8
+ '<script type="module" src="/src/main.tsx"></script>',
9
+ ]
10
+
11
+ parts.map(remove => {
12
+ indexHtml = indexHtml.replace(`${remove}\n`, "")
13
+ })
14
+
15
+ return indexHtml
16
+ }
@@ -0,0 +1,13 @@
1
+ import { preview } from "vite"
2
+
3
+ export const vitePreview = async () => {
4
+ const previewServer = await preview({
5
+ preview: {
6
+ port: 8084,
7
+ },
8
+ })
9
+
10
+ if (!previewServer.resolvedUrls?.local?.length) throw new Error("vite is not defined")
11
+
12
+ return [previewServer.resolvedUrls?.local[0].slice(0, -1), () => previewServer.close()] as [string, () => void]
13
+ }
package/src/templates.ts CHANGED
@@ -9,30 +9,34 @@ export type input = {
9
9
  }
10
10
 
11
11
  export interface PageData {
12
+ id: number
12
13
  mask: string
13
14
  root: string
14
15
  input: input[]
16
+ test?: string
15
17
  }
16
18
 
17
19
  export const getRawPageData = async (
18
20
  url: string,
19
21
  pages: string[],
22
+ test: boolean = false,
20
23
  debug: boolean = false,
21
24
  ): Promise<{ pages: PageData[]; indexHtml?: string }> => {
22
25
  const task = new Task()
23
- task.next("Parsing pages", "yellow", spinners.dotsCircle)
26
+ task.next("Router analysis", "yellow", spinners.dotsCircle)
24
27
 
25
28
  const result: PageData[] = []
26
29
 
27
30
  const browser = await chromium.launch({ headless: !debug, devtools: true })
31
+ const test_browser = await chromium.launch({ headless: true })
32
+
28
33
  const context = await browser.newContext({
29
34
  userAgent: "____fast-ssr-tool___",
30
35
  })
31
36
 
32
- // let date: any[] = [];
33
37
  let indexHtml: string | undefined = undefined
34
38
 
35
- for await (const mask of pages) {
39
+ for await (const [id, mask] of pages.entries()) {
36
40
  task.next(mask, "yellow", spinners.dotsCircle, 1)
37
41
 
38
42
  const page = await context.newPage()
@@ -51,13 +55,21 @@ export const getRawPageData = async (
51
55
 
52
56
  await page.waitForTimeout(1000)
53
57
 
54
- const requests = await page.evaluate(() => {
55
- return (window as any).__ssr_preload
58
+ const input = await page.evaluate(() => {
59
+ return (window as any).__page_requests
56
60
  })
57
61
 
58
- const input = requests ? (JSON.parse(requests) as input[]) : []
59
-
62
+ if (debug) console.log({ input })
60
63
  const root = await page.locator("#root").innerHTML()
64
+ let testRoot: string | undefined = undefined
65
+
66
+ if (test) {
67
+ const testPage = await test_browser.newPage()
68
+ await testPage.goto(url + path)
69
+ await testPage.waitForLoadState("networkidle")
70
+ await testPage.waitForTimeout(1000)
71
+ testRoot = await page.locator("#root").innerHTML()
72
+ }
61
73
 
62
74
  if (debug) {
63
75
  await task.ask("continue?")
@@ -66,13 +78,16 @@ export const getRawPageData = async (
66
78
  page.close()
67
79
 
68
80
  result.push({
81
+ id,
69
82
  input,
70
83
  mask,
71
84
  root,
85
+ test: testRoot,
72
86
  })
73
87
  }
74
88
 
75
89
  await browser.close()
90
+ await test_browser.close()
76
91
 
77
92
  task.success()
78
93
  return { pages: result, indexHtml }
@@ -0,0 +1,12 @@
1
+ header {{SUPERVALUE}}
2
+ <loopwrapper>
3
+ ArrayDataKey=FIRST_KEY|FIRST_LOOP_CONTENT
4
+ <loopwrapper
5
+ >ArrayDataKey=SECOND_KEY|
6
+ <img
7
+ src="{{0#/Api/Catalog/GetCatalog@/[ITEM=0]/preview}}"
8
+ alt="{{0#/Api/Catalog/GetCatalog@/[ITEM=0]/title}}"
9
+ />
10
+ </loopwrapper>
11
+ </loopwrapper>
12
+ footer
@@ -0,0 +1,54 @@
1
+ [
2
+ {
3
+ "type": "html",
4
+ "content": "header "
5
+ },
6
+ {
7
+ "type": "value",
8
+ "key": "SUPERVALUE"
9
+ },
10
+ {
11
+ "type": "html",
12
+ "content": " "
13
+ },
14
+ {
15
+ "type": "array",
16
+ "data": [
17
+ {
18
+ "type": "html",
19
+ "content": "FIRST_LOOP_CONTENT "
20
+ },
21
+ {
22
+ "type": "array",
23
+ "data": [
24
+ {
25
+ "type": "html",
26
+ "content": " <img src=\""
27
+ },
28
+ {
29
+ "type": "value",
30
+ "key": "0#/Api/Catalog/GetCatalog@/[ITEM=0]/preview"
31
+ },
32
+ {
33
+ "type": "html",
34
+ "content": "\" alt=\""
35
+ },
36
+ {
37
+ "type": "value",
38
+ "key": "0#/Api/Catalog/GetCatalog@/[ITEM=0]/title"
39
+ },
40
+ {
41
+ "type": "html",
42
+ "content": "\" />"
43
+ }
44
+ ],
45
+ "key": "SECOND_KEY"
46
+ }
47
+ ],
48
+ "key": "FIRST_KEY"
49
+ },
50
+ {
51
+ "type": "html",
52
+ "content": " footer"
53
+ }
54
+ ]
@@ -0,0 +1,16 @@
1
+ <header>
2
+ {{SITE_NAME}}
3
+ <nav>
4
+ <loopwrapper
5
+ >ArrayDataKey=MenuKey|
6
+ <a href="{{0#/Api/Menu@/[ITEM=0]/link}}">
7
+ {{0#/Api/Menu@/[ITEM=0]/title}}
8
+ <loopwrapper
9
+ >ArrayDataKey=0#/Api/Menu@/[ITEM=0]/submenu|
10
+ <span>{{0#/Api/Menu@/[ITEM=0]/submenu/[ITEM=1]/name}}</span>
11
+ </loopwrapper>
12
+ </a>
13
+ </loopwrapper>
14
+ </nav>
15
+ </header>
16
+ {{HERO_BANNER}}
@@ -0,0 +1,28 @@
1
+ [
2
+ { "type": "html", "content": "<header> " },
3
+ { "type": "value", "key": "SITE_NAME" },
4
+ { "type": "html", "content": " <nav>" },
5
+ {
6
+ "type": "array",
7
+ "data": [
8
+ { "type": "html", "content": " <a href=\"" },
9
+ { "type": "value", "key": "0#/Api/Menu@/[ITEM=0]/link" },
10
+ { "type": "html", "content": "\"> " },
11
+ { "type": "value", "key": "0#/Api/Menu@/[ITEM=0]/title" },
12
+ { "type": "html", "content": " " },
13
+ {
14
+ "type": "array",
15
+ "data": [
16
+ { "type": "html", "content": " <span>" },
17
+ { "type": "value", "key": "0#/Api/Menu@/[ITEM=0]/submenu/[ITEM=1]/name" },
18
+ { "type": "html", "content": "</span>" }
19
+ ],
20
+ "key": "0#/Api/Menu@/[ITEM=0]/submenu"
21
+ },
22
+ { "type": "html", "content": "</a>" }
23
+ ],
24
+ "key": "MenuKey"
25
+ },
26
+ { "type": "html", "content": "</nav></header> " },
27
+ { "type": "value", "key": "HERO_BANNER" }
28
+ ]