jinrai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +15 -0
- package/index.ts +1 -0
- package/lib/bin.js +3322 -0
- package/lib/generate.js +26 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +7 -0
- package/lib/src/config/define.d.ts +2 -0
- package/lib/src/config/userConfig.d.ts +14 -0
- package/package.json +35 -0
- package/readme.md +24 -0
- package/src/bin.ts +43 -0
- package/src/config/define.ts +7 -0
- package/src/config/userConfig.ts +34 -0
- package/src/routes/getRoutes.ts +29 -0
- package/src/routes/parser.ts +58 -0
- package/src/routes/splitByTag.ts +38 -0
- package/src/templates.ts +67 -0
- package/src/types/shims.d.ts +1 -0
- package/src/ui/task.tsx +52 -0
- package/test/.ssr.config.ts +20 -0
- package/tsconfig.json +13 -0
package/lib/generate.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/config/userConfig.ts
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
import { resolve } from "path";
|
|
7
|
+
var getUserConfig = async () => {
|
|
8
|
+
const jiti = createJiti(import.meta.url, {
|
|
9
|
+
debug: false
|
|
10
|
+
});
|
|
11
|
+
const configPath = pathToFileURL(resolve(process.cwd(), ".ssr.config")).href;
|
|
12
|
+
console.log("#######", { configPath });
|
|
13
|
+
const configModule = await jiti.import(configPath);
|
|
14
|
+
return configModule.default;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/generate.ts
|
|
18
|
+
import ReactDOMServer from "react-dom/server";
|
|
19
|
+
console.log(">>");
|
|
20
|
+
var config = await getUserConfig();
|
|
21
|
+
for (const mask of config.pages) {
|
|
22
|
+
console.log(" - ", mask);
|
|
23
|
+
const url = mask.replaceAll("{", "").replaceAll("}", "");
|
|
24
|
+
const html = ReactDOMServer.renderToString(config.renderPage(url));
|
|
25
|
+
console.log(html);
|
|
26
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { defineConfig } from "./src/config/define";
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface ExportHTML {
|
|
2
|
+
index: string;
|
|
3
|
+
outDir: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Config {
|
|
6
|
+
url: string;
|
|
7
|
+
pages: string[];
|
|
8
|
+
api: string;
|
|
9
|
+
dev?: boolean;
|
|
10
|
+
meta: string;
|
|
11
|
+
export: ExportHTML;
|
|
12
|
+
}
|
|
13
|
+
export declare const getUserConfig: () => Promise<Config>;
|
|
14
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jinrai",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"main": "lib/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node ./lib/bin",
|
|
8
|
+
"test2": "node --import tsx src/bin.ts",
|
|
9
|
+
"build": "npm run build-config && npm run build-index && npm run build:types",
|
|
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"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "ISC",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^24.5.2",
|
|
20
|
+
"@types/ora": "^3.1.0",
|
|
21
|
+
"dotenv": "^17.2.2",
|
|
22
|
+
"tsx": "^4.20.6",
|
|
23
|
+
"typescript": "^5.9.2"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"rsr": "lib/bin.js"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@types/prettier": "^2.7.3",
|
|
30
|
+
"jiti": "^2.6.0",
|
|
31
|
+
"ora": "^9.0.0",
|
|
32
|
+
"playwright": "^1.55.1",
|
|
33
|
+
"prettier": "^3.6.2"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# **Jinrai - Instant Static Rendering for SPAs.**
|
|
2
|
+
|
|
3
|
+
A powerful library that analyzes your modern web application and automatically
|
|
4
|
+
generates a perfectly rendered, static snapshot of its pages. Experience unparalleled
|
|
5
|
+
loading speed and SEO clarity without the complexity of traditional SSR setups.
|
|
6
|
+
Simply point Jinrai at your SPA and witness divine speed.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
.ssr.config.ts
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { defineConfig } from "jinrai"
|
|
14
|
+
|
|
15
|
+
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
|
+
},
|
|
23
|
+
})
|
|
24
|
+
```
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { writeFile } from "node:fs/promises"
|
|
4
|
+
import { Config, getUserConfig } from "./config/userConfig"
|
|
5
|
+
import { getRoutesAndTemplates } from "./routes/getRoutes"
|
|
6
|
+
import { getRawPageData } from "./templates"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import { mkdir } from "node:fs/promises"
|
|
9
|
+
import prettier from "prettier"
|
|
10
|
+
import Task from "./ui/task"
|
|
11
|
+
|
|
12
|
+
const task = new Task()
|
|
13
|
+
|
|
14
|
+
task.do("Load config")
|
|
15
|
+
const config: Config = await getUserConfig()
|
|
16
|
+
task.success()
|
|
17
|
+
|
|
18
|
+
const data = await getRawPageData(config.url, config.pages, config.dev)
|
|
19
|
+
|
|
20
|
+
task.do("Format")
|
|
21
|
+
const { routes, templates } = getRoutesAndTemplates(data)
|
|
22
|
+
|
|
23
|
+
task.next(`Export: ${config.export.outDir} (${templates.length})`)
|
|
24
|
+
await mkdir(config.export.outDir, { recursive: true })
|
|
25
|
+
|
|
26
|
+
await writeFile(path.join(config.export.outDir, "config.json"), JSON.stringify(routes, null, 2))
|
|
27
|
+
|
|
28
|
+
for await (let [index, template] of templates.entries()) {
|
|
29
|
+
try {
|
|
30
|
+
template = await prettier.format(template, {
|
|
31
|
+
parser: "html",
|
|
32
|
+
printWidth: 500,
|
|
33
|
+
tabWidth: 2,
|
|
34
|
+
useTabs: false,
|
|
35
|
+
semi: true,
|
|
36
|
+
singleQuote: true,
|
|
37
|
+
})
|
|
38
|
+
} catch (error) {}
|
|
39
|
+
|
|
40
|
+
await writeFile(path.join(config.export.outDir, `${index}.html`), template)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
task.success()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createJiti } from "jiti"
|
|
2
|
+
import { pathToFileURL } from "url"
|
|
3
|
+
import { resolve } from "path"
|
|
4
|
+
|
|
5
|
+
interface ExportHTML {
|
|
6
|
+
// root: string
|
|
7
|
+
// assets: {
|
|
8
|
+
// root: string
|
|
9
|
+
// prefix: string
|
|
10
|
+
// }
|
|
11
|
+
index: string
|
|
12
|
+
outDir: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Config {
|
|
16
|
+
url: string
|
|
17
|
+
pages: string[]
|
|
18
|
+
api: string
|
|
19
|
+
dev?: boolean
|
|
20
|
+
meta: string
|
|
21
|
+
export: ExportHTML
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const getUserConfig = async (): Promise<Config> => {
|
|
25
|
+
const jiti = createJiti(import.meta.url, {
|
|
26
|
+
debug: false,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const configPath = pathToFileURL(resolve(process.cwd(), ".ssr.config")).href
|
|
30
|
+
|
|
31
|
+
const configModule = (await jiti.import(configPath)) as { default: Config }
|
|
32
|
+
|
|
33
|
+
return configModule.default
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { input, PageData } from "../templates"
|
|
2
|
+
import { ParseItem, Parser } from "./parser"
|
|
3
|
+
|
|
4
|
+
interface Route {
|
|
5
|
+
mask: string
|
|
6
|
+
requests: input[]
|
|
7
|
+
content: ParseItem[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const getRoutesAndTemplates = (templates: PageData[]) => {
|
|
11
|
+
const routes: Route[] = []
|
|
12
|
+
const parser = new Parser()
|
|
13
|
+
|
|
14
|
+
for (const template of templates) {
|
|
15
|
+
const content = parser.parse(template.root)
|
|
16
|
+
const mask = template.mask.replaceAll("/", "\\/").replace(/{(.*?)}/, ".+?")
|
|
17
|
+
|
|
18
|
+
routes.push({
|
|
19
|
+
content,
|
|
20
|
+
mask,
|
|
21
|
+
requests: template.input,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
routes,
|
|
27
|
+
templates: parser.templates,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { splitByTag } from "./splitByTag.ts"
|
|
2
|
+
|
|
3
|
+
export interface ParseItem {
|
|
4
|
+
type: string
|
|
5
|
+
template: ParseItem[] | number
|
|
6
|
+
contentKey?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const toBinary = (content: string) => {
|
|
10
|
+
return btoa(unescape(encodeURIComponent(content)))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Parser {
|
|
14
|
+
templates: string[] = []
|
|
15
|
+
ctemplates: string[] = []
|
|
16
|
+
convertor?: (content: string) => string
|
|
17
|
+
|
|
18
|
+
constructor(convertor?: (content: string) => string) {
|
|
19
|
+
this.convertor = convertor
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
parse(html: string): ParseItem[] {
|
|
23
|
+
const parts = splitByTag("loopwrapper", html)
|
|
24
|
+
|
|
25
|
+
return parts.map(content => {
|
|
26
|
+
const isArray = content.startsWith("ArrayDataKey=")
|
|
27
|
+
|
|
28
|
+
let contentKey = undefined
|
|
29
|
+
if (isArray) {
|
|
30
|
+
;[contentKey, content] = this.firstPypeSplit(content)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
type: isArray ? "array" : "html",
|
|
35
|
+
template: isArray ? this.parse(content) : this.getTemplateId(content),
|
|
36
|
+
contentKey,
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getTemplateId(template: string): number {
|
|
42
|
+
const index = this.templates.indexOf(template)
|
|
43
|
+
if (index != -1) {
|
|
44
|
+
return index
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.templates.push(template)
|
|
48
|
+
this.ctemplates.push(this.convertor ? this.convertor(template) : toBinary(template))
|
|
49
|
+
|
|
50
|
+
return this.templates.length - 1
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
firstPypeSplit(content: string) {
|
|
54
|
+
const index = content.indexOf("|")
|
|
55
|
+
|
|
56
|
+
return [content.slice(13, index), content.slice(index + 1)]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const splitByTag = (tag: string, content: string): string[] => {
|
|
2
|
+
const htmls = content
|
|
3
|
+
.replace(new RegExp(`(<\/?${tag}.*?>)`, "gm"), (_, find) => {
|
|
4
|
+
return `<**${tag}**>` + (find.startsWith('</') ? 'CLS' : 'OPN')
|
|
5
|
+
})
|
|
6
|
+
.split(`<**${tag}**>`)
|
|
7
|
+
|
|
8
|
+
if (htmls.length == 1)
|
|
9
|
+
return htmls
|
|
10
|
+
|
|
11
|
+
let count = 0
|
|
12
|
+
const result: string[][] = []
|
|
13
|
+
|
|
14
|
+
for (let itm of htmls) {
|
|
15
|
+
let step = 0
|
|
16
|
+
|
|
17
|
+
if (itm.startsWith('OPN')) step = 1
|
|
18
|
+
if (itm.startsWith('CLS')) step = -1
|
|
19
|
+
|
|
20
|
+
if (step != 0)
|
|
21
|
+
itm = itm.substring(3)
|
|
22
|
+
|
|
23
|
+
if (step > 0) {
|
|
24
|
+
count+=step
|
|
25
|
+
step = 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (count<=1)
|
|
29
|
+
result.push([itm])
|
|
30
|
+
else
|
|
31
|
+
result[result.length -1].push(itm)
|
|
32
|
+
|
|
33
|
+
count+=step
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return result.map(itms => itms.join(`<**${tag}**>`))
|
|
37
|
+
};
|
|
38
|
+
|
package/src/templates.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { chromium } from "playwright"
|
|
2
|
+
import Task from "./ui/task"
|
|
3
|
+
import { spinners } from "ora"
|
|
4
|
+
|
|
5
|
+
export type input = {
|
|
6
|
+
method: string
|
|
7
|
+
url: string
|
|
8
|
+
input: object
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PageData {
|
|
12
|
+
mask: string
|
|
13
|
+
root: string
|
|
14
|
+
input: input[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const getRawPageData = async (url: string, pages: string[], debug: boolean = false): Promise<PageData[]> => {
|
|
18
|
+
const task = new Task()
|
|
19
|
+
task.next("Parsing pages", "yellow", spinners.dotsCircle)
|
|
20
|
+
|
|
21
|
+
const result: PageData[] = []
|
|
22
|
+
|
|
23
|
+
const browser = await chromium.launch({ headless: !debug, devtools: true })
|
|
24
|
+
const context = await browser.newContext({
|
|
25
|
+
userAgent: "____fast-ssr-tool___",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
let date: any[] = []
|
|
29
|
+
|
|
30
|
+
for await (const mask of pages) {
|
|
31
|
+
task.next(mask, "yellow", spinners.dotsCircle, 1)
|
|
32
|
+
|
|
33
|
+
const page = await context.newPage()
|
|
34
|
+
|
|
35
|
+
const path = mask.replaceAll("{", "").replaceAll("}", "")
|
|
36
|
+
await page.goto(url + path)
|
|
37
|
+
|
|
38
|
+
await page.waitForLoadState("networkidle")
|
|
39
|
+
|
|
40
|
+
await page.waitForTimeout(1000)
|
|
41
|
+
|
|
42
|
+
const requests = await page.evaluate(() => {
|
|
43
|
+
return (window as any).__ssr_preload
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const input = requests ? (JSON.parse(requests) as input[]) : []
|
|
47
|
+
|
|
48
|
+
const root = await page.locator("#root").innerHTML()
|
|
49
|
+
|
|
50
|
+
if (debug) {
|
|
51
|
+
await task.ask("continue?")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
page.close()
|
|
55
|
+
|
|
56
|
+
result.push({
|
|
57
|
+
input,
|
|
58
|
+
mask,
|
|
59
|
+
root,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await browser.close()
|
|
64
|
+
|
|
65
|
+
task.success()
|
|
66
|
+
return result
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "jiti"
|
package/src/ui/task.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import ora, { Color, Ora, Spinner, spinners } from "ora";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
|
|
5
|
+
export default class Task {
|
|
6
|
+
spiner: Ora | undefined = undefined;
|
|
7
|
+
|
|
8
|
+
do(task: string, color?: Color, spinner?: Spinner, prefix?: string) {
|
|
9
|
+
this.spiner = ora({
|
|
10
|
+
text: task,
|
|
11
|
+
color,
|
|
12
|
+
spinner: spinner,
|
|
13
|
+
prefixText: prefix,
|
|
14
|
+
}).start();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
next(task: string, color?: Color, spinner?: Spinner, level: number = 0) {
|
|
18
|
+
this.success();
|
|
19
|
+
this.do(
|
|
20
|
+
task,
|
|
21
|
+
color,
|
|
22
|
+
spinner,
|
|
23
|
+
level ? " ".repeat(level) + "└─" : undefined
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
success() {
|
|
28
|
+
if (this.spiner) this.spiner.succeed();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fail() {
|
|
32
|
+
if (this.spiner) this.spiner.fail();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async ask(question: string, defaultAnswer: string = ""): Promise<string> {
|
|
36
|
+
if (this.spiner) {
|
|
37
|
+
// this.spiner.text = `${question}: `
|
|
38
|
+
this.spiner.spinner = {
|
|
39
|
+
interval: 100,
|
|
40
|
+
frames: [
|
|
41
|
+
"?", ".", " ", "."
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rl = readline.createInterface({ input, output });
|
|
47
|
+
const answer = (await rl.question("")).trim();
|
|
48
|
+
rl.close();
|
|
49
|
+
|
|
50
|
+
return answer ? answer : defaultAnswer;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from "../src/config/define";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
url: "https://fld.ru",
|
|
5
|
+
dev: true,
|
|
6
|
+
pages: [
|
|
7
|
+
"/",
|
|
8
|
+
"/products",
|
|
9
|
+
"/products/{klapany}",
|
|
10
|
+
"/product/{krany_sharovye_serii_105_prohodnoj_2h_hodovoj}",
|
|
11
|
+
"/{CMC-8M-4N}",
|
|
12
|
+
],
|
|
13
|
+
// dev: true,
|
|
14
|
+
api: "http://nginx",
|
|
15
|
+
meta: "http://nginx/Api/Meta/GetTags",
|
|
16
|
+
export: {
|
|
17
|
+
outDir: "export",
|
|
18
|
+
index: "index.html",
|
|
19
|
+
},
|
|
20
|
+
});
|
package/tsconfig.json
ADDED